Skip to main content

pdf_engine/
blocks.rs

1//! Paragraphs out of the lines a page draws (#567).
2//!
3//! [`crate::text`] turns a page's glyphs into lines: one [`TextBlock`] per band,
4//! in the order an XY-cut says the page is read in. That is where extraction
5//! stopped. A line is not a unit anybody chunks, cites or embeds -- the unit is
6//! the paragraph, and the line breaks inside it are an accident of the measure
7//! the typesetter chose.
8//!
9//! So this module merges lines into [`Block`]s. It decides on geometry the page
10//! already carries, never on the words: leading, the left edge, the right edge,
11//! the type size, and the rules the document itself draws. Two lines join when
12//! nothing on that list says they are apart.
13//!
14//! WHAT IT REFUSES TO CROSS, and why each one is a case rather than a rule
15//!
16//! * A COLUMN GAP. Lines from two columns are never adjacent in the list the
17//!   XY-cut produces, but the last line of the left column and the first of the
18//!   right one are -- and the second runs UP the page. A pair that does not
19//!   descend, or whose horizontal extents barely meet, is two columns meeting.
20//! * A TABLE. The page's own ruling lines say "rows" where no geometry of the
21//!   text can (#545), and an unruled table says it a second way: two lines with
22//!   a wide gap at the same x is a gutter between cells, which is the one thing
23//!   justified prose -- the failure case this has to survive -- cannot produce
24//!   twice at the same place.
25//! * A CHANGE OF TYPE. A heading, a caption and a footnote are set in something
26//!   other than the body, and a paragraph that swallows one loses both. Size
27//!   and WEIGHT both count: a document set in one size says "heading" with its
28//!   bold face and nothing else, and a merge deaf to weight hands #568 a
29//!   paragraph with the heading inside it.
30//! * A LIST ITEM. The commonest list of all -- one line per item, set solid at
31//!   one left edge -- differs from a paragraph in nothing the rails above
32//!   measure, so a merge deaf to markers hands back one block with every bullet
33//!   inside it. `crate::lists` says which lines a column has confirmed as
34//!   items, and this refuses to merge into one (#569).
35//!
36//! WHAT IT JOINS THAT LOOKS LIKE A BREAK
37//!
38//! * A HANGING INDENT: the continuation lines of a bibliography entry are
39//!   indented and belong to the entry above them. A FIRST-LINE INDENT is the
40//!   same measurement with the opposite meaning, so the two are told apart once
41//!   per column, from what sits ABOVE each indented line: a first-line indent
42//!   follows the short last line of the paragraph before it, a hanging
43//!   continuation follows a line that ran the full measure.
44//! * A DROP CAP: an initial set three lines deep is drawn in the band of the
45//!   third line, so the naive reading puts it in the middle of its own
46//!   paragraph. It is taken out of that line's geometry and put back at the
47//!   front of the text.
48//!
49//! The kind is [`BlockKind::Paragraph`] or, where the merge stopped at a
50//! confirmed marker, [`BlockKind::ListItem`]. A heading is decided afterwards
51//! instead: `crate::headings` reads the blocks a second time (#568), because
52//! that evidence is the whole DOCUMENT's typography where a list's is one
53//! column's. A TABLE is taken out before the merge runs: `crate::tables` reads
54//! each region's lines for the grid they stand in, those lines never reach the
55//! merge, and they come out one block per cell (#570). Running heads (#571)
56//! add their own variant, which is what `#[non_exhaustive]` is for.
57
58// Copyright (c) 2026 Innovation Trigger B.V.
59//
60// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
61// the PDFluent Commercial Licence. See the LICENSE file in this repository --
62// that file travels with the copy you received, which a URL does not.
63
64use std::cmp::Ordering;
65use std::hash::{Hash, Hasher};
66use std::path::PathBuf;
67
68use crate::error::{EngineError, OptionsError, Result};
69use crate::lists::{self, Marker};
70use crate::table_trace::{LearnedTrace, TableOrigin};
71use crate::tables;
72use crate::text::{is_hyphen_wrap_candidate, HorizontalRule, TextBlock, TextSpan};
73
74/// How far apart two baselines may be, as a multiple of the leading in force,
75/// and still belong to one paragraph.
76///
77/// Extra space between paragraphs is conventionally half a line or more, so
78/// 1.5 is where the two populations sit; the threshold is under it because a
79/// leading estimated from a page's tightest quartile runs a little low, and a
80/// paragraph wrongly split is a worse answer than two wrongly joined -- the
81/// first loses a sentence's context, the second only its boundary.
82const PARAGRAPH_LEADING_TOLERANCE: f64 = 1.35;
83
84/// The largest baseline step that can be running text, as a multiple of the
85/// type size, whatever the column's own leading says.
86///
87/// [`run_leading`] measures what the column IS set on, which is what tells a
88/// paragraph break from a line break -- and on a page set entirely at a
89/// paragraph's distance it measures the paragraph distance and merges the page.
90/// `Overlayed-with-rot0` is that page: eleven-point type on a twenty-five-point
91/// pitch, which mutool and poppler both read as one block per line and this read
92/// as one block. So there is a second rail, and it is typographic rather than
93/// measured: running text is set between about 1.1 and 1.5 times its own size,
94/// and white the depth of most of another line is a break however regularly it
95/// recurs.
96const MAX_LEADING_EM: f64 = 1.8;
97
98/// The largest baseline step at which a line under an item's hang is still
99/// the item's, as a multiple of the type size (#599).
100///
101/// [`MAX_LEADING_EM`] is right about a line of prose and wrong about a
102/// bibliography set double-spaced: `[2] A. Mizel, ... Phys. Rev. A.` on one
103/// line and `Rapid Comm., 63:40302, 2001` two ems below it, starting exactly
104/// where the item's text starts. The hang is the list's own statement that
105/// the line continues the item -- the next item would start at the marker --
106/// so under it a double-spaced step is allowed, and nothing wider.
107///
108/// What it cannot tell: a list set double-spaced throughout, followed two ems
109/// down by a paragraph set at the list's indent -- an EU supply contract in
110/// DocLayNet's validation split. Its geometry is the bibliography's to the
111/// point (the item-to-item pitch is the same two ems), and only the words
112/// differ. On the 34 validation documents that is one item lost against 43
113/// found (#599).
114const HANG_MAX_LEADING_EM: f64 = 2.2;
115
116/// The smallest baseline step that counts as "the next line down", as a
117/// multiple of the type size. Below it the two lines are the same line: a
118/// superscript, a sub-baseline rule of a formula, or a band the cut split.
119const MIN_BASELINE_STEP_EM: f64 = 0.25;
120
121/// How much of the narrower line's width has to lie under the wider one before
122/// the two count as being in the same text column.
123const COLUMN_OVERLAP_FRACTION: f64 = 0.35;
124
125/// How far the type size may differ inside one paragraph, as a fraction.
126/// A tenth admits the rounding of a size that arrives through a text matrix and
127/// refuses the step from body to heading, caption or footnote.
128const SIZE_TOLERANCE: f64 = 0.10;
129
130/// How far short of the measure a line has to fall, in ems, before its full
131/// stop is read as the end of the paragraph rather than the end of a sentence.
132const SHORT_LINE_EM: f64 = 2.5;
133
134/// How far apart two baselines may be and still be one: the rounding a text
135/// matrix arrives through, and nothing else.
136const SAME_BASELINE: f64 = 1e-6;
137
138/// How much of a run's row pitch a line may sit below the row above it and
139/// still belong to that row. Three quarters: a cell's wrapped line sits at the
140/// leading, and a table is set on a pitch of a line plus its cells' padding.
141const ROW_PITCH_FRACTION: f64 = 0.75;
142
143/// How much wider than the step under it a step has to be before the run is
144/// read as having two kinds of step in it: the leading a cell wraps at, and
145/// the pitch the rows are set on.
146///
147/// Two fifths more. A table of descriptions sets its lines at the leading and
148/// parts its rows by half a line again, which is what a reader sees the rows
149/// by; a table whose cells are one line each has one step and no jump, and
150/// asking this of it answers no rather than guessing a rail. Lower and the
151/// rounding between two nominally equal steps becomes a row boundary.
152const ROW_PITCH_JUMP: f64 = 1.4;
153
154/// How many steps above the jump there have to be before the wider side is a
155/// row pitch rather than a single gap -- the space under a header, or the end
156/// of the table. Two.
157const ROW_PITCH_CLUSTER: usize = 2;
158
159/// How far from a cell's left edge, in ems, a line may open and still be read
160/// as continuing that cell. Half: a cell's second line is set at the cell's
161/// own edge to the point, and the tolerance is for the rounding a text matrix
162/// arrives through and for a leading space.
163const CELL_LEFT_EM: f64 = 0.5;
164
165/// The smallest left-edge step that counts as an indent, in ems.
166const INDENT_MIN_EM: f64 = 0.8;
167
168/// How close two left edges have to be, in ems, to count as the same edge.
169const INDENT_MATCH_EM: f64 = 0.35;
170
171/// The smallest gap inside a line, in ems, that can be a gutter between cells
172/// rather than a word space. Justified setting stretches a word space to
173/// perhaps an em and a half on a bad line; three ems is past anything a
174/// justifier produces, and it still has to happen at the same x on a second
175/// line before it is believed.
176const TABULAR_GAP_EM: f64 = 3.0;
177
178/// The longest text an oversized initial may hold before it stops being an
179/// initial. Two, because a drop cap is sometimes a letter and its quotation
180/// mark.
181const INITIAL_MAX_CHARS: usize = 2;
182
183/// How much larger than the line it sits in an initial has to be set.
184const INITIAL_SIZE_RATIO: f64 = 1.8;
185
186/// How much smaller than the line a figure has to be set before it is raised
187/// out of it rather than part of it (#578).
188///
189/// Nine tenths: a reference number is set at six or seven point over ten-point
190/// type, and the floor is high enough to admit the small end of that while
191/// keeping a line whose first span is merely a hair smaller out.
192const RAISED_SIZE: f64 = 0.9;
193
194/// How far above the line's own baseline a raised figure sits, in ems of the
195/// line (#578). A tenth: a superscript is set a third of an em up and a
196/// baseline that wandered by rounding is nowhere near it.
197const RAISED_RISE: f64 = 0.1;
198
199/// The most characters a raised figure may hold and still be a marker (#578).
200///
201/// Three, which is what `lists::MAX_ORDINAL_DIGITS` reads: a bibliography
202/// passes a hundred entries often enough, and a longer run of small type at
203/// the head of a line is a formula rather than a number.
204const MAX_ORDINAL_CHARS: usize = 3;
205
206/// The gap after a glyph before the page is setting it apart as a marker, in
207/// ems of the line (#578).
208///
209/// Two fifths, which is wider than any word space -- a quarter of an em is the
210/// usual one -- and narrower than the hanging indent a list of any kind sets
211/// its text at.
212const MARKER_GAP_EM: f64 = 0.4;
213
214/// The longest a run of picture-font glyphs may be and still be a list marker
215/// rather than a line of the document (#569). Two, because a marker is a glyph
216/// and sometimes a glyph and its space; a formula set in Symbol opens a line in
217/// a picture font too, and it is not an item.
218const MARKER_MAX_CHARS: usize = 2;
219
220/// How far apart the left edges of two one-line blocks may stand, in ems, and
221/// still be read as one narrow column by the line join below (#574).
222///
223/// Half an em: the rounding a text matrix arrives through, and a ragged edge's
224/// own jitter. A first-line indent (0.8 em and up) never clears it, which is
225/// what keeps two one-line paragraphs of an indented page apart.
226const NARROW_X0_EM: f64 = 0.5;
227
228/// How far apart the baselines of a narrow-column join may stand, as a multiple
229/// of the pitch of the block above (#574).
230///
231/// Just over one: the join is for lines the cut sliced at their own leading,
232/// not for lines the typesetter spaced apart. The absolute ceiling stays
233/// [`MAX_LEADING_EM`], whatever the pitch measures.
234const NARROW_PITCH_MULT: f64 = 1.2;
235
236/// How far apart the type sizes of a narrow-column join may stand, in points
237/// (#574). Half a point: the rounding of a size that arrives through a text
238/// matrix, and nothing a reader would call a step.
239const NARROW_SIZE_PT: f64 = 0.5;
240
241/// The pitch a one-line block is credited with where its run measures none
242/// (#574). Solid setting, the same 1.16 [`run_leading`] falls back on: a run
243/// of one line has no step to measure, and the page's own quartile is the
244/// offset between two interleaved columns rather than any leading at all.
245///
246/// Shared with the v2 release rejoin, which credits a refused table's column
247/// the same pitch where the column measures none.
248pub(crate) const SOLID_PITCH_EM: f64 = 1.16;
249
250/// What the heuristic table builder had to go on (#574).
251///
252/// The builder knows when it is guessing, and until now it threw that away: the
253/// fill ratio was computed and reduced to a pass, and the rest was never recorded.
254/// These are the facts it has at the moment it decides, for two consumers -- a
255/// caller deciding whether to send a page to a vision model (#624), and the
256/// learned structure model of #600 deciding whether it may overrule the grid.
257///
258/// NOT COLLAPSED INTO A SCORE, which is why there are five numbers and not one. A
259/// weighted worst-of-five would be a hand-made value that reads as a measurement,
260/// and nobody could test which part of it was wrong. Each of these is a fact with
261/// a unit, and each consumer writes its own rule over them where the rule can be
262/// read and argued with. If a scalar is ever wanted, it can be fitted against an
263/// outcome and say what it was fitted on.
264///
265/// A PROPERTY OF THE TABLE, not of the cell that carries it. Every cell of one
266/// table reports the same value, because a cell is where a model looks and a
267/// consumer holding the cells has no other handle on the table they came from.
268#[derive(Debug, Clone, Copy, PartialEq, Default)]
269pub struct GridDoubt {
270    /// How many rows were admitted with too few runs of ink to be a row on their
271    /// own, through the rule that lets a section head stand inside a table
272    /// (#576). Each is a row the builder kept on the strength of its neighbours
273    /// rather than of its own shape.
274    pub thin_rows: usize,
275    /// The share of the grid's cells the page drew ink in, between 0 and 1.
276    /// `MIN_FILL` is the floor a table has to clear, so a grid barely above it is
277    /// one the builder nearly refused.
278    pub fill: f64,
279    /// Which attempt produced the grid: 0 for the run's own first row, 1 or 2
280    /// where the first rows had to be trimmed. Above 0 means the first candidate
281    /// was not a grid.
282    pub retried_from: usize,
283    /// How many rows have ink in every column, which is what the column
284    /// boundaries are derived from. A table whose bounds rest on one full row has
285    /// almost no evidence for where its columns are.
286    pub full_rows: usize,
287    /// How many columns the grid has. Every fact above means something different
288    /// in a two-column table than in a nine-column one, so the scale travels with
289    /// them rather than being guessed at by the consumer.
290    pub columns: usize,
291}
292
293impl Block {
294    /// A block with the fields every kind has, and the rest left at their
295    /// defaults: no confidence, no grid evidence, not a continuation.
296    ///
297    /// The way to build one from outside this crate, now that [`Block`] is
298    /// `#[non_exhaustive]`. The five arguments are the ones no block can do
299    /// without -- a kind, a page, a box, its text and its place in the reading
300    /// order -- and everything else is set afterwards on the returned value.
301    pub fn new(kind: BlockKind, page: usize, bbox: [f64; 4], text: String, index: usize) -> Self {
302        Self {
303            kind,
304            page,
305            bbox,
306            text,
307            spans: Vec::new(),
308            index,
309            continues_previous: false,
310            confidence: None,
311            grid: None,
312            table_origin: TableOrigin::Other,
313            trace_learned: None,
314        }
315    }
316}
317
318/// The kind of a block. Paragraphs (#567), headings (#568) and list items
319/// (#569); tables and running heads arrive with theirs (#570, #571).
320///
321/// `#[non_exhaustive]` because those variants land in a published crate: a
322/// caller that matches on this must keep a fallback arm, and adding a kind must
323/// not be a breaking change.
324#[non_exhaustive]
325#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
326pub enum BlockKind {
327    /// Running text: one or more lines the page sets as one paragraph.
328    Paragraph,
329    /// A heading, at a level from 1 to 3 (#568).
330    ///
331    /// The level is the block's rank among the type the DOCUMENT sets above its
332    /// own body, never a point size: see `crate::headings`. Levels below the
333    /// third fold into it, so `level` is always 1, 2 or 3 and a consumer
334    /// mapping it to `#`..`######` never runs off the end.
335    Heading {
336        /// 1, 2 or 3.
337        level: u8,
338    },
339    /// One item of a list (#569).
340    ///
341    /// ONE BLOCK PER ITEM, not one per list. The item is what a caller chunks,
342    /// cites and embeds -- the same unit every other kind here is -- and a
343    /// container kind would need a second shape of [`Block`], with its own text
344    /// and its own box, for a grouping that consecutive items already say. A
345    /// list is therefore a run of adjacent blocks of this kind, and the
346    /// markdown serializer reads it as one.
347    ///
348    /// The marker itself is NOT in [`Block::text`]: a bullet and a number are
349    /// what the page used to say "item", and a consumer that wanted them back
350    /// would have to strip them again before indexing the words. It is still in
351    /// [`Block::spans`], which carry what the page drew rather than what it
352    /// said. The one exception is an outline's number -- `II.4.1`, `10.2` --
353    /// which stays at the head of the text, because it is the clause's name as
354    /// much as its marker: a cross-reference cites it, and no one ordinal can
355    /// say it (#599).
356    ListItem {
357        /// Nesting depth, 0 for the outermost list. From the indent steps the
358        /// column itself uses, never a measurement in points, and never more
359        /// than four: deeper steps fold into the fourth, the way heading
360        /// clusters below the third fold into level 3.
361        level: u8,
362        /// The number the document wrote -- `3.` is 3, `c)` is 3, `iv.` is 4 --
363        /// or `None` where the item was marked with a bullet, or numbered by
364        /// its place in an outline (`II.4.1`), which no one number says and
365        /// which the item's text therefore keeps.
366        ///
367        /// The document's own number and not a count of this list's items, so a
368        /// list that opens at 5, or one the extraction lost an item out of,
369        /// still says what the page says.
370        ordinal: Option<u32>,
371    },
372    /// One cell of a table (#570).
373    ///
374    /// ONE BLOCK PER CELL, for the reason a list is one block per item: the
375    /// cell is what a caller chunks, cites and embeds, and a container kind
376    /// would need a second shape of [`Block`] -- its own text, its own box --
377    /// for a grouping that `row`, `column` and a run of adjacent blocks already
378    /// say. A table is therefore a run of these in ROW-MAJOR order, opening at
379    /// `row: 0, column: 0`, and `crate::markdown` reads such a run back as one
380    /// table.
381    ///
382    /// The grid is the whole table's, not the row's: `column` is a column of
383    /// the table, so the third cell of a row whose second cell is missing is at
384    /// column 2 and not at column 1.
385    TableCell {
386        /// 0-based row inside the table.
387        row: u16,
388        /// 0-based column inside the table.
389        column: u16,
390        /// How many rows the cell covers, 1 unless the document ruled two rows
391        /// together by breaking the rule between them over this column.
392        row_span: u16,
393        /// How many columns the cell covers, 1 unless its words cross a column
394        /// boundary the rest of the table keeps.
395        column_span: u16,
396        /// Whether the cell is in the table's header row.
397        header: bool,
398    },
399    /// A running header: the line a document repeats at the TOP of its pages
400    /// (#571).
401    ///
402    /// Not in the reading order. `crate::reading_order` keeps these blocks
403    /// after the body rather than dropping them, because what a page repeats
404    /// is still something a caller may want to read -- the chapter it is in,
405    /// the revision of the form -- and it is the body it must not interrupt.
406    Header,
407    /// A running footer, including a page number (#571).
408    ///
409    /// The same block as [`BlockKind::Header`] at the other end of the page:
410    /// see there for why it is kept rather than dropped.
411    Footer,
412    /// A caption: the line or lines that say what a picture or a table beside
413    /// them shows (#636). Read by the learned pass from where the page draws
414    /// its images; the heuristics never produce one.
415    Caption,
416    /// A figure: a placed image or a drawn vector cluster on the page (#658).
417    ///
418    /// Listed late, after the learned pass and the text joins, from the
419    /// figure boxes the text pass recorded: placed images first, then the
420    /// path clusters [`crate::text::TextExtractionDevice::take_graphics`]
421    /// keeps. A page with no text block still carries its placed images --
422    /// a scan reads as its figure markers -- but no drawn cluster, and
423    /// figures merge into their page's reading order by vertical position.
424    ///
425    /// [`Block::text`] is empty: a figure says nothing; the Markdown
426    /// serializer writes it as an image reference from its page and its
427    /// number among that page's figures.
428    Figure,
429}
430
431/// One block of a document's structured text: what a caller chunks, cites or
432/// embeds.
433///
434/// The fields are the ones the serializers consume (#572): the kind, where it
435/// is, what it says, what it was made of, and where it falls in the reading
436/// order of the whole document.
437///
438/// `#[non_exhaustive]`, DECIDED AFTER TWO FIELDS HAD ALREADY BROKEN IT (codex on
439/// #1977 and #1989). This is a type callers READ: `extract_blocks` returns it and
440/// nothing outside this crate has a reason to build one. But it was an exhaustive
441/// public struct in a 1.0.0 crate, so `confidence` and then `grid` each made every
442/// downstream literal fail to compile -- and the learned work has more of both
443/// coming, one per model that has something to say about a block. Closing it now
444/// means the next field costs nobody anything.
445///
446/// Construct one with [`Block::new`] and set the rest, which is what this crate's
447/// own examples do.
448#[non_exhaustive]
449#[derive(Debug, Clone)]
450pub struct Block {
451    /// What this block is.
452    pub kind: BlockKind,
453    /// 0-based page index the block was drawn on.
454    pub page: usize,
455    /// Bounding box in PDF user space, `[x0, y0, x1, y1]` with y up and
456    /// `y0 < y1`. The union of the glyph boxes of every span in the block.
457    pub bbox: [f64; 4],
458    /// The block's text, with the line breaks of the measure removed: lines are
459    /// joined by a space, and a word the typesetter broke across a line is put
460    /// back together.
461    pub text: String,
462    /// Every span the block is made of, in the order the page DRAWS them: left
463    /// to right, line by line.
464    ///
465    /// Visual and not logical, which matters on a line of Arabic or Hebrew: the
466    /// spans carry the geometry, and the geometry is visual, so extraction
467    /// leaves them where the page put them and reorders only the text
468    /// ([`TextBlock::text`], #436). `text` above is therefore the logical
469    /// reading and this is not, and a caller rebuilding the text from the spans
470    /// of a right-to-left block has to reorder them itself (codex, round 1).
471    pub spans: Vec<TextSpan>,
472    /// Position of this block in the document's reading order, counted from the
473    /// first block of the first page.
474    ///
475    /// The running heads a document repeats are not in that order: they carry
476    /// the numbers after the last block of the body, which is where
477    /// [`crate::reading_order`] puts them (#571).
478    pub index: usize,
479    /// Whether the block continues the one before it in the reading order,
480    /// because the page broke a paragraph in two (#571).
481    ///
482    /// A RELATION AND NOT A MERGE. The two halves are set on two pages, so one
483    /// paragraph there has two boxes and two page numbers, and a merged block
484    /// could state neither: [`Self::bbox`] is one rectangle and [`Self::page`]
485    /// is one page. The serializers join what this marks -- `crate::markdown`
486    /// writes the two as one paragraph -- and a caller chunking the document
487    /// joins it the same way, with the geometry of both halves still in front
488    /// of it.
489    pub continues_previous: bool,
490    /// How sure the learned model is of [`Self::kind`], between 0 and 1, or
491    /// `None` where no model spoke about this block (#624).
492    ///
493    /// WHAT `None` MEANS, because it is not "we are unsure". A block that no
494    /// model has an opinion about carries the heuristics' verdict, and the
495    /// heuristics do not produce a probability -- they produce a decision. So
496    /// `None` is "nobody measured this", and a caller that wants to route the
497    /// doubtful cases somewhere must treat it as unknown rather than as low.
498    ///
499    /// Today the only model on master that answers is the list-item one of
500    /// #599, so a paragraph that model considered carries its probability and a
501    /// heading carries `None` until #599's heading model lands. The value is the
502    /// model's own probability for the decision it made, not a calibrated
503    /// confidence over all kinds: 0.9 on a list item means the item model put
504    /// 0.9 on "this is an item", and says nothing about the alternative kinds.
505    ///
506    /// AND IT IS THE PROBABILITY OF THE DECISION THAT PRODUCED THIS BLOCK'S KIND,
507    /// which is not always the one the block began with (codex on #1977). A block
508    /// the opens model cuts into three items has three pieces whose kinds come
509    /// from three different lines' probabilities, and each piece reports its own.
510    /// A block whose kind the heuristics decided carries `None` even where a model
511    /// ran on it and agreed, because a number about a decision nobody acted on
512    /// would read as a number about this kind.
513    pub confidence: Option<f64>,
514    /// What the heuristic builder had to go on when it found this cell's table,
515    /// or `None` for a block that is not a table cell (#574).
516    ///
517    /// See [`GridDoubt`]: five facts rather than a score, and a property of the
518    /// table repeated on each of its cells.
519    pub grid: Option<GridDoubt>,
520    /// Which stage produced this cell's table, for the env-gated table trace
521    /// (#636). Stamped where the cells are built and read back where the
522    /// trace prints; it never decides what is a table.
523    ///
524    /// Crate-internal: [`Block`] is `#[non_exhaustive]` and this travels
525    /// with the block through every transform, so a rebuilt table reports
526    /// its builder and a kept one keeps its stamp.
527    pub(crate) table_origin: TableOrigin,
528    /// Trace-only record of the learned pass's judge probability and forced
529    /// reason for this cell, for the env-gated table trace (#636). Stamped
530    /// where the origin is stamped and read back where the trace prints;
531    /// it never decides what is a table.
532    ///
533    /// Crate-internal like [`Block::table_origin`](Self::table_origin).
534    pub(crate) trace_learned: Option<LearnedTrace>,
535}
536
537#[non_exhaustive]
538/// How a change of page reaches the Markdown output of
539/// [`PdfDocument::extract_markdown`](crate::PdfDocument::extract_markdown).
540///
541/// [`ExtractOptions::page_separators`]. Default [`PageSeparators::Comment`]:
542/// today's `<!-- page N -->` marker. [`PageSeparators::None`] writes no
543/// marker at all; the paragraphs on either side stay separated by a blank
544/// line, the way two paragraphs of one page are.
545#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
546pub enum PageSeparators {
547    /// A change of page writes `<!-- page N -->` as its own paragraph, `N`
548    /// the 0-based [`Block::page`] of the page that opens -- the default, and
549    /// what the output has always been.
550    #[default]
551    Comment,
552    /// A change of page writes nothing.
553    None,
554}
555
556/// Which tier reads the document: the shipping engine, or the layout model
557/// beside it (#641).
558///
559/// Read by [`crate::PdfDocument::extract_markdown`] only: a call that returns
560/// blocks ([`crate::PdfDocument::extract_blocks`],
561/// [`crate::PdfDocument::extract_docling`]) answers
562/// [`OptionsError::BalancedIsMarkdownOnly`] for [`Quality::Balanced`], because
563/// the balanced tier assembles markdown and there are no balanced blocks to
564/// return.
565///
566/// `#[non_exhaustive]`: `best` stays a scripts tier for now and arrives here
567/// as a new variant later, which must not break a downstream match.
568#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
569#[non_exhaustive]
570pub enum Quality {
571    /// The shipping engine: geometric extraction, no model, every build.
572    /// The default.
573    #[default]
574    Fast,
575    /// The balanced tier: the layout model (Heron-ft ep03) regions the page
576    /// and the engine's own text fills them, through `balanced_markdown` on
577    /// a build with the `ort-layout` cargo feature.
578    ///
579    /// Needs the model and the runtime beside the feature: ONNX Runtime
580    /// 1.23.2 under `ort` =2.0.0-rc.13, and the weights installed by an
581    /// explicit `pdfluent models install layout` -- nothing is downloaded by
582    /// default, ever. Without the feature the call fails with
583    /// `MODEL_NOT_AVAILABLE` naming the feature and the install command;
584    /// with it but without the files, with `MODEL_NOT_AVAILABLE` naming the
585    /// path looked at and the install command. [`ExtractOptions::model_dir`]
586    /// points the tier at a scratch directory, and
587    /// [`ExtractOptions::keep_furniture`] keeps running heads as paragraphs.
588    Balanced,
589}
590
591/// TATR table structure, opted into per call (track B, P3).
592///
593/// Set on [`ExtractOptions`] with [`ExtractOptions::with_tatr`]. When set,
594/// every table region is offered to the Table Transformer first: where it
595/// fires its cells are the region's table, and where it does not the current
596/// grid runs unchanged. Unset, extraction never touches the model and reads
597/// byte for byte as without the option.
598///
599/// The knobs mirror the shared layer's `RuntimeConfig` without naming it:
600/// that type only exists behind the `ort` feature, and
601/// this one must exist on every build so the option, its refusal, and the
602/// test seam below compile everywhere. Behind `ort-tables` the engine builds
603/// the runtime config from these fields; without it, asking for TATR is an
604/// [`OptionsError::TatrNeedsOrtTables`], never a silent fast run.
605///
606/// `#[non_exhaustive]`, like every option type here: start from [`Default`]
607/// and assign the fields. All fields are `Eq`, so [`ExtractOptions`] keeps
608/// its `Eq`.
609#[derive(Debug, Clone, PartialEq, Eq)]
610#[non_exhaustive]
611pub struct TatrOptions {
612    /// Explicit model dir; wins over the env var and the default, as the
613    /// shared layer's `model_dir` does. Default `None`: resolve it there.
614    pub model_dir: Option<PathBuf>,
615    /// Explicit ONNX Runtime library; wins over the env var and the
616    /// default. Default `None`: resolve it there.
617    pub ort_dylib: Option<PathBuf>,
618    /// ONNX Runtime intra-op threads per session. Default 4.
619    pub intra_threads: usize,
620    /// Sessions per model: a pool so parallel pages do not serialise on one
621    /// session. Default 1.
622    pub sessions_per_model: usize,
623    /// TATR acts only on regions from a detector or teacher box. Default
624    /// false: every region [`crate::learned::tables::rebuild_region`]
625    /// rebuilds is offered to TATR, whether the learned pass or a teacher
626    /// box drew it. The switch P4 restricts with once the detector lands.
627    pub detector_regions_only: bool,
628    /// The P3 test seam: a canned structurer that answers without a model.
629    /// Hidden: tests name it, callers never do. When set, the hook answers
630    /// from the fake and never loads the model -- on any build.
631    #[doc(hidden)]
632    pub test_hook: Option<TatrTestHook>,
633}
634
635impl Default for TatrOptions {
636    fn default() -> Self {
637        Self {
638            model_dir: None,
639            ort_dylib: None,
640            intra_threads: 4,
641            sessions_per_model: 1,
642            detector_regions_only: false,
643            test_hook: None,
644        }
645    }
646}
647
648impl TatrOptions {
649    /// The shared layer's config these options name.
650    #[cfg(feature = "ort-tables")]
651    pub(crate) fn runtime_config(&self) -> pdf_ocr::runtime::RuntimeConfig {
652        let mut config = pdf_ocr::runtime::RuntimeConfig::new()
653            .with_intra_threads(self.intra_threads)
654            .with_sessions_per_model(self.sessions_per_model);
655        if let Some(dir) = &self.model_dir {
656            config = config.with_model_dir(dir.clone());
657        }
658        if let Some(lib) = &self.ort_dylib {
659            config = config.with_ort_dylib(lib.clone());
660        }
661        config
662    }
663}
664
665/// The P3 test seam: a canned structurer behind [`TatrOptions::test_hook`].
666///
667/// Hidden rather than published: integration tests name it to prove the hook
668/// wiring without a model or a dylib, and no caller does. `Eq`, so
669/// [`TatrOptions`] -- and through it [`ExtractOptions`] -- keeps its `Eq`.
670#[doc(hidden)]
671#[derive(Debug, Clone, PartialEq, Eq)]
672#[non_exhaustive]
673pub enum TatrTestHook {
674    /// Fire with these cells: they land in the region's table as TATR's own
675    /// would, text filled from the region's words.
676    Cells(Vec<TatrTestCell>),
677    /// Decline: the base output, exactly.
678    Declined,
679    /// Fail like a per-region inference error: traced, and the region falls
680    /// back to the base grid.
681    Failed,
682}
683
684/// One canned cell of [`TatrTestHook::Cells`]: its grid position and the
685/// region words it claims, by index into the region's words.
686#[doc(hidden)]
687#[derive(Debug, Clone, PartialEq, Eq)]
688#[non_exhaustive]
689pub struct TatrTestCell {
690    /// The rows this cell occupies, ascending, 0 at the top.
691    pub rows: Vec<usize>,
692    /// The columns this cell occupies, ascending, 0 at the left.
693    pub columns: Vec<usize>,
694    /// Whether the cell is in the table's header row.
695    pub header: bool,
696    /// Indices into the region's words, in reading order.
697    pub words: Vec<usize>,
698}
699
700#[doc(hidden)]
701impl TatrTestCell {
702    /// A canned cell: grid position, header flag, and claimed word indices.
703    pub fn new(rows: Vec<usize>, columns: Vec<usize>, header: bool, words: Vec<usize>) -> Self {
704        Self {
705            rows,
706            columns,
707            header,
708            words,
709        }
710    }
711}
712
713/// One caller-supplied steering of what counts as a heading (lane L1).
714///
715/// The document's own statistics decide headings by default; an override
716/// relevels the block whose text matches [`Self::text`] exactly, after
717/// every other heading decision, so the caller always wins. `level` `None`
718/// demotes the block to prose, `Some(n)` promotes it to a heading at that
719/// level, clamped to 1..=3 (the levels [`BlockKind::Heading`] carries).
720#[derive(Debug, Clone, PartialEq, Eq, Hash)]
721pub struct HeadingOverride {
722    /// The block text to steer, matched exactly.
723    pub text: String,
724    /// The level to force, or `None` to force prose.
725    pub level: Option<u8>,
726}
727
728/// Which part of the document [`crate::PdfDocument::extract_blocks`] and
729/// [`crate::PdfDocument::extract_markdown`] hand back (#573).
730///
731/// A struct rather than two arguments, because this is the parameter both
732/// methods take across six languages: a field added here is a field added to
733/// every binding at once, and a caller that built it from
734/// `ExtractOptions::default()` keeps compiling. `#[non_exhaustive]`, so that
735/// is the only way to build one from outside this crate: struct literals are
736/// refused, and a field added here cannot break a caller that never named it.
737/// Start from [`Default`], [`Self::all`] or [`Self::window`] and assign the
738/// fields you want -- the fields stay public and assignable, only the literal
739/// is gone. (Unlike [`crate::RenderOptions`], which still takes literals.)
740///
741/// THE WINDOW IS A WINDOW ON THE ANSWER, NOT ON THE WORK. Whatever it asks for,
742/// the document is read whole, because everything the block model says about a
743/// block is said against the rest of the document: a size is a heading against
744/// what the body is set in (#568), a line is a running head because THREE pages
745/// repeat it (#571), and a window that re-derived those from its own pages
746/// would call a different thing a heading than the same document does. A caller
747/// that wants one page read on its own, and the statistics of that page alone,
748/// asks [`crate::PdfDocument::extract_page_blocks`] instead -- which is the
749/// cheap path, and says so.
750///
751/// # Example
752///
753/// ```no_run
754/// use pdf_engine::{ExtractOptions, PdfDocument};
755///
756/// let document = PdfDocument::open(std::fs::read("report.pdf").unwrap()).unwrap();
757///
758/// // The whole document.
759/// let all = document.extract_blocks(&ExtractOptions::default()).unwrap();
760///
761/// // Pages 10 to 19, for a caller indexing a long document in windows.
762/// let window = document.extract_blocks(&ExtractOptions::window(10, 10)).unwrap();
763/// # let _ = (all, window);
764/// ```
765#[derive(Debug, Clone, PartialEq, Eq)]
766#[non_exhaustive]
767pub struct ExtractOptions {
768    /// The first page of the window, counted from zero. Default 0.
769    pub first_page: usize,
770    /// How many pages from [`Self::first_page`], or `None` for the rest of the
771    /// document. Default `None`.
772    pub page_count: Option<usize>,
773    /// An explicit list of pages, counted from zero, instead of the
774    /// [`Self::first_page`]/[`Self::page_count`] window. Default `None`.
775    ///
776    /// A SELECTION, NOT AN ORDER: `[2, 0]` hands back the blocks of pages 0
777    /// and 2 in the document's reading order, the same order the window
778    /// would. Anything else would renumber [`Block::index`] and break
779    /// [`Block::continues_previous`], which both speak document order.
780    ///
781    /// A page past the last one is an error, where a window past the last
782    /// page is empty: a window walks a document until the answer runs out,
783    /// but a list names its pages and a name that is not there is a
784    /// mistake. Setting this beside a window -- `first_page` other than 0
785    /// or `page_count` set -- is an error too.
786    pub pages: Option<Vec<usize>>,
787    /// Whether the learned pass runs over the finished blocks. Default true,
788    /// which is what a caller gets.
789    ///
790    /// FOR A TEST OF A HEURISTIC, AND NOT FOR A CALLER TO TUNE. The rules in
791    /// `headings`, `lists` and `blocks` each have operator tests that assert
792    /// what THAT rule does; the learned pass is a stage after them with a
793    /// switch of its own (`learned::SWITCHES`), and a test of the first that
794    /// silently measures the second tells you nothing about either. Every such
795    /// test says so where it sets this (#599).
796    pub learned: bool,
797    /// Whether a tagged PDF's own structure tree may decide kinds and order.
798    /// Default false: geometry decides, exactly as without the option.
799    ///
800    /// When true and the document carries a structure tree that passes the
801    /// validity check, heading levels come from `H1`-`H6`, list items from
802    /// `LI`, table cells and spans from `TD`/`TH`, the reading order from the
803    /// tree, and `/Artifact` content becomes furniture. A tree that fails the
804    /// check -- or a document without one -- falls back to the geometric
805    /// answer, so the option can only replace output the tree earns (#636).
806    pub use_tags: bool,
807    /// Whether spans carry the document's own font names and the
808    /// descriptor's metrics. Default false: embedded Type1/CFF spans report
809    /// the substitute face and its AFM pair, exactly as without the option.
810    ///
811    /// When true, [`TextSpan::font_name`](crate::TextSpan::font_name) is the
812    /// font dictionary's /BaseFont with any subset prefix stripped -- the
813    /// name the page set its text in -- and
814    /// [`TextSpan::font_metrics`](crate::TextSpan::font_metrics) is the
815    /// FontDescriptor /Ascent /Descent pair (/CapHeight /XHeight), in /1000
816    /// em, falling back to the substitute face's AFM values where the
817    /// descriptor carries none. Span merging still reads the face either
818    /// way (#643).
819    pub document_font_names: bool,
820    /// Whether the running heads reach the Markdown. Default false: they are
821    /// marked [`BlockKind::Header`] and left out of
822    /// [`PdfDocument::extract_markdown`](crate::PdfDocument::extract_markdown),
823    /// exactly as without the option.
824    ///
825    /// When true they are written as plain paragraphs at their reading-order
826    /// position -- never as headings, which they are not -- for callers that
827    /// need running titles in the text (citations, legal documents). Read
828    /// only by `extract_markdown`: `extract_blocks` always carries the
829    /// furniture, whatever this says.
830    pub include_headers: bool,
831    /// Whether the running footers and page numbers reach the Markdown.
832    /// Default false: they are marked [`BlockKind::Footer`] and left out of
833    /// [`PdfDocument::extract_markdown`](crate::PdfDocument::extract_markdown),
834    /// exactly as without the option.
835    ///
836    /// When true they are written as plain paragraphs at their reading-order
837    /// position -- never as headings, which they are not -- for callers that
838    /// need page numbers in the text. Read only by `extract_markdown`:
839    /// `extract_blocks` always carries the furniture, whatever this says.
840    pub include_footers: bool,
841    /// How a change of page is marked in the Markdown. Default
842    /// [`PageSeparators::Comment`]: today's `<!-- page N -->` marker.
843    /// [`PageSeparators::None`] writes no marker at all, and the paragraphs
844    /// on either side stand separated by a blank line. Read only by
845    /// [`PdfDocument::extract_markdown`](crate::PdfDocument::extract_markdown).
846    pub page_separators: PageSeparators,
847    /// Whether monospace runs reach the Markdown as code. Default true.
848    ///
849    /// A run of spans set in a monospace face (`TextSpan::is_monospace`) inside
850    /// a paragraph or a list item is written `` `like this` ``, and a paragraph
851    /// output from before code existed, byte for byte -- the off switch
852    /// pymupdf4llm spells `ignore_code` (#636).
853    pub code: bool,
854    /// Caller-supplied heading steers, applied after every other heading
855    /// decision. Default empty: the document's own statistics decide.
856    ///
857    /// Each override relevels the block whose text matches exactly: `None`
858    /// demotes it to prose, `Some(n)` promotes it to a heading. The front
859    /// door steers its own headings the same way, with a detector object
860    /// the caller hands in (SPEC G24).
861    pub heading_overrides: Vec<HeadingOverride>,
862    /// Whether text the page hides reaches the blocks. Default false: text
863    /// drawn with render mode 3 or painted with zero alpha is dropped, the
864    /// way the front door's own default engine drops hidden lines from its
865    /// output (SPEC G165).
866    ///
867    /// Invisible text that is a page's ONLY text is kept either way: without
868    /// it the page would read empty, and that is how a scanned page reads
869    /// through its own layer. Pass true to keep every hidden line, for
870    /// callers that filter themselves.
871    pub include_hidden: bool,
872    /// Whether blocks follow the content stream instead of the reading order.
873    /// Default false: the reading order decides, exactly as without the
874    /// option.
875    ///
876    /// When true, `extract_blocks` hands back the runs the page draws in the
877    /// order it draws them, grouped into lines and blocks by content-order
878    /// proximity -- and the reading-order sort, the furniture, the headings
879    /// and the learned pass never run (#643). The compat layer reads through
880    /// this where the recorded shapes need draw order rather than reading
881    /// order.
882    pub raw_order: bool,
883    /// Whether pages that need it are OCR'd before extraction. Default
884    /// [`OcrMode::Never`]: the SDK and the rig read what the document draws,
885    /// and OCR is an explicit request.
886    ///
887    /// [`OcrMode::Auto`] recognizes the pages under
888    /// `pdf-ocr`'s text threshold and leaves the rest untouched;
889    /// [`OcrMode::Force`] recognizes every page in the window, replacing a
890    /// born-digital page's own text with the OCR layer rather than emitting
891    /// both. Either way the recognized words go through the same
892    /// segmentation as born-digital text, because extraction reads a copy of
893    /// the document carrying them as an invisible text layer.
894    ///
895    pub ocr: OcrMode,
896    /// The Tesseract language tag OCR runs in (`-l`). Default `"eng"`.
897    /// Read only when [`Self::ocr`] is not [`OcrMode::Never`]; several tags
898    /// join with `+` as Tesseract takes them.
899    pub ocr_language: String,
900    /// The resolution OCR renders pages at, in dots per inch. Default 150.
901    /// Read only when [`Self::ocr`] is not [`OcrMode::Never`]; must be
902    /// positive when it is read.
903    pub ocr_dpi: u32,
904    /// Clip margins in points, or `None` for the whole page. Default `None`.
905    pub clip_margins: Option<ClipMargins>,
906    /// Which tier reads the document. Default [`Quality::Fast`]: the shipping
907    /// engine, byte for byte what a caller got before this field existed.
908    ///
909    /// [`Quality::Balanced`] is read by
910    /// [`crate::PdfDocument::extract_markdown`] only; a call that returns
911    /// blocks answers [`OptionsError::BalancedIsMarkdownOnly`] for it.
912    pub quality: Quality,
913    /// Where the model tier looks for its weights. Default `None`:
914    /// `PDFLUENT_MODEL_DIR`, else the per-user `<data dir>/pdfluent/models/`.
915    ///
916    /// Read only when [`Self::quality`] is [`Quality::Balanced`]: an explicit
917    /// dir for a caller -- and a test -- that keeps models outside the
918    /// default. Wins over the environment and the default, exactly as the
919    /// shared runtime resolves it.
920    pub model_dir: Option<PathBuf>,
921    /// Whether the balanced tier keeps running heads and feet as paragraphs.
922    /// Default false: furniture lines are dropped, exactly as without the
923    /// option.
924    ///
925    /// Read only when [`Self::quality`] is [`Quality::Balanced`], where it
926    /// maps to the balanced assembly's keep-furniture switch.
927    pub keep_furniture: bool,
928    /// Whether the word boxes travel with the answer. Default false: the
929    /// blocks, the markdown and [`crate::to_json`] read exactly as without
930    /// the option. Set with [`Self::with_word_boxes`].
931    pub word_boxes: bool,
932    /// TATR table structure, opted into per call. Default `None`: the
933    /// current grid, byte for byte. Set with [`Self::with_tatr`] only --
934    /// a private field, so the opt-in stays a builder call and a caller
935    /// matching on options never names it.
936    tatr: Option<TatrOptions>,
937    /// Whether figures reach the Markdown as image references. Default true.
938    ///
939    /// Read only by
940    /// [`PdfDocument::extract_markdown`](crate::PdfDocument::extract_markdown),
941    /// the way [`Self::include_headers`] is: `extract_blocks` always carries
942    /// the figures, whatever this says. False writes no reference at all.
943    pub include_figures: bool,
944    /// Whether figure references embed the figure's pixels. Default false:
945    /// references are markers naming the figure.
946    ///
947    /// True writes `![](data:image/png;base64,...)` with the figure's box
948    /// rendered at 150 dots per inch. Read only by
949    /// [`PdfDocument::extract_markdown`](crate::PdfDocument::extract_markdown);
950    /// [`Self::figure_image_dir`] wins where both are set.
951    pub embed_figures: bool,
952    /// Where figure image files are written, or `None` for no files.
953    /// Default `None`.
954    ///
955    /// Read only by
956    /// [`PdfDocument::extract_markdown`](crate::PdfDocument::extract_markdown):
957    /// each figure is rendered at 150 dots per inch, written under the
958    /// directory, and referenced from the Markdown under that path. The
959    /// directory -- and any parents -- is created; a write failure fails the
960    /// extraction. The one writer of files on this path, so the references
961    /// and the files cannot disagree.
962    pub figure_image_dir: Option<PathBuf>,
963    /// Whether text drawn over a placed image is dropped. Default false:
964    /// every line is kept, wherever it stands.
965    ///
966    /// True drops a body block whose centre falls inside a placed image's
967    /// box on the same page -- the over-picture line of a caption pair --
968    /// before figures merge into the blocks. Running heads and feet are
969    /// never dropped: page chrome is positioned independently of figures.
970    pub drop_text_over_images: bool,
971    /// Which evidence may find a table. Default [`TableStrategy::Both`]:
972    /// today's combined detection, byte for byte.
973    ///
974    /// [`TableStrategy::Lines`] keeps only the tables a drawn rule
975    /// crosses -- the rest go back to the paragraph merge -- and
976    /// [`TableStrategy::Text`] drops the drawn grid from the evidence.
977    /// Read by [`crate::PdfDocument::extract_blocks`] and, through it,
978    /// [`crate::PdfDocument::extract_markdown`]; the balanced tier and
979    /// [`crate::PdfDocument::extract_page_blocks`] read every table.
980    pub table_strategy: TableStrategy,
981    /// How tables reach the Markdown. Default [`TableRenderMode::Auto`]:
982    /// pipes where Markdown holds the table's shape, HTML elsewhere --
983    /// today's output, byte for byte.
984    ///
985    /// [`TableRenderMode::Markdown`] writes every table as pipes and
986    /// [`TableRenderMode::Html`] every table as HTML. Read only by
987    /// [`PdfDocument::extract_markdown`](crate::PdfDocument::extract_markdown):
988    /// [`PdfDocument::extract_blocks`](crate::PdfDocument::extract_blocks)
989    /// always carries the cells, whatever this says.
990    pub table_render_mode: TableRenderMode,
991    /// Whether table-cell words join on the cell path. Default false: the
992    /// leader fold, the cell-word join, the TATR fill and the quarter-em
993    /// word-space boundary read exactly as without the option.
994    ///
995    /// When true, the four cell-path word rules run: leader dots abutting
996    /// their label join it as text and box, keeping the dots' glyph boxes;
997    /// cell words join by the shared gap rule with only a drawn space
998    /// vetoing; the TATR fill aligns fragments onto text tokens in both
999    /// directions; and a word space set at exactly a quarter em reads as a
1000    /// space (#659). Read by the `extract_blocks` family; the direct learned
1001    /// entries keep the shipped words either way. Independent of
1002    /// [`Self::actual_text`]: this option never applies `/ActualText`.
1003    pub cell_path_words: bool,
1004    /// Whether `/ActualText` replacements read instead of their marked runs.
1005    /// Default false: replacements are ignored, exactly as without the
1006    /// option.
1007    ///
1008    /// When true, an `/ActualText` replacement reads instead of the glyphs of
1009    /// its marked run, once, on the run's first glyph; a run that closes
1010    /// without a glyph to carry it reads once at the box it drew instead.
1011    /// Without the option the run reads what the page drew (#663). Opt-in
1012    /// pending a variant measurement: the default output is what it was
1013    /// before the replacements were read. A glyphless replacement in a
1014    /// widget appearance reads either way: the appearance is the only place
1015    /// that value is painted (#549). Read by the `extract_blocks` family;
1016    /// independent of [`Self::cell_path_words`], which moves only the four
1017    /// word rules.
1018    pub actual_text: bool,
1019}
1020
1021/// Clip margins in points: left, top, right, bottom.
1022///
1023/// A validated newtype rather than a bare array, for two reasons. `f64` is
1024/// not `Eq` -- NaN is not equal to itself -- so a bare `[f64; 4]` on
1025/// [`ExtractOptions`] cost the options their `Eq`, which is public API a
1026/// caller may compare or key on. And a margin is a distance into the page:
1027/// negative or non-finite is a mistake in the call, caught by
1028/// [`ClipMargins::new`] rather than read as a rectangle.
1029#[derive(Debug, Clone, Copy)]
1030pub struct ClipMargins([f64; 4]);
1031
1032impl ClipMargins {
1033    /// `[left, top, right, bottom]` in points. Each side must be finite and
1034    /// at least zero; anything else is an
1035    /// [`OptionsError::InvalidClipMargins`] naming the side.
1036    ///
1037    /// `-0.0` is normalised to `0.0`, so margins that compare equal as floats
1038    /// compare equal here too.
1039    pub fn new(
1040        left: f64,
1041        top: f64,
1042        right: f64,
1043        bottom: f64,
1044    ) -> std::result::Result<Self, OptionsError> {
1045        for (side, value) in [
1046            ("left", left),
1047            ("top", top),
1048            ("right", right),
1049            ("bottom", bottom),
1050        ] {
1051            if !value.is_finite() || value < 0.0 {
1052                return Err(OptionsError::InvalidClipMargins { side, value });
1053            }
1054        }
1055        Ok(Self([left + 0.0, top + 0.0, right + 0.0, bottom + 0.0]))
1056    }
1057
1058    /// The margins as `[left, top, right, bottom]` in points.
1059    pub fn as_array(&self) -> [f64; 4] {
1060        self.0
1061    }
1062}
1063
1064impl PartialEq for ClipMargins {
1065    fn eq(&self, other: &Self) -> bool {
1066        self.0.map(f64::to_bits) == other.0.map(f64::to_bits)
1067    }
1068}
1069
1070impl Eq for ClipMargins {}
1071
1072impl Hash for ClipMargins {
1073    fn hash<H: Hasher>(&self, state: &mut H) {
1074        self.0.map(f64::to_bits).hash(state);
1075    }
1076}
1077
1078/// Whether extraction recognizes pages that need it before reading them.
1079///
1080/// The engine default is [`OcrMode::Never`], so default output never changes
1081/// under an OCR install: a caller opts in per call. The pymupdf4llm front
1082/// door maps upstream's `use_ocr` onto [`OcrMode::Auto`] and `force_ocr`
1083/// onto [`OcrMode::Force`].
1084///
1085/// Non-exhaustive: re-exported from the facade, so new modes must not break a
1086/// downstream match.
1087#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1088#[non_exhaustive]
1089pub enum OcrMode {
1090    /// No OCR: read what the document draws. The default.
1091    #[default]
1092    Never,
1093    /// OCR the pages that need it -- the ones under `pdf-ocr`'s text
1094    /// threshold -- and leave the rest untouched. With no `tesseract`
1095    Auto,
1096    /// OCR every page in the window. On a page that already has a text
1097    /// layer the OCR layer REPLACES the native text for that page; without
1098    /// a `tesseract` binary this raises naming it, where upstream stays
1099    /// silent.
1100    Force,
1101}
1102
1103/// Which evidence may find a table: the lines the page drew, the alignment
1104/// of its words, or both (#659).
1105///
1106/// Read by [`ExtractOptions::table_strategy`]. The builder finds its grids
1107/// from gutters and the learned proposers from words, while the lattice
1108/// reads the drawn grid and the rules refuse the cuts a table stands
1109/// across: [`TableStrategy::Lines`] keeps only the tables a drawn rule
1110/// crosses, [`TableStrategy::Text`] runs without the drawn grid, and the
1111/// default [`TableStrategy::Both`] is today's combined detection.
1112///
1113/// Non-exhaustive: re-exported from the facade, so new strategies must not
1114/// break a downstream match.
1115#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1116#[non_exhaustive]
1117pub enum TableStrategy {
1118    /// Only tables the page ruled: a grid no drawn rule crosses goes back
1119    /// to the paragraph merge, and the word-reading learned proposers sit
1120    /// out, so nothing puts it back.
1121    Lines,
1122    /// Only tables the words align into: the lattice proposer, which
1123    /// reads the drawn grid alone, sits out. The builder keeps its rules
1124    /// -- they only refine grids its gutters already found, and a ruled
1125    /// table whose words align is still found.
1126    Text,
1127    /// Today's combined detection: every proposer runs and every grid the
1128    /// builder reads stands. The default.
1129    #[default]
1130    Both,
1131}
1132
1133/// How a table reaches the Markdown: pipes, HTML, or the automatic choice
1134/// between them (#659).
1135///
1136/// Read by [`ExtractOptions::table_render_mode`]. Pipes hold a rectangular
1137/// table with at most one header row; anything else -- a spanned grid, a
1138/// headless one -- needs HTML to say what it is. The default
1139/// [`TableRenderMode::Auto`] writes each table the way it always was, and
1140/// the other two ask for one form for every table.
1141///
1142/// Non-exhaustive: re-exported from the facade, so new modes must not break
1143/// a downstream match.
1144#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1145#[non_exhaustive]
1146pub enum TableRenderMode {
1147    /// Each table the way it always was: pipes where Markdown holds its
1148    /// shape, HTML elsewhere. The default.
1149    #[default]
1150    Auto,
1151    /// Every table as a pipe table, its first row above the divider.
1152    Markdown,
1153    /// Every table as an HTML table, header cells marked `th`.
1154    Html,
1155}
1156
1157impl Default for ExtractOptions {
1158    fn default() -> Self {
1159        Self {
1160            first_page: 0,
1161            page_count: None,
1162            pages: None,
1163            // A caller gets the learned pass; only a test of a heuristic turns
1164            // it off, and says why where it does.
1165            learned: true,
1166            // Tags never decide unless asked: the geometric answer is the
1167            // default, and the tree is evidence for an option, not a second
1168            // default (#636).
1169            use_tags: false,
1170            // And the document's font names stay opt-in: the default
1171            // output is what it was before the option existed (#643).
1172            document_font_names: false,
1173            // The markdown a caller of the default gets is what the rig
1174            // measures, so the markdown switches default to today's output:
1175            // no furniture, and every page change a comment.
1176            include_headers: false,
1177            include_footers: false,
1178            page_separators: PageSeparators::Comment,
1179            // Monospace reads as code unless the gate below says it costs the
1180            // rig more than 0.002 on any axis, in which case this is false and
1181            // the option is opt-in (#636).
1182            code: true,
1183            // No caller steers headings, and hidden text stays out: the
1184            // document's own statistics decide, and what the page hides is
1185            // not content (lane L1).
1186            heading_overrides: Vec::new(),
1187            include_hidden: false,
1188            // And the reading order decides unless asked: the raw path is a
1189            // compat instrument, never a second default (#643).
1190            raw_order: false,
1191            // And OCR never runs unless asked: the default output is what
1192            // the document draws, with or without a tesseract installed.
1193            ocr: OcrMode::Never,
1194            ocr_language: "eng".to_string(),
1195            ocr_dpi: 150,
1196            clip_margins: None,
1197            // The shipping engine, the default model root, furniture dropped:
1198            // a caller that never heard of the balanced tier reads exactly
1199            // what it read before these fields existed.
1200            quality: Quality::Fast,
1201            model_dir: None,
1202            keep_furniture: false,
1203            // Word boxes travel only when asked: the default answer is what
1204            // it was before they existed.
1205            word_boxes: false,
1206            // And TATR stays off unless asked: the default output is the
1207            // current grid, with or without the model installed.
1208            tatr: None,
1209            // Figures are listed and marked unless asked otherwise; embeds,
1210            // files and the over-image drop are all opt-in.
1211            include_figures: true,
1212            embed_figures: false,
1213            figure_image_dir: None,
1214            drop_text_over_images: false,
1215            // And tables are found the way they always were: every
1216            // proposer runs, whatever the evidence.
1217            table_strategy: TableStrategy::Both,
1218            // And rendered the way they always were: each table in the
1219            // form that holds its shape.
1220            table_render_mode: TableRenderMode::Auto,
1221            // And cell words join the way they always did: the cell path
1222            // is opt-in, never a second default (#659).
1223            cell_path_words: false,
1224            // And marked runs read what the page drew: the replacement is
1225            // opt-in, never a second default (#663).
1226            actual_text: false,
1227        }
1228    }
1229}
1230
1231impl ExtractOptions {
1232    /// The whole document, which is what [`Default`] gives too.
1233    pub fn all() -> Self {
1234        Self::default()
1235    }
1236
1237    /// The same window with the learned pass off, for a test that asserts what
1238    /// a heuristic does (#599).
1239    pub fn without_the_learned_pass(self) -> Self {
1240        Self {
1241            learned: false,
1242            ..self
1243        }
1244    }
1245
1246    /// Rebuild table regions with TATR first, where it fires.
1247    ///
1248    /// The only way to set the TATR opt-in: there is no public field. With
1249    /// it set, every table region is offered to the Table Transformer
1250    /// before the current grid runs; where TATR fires its cells are the
1251    /// region's table, and where it declines, or a region's inference
1252    /// fails, the current grid runs unchanged. A missing model is an
1253    /// error, never a silent fast run -- as is asking on a build without
1254    /// the `ort-tables` feature.
1255    ///
1256    /// # Example
1257    ///
1258    /// ```no_run
1259    /// use pdf_engine::{ExtractOptions, TatrOptions};
1260    ///
1261    /// let options = ExtractOptions::default().with_tatr(TatrOptions::default());
1262    /// # let _ = options;
1263    /// ```
1264    pub fn with_tatr(self, tatr: TatrOptions) -> Self {
1265        Self {
1266            tatr: Some(tatr),
1267            ..self
1268        }
1269    }
1270
1271    /// The TATR opt-in, if set.
1272    pub(crate) fn tatr(&self) -> Option<&TatrOptions> {
1273        self.tatr.as_ref()
1274    }
1275
1276    /// `count` pages from `first`, counted from zero.
1277    ///
1278    /// A window past the last page is EMPTY rather than an error, and so is a
1279    /// `count` of zero: a caller stepping through a long document a window at a
1280    /// time stops when the answer is empty, and an error at the end would make
1281    /// the ordinary end of that loop indistinguishable from a broken file.
1282    pub fn window(first: usize, count: usize) -> Self {
1283        Self {
1284            first_page: first,
1285            page_count: Some(count),
1286            ..Self::default()
1287        }
1288    }
1289
1290    /// Which tier reads the document: [`Quality::Fast`] (the default) or
1291    /// [`Quality::Balanced`], read by
1292    /// [`crate::PdfDocument::extract_markdown`] only.
1293    #[must_use]
1294    pub fn with_quality(mut self, quality: Quality) -> Self {
1295        self.quality = quality;
1296        self
1297    }
1298
1299    /// Where the model tier looks for its weights, winning over
1300    /// `PDFLUENT_MODEL_DIR` and the per-user default. Read only when
1301    /// [`Self::quality`] is [`Quality::Balanced`].
1302    #[must_use]
1303    pub fn with_model_dir(mut self, dir: impl Into<PathBuf>) -> Self {
1304        self.model_dir = Some(dir.into());
1305        self
1306    }
1307
1308    /// Keep running heads and feet as paragraphs in balanced markdown
1309    /// (`true`) or drop them (the default). Read only when [`Self::quality`]
1310    /// is [`Quality::Balanced`], where it maps to the balanced assembly's
1311    /// keep-furniture switch.
1312    #[must_use]
1313    pub fn with_keep_furniture(mut self, keep: bool) -> Self {
1314        self.keep_furniture = keep;
1315        self
1316    }
1317
1318    /// Whether `page` falls inside the window, or is named by [`Self::pages`].
1319    pub(crate) fn holds(&self, page: usize) -> bool {
1320        if let Some(pages) = &self.pages {
1321            return pages.contains(&page);
1322        }
1323        if page < self.first_page {
1324            return false;
1325        }
1326        match self.page_count {
1327            None => true,
1328            Some(count) => page - self.first_page < count,
1329        }
1330    }
1331
1332    /// Whether the window is the whole document, which is the case that needs
1333    /// no filtering at all.
1334    pub(crate) fn is_whole_document(&self) -> bool {
1335        self.first_page == 0 && self.page_count.is_none() && self.pages.is_none()
1336    }
1337
1338    ///
1339    /// The window needs no validation -- past the end is empty -- but a
1340    /// list names its pages: one past the end is
1341    /// [`EngineError::PageOutOfRange`], and a list beside a window is an
1342    /// [`OptionsError::ConflictingPageSelection`] carried in an
1343    pub(crate) fn validate(&self, page_count: usize) -> Result<()> {
1344        // The balanced tier assembles markdown, so it is read by
1345        // `extract_markdown` before it ever reaches here; a call that returns
1346        // blocks has no balanced answer and says so, rather than handing back
1347        // the fast tier under a balanced name.
1348        if matches!(self.quality, Quality::Balanced) {
1349            return Err(EngineError::Io(std::io::Error::new(
1350                std::io::ErrorKind::InvalidInput,
1351                OptionsError::BalancedIsMarkdownOnly,
1352            )));
1353        }
1354        // Asked for TATR on a build without it: a refusal, never a silent
1355        // fast run. The test seam answers without a model, so it is not
1356        // asked for anything this build lacks.
1357        #[cfg(not(feature = "ort-tables"))]
1358        if self.tatr.as_ref().is_some_and(|t| t.test_hook.is_none()) {
1359            return Err(EngineError::Io(std::io::Error::new(
1360                std::io::ErrorKind::InvalidInput,
1361                OptionsError::TatrNeedsOrtTables,
1362            )));
1363        }
1364        let Some(pages) = &self.pages else {
1365            return Ok(());
1366        };
1367        if self.first_page != 0 || self.page_count.is_some() {
1368            return Err(EngineError::Io(std::io::Error::new(
1369                std::io::ErrorKind::InvalidInput,
1370                OptionsError::ConflictingPageSelection,
1371            )));
1372        }
1373        if let Some(&index) = pages.iter().find(|page| **page >= page_count) {
1374            return Err(EngineError::PageOutOfRange {
1375                index,
1376                count: page_count,
1377            });
1378        }
1379        Ok(())
1380    }
1381}
1382
1383/// The blocks of `options`' window, in the order they were read in (#573).
1384///
1385/// The document was classified whole, so what survives here is what the WHOLE
1386/// document said each block was; the window only decides which of them the
1387/// caller asked for.
1388///
1389/// [`Block::index`] keeps the document's own numbering rather than being
1390/// counted again from zero. It is a place in the document's reading order --
1391/// two windows of one document have to agree about the same block -- and a
1392/// caller that wants the position within the window it asked for has the
1393/// position within the vector it is holding.
1394///
1395/// [`Block::continues_previous`] is cleared wherever the block it continues
1396/// fell outside the window: the flag says "join me onto the one above", and
1397/// there is no longer one above to join onto. Left standing, the markdown
1398/// serializer would glue the opening half-sentence of the window onto whatever
1399/// happened to precede it.
1400/// Relevel the blocks the caller steers (see [`HeadingOverride`]).
1401fn apply_heading_overrides(blocks: &mut [Block], overrides: &[HeadingOverride]) {
1402    if overrides.is_empty() {
1403        return;
1404    }
1405    for block in blocks.iter_mut() {
1406        if let Some(hit) = overrides.iter().find(|o| o.text == block.text) {
1407            match hit.level {
1408                None => block.kind = BlockKind::Paragraph,
1409                Some(level) => {
1410                    block.kind = BlockKind::Heading {
1411                        level: level.clamp(1, 3),
1412                    }
1413                }
1414            }
1415        }
1416    }
1417}
1418
1419/// Rebuild a block's text and box from its spans after filtering.
1420fn rebuild_block_text_and_box(block: &mut Block) {
1421    // The shared join rule: two spans with no whitespace between them read
1422    // as one word where `spans_join` says so, and are spaced otherwise --
1423    // the same boundary the word assembly draws, so a block's words joined
1424    // with single spaces are always its own text (#663). The emphasis marks
1425    // find their runs across the same boundary (#668).
1426    let mut text = String::new();
1427    for (index, span) in block.spans.iter().enumerate() {
1428        if index > 0 && !crate::words_join::spans_join(&block.spans[index - 1], span) {
1429            text.push(' ');
1430        }
1431        text.push_str(&span.text);
1432    }
1433    block.text = text;
1434    let mut bbox = [
1435        f64::INFINITY,
1436        f64::INFINITY,
1437        f64::NEG_INFINITY,
1438        f64::NEG_INFINITY,
1439    ];
1440    for span in &block.spans {
1441        bbox[0] = bbox[0].min(span.x);
1442        bbox[1] = bbox[1].min(span.y);
1443        bbox[2] = bbox[2].max(span.x + span.width);
1444        bbox[3] = bbox[3].max(span.y + span.height);
1445    }
1446    block.bbox = bbox;
1447}
1448
1449/// Drop text the page hides, page by page (see [`ExtractOptions::include_hidden`]).
1450///
1451/// Pages that draw visible text lose their hidden spans; a page whose text
1452/// is all hidden keeps it, so a scan still reads through its own layer.
1453/// Spans the engine's own OCR layer wrote stay either way: the layer is the
1454/// engine's own voice, not ink the author hid.
1455/// Blocks emptied by the drop go, and a block that loses spans gets its text
1456/// and box rebuilt from the spans that stay. A block that loses nothing is
1457/// left byte for byte as it arrived: the rebuild joins spans by its own
1458/// rule, which is not the rule the text the block carries was joined by, so
1459/// rebuilding it would respell visible text the drop was never asked about.
1460/// Blocks without spans are kept: with nothing to judge, there is nothing
1461/// to prove hidden.
1462fn drop_hidden_text(blocks: &mut Vec<Block>) {
1463    use std::collections::HashSet;
1464    // Only spans vote: a block with none (a figure marker) carries no text
1465    // either way, and counting it as visible wiped a scanned page clean --
1466    // its whole text layer hides in render mode 3 over its own image, and
1467    // the figure beside it marked the page visible.
1468    let visible_pages: HashSet<usize> = blocks
1469        .iter()
1470        .filter(|b| b.spans.iter().any(|s| !s.is_hidden()))
1471        .map(|b| b.page)
1472        .collect();
1473    let mut kept = Vec::with_capacity(blocks.len());
1474    for mut block in blocks.drain(..) {
1475        if visible_pages.contains(&block.page) && !block.spans.is_empty() {
1476            let span_count = block.spans.len();
1477            block.spans.retain(|s| !s.is_hidden() || s.is_ocr_layer);
1478            if block.spans.is_empty() {
1479                continue;
1480            }
1481            if block.spans.len() != span_count {
1482                rebuild_block_text_and_box(&mut block);
1483            }
1484        }
1485        kept.push(block);
1486    }
1487    *blocks = kept;
1488}
1489
1490/// Drop text the page hides from raw blocks, page by page (#643).
1491///
1492/// The same page rule as [`drop_hidden_text`]: pages that draw visible text
1493/// lose their hidden spans, and a page whose text is all hidden keeps it.
1494/// The survivors keep RAW text -- the runs concatenated as drawn -- where the
1495/// default path joins its spans with spaces. Like that path, a block that
1496/// loses no spans is left as it arrived.
1497fn drop_hidden_text_raw(blocks: &mut Vec<Block>) {
1498    use std::collections::HashSet;
1499    // Only spans vote, as above: a spanless block must not mark its page
1500    // visible, or a scanned page reads empty on the raw path too.
1501    let visible_pages: HashSet<usize> = blocks
1502        .iter()
1503        .filter(|b| b.spans.iter().any(|s| !s.is_hidden()))
1504        .map(|b| b.page)
1505        .collect();
1506    let mut kept = Vec::with_capacity(blocks.len());
1507    for mut block in blocks.drain(..) {
1508        if visible_pages.contains(&block.page) && !block.spans.is_empty() {
1509            let span_count = block.spans.len();
1510            block.spans.retain(|s| !s.is_hidden() || s.is_ocr_layer);
1511            if block.spans.is_empty() {
1512                continue;
1513            }
1514            if block.spans.len() != span_count {
1515                let (text, bbox) = crate::text::raw_text_and_box(&block.spans);
1516                block.text = text;
1517                block.bbox = bbox;
1518            }
1519        }
1520        kept.push(block);
1521    }
1522    *blocks = kept;
1523}
1524
1525pub(crate) fn window(blocks: Vec<Block>, options: &ExtractOptions) -> Vec<Block> {
1526    // Lane L1: the caller steers and the hidden text drops here, before the
1527    // window cuts pages. This is the only post-decision point in this module
1528    // that sees the options; both passes read the whole document, which is
1529    // what the page rule for hidden text needs.
1530    let mut blocks = blocks;
1531    apply_heading_overrides(&mut blocks, &options.heading_overrides);
1532    if !options.include_hidden {
1533        if options.raw_order {
1534            drop_hidden_text_raw(&mut blocks);
1535        } else {
1536            drop_hidden_text(&mut blocks);
1537        }
1538    }
1539    if options.is_whole_document() {
1540        return blocks;
1541    }
1542    let mut out: Vec<Block> = Vec::new();
1543    let mut previous_kept = false;
1544    for mut block in blocks {
1545        if !options.holds(block.page) {
1546            previous_kept = false;
1547            continue;
1548        }
1549        if !previous_kept {
1550            block.continues_previous = false;
1551        }
1552        previous_kept = true;
1553        out.push(block);
1554    }
1555    out
1556}
1557
1558/// Takes a table's caption, and the note or sentence under it, back out of the
1559/// table (#574).
1560///
1561/// A grid built from a region's rows takes whatever the region held: the
1562/// `Table 3 Corticobulbar projections…` over it, the `AA, arachidonic acid;
1563/// PFA-100, …` under it, a line of the running text the region reached into.
1564/// Each is ONE filled cell spanning a row of a table of several columns, and
1565/// each says so in its words: a caption opens with `Table n`, a note with a
1566/// mark or `Note`, and a sentence runs to a full stop or opens in lower case.
1567/// A spanning header -- `Male Female`, `Diplotype No. …` -- is short and says
1568/// none of it, and stays. On 700 PubTables train pages the top row rule was
1569/// right 24 times and wrong once, the bottom row 16 and once.
1570///
1571/// Only the first and the last row, once each, and only in a table of three
1572/// rows or more: what is left must still be a table.
1573///
1574/// `runs_on_figures` is the `table_runs_on_figures` switch: a complete-figure
1575/// line never runs on from another, so stacked figure rows stay split here
1576/// as where the learned pass split them.
1577pub(crate) fn peel_table_edges(blocks: &mut Vec<Block>, runs_on_figures: bool) {
1578    let mut i = 0;
1579    let mut moved = false;
1580    while i < blocks.len() {
1581        let Some(end) = crate::markdown::table_run(blocks, i) else {
1582            i += 1;
1583            continue;
1584        };
1585        // The half of a table a page broke, as `tables_across_pages` will
1586        // read it: first on its page with a cell last on the page before, or
1587        // last on its page with a cell first on the page after, furniture
1588        // aside.
1589        let body = |b: &Block| !matches!(b.kind, BlockKind::Header | BlockKind::Footer);
1590        let cell = |b: &Block| matches!(b.kind, BlockKind::TableCell { .. });
1591        let page = blocks[i].page;
1592        let first_on_page = !blocks[..i]
1593            .iter()
1594            .rev()
1595            .take_while(|b| b.page == page)
1596            .any(body);
1597        let last_on_page = !blocks[end..]
1598            .iter()
1599            .take_while(|b| b.page == page)
1600            .any(body);
1601        // AND WITH THE SAME COLUMNS as the table it would join, which is what
1602        // `tables_across_pages` asks too: a one-row grid under a page's last
1603        // table with columns of its own is no half of it (codex, #2058).
1604        let table_ending_at = |last: usize| -> &[Block] {
1605            let mut start = last;
1606            while start > 0
1607                && cell(&blocks[start - 1])
1608                && blocks[start - 1].page == blocks[last].page
1609            {
1610                start -= 1;
1611            }
1612            &blocks[start..=last]
1613        };
1614        let table_starting_at = |first: usize| -> &[Block] {
1615            let mut stop = first;
1616            while stop < blocks.len()
1617                && cell(&blocks[stop])
1618                && blocks[stop].page == blocks[first].page
1619            {
1620                stop += 1;
1621            }
1622            &blocks[first..stop]
1623        };
1624        let continues_before = blocks[..i]
1625            .iter()
1626            .rposition(body)
1627            .filter(|&k| blocks[k].page + 1 == page && cell(&blocks[k]))
1628            .is_some_and(|k| same_columns(table_ending_at(k), &blocks[i..end]));
1629        // The table after it as it will stand, not as it stands now: a table
1630        // read as one column gets its columns back only when this loop
1631        // reaches it, and the columns compared are those (codex, #2065).
1632        let continues_after = (end..blocks.len())
1633            .find(|&k| body(&blocks[k]))
1634            .filter(|&k| blocks[k].page == page + 1 && cell(&blocks[k]))
1635            .is_some_and(|k| {
1636                let (_, regridded, _) = regrid_one_column(table_starting_at(k).to_vec());
1637                same_columns(&blocks[i..end], &regridded)
1638            });
1639        let at_page_edge = (first_on_page && continues_before) || (last_on_page && continues_after);
1640        let run: Vec<Block> = blocks.drain(i..end).collect();
1641        let before = run.len();
1642        let (head, run) = caption_row_or_single_row(run, at_page_edge);
1643        let (over, run, under) = regrid_one_column(run);
1644        let over: Vec<Block> = head.into_iter().chain(over).collect();
1645        let (mut table, after) = split_prose_column(run);
1646        moved |= !over.is_empty()
1647            || !under.is_empty()
1648            || !after.is_empty()
1649            || !table
1650                .iter()
1651                .any(|b| matches!(b.kind, BlockKind::TableCell { .. }));
1652        peel_run(&mut table);
1653        // A label set on two lines is one cell, in the builder's grid as in
1654        // the decoder's (#574).
1655        crate::learned::cell_merge::join_run_on_rows(&mut table, runs_on_figures);
1656        let mut placed: Vec<Block> = over;
1657        placed.extend(table);
1658        placed.extend(after);
1659        placed.extend(under);
1660        let n = placed.len();
1661        // A table cut into more cells, or fewer, moves every block after it
1662        // (codex, #2051): the indices are the reading order.
1663        moved |= n != before;
1664        blocks.splice(i..i, placed);
1665        i += n.max(1);
1666    }
1667    if moved {
1668        for (n, block) in blocks.iter_mut().enumerate() {
1669            block.index = n;
1670        }
1671    }
1672}
1673
1674/// A caption spread over a table's first row, and a table of one row (#574).
1675///
1676/// Read with the weight its font names give it, a caption set in bold over a
1677/// table is a row of the grid like any other, and the grid cuts it at the
1678/// table's own column gutters: `Table 1: Comparison | of the | logP values
1679/// calculated | by several`. Joined in column order it opens as a numbered
1680/// caption, and comes out whole. A running head with its page number --
1681/// `New records of the endemic Sicilian land snail species … | 137` -- is a
1682/// table of one row, which no table is.
1683///
1684/// Answers the paragraph to set over the table, and the table.
1685fn caption_row_or_single_row(run: Vec<Block>, at_page_edge: bool) -> (Option<Block>, Vec<Block>) {
1686    let place = |b: &Block| match b.kind {
1687        BlockKind::TableCell { row, column, .. } => Some((row, column)),
1688        _ => None,
1689    };
1690    let rows: std::collections::BTreeSet<u16> =
1691        run.iter().filter_map(|b| place(b).map(|p| p.0)).collect();
1692    let Some(&top) = rows.iter().next() else {
1693        return (None, run);
1694    };
1695    let join = |cells: Vec<Block>| -> Option<Block> {
1696        let mut cells: Vec<Block> = cells
1697            .into_iter()
1698            .filter(|b| !b.text.trim().is_empty())
1699            .collect();
1700        cells.sort_by_key(|b| place(b).map(|p| p.1));
1701        let mut cells = cells.into_iter();
1702        let mut out = cells.next()?;
1703        out.kind = BlockKind::Paragraph;
1704        out.grid = None;
1705        out.text = out.text.trim().to_string();
1706        for cell in cells {
1707            out.text.push(' ');
1708            out.text.push_str(cell.text.trim());
1709            out.bbox = [
1710                out.bbox[0].min(cell.bbox[0]),
1711                out.bbox[1].min(cell.bbox[1]),
1712                out.bbox[2].max(cell.bbox[2]),
1713                out.bbox[3].max(cell.bbox[3]),
1714            ];
1715            out.spans.extend(cell.spans);
1716        }
1717        Some(out)
1718    };
1719    // A contents line run out to its page numbers on dot leaders is a table
1720    // the annotation keeps, one row or not (DocLayNet validation).
1721    let leaders = run.iter().any(|b| {
1722        b.text
1723            .chars()
1724            .filter(|c| !c.is_whitespace())
1725            .collect::<String>()
1726            .contains("....")
1727    });
1728    // NOR AT A PAGE'S EDGE: a table of many rows can leave one of them on a
1729    // page, and that row is the table's, not a line (codex, #2055).
1730    if rows.len() == 1 && !leaders && !at_page_edge {
1731        return (join(run), Vec::new());
1732    }
1733    let mut on_top: Vec<&Block> = run
1734        .iter()
1735        .filter(|b| place(b).is_some_and(|p| p.0 == top) && !b.text.trim().is_empty())
1736        .collect();
1737    if on_top.len() < 2 {
1738        return (None, run);
1739    }
1740    on_top.sort_by_key(|b| place(b).map(|p| p.1));
1741    // The caption opens the row, and the row is the caption's only where
1742    // every other cell carries its sentence on (`of the`, `logP values …`).
1743    // Beside column heads -- `Statistics`, `PR P1` -- only the first cell is
1744    // the caption, and the heads stay (PubTables train).
1745    if !is_numbered_caption(on_top[0].text.trim()) {
1746        return (None, run);
1747    }
1748    let carried = on_top[1..]
1749        .iter()
1750        .all(|b| b.text.trim().chars().next().is_some_and(char::is_lowercase));
1751    let caption_at: Vec<(u16, u16)> = if carried {
1752        on_top.iter().filter_map(|b| place(b)).collect()
1753    } else {
1754        place(on_top[0]).into_iter().collect()
1755    };
1756    let (caption, mut rest): (Vec<Block>, Vec<Block>) = run
1757        .into_iter()
1758        .partition(|b| place(b).is_some_and(|p| caption_at.contains(&p)));
1759    let row_left = rest.iter().any(|b| place(b).is_some_and(|p| p.0 == top));
1760    if !row_left {
1761        // The table now starts a row lower; its rows are counted from there.
1762        for b in rest.iter_mut() {
1763            if let BlockKind::TableCell { row, .. } = &mut b.kind {
1764                *row = row.saturating_sub(top + 1);
1765            }
1766        }
1767    }
1768    (join(caption), rest)
1769}
1770
1771/// A table read as one column whose rows are two or more runs of ink at the
1772/// same places, cut back into its columns (#574).
1773///
1774/// The grid finds a table's columns from the white that runs down between
1775/// them, and one line that crosses it -- the note under a table,
1776/// `Note that the AUC score for Biosignature-15 …` -- closes that white for
1777/// the whole region. What is left is a table of one column whose every row
1778/// still says `CSF t-tau` at one x and `0.616` at another. The runs are read
1779/// again: each row's spans split where more white than `REGRID_GAP_EM` stands
1780/// between them, and the left edges the rows agree on are the columns. A row
1781/// at the table's top or bottom that runs across a column edge is the line
1782/// that closed the white, and comes out as a paragraph.
1783///
1784/// Answers the paragraphs to set over the table, the table, and those under.
1785fn regrid_one_column(run: Vec<Block>) -> (Vec<Block>, Vec<Block>, Vec<Block>) {
1786    let one_column = run.iter().all(|b| {
1787        matches!(
1788            b.kind,
1789            BlockKind::TableCell {
1790                column: 0,
1791                column_span: 1,
1792                ..
1793            }
1794        )
1795    });
1796    if !one_column || run.len() < REGRID_MIN_ROWS {
1797        return (Vec::new(), run, Vec::new());
1798    }
1799    // Each cell's runs of ink: its spans, left to right, split at wide white.
1800    let groups: Vec<Vec<Vec<usize>>> = run
1801        .iter()
1802        .map(|cell| {
1803            let mut order: Vec<usize> = (0..cell.spans.len())
1804                .filter(|&k| !cell.spans[k].text.trim().is_empty())
1805                .collect();
1806            order.sort_by(|&a, &b| cell.spans[a].x.total_cmp(&cell.spans[b].x));
1807            let mut out: Vec<Vec<usize>> = Vec::new();
1808            let mut right = f64::NEG_INFINITY;
1809            for k in order {
1810                let span = &cell.spans[k];
1811                let gap = REGRID_GAP_EM * span.font_size.max(1.0);
1812                match out.last_mut() {
1813                    Some(group) if span.x - right < gap => group.push(k),
1814                    _ => out.push(vec![k]),
1815                }
1816                right = right.max(span.x + span.width.max(0.0));
1817            }
1818            out
1819        })
1820        .collect();
1821    // The left edges of the runs, over the rows that have two or more.
1822    let multi = groups.iter().filter(|g| g.len() >= 2).count();
1823    if multi < REGRID_MIN_ROWS || (multi as f64) < REGRID_SHARE * run.len() as f64 {
1824        return (Vec::new(), run, Vec::new());
1825    }
1826    let mut lefts: Vec<f64> = groups
1827        .iter()
1828        .zip(&run)
1829        .filter(|(g, _)| g.len() >= 2)
1830        .flat_map(|(g, cell)| {
1831            g.iter()
1832                .map(|group| cell.spans[group[0]].x)
1833                .collect::<Vec<_>>()
1834        })
1835        .collect();
1836    lefts.sort_by(f64::total_cmp);
1837    let mut edges: Vec<(f64, usize)> = Vec::new(); // (left, rows)
1838    for x in lefts {
1839        match edges.last_mut() {
1840            Some((left, n)) if x - *left <= REGRID_EDGE_TOLERANCE => *n += 1,
1841            _ => edges.push((x, 1)),
1842        }
1843    }
1844    let needed = ((REGRID_SHARE * multi as f64).ceil() as usize).max(REGRID_MIN_ROWS - 1);
1845    let edges: Vec<f64> = edges
1846        .into_iter()
1847        .filter(|(_, n)| *n >= needed)
1848        .map(|(x, _)| x)
1849        .collect();
1850    if edges.len() < 2 {
1851        return (Vec::new(), run, Vec::new());
1852    }
1853    let column_at = |x: f64| -> usize {
1854        edges
1855            .iter()
1856            .rposition(|&e| x >= e - REGRID_EDGE_TOLERANCE)
1857            .unwrap_or(0)
1858    };
1859    // A single run across a column edge: the line that closed the white.
1860    let crosses = |cell: &Block, g: &[Vec<usize>]| -> bool {
1861        if g.len() != 1 {
1862            return false;
1863        }
1864        let left = g[0]
1865            .iter()
1866            .map(|&k| cell.spans[k].x)
1867            .fold(f64::INFINITY, f64::min);
1868        let right = g[0]
1869            .iter()
1870            .map(|&k| cell.spans[k].x + cell.spans[k].width.max(0.0))
1871            .fold(f64::NEG_INFINITY, f64::max);
1872        edges[1..]
1873            .iter()
1874            .any(|&e| left < e - REGRID_EDGE_TOLERANCE && right > e)
1875    };
1876    let rows = run.len();
1877    let mut top = 0;
1878    while top < rows && crosses(&run[top], &groups[top]) {
1879        top += 1;
1880    }
1881    let mut bottom = rows;
1882    while bottom > top && crosses(&run[bottom - 1], &groups[bottom - 1]) {
1883        bottom -= 1;
1884    }
1885    let first_row = run
1886        .get(top)
1887        .and_then(|b| match b.kind {
1888            BlockKind::TableCell { row, .. } => Some(row),
1889            _ => None,
1890        })
1891        .unwrap_or(0);
1892    let mut over = Vec::new();
1893    let mut table = Vec::new();
1894    let mut under = Vec::new();
1895    for (k, (cell, g)) in run.into_iter().zip(groups).enumerate() {
1896        if k < top {
1897            over.push(cell);
1898            continue;
1899        }
1900        if k >= bottom {
1901            under.push(cell);
1902            continue;
1903        }
1904        let row = match cell.kind {
1905            BlockKind::TableCell { row, .. } => row.saturating_sub(first_row),
1906            _ => 0,
1907        };
1908        for group in g {
1909            let spans: Vec<TextSpan> = group.iter().map(|&i| cell.spans[i].clone()).collect();
1910            let column = column_at(spans[0].x) as u16;
1911            // A space only where the page left white: `p-` and `tau` drawn
1912            // in two faces are one word (codex, #2051).
1913            let mut text = String::new();
1914            let mut right = f64::NEG_INFINITY;
1915            for s in &spans {
1916                if !text.is_empty() && s.x - right > REGRID_WORD_GAP_EM * s.font_size.max(1.0) {
1917                    text.push(' ');
1918                }
1919                text.push_str(&s.text);
1920                right = right.max(s.x + s.width.max(0.0));
1921            }
1922            let text = text.trim().to_string();
1923            let left = spans.iter().map(|s| s.x).fold(f64::INFINITY, f64::min);
1924            let right = spans
1925                .iter()
1926                .map(|s| s.x + s.width.max(0.0))
1927                .fold(f64::NEG_INFINITY, f64::max);
1928            let mut piece = cell.clone();
1929            piece.kind = BlockKind::TableCell {
1930                row,
1931                column,
1932                row_span: 1,
1933                column_span: 1,
1934                header: row == 0,
1935            };
1936            piece.bbox = [left, cell.bbox[1], right, cell.bbox[3]];
1937            piece.text = text;
1938            piece.spans = spans;
1939            table.push(piece);
1940        }
1941    }
1942    // Row-major, as a table is read, and ONE CELL A PLACE: two runs of a row
1943    // that fall to the same column are one cell's words, and two blocks at
1944    // one place would read as a second table starting.
1945    table.sort_by_key(|b| match b.kind {
1946        BlockKind::TableCell { row, column, .. } => (row, column),
1947        _ => (u16::MAX, u16::MAX),
1948    });
1949    let place = |b: &Block| match b.kind {
1950        BlockKind::TableCell { row, column, .. } => Some((row, column)),
1951        _ => None,
1952    };
1953    let mut cells: Vec<Block> = Vec::new();
1954    for piece in table {
1955        match cells.last_mut() {
1956            Some(last) if place(last) == place(&piece) => {
1957                last.text.push(' ');
1958                last.text.push_str(&piece.text);
1959                last.bbox[0] = last.bbox[0].min(piece.bbox[0]);
1960                last.bbox[2] = last.bbox[2].max(piece.bbox[2]);
1961                last.spans.extend(piece.spans);
1962            }
1963            _ => cells.push(piece),
1964        }
1965    }
1966    (join_as_paragraphs(over), cells, join_as_paragraphs(under))
1967}
1968
1969/// The white, in ems, between two spans of one cell that is a word space.
1970const REGRID_WORD_GAP_EM: f64 = 0.15;
1971
1972/// The fewest rows a one-column table needs before its runs are read.
1973const REGRID_MIN_ROWS: usize = 4;
1974/// The white, in ems, that parts two runs of a row.
1975const REGRID_GAP_EM: f64 = 2.0;
1976/// The share of rows that must agree before a left edge is a column.
1977const REGRID_SHARE: f64 = 0.5;
1978/// How far apart two runs' left edges may stand, in points, and be one edge.
1979const REGRID_EDGE_TOLERANCE: f64 = 6.0;
1980
1981/// A table's first or last column that is a column of the page's running
1982/// text, taken back out as the paragraph it is (#574).
1983///
1984/// On a page set in two columns a table in one reaches into the other, and
1985/// every line of the neighbouring text becomes a cell of an extra column:
1986/// `group) indicated that 69% of the total genetic var…`, one line a row. What
1987/// gives it away is that it FLOWS: a column of descriptions is cells that each
1988/// start afresh, and a column of running text is lines that carry on from the
1989/// one above, opening in lower case or closing on a hyphen. On 700 PubTables
1990/// train pages, 32 edge columns of long lines flowed at 0.4 or more; 31 of
1991/// them were not in the table.
1992///
1993/// Answers the table and the paragraphs to set after it; a table that is
1994/// left with one column comes back as paragraphs, whole.
1995fn split_prose_column(run: Vec<Block>) -> (Vec<Block>, Vec<Block>) {
1996    let column_of = |b: &Block| match b.kind {
1997        BlockKind::TableCell { column, .. } => Some(column),
1998        _ => None,
1999    };
2000    let row_of = |b: &Block| match b.kind {
2001        BlockKind::TableCell { row, .. } => Some(row),
2002        _ => None,
2003    };
2004    let columns: Vec<u16> = run.iter().filter_map(column_of).collect();
2005    let (Some(&first), Some(&last)) = (columns.iter().min(), columns.iter().max()) else {
2006        return (run, Vec::new());
2007    };
2008    // THE LAST COLUMN ONLY. A first column that flows is as often a table's
2009    // own row labels wrapping (`Temporary differences relating to investm-
2010    // ents … / w hich deferred tax …`) as the text column to its left, and on
2011    // the DocLayNet validation proxy taking it cost a statement its labels.
2012    let col = last;
2013    if first == last {
2014        return (run, Vec::new());
2015    }
2016    let texts: Vec<&str> = run
2017        .iter()
2018        .filter(|b| column_of(b) == Some(col))
2019        .map(|b| b.text.trim())
2020        .filter(|t| !t.is_empty())
2021        .collect();
2022    if texts.len() < PROSE_COLUMN_MIN_CELLS {
2023        return (run, Vec::new());
2024    }
2025    let lines = texts.iter().filter(|t| is_a_line_of_prose(t)).count();
2026    // A line carried on from the one above counts only BESIDE A ROW OF THE
2027    // TABLE: a description that wraps inside its own cell carries on on a row
2028    // the other columns leave empty (Sasol's resolutions).
2029    let others_on = |row: u16| {
2030        run.iter().any(|b| {
2031            column_of(b).is_some_and(|c| c != col)
2032                && row_of(b) == Some(row)
2033                && !b.text.trim().is_empty()
2034        })
2035    };
2036    let carried = run
2037        .iter()
2038        .filter(|b| column_of(b) == Some(col))
2039        .filter(|b| {
2040            let t = b.text.trim();
2041            (t.chars().next().is_some_and(char::is_lowercase) || t.ends_with('-'))
2042                && row_of(b).is_some_and(others_on)
2043        })
2044        .count();
2045    if (lines as f64) < PROSE_COLUMN_SHARE * texts.len() as f64
2046        || (carried as f64) < PROSE_COLUMN_FLOW * texts.len() as f64
2047    {
2048        return (run, Vec::new());
2049    }
2050    let (prose, table): (Vec<Block>, Vec<Block>) =
2051        run.into_iter().partition(|b| column_of(b) == Some(col));
2052    let paragraphs = join_as_paragraphs(prose);
2053    // WHAT IS LEFT OF ONE COLUMN IS NO TABLE: a page of two columns of prose
2054    // that the grid took whole comes apart into the two, the first read
2055    // first (an annual report's accounting policies, DocLayNet validation).
2056    let left: std::collections::BTreeSet<u16> = table.iter().filter_map(column_of).collect();
2057    if left.len() < 2 {
2058        let mut all = join_as_paragraphs(table);
2059        all.extend(paragraphs);
2060        return (all, Vec::new());
2061    }
2062    (table, paragraphs)
2063}
2064
2065/// The fewest filled cells a column needs before its flow is read.
2066const PROSE_COLUMN_MIN_CELLS: usize = 6;
2067/// The share of a column's cells that must be lines of prose.
2068const PROSE_COLUMN_SHARE: f64 = 0.7;
2069/// The share of its cells that must carry on from the line above.
2070const PROSE_COLUMN_FLOW: f64 = 0.4;
2071
2072/// A line of running text: 30 characters or more, five words of three letters.
2073fn is_a_line_of_prose(text: &str) -> bool {
2074    text.chars().count() >= 30
2075        && text
2076            .split_whitespace()
2077            .filter(|w| w.chars().filter(|c| c.is_alphabetic()).count() >= 3)
2078            .count()
2079            >= 5
2080}
2081
2082/// Lines joined back into the paragraphs they were: a line's closing hyphen
2083/// joined to the word it broke, and a paragraph ended where a line ends a
2084/// sentence and more white than the column's leading stands under it. One
2085/// paragraph for the whole column would be one block where the page has four,
2086/// and each of the four is its own item for a reader and for a chunker.
2087fn join_as_paragraphs(lines: Vec<Block>) -> Vec<Block> {
2088    let lines: Vec<Block> = lines
2089        .into_iter()
2090        .filter(|b| !b.text.trim().is_empty())
2091        .collect();
2092    // The white between each line and the next, top of the page first.
2093    let gaps: Vec<f64> = lines
2094        .windows(2)
2095        .map(|pair| pair[0].bbox[1] - pair[1].bbox[3])
2096        .collect();
2097    let mut sorted = gaps.clone();
2098    sorted.sort_by(|a, b| a.total_cmp(b));
2099    let leading = sorted.get(sorted.len() / 2).copied().unwrap_or(0.0);
2100    let mut out: Vec<Block> = Vec::new();
2101    let mut open = false;
2102    for (k, line) in lines.into_iter().enumerate() {
2103        let next = line.text.trim().to_string();
2104        let white_under = gaps.get(k).copied().unwrap_or(f64::INFINITY);
2105        let ends = next.ends_with(['.', '!', '?', ':'])
2106            && white_under > PARAGRAPH_GAP_FACTOR * leading.max(0.5);
2107        match out.last_mut() {
2108            Some(current) if open => {
2109                if current.text.ends_with('-')
2110                    && next.chars().next().is_some_and(char::is_lowercase)
2111                {
2112                    current.text.pop();
2113                } else {
2114                    current.text.push(' ');
2115                }
2116                current.text.push_str(&next);
2117                current.bbox = [
2118                    current.bbox[0].min(line.bbox[0]),
2119                    current.bbox[1].min(line.bbox[1]),
2120                    current.bbox[2].max(line.bbox[2]),
2121                    current.bbox[3].max(line.bbox[3]),
2122                ];
2123                current.spans.extend(line.spans);
2124            }
2125            _ => {
2126                let mut block = line;
2127                block.kind = BlockKind::Paragraph;
2128                block.grid = None;
2129                block.continues_previous = false;
2130                block.text = next;
2131                out.push(block);
2132            }
2133        }
2134        open = !ends;
2135    }
2136    out
2137}
2138
2139/// How many times the column's own leading the white under a line must be for
2140/// the line to end its paragraph.
2141const PARAGRAPH_GAP_FACTOR: f64 = 1.5;
2142
2143fn peel_run(cells: &mut [Block]) {
2144    let at = |b: &Block| match b.kind {
2145        BlockKind::TableCell {
2146            row,
2147            column,
2148            column_span,
2149            ..
2150        } => Some((row, column, column_span)),
2151        _ => None,
2152    };
2153    let rows: Vec<u16> = cells
2154        .iter()
2155        .filter_map(|b| at(b).map(|(r, _, _)| r))
2156        .collect();
2157    let (Some(&top), Some(&bottom)) = (rows.iter().min(), rows.iter().max()) else {
2158        return;
2159    };
2160    let columns = cells
2161        .iter()
2162        .filter_map(|b| at(b).map(|(_, c, s)| c + s.max(1)))
2163        .max()
2164        .unwrap_or(0);
2165    if bottom - top < 2 || columns < 2 {
2166        return;
2167    }
2168    let lone = |row: u16| -> Option<usize> {
2169        let filled: Vec<usize> = (0..cells.len())
2170            .filter(|&k| at(&cells[k]).is_some_and(|(r, _, _)| r == row))
2171            .filter(|&k| !cells[k].text.trim().is_empty())
2172            .collect();
2173        match filled[..] {
2174            [k] if reads_as_caption_or_note(&cells[k].text, row == bottom) => Some(k),
2175            _ => None,
2176        }
2177    };
2178    let top_cell = lone(top);
2179    let bottom_cell = lone(bottom);
2180    for k in [top_cell, bottom_cell].into_iter().flatten() {
2181        cells[k].kind = BlockKind::Paragraph;
2182        // A paragraph carries no table evidence (codex, #2048).
2183        cells[k].grid = None;
2184    }
2185    if top_cell.is_some() {
2186        // The table now starts a row lower; its rows are counted from there.
2187        for b in cells.iter_mut() {
2188            if let BlockKind::TableCell { row, .. } = &mut b.kind {
2189                *row = row.saturating_sub(top + 1);
2190            }
2191        }
2192    }
2193}
2194
2195/// Whether a text opens as a numbered caption: `Table 3`, `TABLE 2`,
2196/// `aTable 2.`, `*Table 1`, `Tab. 1`.
2197fn is_numbered_caption(t: &str) -> bool {
2198    // Read on the page's own case: a mark glued to the caption is a small
2199    // letter against a capital (`aTable 2.`), and `Stable isotope …` is a
2200    // word that happens to contain `table` (codex, #2048).
2201    let marked = t.trim_start_matches(['*', '\u{2020}']);
2202    let mut chars = marked.chars();
2203    let glued = matches!(
2204        (chars.next(), chars.next()),
2205        (Some(a), Some('T')) if a.is_ascii_lowercase()
2206    );
2207    let lowered = marked.to_lowercase();
2208    let s = if glued {
2209        &lowered[1..]
2210    } else {
2211        lowered.as_str()
2212    };
2213    let rest = s
2214        .strip_prefix("table")
2215        .or_else(|| s.strip_prefix("tab."))
2216        .map(str::trim_start);
2217    // A whole identifier: a figure, a Roman numeral that ends at a mark or
2218    // white (`II.`, `IV:`), or `S` and a figure (`S1`). `Table size` and
2219    // `Table value` are heads (codex, #2055).
2220    let Some(rest) = rest else {
2221        return false;
2222    };
2223    if rest.chars().next().is_some_and(|c| c.is_ascii_digit()) {
2224        return true;
2225    }
2226    let word: String = rest.chars().take_while(|c| c.is_alphanumeric()).collect();
2227    let roman = !word.is_empty() && word.len() <= 5 && word.chars().all(|c| "ivxlc".contains(c));
2228    let supplementary =
2229        word.len() >= 2 && word.starts_with('s') && word[1..].chars().all(|c| c.is_ascii_digit());
2230    roman || supplementary
2231}
2232
2233/// Whether a lone cell's text is a caption, a note or a sentence rather than
2234/// a table's own words.
2235fn reads_as_caption_or_note(text: &str, below: bool) -> bool {
2236    let t = text.trim();
2237    let words = t.split_whitespace().count();
2238    let lower = t.to_lowercase();
2239    let first = t.chars().next();
2240    let caption = is_numbered_caption(t);
2241    // A sentence: long, and it ends like one or opens mid-way through one.
2242    // Of words, not of symbols: `x y z U iso*/ U eq` is seven tokens and a
2243    // crystal table's head.
2244    let real_words = t
2245        .split_whitespace()
2246        .filter(|w| w.chars().filter(|c| c.is_alphabetic()).count() >= 3)
2247        .count();
2248    let sentence = words >= 7
2249        && real_words >= 5
2250        && (t.ends_with('.') || first.is_some_and(char::is_lowercase));
2251    // A note: its opening word, or a mark, says what it is.
2252    let note = words >= 4
2253        && (["note", "abbreviation", "source", "data are", "values are"]
2254            .iter()
2255            .any(|w| lower.starts_with(w))
2256            // `Symmetry codes: (i) x −1, …`: a label of a word or three and a
2257            // colon, then what it explains.
2258            || t.split_once(':').is_some_and(|(label, rest)| {
2259                (1..=3).contains(&label.split_whitespace().count())
2260                    && label.chars().all(|c| c.is_alphabetic() || c.is_whitespace())
2261                    && rest.split_whitespace().count() >= 3
2262            })
2263            || first.is_some_and(|c| "*\u{2020}\u{2021}\u{a7}\u{b6}#".contains(c))
2264            // `a Values are…`, `b P for trend`: a letter mark, then a word.
2265            // `x y z U iso` is a crystal table's head, and its letters are
2266            // coordinates (PubTables train).
2267            || (first.is_some_and(|c| c.is_ascii_lowercase())
2268                && t.chars().nth(1).is_some_and(char::is_whitespace)
2269                && t.split_whitespace()
2270                    .nth(1)
2271                    .is_some_and(|w| w.chars().filter(|c| c.is_alphabetic()).count() >= 3)));
2272    // A SENTENCE ONLY UNDER THE TABLE. Over it, a lone cell that reads like
2273    // one is a cell the page before began: a table set over a page break
2274    // opens with the tail of its last row (ParseBench, a work programme).
2275    caption || note || (below && sentence)
2276}
2277
2278/// Marks a table that runs on over a page break as the continuation of the
2279/// table before it (#574): the first cell of the next page's table gets
2280/// [`Block::continues_previous`], and the serializer reads the two as one.
2281///
2282/// A RELATION, as for a paragraph the page broke (#571): the two halves keep
2283/// their own pages, boxes and row numbers.
2284///
2285/// The page must say so three ways, because a page of one table followed by a
2286/// page of another is the common case and the halves of one table are not:
2287/// the table is the LAST thing on its page but for furniture, the next page's
2288/// table is the FIRST thing on it but for furniture, and the two have the same
2289/// number of columns standing at the same places -- each column's left edge
2290/// within `CONTINUED_COLUMN_TOLERANCE`. A head the second half repeats is left
2291/// in the blocks and left out of the table by the serializer.
2292pub(crate) fn tables_across_pages(blocks: &mut [Block]) {
2293    let body = |b: &Block| !matches!(b.kind, BlockKind::Header | BlockKind::Footer);
2294    let cell = |b: &Block| matches!(b.kind, BlockKind::TableCell { .. });
2295    let mut k = 1;
2296    while k < blocks.len() {
2297        if blocks[k].page == blocks[k - 1].page || !cell(&blocks[k]) {
2298            k += 1;
2299            continue;
2300        }
2301        // The last body block before the break must be a cell, and nothing but
2302        // furniture may stand between it and the break; the same after it.
2303        let before = (0..k).rev().find(|&i| body(&blocks[i]));
2304        let first_after = (k..blocks.len())
2305            .take_while(|&i| blocks[i].page == blocks[k].page)
2306            .find(|&i| body(&blocks[i]));
2307        let (Some(last), Some(first)) = (before, first_after) else {
2308            k += 1;
2309            continue;
2310        };
2311        if first != k || !cell(&blocks[last]) || blocks[last].page + 1 != blocks[k].page {
2312            k += 1;
2313            continue;
2314        }
2315        // The two tables' cells.
2316        let mut start = last;
2317        while start > 0 && cell(&blocks[start - 1]) && blocks[start - 1].page == blocks[last].page {
2318            start -= 1;
2319        }
2320        let mut end = k;
2321        while end < blocks.len() && cell(&blocks[end]) && blocks[end].page == blocks[k].page {
2322            end += 1;
2323        }
2324        if same_columns(&blocks[start..=last], &blocks[k..end]) {
2325            blocks[k].continues_previous = true;
2326        }
2327        k = end.max(k + 1);
2328    }
2329}
2330
2331/// A heading keeps the tight subtitle line under it (#636).
2332///
2333/// A title with its subtitle directly under it -- `Statements of cash flows`
2334/// over `for the year ended 30 June 2003`, flush with it and nearly touching,
2335/// often in a smaller size -- is annotated as ONE heading, and the extraction
2336/// hands back a heading plus a paragraph (or two headings). So a heading joins
2337/// the block under it when that block sits tight, aligned and is no larger:
2338/// the same left edge or the same centre within a point, a gap of at most
2339/// 0.25 of the heading's size, no larger than the heading, no longer than ten
2340/// words, and neither side ending in `. ! ? :` nor the lower one opening with
2341/// a list marker. The join is greedy: a heading that took its subtitle tests
2342/// the line under that against the joined box, for the titles set on three
2343/// lines.
2344///
2345/// Runs after the learned pass and the table passes, where the kinds are
2346/// final, because that is what it reads: a `Heading` over a `Heading` or a
2347/// `Paragraph`. The kind stays `Heading`, the level stays the heading's, the
2348/// text is joined with a space, the spans are concatenated and the box is the
2349/// union.
2350pub(crate) fn join_heading_subtitles(blocks: &mut Vec<Block>) {
2351    let mut out: Vec<Block> = Vec::with_capacity(blocks.len());
2352    // The size the accumulated heading is set in: the median of the ORIGINAL
2353    // heading's spans, which the lines it collects do not move -- a subtitle
2354    // in a smaller size must not shrink the leading its own successor is
2355    // judged by.
2356    let mut head_size = 0.0;
2357    let mut joined = false;
2358    for block in blocks.drain(..) {
2359        let joins = out
2360            .last()
2361            .filter(|head| matches!(head.kind, BlockKind::Heading { .. }))
2362            .is_some_and(|head| {
2363                matches!(block.kind, BlockKind::Heading { .. } | BlockKind::Paragraph)
2364                    && block.page == head.page
2365                    && subtitle_joins(head, &block, head_size)
2366            });
2367        if joins {
2368            if let Some(head) = out.last_mut() {
2369                head.text.push(' ');
2370                head.text.push_str(&block.text);
2371                head.spans.extend(block.spans);
2372                head.bbox = [
2373                    head.bbox[0].min(block.bbox[0]),
2374                    head.bbox[1].min(block.bbox[1]),
2375                    head.bbox[2].max(block.bbox[2]),
2376                    head.bbox[3].max(block.bbox[3]),
2377                ];
2378            }
2379            joined = true;
2380        } else {
2381            head_size = if matches!(block.kind, BlockKind::Heading { .. }) {
2382                median_span_size(&block)
2383            } else {
2384                0.0
2385            };
2386            out.push(block);
2387        }
2388    }
2389    *blocks = out;
2390    if joined {
2391        for (n, block) in blocks.iter_mut().enumerate() {
2392            block.index = n;
2393        }
2394    }
2395}
2396
2397/// How far the lower block's left edge, or the two blocks' centres, may stand
2398/// apart and still be the same title.
2399const SUBTITLE_ALIGN_TOLERANCE: f64 = 1.0;
2400
2401/// The leading under a heading that still says "subtitle", as a multiple of
2402/// the heading's size. 0.25, chosen on the proxy tuning half plus the
2403/// independent tuning half: 0.3 joins the same proxy lines, so the smaller
2404/// value stays, and 0.35 and above add wrong joins. The held-out halves are
2405/// descriptive only.
2406const SUBTITLE_LEADING_EM: f64 = 0.25;
2407
2408/// How much larger than the heading the line under it may be set.
2409const SUBTITLE_SIZE_TOLERANCE: f64 = 0.5;
2410
2411/// The longest a subtitle is, in words. The middle of 8-12, which the tuning
2412/// half does not separate.
2413const SUBTITLE_MAX_WORDS: usize = 10;
2414
2415/// The size a heading without spans is judged by. A block always carries the
2416/// spans it was made of, so this is the fallback for a block built without
2417/// them rather than a measurement.
2418const SUBTITLE_FALLBACK_EM: f64 = 10.0;
2419
2420/// Whether the block under a heading is its subtitle: tight, aligned, no
2421/// larger, short, and neither prose nor a label.
2422fn subtitle_joins(head: &Block, under: &Block, head_size: f64) -> bool {
2423    let left_aligned = (under.bbox[0] - head.bbox[0]).abs() <= SUBTITLE_ALIGN_TOLERANCE;
2424    let head_centre = (head.bbox[0] + head.bbox[2]) / 2.0;
2425    let under_centre = (under.bbox[0] + under.bbox[2]) / 2.0;
2426    if !left_aligned && (under_centre - head_centre).abs() > SUBTITLE_ALIGN_TOLERANCE {
2427        return false;
2428    }
2429    let em = if head_size > 0.0 {
2430        head_size
2431    } else {
2432        SUBTITLE_FALLBACK_EM
2433    };
2434    // The boxes are `[x0, y0, x1, y1]` with y up: the heading's bottom is its
2435    // `y0`, the subtitle's top its `y1`.
2436    let gap = head.bbox[1] - under.bbox[3];
2437    if gap < 0.0 || gap > SUBTITLE_LEADING_EM * em {
2438        return false;
2439    }
2440    if median_span_size(under) > head_size + SUBTITLE_SIZE_TOLERANCE {
2441        return false;
2442    }
2443    if under.text.split_whitespace().count() > SUBTITLE_MAX_WORDS {
2444        return false;
2445    }
2446    // A heading joined with the SENTENCE under it (`January 1989` over
2447    // `Emperor Hirohito of Japan dead at 87.`), or a label with its value
2448    // (`Vollzitat:`): prose ends in `. ! ?` and a label in `:`.
2449    if ends_like_prose_or_a_label(&head.text) || ends_like_prose_or_a_label(&under.text) {
2450        return false;
2451    }
2452    !opens_with_a_list_marker(&under.text)
2453}
2454
2455/// The median of the sizes the block's spans are set in, or 0 where the block
2456/// carries no spans.
2457fn median_span_size(block: &Block) -> f64 {
2458    let mut sizes: Vec<f64> = block.spans.iter().map(|span| span.font_size).collect();
2459    if sizes.is_empty() {
2460        return 0.0;
2461    }
2462    sizes.sort_by(f64::total_cmp);
2463    let mid = sizes.len() / 2;
2464    if sizes.len() % 2 == 1 {
2465        sizes[mid]
2466    } else {
2467        (sizes[mid - 1] + sizes[mid]) / 2.0
2468    }
2469}
2470
2471/// Whether the text ends like a sentence or a label: a full stop, an
2472/// exclamation or question mark, or a colon.
2473fn ends_like_prose_or_a_label(text: &str) -> bool {
2474    matches!(text.trim_end().chars().last(), Some('.' | '!' | '?' | ':'))
2475}
2476
2477/// Whether the text opens with a list marker: `1.`, `(a)`, a bullet.
2478///
2479/// An optional opening bracket, one to three figures or one lowercase letter
2480/// with its delimiter, or one bullet character with the space after it. What
2481/// the guard refuses is the line the list pass did not confirm as an item but
2482/// still opens as one.
2483fn opens_with_a_list_marker(text: &str) -> bool {
2484    let rest = text.trim_start_matches(char::is_whitespace);
2485    let rest = rest.strip_prefix('(').unwrap_or(rest);
2486    let mut chars = rest.chars();
2487    let Some(first) = chars.next() else {
2488        return false;
2489    };
2490    if first.is_ascii_digit() {
2491        let mut digits = 1;
2492        for c in chars {
2493            if c.is_ascii_digit() {
2494                digits += 1;
2495                if digits > 3 {
2496                    return false;
2497                }
2498            } else {
2499                return c == '.' || c == ')';
2500            }
2501        }
2502        return false;
2503    }
2504    if first.is_ascii_lowercase() {
2505        return match (chars.next(), chars.next()) {
2506            (Some(delimiter), Some(after)) if delimiter == '.' || delimiter == ')' => {
2507                after.is_whitespace()
2508            }
2509            _ => false,
2510        };
2511    }
2512    matches!(first, '•' | '●' | '▪' | '■' | '–' | '—' | '-' | '*')
2513        && chars.next().is_some_and(|c| c.is_whitespace())
2514}
2515
2516/// A multi-line heading set in one style stays one heading (#574).
2517///
2518/// A heading the page sets on two or three lines in ONE style -- `ELECTRONIC
2519/// DELIVERY OF` over `ANNUAL MEETING MATERIALS`, a name over its title in the
2520/// same bold -- arrives as blocks of its own: the paragraph merge parts lines
2521/// the heading classifier then reads separately, and the extraction hands back
2522/// a heading plus a paragraph (or two headings) where the annotation says one.
2523/// So a heading joins the block next to it when the two are set alike -- the
2524/// same size after rounding to half a point, bold in both or in neither,
2525/// italic likewise -- stand on the same left edge or centre within a point at
2526/// most half an em apart, and together hold at most twenty words: the same
2527/// shape as [`join_heading_subtitles`], with the style rail added and the
2528/// leading rail widened, because these lines are set as one heading and not as
2529/// a title with its subtitle.
2530///
2531/// EITHER side may be the heading: a paragraph line over its heading joins
2532/// upward, and the result keeps the level of whichever piece was a heading --
2533/// the first, where both were. What it refuses is prose: the upper side ends
2534/// in `. ! ? ;`, or a PARAGRAPH under it ends in `. ! ? ; :`. A lower piece
2535/// the classifier already read as a heading joins whatever punctuation it
2536/// closes with: the guard exists to stop a heading swallowing a paragraph's
2537/// first sentence, and that risk only arises where the lower line is a
2538/// paragraph. A lower line that opens with a list marker always refuses. The
2539/// join chains to at most three pieces.
2540///
2541/// Runs beside [`join_heading_subtitles`], right after it, where the kinds are
2542/// final: a `Heading` over or under a `Heading` or a `Paragraph`, never a
2543/// table cell, a list item, a caption or furniture. The kind is `Heading`, the
2544/// text is joined with a space, the spans are concatenated and the box is the
2545/// union.
2546pub(crate) fn join_heading_lines(blocks: &mut Vec<Block>) {
2547    let mut out: Vec<Block> = Vec::with_capacity(blocks.len());
2548    // How many pieces the accumulated heading holds: at most three join.
2549    let mut pieces = 0usize;
2550    let mut joined = false;
2551    for block in blocks.drain(..) {
2552        let joins = match out.last() {
2553            Some(head)
2554                if matches!(head.kind, BlockKind::Heading { .. } | BlockKind::Paragraph)
2555                    && matches!(block.kind, BlockKind::Heading { .. } | BlockKind::Paragraph)
2556                    && (matches!(head.kind, BlockKind::Heading { .. })
2557                        || matches!(block.kind, BlockKind::Heading { .. }))
2558                    && block.page == head.page
2559                    && pieces < HEADING_LINE_MAX_PIECES =>
2560            {
2561                heading_line_joins(head, &block)
2562            }
2563            _ => false,
2564        };
2565        if joins {
2566            if let Some(head) = out.last_mut() {
2567                let level = match (head.kind, block.kind) {
2568                    (BlockKind::Heading { level }, _) => level,
2569                    (_, BlockKind::Heading { level }) => level,
2570                    _ => unreachable!("a heading join with no heading in it"),
2571                };
2572                head.kind = BlockKind::Heading { level };
2573                head.text.push(' ');
2574                head.text.push_str(&block.text);
2575                head.spans.extend(block.spans);
2576                head.bbox = [
2577                    head.bbox[0].min(block.bbox[0]),
2578                    head.bbox[1].min(block.bbox[1]),
2579                    head.bbox[2].max(block.bbox[2]),
2580                    head.bbox[3].max(block.bbox[3]),
2581                ];
2582            }
2583            pieces += 1;
2584            joined = true;
2585        } else {
2586            pieces = 1;
2587            out.push(block);
2588        }
2589    }
2590    *blocks = out;
2591    if joined {
2592        for (n, block) in blocks.iter_mut().enumerate() {
2593            block.index = n;
2594        }
2595    }
2596}
2597
2598/// How far the lower block's left edge, or the two blocks' centres, may stand
2599/// apart and still be one heading.
2600const HEADING_LINE_ALIGN_TOLERANCE: f64 = 1.0;
2601
2602/// The leading under a heading line that still says "one heading", as a
2603/// multiple of the size both lines are set in.
2604const HEADING_LINE_LEADING_EM: f64 = 0.5;
2605
2606/// How far the lower block may overlap the upper one and still be its
2607/// continuation, in points.
2608const HEADING_LINE_MIN_GAP: f64 = -0.5;
2609
2610/// The longest a multi-line heading is, in words over all its pieces.
2611const HEADING_LINE_MAX_WORDS: usize = 20;
2612
2613/// How many pieces a heading joins from, at most.
2614const HEADING_LINE_MAX_PIECES: usize = 3;
2615
2616/// The size a heading line without a measurable size is judged by. A block
2617/// always carries the spans it was made of, so this is the fallback for a
2618/// block built without a size rather than a measurement.
2619const HEADING_LINE_FALLBACK_EM: f64 = 10.0;
2620
2621/// Whether the block under a heading line is its continuation: set alike,
2622/// aligned, standing close, short, and neither prose nor a label.
2623fn heading_line_joins(head: &Block, under: &Block) -> bool {
2624    let (Some(over), Some(below)) = (heading_line_style(head), heading_line_style(under)) else {
2625        return false;
2626    };
2627    if over != below {
2628        return false;
2629    }
2630    let em = if over.0 > 0.0 {
2631        over.0
2632    } else {
2633        HEADING_LINE_FALLBACK_EM
2634    };
2635    // The boxes are `[x0, y0, x1, y1]` with y up: the upper block's bottom is
2636    // its `y0`, the lower block's top its `y1`.
2637    let gap = head.bbox[1] - under.bbox[3];
2638    if gap < HEADING_LINE_MIN_GAP || gap > HEADING_LINE_LEADING_EM * em {
2639        return false;
2640    }
2641    let left_aligned = (under.bbox[0] - head.bbox[0]).abs() <= HEADING_LINE_ALIGN_TOLERANCE;
2642    let head_centre = (head.bbox[0] + head.bbox[2]) / 2.0;
2643    let under_centre = (under.bbox[0] + under.bbox[2]) / 2.0;
2644    if !left_aligned && (under_centre - head_centre).abs() > HEADING_LINE_ALIGN_TOLERANCE {
2645        return false;
2646    }
2647    // A heading joined with the SENTENCE under it (`Remuneration` over its
2648    // paragraph's first line), or a label with its value (`Vollzitat:`): prose
2649    // ends in `. ! ? ;` and a label in `:`. The lower guard only binds a
2650    // PARAGRAPH: a piece the classifier already read as a heading is reunited
2651    // with its first line whatever punctuation it closes with.
2652    if ends_like_a_sentence(&head.text)
2653        || (matches!(under.kind, BlockKind::Paragraph)
2654            && ends_like_a_sentence_or_a_label(&under.text))
2655    {
2656        return false;
2657    }
2658    if opens_with_a_list_marker(&under.text) {
2659        return false;
2660    }
2661    head.text.split_whitespace().count() + under.text.split_whitespace().count()
2662        <= HEADING_LINE_MAX_WORDS
2663}
2664
2665/// The style a block is set in: its median size rounded to half a point, and
2666/// whether at least half its spans are bold or italic. `None` where the block
2667/// carries no spans, which no style can be read off.
2668fn heading_line_style(block: &Block) -> Option<(f64, bool, bool)> {
2669    if block.spans.is_empty() {
2670        return None;
2671    }
2672    let size = (median_span_size(block) * 2.0).round() / 2.0;
2673    let bold = block.spans.iter().filter(|span| span.is_bold).count() * 2 >= block.spans.len();
2674    let italic = block.spans.iter().filter(|span| span.is_italic).count() * 2 >= block.spans.len();
2675    Some((size, bold, italic))
2676}
2677
2678/// Whether the text ends like a sentence: a full stop, an exclamation or
2679/// question mark, or a semicolon.
2680fn ends_like_a_sentence(text: &str) -> bool {
2681    matches!(text.trim_end().chars().last(), Some('.' | '!' | '?' | ';'))
2682}
2683
2684/// Whether the text ends like a sentence or a label: as above, and a colon.
2685fn ends_like_a_sentence_or_a_label(text: &str) -> bool {
2686    matches!(
2687        text.trim_end().chars().last(),
2688        Some('.' | '!' | '?' | ';' | ':')
2689    )
2690}
2691
2692/// How much wider than a paragraph's own median step a step inside it has to
2693/// be before it is read as a paragraph boundary (#574).
2694///
2695/// One and a half, chosen on the proxy's tuning half and the independent
2696/// tuning half: 1.5 beats 1.8 on both (proxy tuning coverage +0.0025 against
2697/// +0.0017, independent tuning +0.0083 against +0.0071), and the held-out
2698/// halves only confirm.
2699const PARAGRAPH_STEP_JUMP: f64 = 1.5;
2700
2701/// How far the left edges of the two lines either side of a step jump may
2702/// stand apart, in ems, before the jump is a column change and not this
2703/// rule's business (#574). One em: a first-line indent never clears it, and
2704/// the cut owns whatever does.
2705const PARAGRAPH_STEP_LEFT_EM: f64 = 1.0;
2706
2707/// How many non-space characters the median line of a paragraph may hold
2708/// before its lines stop reading as stacked glyphs (#574). Two, chosen on
2709/// the proxy's tuning half and the independent tuning half: the stacked
2710/// cuts there sit at one or two to the line and the shortest intended prose
2711/// cut at five, so the value refuses the stacked cuts and no prose. Two
2712/// word-changing cuts sit higher still, and the reassembly guard below
2713/// catches those. The held-out halves only confirm.
2714const PARAGRAPH_STEP_STACKED_LINE: usize = 2;
2715
2716/// How far off the horizontal a span's transform may lean before the span
2717/// counts as rotated (#574): |b| or |c| past a tenth of |a|. An upright
2718/// span sits at zero against its own size; a 90-degree stamp sits at all
2719/// of it. The page set the block vertically, whatever the line grouping
2720/// says.
2721const PARAGRAPH_STEP_ROTATION: f64 = 0.1;
2722
2723/// How much of a paragraph's non-space characters may be figures and their
2724/// punctuation before the block is a number block and not prose (#574).
2725/// Half: ORI p64's released table sits at practically all of them, while
2726/// the most numeric intended prose cut on either tuning half sits under
2727/// it. Chosen on the proxy's tuning half and the independent tuning half,
2728/// and the held-out halves only confirm.
2729const PARAGRAPH_STEP_DIGIT_SHARE: f64 = 0.5;
2730
2731/// A paragraph the builder merged across a paragraph-sized step, cut where its
2732/// own white jumps (#574).
2733///
2734/// The builder's own break, `PARAGRAPH_LEADING_TOLERANCE` (1.35) times the
2735/// leading in force, misses these because that leading is measured on the RUN
2736/// while the paragraph still has one line to its name -- and on a run set at
2737/// paragraph distance the run leading IS the paragraph gap. The learned pass's
2738/// merges and the list continuation then join more blocks without asking the
2739/// geometry at all. So this runs on the FINAL blocks, after the learned pass
2740/// and the table passes, beside [`join_heading_subtitles`]: for each paragraph
2741/// of three lines or more, cut between two lines where the baseline step is at
2742/// least [`PARAGRAPH_STEP_JUMP`] times the block's own median step. The pieces
2743/// keep the parent's kind, page and verdicts, and carry their own spans, text
2744/// and boxes, in order.
2745///
2746/// NOT APPLIED to headings, list items, captions or table cells: their kinds
2747/// are statements about the whole block, and a step inside one is the shape of
2748/// the thing rather than a boundary. Not applied where the two lines' left
2749/// edges differ by more than an em, which is the cut's business.
2750///
2751/// Three guards keep the cut from doing damage, all refused in full -- a
2752/// block the rule may not cut is returned whole, never in part:
2753///
2754/// 1. The pieces must read the parent's characters in the same order
2755///    ([`step_split_reassembles`]). A cut between stacked glyphs reassembles
2756///    with spaces inserted or, where the page set the word bottom-to-top,
2757///    backwards (`Thickness` into `ss e` plus `kn ic h T`); every intended
2758///    prose cut reassembles identically, so the guard refuses exactly the
2759///    salad.
2760/// 2. Stacked glyphs are not lines ([`step_split_stacked`],
2761///    [`step_split_vertical`]): a block whose median line holds
2762///    [`PARAGRAPH_STEP_STACKED_LINE`] non-space characters or fewer, or
2763///    whose spans mostly carry a rotation, is vertical text the rule never
2764///    looks at.
2765/// 3. A number block is not prose ([`step_split_number_block`]): a block at
2766///    or past [`PARAGRAPH_STEP_DIGIT_SHARE`] figures and their punctuation
2767///    is the table pass's business.
2768///
2769/// Every refusal goes out under `PDFLUENT_BLOCK_TRACE`, naming the guard
2770/// and the parent, the way the builder's own rails do.
2771pub(crate) fn split_paragraphs_on_steps(blocks: &mut Vec<Block>) {
2772    let mut out: Vec<Block> = Vec::with_capacity(blocks.len());
2773    let mut split = false;
2774    for block in blocks.drain(..) {
2775        if !matches!(block.kind, BlockKind::Paragraph) {
2776            out.push(block);
2777            continue;
2778        }
2779        let pieces = split_paragraph_on_steps(&block);
2780        if pieces.len() <= 1 {
2781            out.push(block);
2782        } else {
2783            split = true;
2784            out.extend(pieces);
2785        }
2786    }
2787    *blocks = out;
2788    if split {
2789        for (n, block) in blocks.iter_mut().enumerate() {
2790            block.index = n;
2791        }
2792    }
2793}
2794
2795/// Whether the block's characters are mostly figures and their punctuation
2796/// -- a table released as one paragraph, like ORI p64's, and the table
2797/// pass's business rather than this rule's (#574).
2798fn step_split_number_block(block: &Block) -> bool {
2799    let mut total = 0usize;
2800    let mut figures = 0usize;
2801    for c in block.text.chars().filter(|c| !c.is_whitespace()) {
2802        total += 1;
2803        if matches!(
2804            c,
2805            '0'..='9' | '.' | ',' | '(' | ')' | '%' | '$' | '€' | '£' | '–' | '-'
2806        ) {
2807            figures += 1;
2808        }
2809    }
2810    total > 0 && figures as f64 >= PARAGRAPH_STEP_DIGIT_SHARE * total as f64
2811}
2812
2813/// Whether the span leans off the horizontal: |b| or |c| past
2814/// [`PARAGRAPH_STEP_ROTATION`] of |a| (#574). A span with no transform
2815/// carries no lean.
2816fn step_split_rotated(span: &TextSpan) -> bool {
2817    match span.transform {
2818        Some([a, b, c, ..]) => {
2819            b.abs() > PARAGRAPH_STEP_ROTATION * a.abs()
2820                || c.abs() > PARAGRAPH_STEP_ROTATION * a.abs()
2821        }
2822        None => false,
2823    }
2824}
2825
2826/// Whether the block's lines are stacked glyphs rather than lines of prose
2827/// -- vertical text, a rotated stamp, a letter-stacked axis label (#574):
2828/// the median line holds [`PARAGRAPH_STEP_STACKED_LINE`] non-space
2829/// characters or fewer.
2830fn step_split_stacked(block: &Block, lines: &[Vec<usize>]) -> bool {
2831    let mut counts: Vec<usize> = lines
2832        .iter()
2833        .map(|line| line.iter().map(|i| char_count(&block.spans[*i])).sum())
2834        .collect();
2835    counts.sort_unstable();
2836    !counts.is_empty() && counts[counts.len() / 2] <= PARAGRAPH_STEP_STACKED_LINE
2837}
2838
2839/// Whether the page set the block vertically, whatever the line grouping
2840/// says: most spans carry a rotation (#574).
2841fn step_split_vertical(block: &Block) -> bool {
2842    let rotated = block.spans.iter().filter(|s| step_split_rotated(s)).count();
2843    rotated * 2 > block.spans.len()
2844}
2845
2846/// Whether the pieces read the parent's characters in the same order (#574).
2847/// Both sides drop the hyphen-plus-space a merge boundary leaves where
2848/// [`join_lines`] would have closed the word, and all white space; what is
2849/// left must match exactly. Script-neutral, and exact for every intended
2850/// prose cut.
2851fn step_split_reassembles(parent: &Block, pieces: &[Block]) -> bool {
2852    let plain = |text: &str| {
2853        text.replace("- ", "")
2854            .chars()
2855            .filter(|c| !c.is_whitespace())
2856            .collect::<Vec<_>>()
2857    };
2858    let want = plain(&parent.text);
2859    let mut got: Vec<char> = Vec::with_capacity(want.len());
2860    for piece in pieces {
2861        got.extend(plain(&piece.text));
2862    }
2863    want == got
2864}
2865
2866/// A refused split, out under `PDFLUENT_BLOCK_TRACE` the way the builder's
2867/// own rails go: the guard and the head of the parent.
2868fn step_split_trace(guard: &str, block: &Block) {
2869    if tracing() {
2870        let head: String = block.text.chars().take(80).collect();
2871        eprintln!("STEP-SPLIT {guard} | {head}");
2872    }
2873}
2874
2875/// The one paragraph cut into its pieces, or itself alone where no step jumps.
2876fn split_paragraph_on_steps(block: &Block) -> Vec<Block> {
2877    if step_split_number_block(block) {
2878        step_split_trace("number-block", block);
2879        return vec![block.clone()];
2880    }
2881    let mut order: Vec<usize> = (0..block.spans.len()).collect();
2882    order.sort_by(|a, b| {
2883        block.spans[*b]
2884            .y
2885            .partial_cmp(&block.spans[*a].y)
2886            .unwrap_or(Ordering::Equal)
2887    });
2888    // Back into lines by baseline, the way `assemble_cell` groups a cell's
2889    // spans: half an em holds a superscript on its line and never two lines.
2890    let mut lines: Vec<Vec<usize>> = Vec::new();
2891    for i in order {
2892        let same_line = lines.last().is_some_and(|line: &Vec<usize>| {
2893            let first = &block.spans[line[0]];
2894            let span = &block.spans[i];
2895            (first.y - span.y).abs() <= first.font_size.max(span.font_size) * 0.5
2896        });
2897        match lines.last_mut() {
2898            Some(line) if same_line => line.push(i),
2899            _ => lines.push(vec![i]),
2900        }
2901    }
2902    if lines.len() < 3 {
2903        return vec![block.clone()];
2904    }
2905    if step_split_stacked(block, &lines) {
2906        step_split_trace("stacked-glyphs", block);
2907        return vec![block.clone()];
2908    }
2909    if step_split_vertical(block) {
2910        step_split_trace("rotated-spans", block);
2911        return vec![block.clone()];
2912    }
2913    // The baseline each line sits on: its longest span's, the way the builder
2914    // anchors a line, so a raised figure does not move it.
2915    let baseline_of = |line: &[usize]| -> f64 {
2916        line.iter()
2917            .map(|i| &block.spans[*i])
2918            .max_by_key(|span| char_count(span))
2919            .map(|span| span.y)
2920            .unwrap_or(0.0)
2921    };
2922    let left_of = |line: &[usize]| -> f64 {
2923        line.iter()
2924            .map(|i| block.spans[*i].x)
2925            .fold(f64::INFINITY, f64::min)
2926    };
2927    let mut steps: Vec<f64> = lines
2928        .windows(2)
2929        .map(|pair| baseline_of(&pair[0]) - baseline_of(&pair[1]))
2930        .filter(|step| *step > 0.0)
2931        .collect();
2932    if steps.is_empty() {
2933        return vec![block.clone()];
2934    }
2935    steps.sort_by(f64::total_cmp);
2936    let median = steps[steps.len() / 2];
2937    if !median.is_finite() || median <= 0.0 {
2938        return vec![block.clone()];
2939    }
2940    let em = median_span_size(block);
2941    let em = if em > 0.0 { em } else { SUBTITLE_FALLBACK_EM };
2942    let mut cuts: Vec<bool> = vec![false; lines.len() - 1];
2943    for (i, pair) in lines.windows(2).enumerate() {
2944        let step = baseline_of(&pair[0]) - baseline_of(&pair[1]);
2945        cuts[i] = step >= PARAGRAPH_STEP_JUMP * median
2946            && (left_of(&pair[0]) - left_of(&pair[1])).abs() <= PARAGRAPH_STEP_LEFT_EM * em;
2947    }
2948    if !cuts.iter().any(|cut| *cut) {
2949        return vec![block.clone()];
2950    }
2951    let mut pieces: Vec<Block> = Vec::new();
2952    let mut start = 0usize;
2953    for (i, cut) in cuts.iter().enumerate() {
2954        if *cut {
2955            pieces.push(piece_of(block, &lines, start, i + 1));
2956            start = i + 1;
2957        }
2958    }
2959    pieces.push(piece_of(block, &lines, start, lines.len()));
2960    if !step_split_reassembles(block, &pieces) {
2961        step_split_trace("words-changed", block);
2962        return vec![block.clone()];
2963    }
2964    pieces
2965}
2966
2967/// Lines `start..end` of the paragraph as a block of their own: the parent's
2968/// kind, page and verdicts, their own spans, text and box. The text goes
2969/// through the same assembly as any block's -- the word spacing and the
2970/// right-to-left reordering per line, [`join_lines`] across them -- so a piece
2971/// reads the way the builder would have written it.
2972fn piece_of(block: &Block, lines: &[Vec<usize>], start: usize, end: usize) -> Block {
2973    let mut indices: Vec<usize> = lines[start..end].iter().flatten().copied().collect();
2974    indices.sort_unstable();
2975    let spans: Vec<TextSpan> = indices.iter().map(|i| block.spans[*i].clone()).collect();
2976    let mut texts: Vec<String> = Vec::with_capacity(end - start);
2977    for line in &lines[start..end] {
2978        let mut order: Vec<usize> = line.clone();
2979        order.sort_unstable();
2980        let row: Vec<TextSpan> = order.iter().map(|i| block.spans[*i].clone()).collect();
2981        texts.push(TextBlock { spans: row }.text());
2982    }
2983    let mut piece = Block {
2984        kind: block.kind,
2985        page: block.page,
2986        bbox: [0.0; 4],
2987        text: join_lines(&texts),
2988        spans,
2989        index: 0,
2990        continues_previous: start == 0 && block.continues_previous,
2991        confidence: block.confidence,
2992        grid: block.grid,
2993        table_origin: block.table_origin,
2994        trace_learned: block.trace_learned,
2995    };
2996    recompute_bbox(&mut piece);
2997    piece
2998}
2999
3000/// The left edge of every column of a run of cells, from its unspanned cells.
3001fn column_lefts(cells: &[Block]) -> Vec<Option<f64>> {
3002    let mut lefts: Vec<Option<f64>> = Vec::new();
3003    for b in cells {
3004        if let BlockKind::TableCell {
3005            column,
3006            column_span: 1,
3007            ..
3008        } = b.kind
3009        {
3010            let c = column as usize;
3011            if lefts.len() <= c {
3012                lefts.resize(c + 1, None);
3013            }
3014            let x = b.bbox[0];
3015            if x.is_finite() && b.bbox != [0.0; 4] {
3016                lefts[c] = Some(lefts[c].map_or(x, |l: f64| l.min(x)));
3017            }
3018        }
3019    }
3020    lefts
3021}
3022
3023fn same_columns(a: &[Block], b: &[Block]) -> bool {
3024    let (la, lb) = (column_lefts(a), column_lefts(b));
3025    la.len() >= 2
3026        && la.len() == lb.len()
3027        && la.iter().zip(&lb).all(|(x, y)| match (x, y) {
3028            (Some(x), Some(y)) => (x - y).abs() <= CONTINUED_COLUMN_TOLERANCE,
3029            _ => true,
3030        })
3031        && la
3032            .iter()
3033            .zip(&lb)
3034            .filter(|(x, y)| x.is_some() && y.is_some())
3035            .count()
3036            >= 2
3037}
3038
3039/// How far apart, in points, a column's left edge may stand on two pages and
3040/// still be the same column.
3041const CONTINUED_COLUMN_TOLERANCE: f64 = 6.0;
3042
3043/// Which way a column's indents point, decided once for the whole column.
3044#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3045enum IndentStyle {
3046    /// No line in the column is indented against the others.
3047    Flat,
3048    /// An indented line STARTS a paragraph.
3049    FirstLine,
3050    /// An indented line CONTINUES the one above it.
3051    Hanging,
3052}
3053
3054/// One line, reduced to the numbers the merge decides on.
3055struct LineMetrics {
3056    /// Leftmost edge of the line's ordinary text.
3057    left: f64,
3058    /// The left edge the indent is measured from. Equal to `left`, except
3059    /// beside a drop cap, where it is the cap's own left: the lines set around
3060    /// an initial are indented by the initial and not by the typesetter.
3061    indent_left: f64,
3062    /// Rightmost edge of the line.
3063    right: f64,
3064    /// The baseline the line sits on.
3065    baseline: f64,
3066    /// Top of the line's glyphs.
3067    top: f64,
3068    /// Bottom of the line's glyphs.
3069    bottom: f64,
3070    /// The type size most of the line's characters are set in.
3071    font_size: f64,
3072    /// Whether most of the line's characters are set in a bold face.
3073    bold: bool,
3074    /// The line as it reads.
3075    text: String,
3076    /// Index into `spans` of an oversized initial, when the line carries one.
3077    initial: Option<usize>,
3078    /// Gaps inside the line wide enough to be a gutter between table cells.
3079    wide_gaps: Vec<(f64, f64)>,
3080    /// Whether a second line confirmed one of those gaps.
3081    tabular: bool,
3082    /// The list marker this line opens with, if any -- a CANDIDATE, which one
3083    /// line's own text is all the evidence there is for (#569).
3084    marker: Option<Marker>,
3085    /// Where the text after that marker begins, when the page drew the marker
3086    /// in a span of its own. `None` where it did not: a marker glued into the
3087    /// line's first span has no column of its own to measure.
3088    marker_text_left: Option<f64>,
3089    /// The candidate once a run of agreeing markers in the same column has
3090    /// confirmed it. Set by [`mark_list_items`] and by nothing else.
3091    item: Option<ItemMark>,
3092}
3093
3094/// A confirmed list item: what to take off the front of the line, how deep it
3095/// sits, and the number the document gave it.
3096#[derive(Debug, Clone, Copy)]
3097struct ItemMark {
3098    /// Bytes of the marker and the white space after it, from the start of the
3099    /// line's text.
3100    len: usize,
3101    /// Nesting depth, 0 for the outermost list in the column.
3102    level: u8,
3103    /// The number the document wrote, or `None` for a bullet or an outline's
3104    /// `II.4.1`, which the item's text keeps.
3105    ordinal: Option<u32>,
3106    /// The item's own content column: where its text starts, and therefore
3107    /// where its continuation lines hang from.
3108    text_left: Option<f64>,
3109}
3110
3111/// The right edge of a span, measured where the font gave a width and estimated
3112/// at half an em per character where it did not -- the same fallback
3113/// [`crate::text_info::TextSpanInfo`] states as the wire contract.
3114pub(crate) fn span_right(span: &TextSpan) -> f64 {
3115    if span.width > 0.0 {
3116        span.x + span.width
3117    } else {
3118        span.x + span.font_size * 0.5 * span.text.chars().count() as f64
3119    }
3120}
3121
3122/// The vertical extent of a span, from its glyph boxes where they exist and
3123/// from the type size where they do not.
3124fn span_extent(span: &TextSpan) -> (f64, f64) {
3125    let mut bottom = f64::INFINITY;
3126    let mut top = f64::NEG_INFINITY;
3127    for bounds in &span.char_bounds {
3128        bottom = bottom.min(bounds[1]);
3129        top = top.max(bounds[3]);
3130    }
3131    if bottom.is_finite() && top.is_finite() && top > bottom {
3132        return (bottom, top);
3133    }
3134    // A conventional ascender and descender. Used only to ask whether a ruling
3135    // line falls between two lines and to bound a block, so it does not have to
3136    // be the font's own.
3137    (span.y - span.font_size * 0.2, span.y + span.font_size * 0.8)
3138}
3139
3140fn char_count(span: &TextSpan) -> usize {
3141    span.text.chars().filter(|c| !c.is_whitespace()).count()
3142}
3143
3144/// Where the text after a list marker begins: the left edge of the first span
3145/// the marker does not reach into (#569).
3146///
3147/// A typeset list draws its marker in a run of its own, so the answer is the
3148/// next span's `x` and it is exact. A marker the page drew inside the line's
3149/// first span -- one text run holding `1. The committee` -- has no edge of its
3150/// own to give, and this answers `None` rather than estimating one: the caller
3151/// has a geometric fallback for that case and an invented column would silence
3152/// it.
3153///
3154/// Counted in non-whitespace characters, because the line's text is the spans
3155/// joined with a space wherever the page left a gap: the byte offsets of the
3156/// marker inside that text are not offsets into any span, and the characters
3157/// are the one thing both sides agree on.
3158///
3159/// EXACTLY, and `None` the moment the count overshoots. A span that runs past
3160/// the end of the marker is a span the marker is INSIDE, and the next span
3161/// after it is wherever the line happens to change face -- on `cweb` that is a
3162/// word in the middle of the item, forty points in, and an item whose content
3163/// column was read from there loses every line that wraps under it.
3164fn text_left_after(line: &TextBlock, order: &[usize], marker_chars: usize) -> Option<f64> {
3165    if marker_chars == 0 {
3166        return None;
3167    }
3168    let mut seen = 0usize;
3169    for i in order {
3170        match seen.cmp(&marker_chars) {
3171            // A run of white space after the marker is the spacing the page
3172            // set it apart with, not where its text begins (#574).
3173            Ordering::Equal if char_count(&line.spans[*i]) == 0 => {}
3174            Ordering::Equal => return Some(line.spans[*i].x),
3175            Ordering::Greater => return None,
3176            Ordering::Less => seen += char_count(&line.spans[*i]),
3177        }
3178    }
3179    None
3180}
3181
3182impl LineMetrics {
3183    /// Everything the merge asks about one line, an oversized initial taken out
3184    /// of its geometry where the line has one.
3185    fn of(line: &TextBlock) -> Self {
3186        Self::of_with(line, true, false)
3187    }
3188
3189    /// The same, with `allow_initial` false: every span counts, including a
3190    /// leading oversized one. [`confirm_initials`] asks for this again on a
3191    /// line whose initial turned out to be a marker -- the geometry taken with
3192    /// the marker held out is not the geometry of a line that keeps it, and
3193    /// leaving it behind moved that line's left edge a marker's width to the
3194    /// right and made the page read as first-line indented.
3195    ///
3196    /// `cell_path_words` is
3197    /// [`ExtractOptions::cell_path_words`](ExtractOptions::cell_path_words):
3198    /// the line's text reads with the cell-path word rules where true.
3199    fn of_with(line: &TextBlock, allow_initial: bool, cell_path_words: bool) -> Self {
3200        let mut order: Vec<usize> = (0..line.spans.len()).collect();
3201        order.sort_by(|a, b| {
3202            line.spans[*a]
3203                .x
3204                .partial_cmp(&line.spans[*b].x)
3205                .unwrap_or(Ordering::Equal)
3206        });
3207
3208        // The initial, if there is one: the leftmost span, no more than two
3209        // characters, set far larger than the rest of the line. It is taken out
3210        // of the geometry below -- a three-line initial sits on the baseline of
3211        // the THIRD line, so left unhandled it makes that line start a page's
3212        // width to the left of its own paragraph and tower over it in size.
3213        let dominant_size = dominant_font_size(line.spans.iter());
3214        let initial = order.first().copied().filter(|&first| {
3215            allow_initial
3216                && line.spans.len() > 1
3217                && char_count(&line.spans[first]) <= INITIAL_MAX_CHARS
3218                && char_count(&line.spans[first]) > 0
3219                && dominant_size > 0.0
3220                && line.spans[first].font_size >= INITIAL_SIZE_RATIO * dominant_size
3221        });
3222
3223        let body: Vec<usize> = order
3224            .iter()
3225            .copied()
3226            .filter(|i| Some(*i) != initial)
3227            .collect();
3228        let measured: Vec<&TextSpan> = if body.is_empty() {
3229            order.iter().map(|i| &line.spans[*i]).collect()
3230        } else {
3231            body.iter().map(|i| &line.spans[*i]).collect()
3232        };
3233
3234        let left = measured.iter().map(|s| s.x).fold(f64::INFINITY, f64::min);
3235        let right = measured
3236            .iter()
3237            .map(|s| span_right(s))
3238            .fold(f64::NEG_INFINITY, f64::max);
3239        let mut bottom = f64::INFINITY;
3240        let mut top = f64::NEG_INFINITY;
3241        for span in &measured {
3242            let (b, t) = span_extent(span);
3243            bottom = bottom.min(b);
3244            top = top.max(t);
3245        }
3246
3247        // The baseline and the size of the span that carries most of the line's
3248        // characters, so a superscript marker or a bullet does not speak for it.
3249        let anchor = anchor_span(measured.iter().copied()).or_else(|| line.spans.first());
3250        let baseline = anchor.map(|s| s.y).unwrap_or(0.0);
3251        let font_size = match dominant_font_size(measured.iter().copied()) {
3252            size if size > 0.0 => size,
3253            _ => anchor.map(|s| s.font_size).unwrap_or(0.0),
3254        };
3255        let bold = spans_read_bold(measured.iter().copied());
3256
3257        // Gaps inside the line wide enough to be a gutter rather than a word
3258        // space. Recorded here and believed only in `mark_tabular`, where a
3259        // second line has to show one at the same x.
3260        let mut wide_gaps = Vec::new();
3261        if font_size > 0.0 {
3262            let threshold = TABULAR_GAP_EM * font_size;
3263            for pair in body.windows(2) {
3264                let end = span_right(&line.spans[pair[0]]);
3265                let start = line.spans[pair[1]].x;
3266                if start - end >= threshold {
3267                    wide_gaps.push((end, start));
3268                }
3269            }
3270        }
3271
3272        // WHAT THE PAGE PUT AT THE HEAD OF THE LINE, in the three ways the
3273        // characters alone cannot say it. All three are read off the span the
3274        // line's TEXT opens with, and only where that span really is the front
3275        // of it: the spans are in draw order and the text is assembled from
3276        // them, so a line whose leftmost span is not its first, or whose text
3277        // was reordered for a right-to-left reading, has no prefix to measure.
3278        // How LONG the prefix may be is each rule's own business, so the cap
3279        // is on each of them and not here.
3280        let text = line.text_with_cell_path_words(cell_path_words);
3281        let head = line
3282            .spans
3283            .first()
3284            .filter(|span| char_count(span) > 0)
3285            .filter(|span| line.spans.len() > 1 && text.starts_with(span.text.as_str()));
3286        // A bullet drawn in a picture font decodes to whatever code the font
3287        // maps it to -- `l` in ZapfDingbats -- so the character says nothing
3288        // and only the font does.
3289        let symbol_prefix = head
3290            .filter(|span| char_count(span) <= MARKER_MAX_CHARS)
3291            .filter(|span| lists::is_symbol_font(span.font_name.as_deref()))
3292            .map(|span| span.text.len());
3293        // A REFERENCE NUMBERED WITH A RAISED FIGURE (#578). Smaller than the
3294        // line it opens and above its baseline is what a journal's reference
3295        // number is and what a sentence opening with a year is not. Three
3296        // characters, because a bibliography passes a hundred entries and the
3297        // grammar next door reads three digits.
3298        let raised_prefix = head
3299            .filter(|span| char_count(span) <= MAX_ORDINAL_CHARS)
3300            .filter(|span| font_size > 0.0 && span.font_size < font_size * RAISED_SIZE)
3301            .filter(|span| span.y > baseline + RAISED_RISE * font_size)
3302            .map(|span| span.text.len());
3303        // A GLYPH THE PAGE SET APART (#578): one character, in a run of its
3304        // own, and then a gap no word space could be. The gap is measured to
3305        // the next span of the line in draw order that draws a character,
3306        // which is where its text goes on: a page that sets the gap with a
3307        // space run of its own (`t`, ` `, `Gross margins`, NYSE_MSA on the
3308        // rig, #574) has put the space in the gap, not closed it.
3309        let apart_prefix = head
3310            .filter(|span| char_count(span) == 1 && font_size > 0.0)
3311            .filter(|span| {
3312                line.spans
3313                    .iter()
3314                    .skip(1)
3315                    .find(|next| char_count(next) > 0)
3316                    .is_some_and(|next| next.x - span_right(span) >= MARKER_GAP_EM * font_size)
3317            })
3318            .map(|span| span.text.len());
3319
3320        let marker = lists::marker_at(
3321            &text,
3322            lists::Lead {
3323                symbol: symbol_prefix,
3324                raised: raised_prefix,
3325                apart: apart_prefix,
3326            },
3327        );
3328        let marker_text_left = marker.and_then(|marker| {
3329            let marker_chars = text[..marker.len]
3330                .chars()
3331                .filter(|c| !c.is_whitespace())
3332                .count();
3333            text_left_after(line, &order, marker_chars)
3334        });
3335
3336        Self {
3337            left,
3338            indent_left: left,
3339            right,
3340            baseline,
3341            top,
3342            bottom,
3343            font_size,
3344            bold,
3345            marker,
3346            marker_text_left,
3347            text,
3348            initial,
3349            wide_gaps,
3350            tabular: false,
3351            item: None,
3352        }
3353    }
3354
3355    /// Whether the line reads as the end of a sentence.
3356    fn ends_sentence(&self) -> bool {
3357        let trimmed = self
3358            .text
3359            .trim_end()
3360            .trim_end_matches(['"', '\'', '”', '’', ')', ']', '»']);
3361        matches!(
3362            trimmed.chars().next_back(),
3363            Some('.') | Some('!') | Some('?') | Some(':') | Some(';')
3364        )
3365    }
3366}
3367
3368/// The type size most of a set of spans' GLYPHS are set in.
3369///
3370/// Per glyph, because a span's own `font_size` is the size of its FIRST glyph
3371/// and a span is a run of glyphs the space consensus merged: a bullet set in a
3372/// symbol font two points smaller than the item beside it makes the whole line
3373/// read as two points smaller, and every item of every bulleted list on
3374/// `tracemonkey` then failed the type-size test against its own continuation.
3375/// `draw_glyph` writes each glyph's bound as `[x, y, x + advance, y + size]`,
3376/// so the size of every glyph drawn is there to be read; a span with no bounds
3377/// -- every hand-built one -- answers with its own.
3378pub(crate) fn dominant_font_size<'a>(spans: impl Iterator<Item = &'a TextSpan>) -> f64 {
3379    let mut sizes: Vec<f64> = Vec::new();
3380    for span in spans {
3381        if span.char_bounds.is_empty() {
3382            sizes.extend(std::iter::repeat_n(span.font_size, char_count(span).max(1)));
3383        } else {
3384            sizes.extend(span.char_bounds.iter().map(|bound| bound[3] - bound[1]));
3385        }
3386    }
3387    sizes.retain(|size| *size > 0.0);
3388    if sizes.is_empty() {
3389        return 0.0;
3390    }
3391    sizes.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
3392    sizes[sizes.len() / 2]
3393}
3394
3395/// Whether most of a set of spans' characters are set in a bold face.
3396///
3397/// Per character, so a bold phrase inside a line of prose does not speak for
3398/// it. Shared with the v2 release rejoin, which reads a released cell's
3399/// weight the way the builder reads a line's.
3400pub(crate) fn spans_read_bold<'a>(spans: impl Iterator<Item = &'a TextSpan>) -> bool {
3401    let mut heavy = 0usize;
3402    let mut total = 0usize;
3403    for span in spans {
3404        let chars = char_count(span);
3405        total += chars;
3406        if span.is_bold {
3407            heavy += chars;
3408        }
3409    }
3410    total > 0 && heavy * 2 > total
3411}
3412
3413/// The span that carries most of a line's characters, so a superscript marker
3414/// or a bullet does not speak for its baseline. Shared with the v2 release
3415/// rejoin, which reads a released cell's baseline the way the builder reads
3416/// a line's.
3417pub(crate) fn anchor_span<'a>(spans: impl Iterator<Item = &'a TextSpan>) -> Option<&'a TextSpan> {
3418    spans.max_by_key(|s| char_count(s))
3419}
3420
3421/// How much of the narrower of two lines lies under the wider one.
3422fn overlap_fraction(a: &LineMetrics, b: &LineMetrics) -> f64 {
3423    overlap_fraction_of(a.left, a.right, b.left, b.right)
3424}
3425
3426/// The same, on bare edges: the narrow-column pair join reads lines the
3427/// builder never measured ([`JoinLine`]), and the fraction is one definition.
3428pub(crate) fn overlap_fraction_of(a_left: f64, a_right: f64, b_left: f64, b_right: f64) -> f64 {
3429    let overlap = a_right.min(b_right) - a_left.max(b_left);
3430    let narrower = (a_right - a_left).max(0.0).min((b_right - b_left).max(0.0));
3431    if narrower <= 0.0 {
3432        return if overlap >= 0.0 { 1.0 } else { 0.0 };
3433    }
3434    (overlap / narrower).max(0.0)
3435}
3436
3437/// A wide gap inside a line is a word space until a neighbouring line shows one
3438/// at the same place. Two lines with white running down between them at the
3439/// same x is a gutter, and a gutter inside a region the XY-cut refused to split
3440/// is a table nobody ruled.
3441fn mark_tabular(metrics: &mut [LineMetrics]) {
3442    let confirmed: Vec<bool> = (0..metrics.len())
3443        .map(|i| {
3444            if metrics[i].wide_gaps.is_empty() {
3445                return false;
3446            }
3447            let neighbours = [i.checked_sub(1), (i + 1 < metrics.len()).then_some(i + 1)];
3448            neighbours.into_iter().flatten().any(|j| {
3449                metrics[i].wide_gaps.iter().any(|mine| {
3450                    metrics[j]
3451                        .wide_gaps
3452                        .iter()
3453                        .any(|theirs| mine.0 < theirs.1 && theirs.0 < mine.1)
3454                })
3455            })
3456        })
3457        .collect();
3458    for (line, is_tabular) in metrics.iter_mut().zip(confirmed) {
3459        line.tabular = is_tabular;
3460    }
3461}
3462
3463/// Which oversized initials are drop caps, and what the lines beside them are
3464/// indented by.
3465///
3466/// WHAT MAKES IT A DROP CAP is that the page set lines BESIDE it. A large
3467/// bullet, a section marker, a pull-quote's opening mark: each is a short span
3468/// set larger than the line it opens, and [`LineMetrics::of`] cannot tell them
3469/// from an initial, because the difference is not in the line -- it is in
3470/// whether anything is set into the space the letter takes up (codex, round 2).
3471/// An initial with nothing beside it is left in its line, where it belongs, and
3472/// the line keeps its own geometry.
3473///
3474/// The lines that ARE beside one are indented by the initial and not by the
3475/// typesetter, so they are measured from the initial's own left edge.
3476fn confirm_initials(metrics: &mut [LineMetrics], lines: &[&TextBlock], cell_path_words: bool) {
3477    let boxes: Vec<Option<(f64, f64, f64, f64)>> = metrics
3478        .iter()
3479        .zip(lines)
3480        .map(|(m, line)| {
3481            let span = &line.spans[m.initial?];
3482            let (bottom, top) = span_extent(span);
3483            Some((span.x, span_right(span), bottom, top))
3484        })
3485        .collect();
3486
3487    // At the cap's top, not below it: the first line beside a three-line
3488    // initial is set with its own cap height against the initial's, so its
3489    // baseline sits AT the top of the initial's box and a strict comparison
3490    // leaves that line -- the one the initial belongs to -- out of the very set
3491    // this exists to build.
3492    let beside = |line: &LineMetrics, (left, right, bottom, top): (f64, f64, f64, f64)| {
3493        let _ = left;
3494        line.baseline > bottom && line.baseline <= top && line.left >= right - 1.0
3495    };
3496
3497    let mut confirmed: Vec<(f64, f64, f64, f64)> = Vec::new();
3498    let mut rejected: Vec<usize> = Vec::new();
3499    for (i, cap) in boxes.iter().enumerate() {
3500        let Some(cap) = *cap else { continue };
3501        // Beside, and ABOVE: a line on the initial's own baseline is the line
3502        // it was drawn in.
3503        let has_neighbour = metrics
3504            .iter()
3505            .enumerate()
3506            .any(|(j, line)| j != i && line.baseline > metrics[i].baseline && beside(line, cap));
3507        if has_neighbour {
3508            confirmed.push(cap);
3509        } else {
3510            rejected.push(i);
3511        }
3512    }
3513    for i in rejected {
3514        metrics[i] = LineMetrics::of_with(lines[i], false, cell_path_words);
3515    }
3516    if confirmed.is_empty() {
3517        return;
3518    }
3519    for line in metrics.iter_mut() {
3520        for cap in &confirmed {
3521            if beside(line, *cap) {
3522                line.indent_left = line.indent_left.min(cap.0);
3523            }
3524        }
3525    }
3526}
3527
3528/// Which candidate markers a region confirms as list items, and how deep each
3529/// one sits (#569).
3530///
3531/// A marker on one line is a coincidence; a region that puts the same KIND of
3532/// marker at the same left edge twice, climbing if it is numbered, is a list.
3533/// `crate::lists` owns that rule and the grammar under it -- this is where the
3534/// page's own geometry is handed to it.
3535///
3536/// THE REGION AND NOT THE COLUMN RUN, which is the same frame a left edge means
3537/// anything in -- the XY-cut's leaf -- and deliberately not the finer split
3538/// [`column_runs`] makes inside it. That split fires when two consecutive lines
3539/// barely overlap horizontally, and a SHORT nested item is exactly that: on
3540///
3541/// ```text
3542/// - Fruit
3543///   - Apple
3544///   - Pear
3545/// - Vegetables
3546/// ```
3547///
3548/// `- Fruit` ends before `- Apple` begins, so the run seam falls between every
3549/// level change. Confirmed inside those slices, each outer marker is a
3550/// singleton and never an item at all, and the inner pair -- the only run left
3551/// -- is the leftmost edge of its slice and comes out as level 0. A list is one
3552/// of the few things on a page whose lines are MEANT not to line up, so the
3553/// rule has to see the whole region the cut handed over (codex, round 1).
3554///
3555/// AND NOT LESS THAN THE COLUMN THE REGIONS STACK INTO (#589). The XY-cut
3556/// splits a column at every band of white a line tall, and a list whose items
3557/// are paragraphs is set with exactly that band between them: `1.` in one
3558/// region, `2.` in the next, each a singleton that no run can confirm. Read on
3559/// the document, that shape was 12 of the 16 list items of one annual report
3560/// and none of them were found. So consecutive regions that stand one under
3561/// the other in the same column are read as one: the regions are consecutive
3562/// slices of `metrics`, which makes the stack a slice too.
3563fn mark_list_items(metrics: &mut [LineMetrics], regions: &[(usize, usize)]) {
3564    let foot = in_the_foot(metrics);
3565    for (start, end) in column_stacks(metrics, regions) {
3566        mark_column_items(&mut metrics[start..end], &foot[start..end]);
3567    }
3568}
3569
3570/// Letters a line needs before it counts as running text rather than a page
3571/// number, a folio or a reference code.
3572const RUNNING_TEXT_LETTERS: usize = 20;
3573
3574/// The share of a page's text height, from the bottom, that a foot may take and
3575/// that the page's body is therefore not measured in.
3576const FOOT_SHARE: f64 = 0.4;
3577
3578/// The size most of these lines' characters are set in.
3579fn body_size_of<'a>(lines: impl Iterator<Item = &'a LineMetrics>) -> f64 {
3580    let mut sizes: Vec<f64> = Vec::new();
3581    for line in lines {
3582        let chars = line.text.chars().filter(|c| !c.is_whitespace()).count();
3583        if line.font_size > 0.0 {
3584            sizes.extend(std::iter::repeat_n(line.font_size, chars.max(1)));
3585        }
3586    }
3587    sizes.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
3588    sizes.get(sizes.len() / 2).copied().unwrap_or(0.0)
3589}
3590
3591/// The lines of a page's foot: set smaller than the page's body and below the
3592/// last line that is set in it.
3593///
3594/// A FOOTNOTE IS NOT AN ITEM (#589). Legislation marks its footnotes `(a)`,
3595/// `(b)`, `(c)` -- a run that climbs like any list's, in a region of its own,
3596/// where the small type IS the body the region is measured against. Only the
3597/// page knows the type is small: measured against the region, a block of
3598/// footnotes is a list of body-size items. A list of references is not caught
3599/// here: where a page is all small type, that small type is its body.
3600fn in_the_foot(metrics: &[LineMetrics]) -> Vec<bool> {
3601    // THE BODY IS MEASURED ABOVE THE FOOT. Near the end of a section the
3602    // footnotes can hold more characters than the text above them, and a
3603    // median over the whole page then calls the footnote size the body --
3604    // after which nothing is smaller than it (codex, #1950). The upper part
3605    // of the page is where the foot is not.
3606    let low = metrics
3607        .iter()
3608        .map(|l| l.bottom)
3609        .fold(f64::INFINITY, f64::min);
3610    let high = metrics
3611        .iter()
3612        .map(|l| l.top)
3613        .fold(f64::NEG_INFINITY, f64::max);
3614    let upper: Vec<usize> = (0..metrics.len())
3615        .filter(|i| metrics[*i].bottom >= low + FOOT_SHARE * (high - low))
3616        .collect();
3617    let page_body = if upper.is_empty() {
3618        column_body(metrics).0
3619    } else {
3620        body_size_of(upper.iter().map(|i| &metrics[*i]))
3621    };
3622    if page_body <= 0.0 {
3623        return vec![false; metrics.len()];
3624    }
3625    // RUNNING TEXT, not any line in body size: legislation prints its page
3626    // number in body size under the footnotes, and measured from that line the
3627    // foot was empty on every page of the order (#589).
3628    let last_body_line = metrics
3629        .iter()
3630        .filter(|l| {
3631            l.font_size >= page_body * (1.0 - SIZE_TOLERANCE)
3632                && l.text.chars().filter(|c| c.is_alphabetic()).count() >= RUNNING_TEXT_LETTERS
3633        })
3634        .map(|l| l.bottom)
3635        .fold(f64::INFINITY, f64::min);
3636    // A HEADING BETWEEN THE BODY AND THE SMALL TYPE opens a section, and a
3637    // section is not a foot: a bibliography that starts low on a page is set
3638    // smaller than the body and under it, exactly like footnotes, and what
3639    // tells them apart is the `References` above it. Nothing opens a block of
3640    // footnotes with a heading (codex, #1950).
3641    let heads: Vec<(f64, f64)> = metrics
3642        .iter()
3643        // A head says something. Legislation draws the rule over its
3644        // footnotes as a line of blank glyphs in a larger size -- it reads
3645        // as larger type and nothing in it -- and counted as a head it opened
3646        // a section over every footnote under it (uksi on the rig, #574). A
3647        // head of figures, `2024`, says something too (codex, #2041).
3648        .filter(|l| {
3649            let letters = l.text.chars().filter(|c| c.is_alphabetic()).count();
3650            l.top <= last_body_line
3651                && l.marker.is_none()
3652                && (l.bold || l.font_size > page_body * (1.0 + SIZE_TOLERANCE))
3653                && l.text.chars().any(char::is_alphanumeric)
3654                && letters < RUNNING_TEXT_LETTERS
3655        })
3656        .map(|l| (l.top, l.bottom))
3657        .collect();
3658    metrics
3659        .iter()
3660        .map(|l| {
3661            l.font_size < page_body * (1.0 - SIZE_TOLERANCE)
3662                && l.top <= last_body_line
3663                && !heads.iter().any(|(_, bottom)| *bottom >= l.top)
3664        })
3665        .collect()
3666}
3667
3668/// How much white, in lines of the type, may stand between two regions of one
3669/// stack. A blank line between items is one; a list further down the column,
3670/// past a paragraph and its spacing, is a list of its own and must not be
3671/// nested under this one by its indent (codex, #1950).
3672const STACK_GAP_LINES: f64 = 3.0;
3673
3674/// Consecutive regions that stand one under the other in the same column,
3675/// merged into one span of lines.
3676///
3677/// Same column means the horizontal extents overlap by at least half the
3678/// narrower of the two, so a side-by-side pair -- two columns, a sidebar
3679/// beside its text -- never joins. Under means the next region starts below
3680/// where the stack ends, so a region the cut ordered back up the page (the
3681/// top of the next column) starts a stack of its own.
3682fn column_stacks(metrics: &[LineMetrics], regions: &[(usize, usize)]) -> Vec<(usize, usize)> {
3683    let extent = |start: usize, end: usize| {
3684        let lines = &metrics[start..end];
3685        let left = lines.iter().map(|l| l.left).fold(f64::INFINITY, f64::min);
3686        let right = lines
3687            .iter()
3688            .map(|l| l.right)
3689            .fold(f64::NEG_INFINITY, f64::max);
3690        let top = lines
3691            .iter()
3692            .map(|l| l.top)
3693            .fold(f64::NEG_INFINITY, f64::max);
3694        let bottom = lines.iter().map(|l| l.bottom).fold(f64::INFINITY, f64::min);
3695        (left, right, top, bottom)
3696    };
3697    let mut stacks: Vec<(usize, usize)> = Vec::new();
3698    let mut last: Option<(f64, f64, f64, f64)> = None;
3699    for &(start, end) in regions {
3700        if start >= end {
3701            continue;
3702        }
3703        let here = extent(start, end);
3704        let joins = match (stacks.last(), last) {
3705            (Some(&(_, stack_end)), Some((left, right, _, bottom))) if stack_end == start => {
3706                let overlap = right.min(here.1) - left.max(here.0);
3707                let narrower = (right - left).min(here.1 - here.0);
3708                let size = metrics[start..end]
3709                    .iter()
3710                    .map(|l| l.font_size)
3711                    .fold(0.0_f64, f64::max);
3712                narrower > 0.0
3713                    && overlap >= 0.5 * narrower
3714                    && here.2 <= bottom + 1.0
3715                    && bottom - here.2 <= STACK_GAP_LINES * size.max(1.0)
3716            }
3717            _ => false,
3718        };
3719        if joins {
3720            if let (Some(stack), Some(prev)) = (stacks.last_mut(), last) {
3721                stack.1 = end;
3722                last = Some((
3723                    prev.0.min(here.0),
3724                    prev.1.max(here.1),
3725                    prev.2,
3726                    here.3.min(prev.3),
3727                ));
3728            }
3729        } else {
3730            stacks.push((start, end));
3731            last = Some(here);
3732        }
3733    }
3734    stacks
3735}
3736
3737/// The same, for one region.
3738fn mark_column_items(column: &mut [LineMetrics], foot: &[bool]) {
3739    let (body_size, body_bold) = column_body(column);
3740
3741    // A CANDIDATE IS SET IN THE BODY -- WHEN IT IS NUMBERED. A numbered
3742    // heading is a run of agreeing markers at one left edge and is not a list;
3743    // what separates it is that the page sets it above its body, which is the
3744    // same evidence `crate::headings` reads and the reason the two never claim
3745    // the same block. Smaller type is admitted -- a list of footnotes is a
3746    // list -- and larger is not. A bullet is neither a heading nor a footnote
3747    // mark, so the weight and the foot are not asked of it: a bold bullet run
3748    // in regular prose, and a small-type bullet list under the body, are lists
3749    // the page set apart the way lists are set apart.
3750    let candidates: Vec<usize> = (0..column.len())
3751        .filter(|i| {
3752            let line = &column[*i];
3753            let numbered = line.marker.is_some_and(|marker| marker.is_ordered());
3754            line.marker.is_some()
3755                && (!numbered || !foot[*i])
3756                && (!line.tabular || hangs_from_its_marker(line))
3757                && line.font_size > 0.0
3758                && line.font_size <= body_size * (1.0 + SIZE_TOLERANCE)
3759                && (!numbered || line.bold == body_bold)
3760        })
3761        .collect();
3762    if candidates.len() < 2 {
3763        return;
3764    }
3765
3766    // Grouped by left edge, because two levels of one list are two sequences
3767    // and their markers interleave on the page.
3768    let mut by_edge: Vec<Vec<usize>> = Vec::new();
3769    let mut sorted = candidates.clone();
3770    sorted.sort_by(|a, b| {
3771        column[*a]
3772            .indent_left
3773            .partial_cmp(&column[*b].indent_left)
3774            .unwrap_or(Ordering::Equal)
3775    });
3776    for i in sorted {
3777        let edge = column[i].indent_left;
3778        match by_edge.last_mut() {
3779            Some(group)
3780                if edge - column[group[0]].indent_left
3781                    <= INDENT_MATCH_EM * column[i].font_size.max(1.0) =>
3782            {
3783                group.push(i)
3784            }
3785            _ => by_edge.push(vec![i]),
3786        }
3787    }
3788
3789    // Lifted out of `column` before anything is written back to it: every read
3790    // below is of a marker, and every write is of an item.
3791    let markers: Vec<Option<Marker>> = column.iter().map(|line| line.marker).collect();
3792    let marker_of = |i: usize| markers[i].expect("a candidate carries its marker");
3793
3794    // Each edge splits again into maximal SEQUENCES, before anything is
3795    // confirmed: a page with two numbered lists on it restarts at 1, and one
3796    // sequence over the whole column would see the fall and throw both lists
3797    // away. The sequence is also what reads a bare letter -- `i. ii. iii.` is
3798    // roman and `g. h. i.` is not, and the marker `i.` is 1 in the first and 9
3799    // in the second.
3800    // A LABEL IS NOT AN ITEM (#589). Notes to a set of accounts number their
3801    // sub-heads -- `(a) Accounts Payable`, then a paragraph, `(b) Acquisition
3802    // of Assets`, then a paragraph -- in body type, and the markers climb like
3803    // any list's. What the page says is that each of them is one short line
3804    // with a paragraph under it: the text after the marker stops well short of
3805    // the measure, so it did not run on, and what follows is not the next
3806    // marker. Where that paragraph starts -- back at the marker's edge or
3807    // hanging under the label's text -- the notes of one annual report do both.
3808    // A list has at most its LAST item in that position; a sequence in which
3809    // EVERY member is a label is a run of numbered sub-heads, and is left for
3810    // the heading pass. On that annual report the shape was 40 of the 96 items
3811    // we produced and none of the 82 the annotation drew.
3812    //
3813    // Short means a title's length, not a line that fell a word shy of the
3814    // measure: legislation sets `(i) the height of the highest part of the
3815    // roof of the original building,` and hangs the rest of the clause under
3816    // it, and that item is also one line with a non-marker line under it. What
3817    // tells the two apart is that the clause runs on -- it ends in a comma, a
3818    // dash, `and`, `or` -- and fills most of the measure, while a label is a
3819    // few words that end where they end.
3820    let left = column.iter().map(|l| l.left).fold(f64::INFINITY, f64::min);
3821    let measure = column
3822        .iter()
3823        .map(|l| l.right)
3824        .fold(f64::NEG_INFINITY, f64::max);
3825    let labels: Vec<bool> = (0..column.len())
3826        .map(|i| {
3827            let line = &column[i];
3828            let short = line.right - left < LABEL_SHARE * (measure - left);
3829            // Numbered only. A bulleted item with a short first line and its
3830            // description under it is the commonest list there is, and a
3831            // bullet carries no number for a sub-head to be counted by
3832            // (codex, #1950).
3833            let next = column.get(i + 1);
3834            // AN OUTLINE'S SUB-HEAD MAY BE LONG (#599). `II.4.11 Conformity of
3835            // the supplies delivered with the contract` fills most of the
3836            // measure and is still a title: it ends no sentence, and the line
3837            // under it did not wrap out of it. A clause does one or the other.
3838            let title = line.marker.is_some_and(|m| m.is_outline())
3839                && !line.ends_sentence()
3840                && next.is_some_and(|next| !wrapped_before(line, next, measure));
3841            line.marker.is_some_and(|m| m.is_ordered())
3842                && (short || title)
3843                && !runs_on(&line.text)
3844                && next.is_none_or(|next| next.marker.is_none())
3845        })
3846        .collect();
3847
3848    let mut confirmed: Vec<(usize, Option<u32>)> = Vec::new();
3849    let take = |sequence: &[usize], confirmed: &mut Vec<(usize, Option<u32>)>| {
3850        if sequence.len() < 2 || sequence.iter().all(|i| labels[*i]) {
3851            return;
3852        }
3853        let run: Vec<Marker> = sequence.iter().copied().map(marker_of).collect();
3854        let roman = lists::is_roman(&run);
3855        confirmed.extend(
3856            sequence
3857                .iter()
3858                .zip(&run)
3859                // An outline's `II.4.1` is no one number, and stays in the
3860                // item's text rather than being written as one.
3861                .map(|(i, marker)| (*i, marker.value(roman).filter(|_| !marker.keeps_its_text()))),
3862        );
3863    };
3864    for group in &mut by_edge {
3865        group.sort_unstable();
3866        let mut sequence: Vec<usize> = Vec::new();
3867        for i in group.iter().copied() {
3868            // A NUMBERED SUB-HEAD IS NOT A CLAUSE (#599). `2.1 Materials` over
3869            // its paragraph is the label shape, and an outline's numbers are
3870            // what both a contract's clauses and a paper's sections are
3871            // numbered with: the clauses run on, the sub-heads stay short, and
3872            // a sub-head among clauses is left where it was rather than
3873            // counted into their run.
3874            if labels[i] && marker_of(i).is_outline() {
3875                continue;
3876            }
3877            let mut extended: Vec<Marker> = sequence.iter().copied().map(marker_of).collect();
3878            extended.push(marker_of(i));
3879            if sequence.is_empty() || lists::is_a_run(&extended) {
3880                sequence.push(i);
3881            } else {
3882                take(&sequence, &mut confirmed);
3883                sequence = vec![i];
3884            }
3885        }
3886        take(&sequence, &mut confirmed);
3887    }
3888    if confirmed.is_empty() {
3889        return;
3890    }
3891
3892    // The levels: the region's own indent steps, ranked. Level 0 is the
3893    // leftmost edge that carries an item, whatever that edge measures.
3894    let tolerance = INDENT_MATCH_EM * body_size.max(1.0);
3895    let mut edges: Vec<f64> = confirmed
3896        .iter()
3897        .map(|(i, _)| column[*i].indent_left)
3898        .collect();
3899    edges.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
3900    let mut steps: Vec<f64> = Vec::new();
3901    for edge in edges {
3902        if steps.last().is_none_or(|last| edge - last > tolerance) {
3903            steps.push(edge);
3904        }
3905    }
3906
3907    for (i, ordinal) in confirmed {
3908        // The NEAREST step, not the last one this edge clears: an edge that
3909        // lands between two steps belongs to the one it is closer to, and a
3910        // walk that takes the last it clears hands it the deeper level every
3911        // time.
3912        let edge = column[i].indent_left;
3913        let level = steps
3914            .iter()
3915            .enumerate()
3916            .min_by(|(_, a), (_, b)| {
3917                (edge - *a)
3918                    .abs()
3919                    .partial_cmp(&(edge - *b).abs())
3920                    .unwrap_or(Ordering::Equal)
3921            })
3922            .map_or(0, |(rank, _)| rank)
3923            .min(lists::MAX_LIST_LEVEL as usize) as u8;
3924        let marker = marker_of(i);
3925        column[i].item = Some(ItemMark {
3926            len: if marker.keeps_its_text() {
3927                0
3928            } else {
3929                marker.len
3930            },
3931            level,
3932            ordinal,
3933            text_left: column[i].marker_text_left,
3934        });
3935    }
3936}
3937
3938/// Whether the one gutter a tabular line has is the white between its marker
3939/// and its text (#1973).
3940///
3941/// A list hung from its markers at one x -- a statute's contents, `§ 70` and
3942/// then the title a hand's width to the right -- has white running down the
3943/// page between the markers and the text, which is what `mark_tabular` calls a
3944/// gutter. That white is the list's hanging indent. A line whose ONE wide gap
3945/// ends where the text after its marker begins is a candidate like any other;
3946/// a second gap is a table's next column, and the line stays out.
3947fn hangs_from_its_marker(line: &LineMetrics) -> bool {
3948    let (Some(text_left), [(_, gap_end)]) = (line.marker_text_left, line.wide_gaps.as_slice())
3949    else {
3950        return false;
3951    };
3952    (gap_end - text_left).abs() <= INDENT_MATCH_EM * line.font_size.max(1.0)
3953}
3954
3955/// How much of the column's measure a numbered sub-head may fill and still be
3956/// read as a label rather than the first line of an item (#589).
3957const LABEL_SHARE: f64 = 0.6;
3958
3959/// Whether a line's text stops mid-sentence: a clause that continues on the
3960/// next line, which a label never does.
3961/// Whether `previous` was wrapped: the room it left before the column's measure
3962/// is too small for the first word of `line` and the space before it, so the
3963/// typesetter had to carry that word down (#599). The measure is the COLUMN's,
3964/// the longest line standing over it: taken from the item and the candidate alone,
3965/// a short paragraph after `2. Pears` would set it and pass for the room that
3966/// was left (codex, #1981). `(i) Implementation of
3967/// training so that report and medical accident` stops three ems short
3968/// because `investigation` is six wide; `2. Pears` stops with room for a
3969/// sentence and is finished (codex, #1950). The word's width is taken at the
3970/// line's own average advance, which is what a count of characters can say.
3971fn wrapped_before(previous: &LineMetrics, line: &LineMetrics, measure: f64) -> bool {
3972    let chars = previous.text.chars().filter(|c| !c.is_whitespace()).count();
3973    let width = previous.right - previous.indent_left;
3974    if chars == 0 || width <= 0.0 {
3975        return false;
3976    }
3977    let advance = width / previous.text.trim().chars().count().max(1) as f64;
3978    let Some(word) = line.text.split_whitespace().next() else {
3979        return false;
3980    };
3981    let needed = (word.chars().count() + 1) as f64 * advance;
3982    let room = measure - previous.right;
3983    room >= 0.0 && room < needed
3984}
3985
3986fn runs_on(text: &str) -> bool {
3987    let text = text.trim_end();
3988    text.ends_with([',', ';', ':', '-', '\u{2013}', '\u{2014}'])
3989        || text
3990            .rsplit(char::is_whitespace)
3991            .next()
3992            .is_some_and(|last| matches!(last, "and" | "or" | "of" | "the" | "to"))
3993}
3994
3995/// What a region sets its body in: the size most of its characters are at, and
3996/// whether most of them are bold.
3997fn column_body(column: &[LineMetrics]) -> (f64, bool) {
3998    let mut sizes: Vec<f64> = Vec::new();
3999    let mut heavy = 0usize;
4000    let mut total = 0usize;
4001    for line in column {
4002        let chars = line.text.chars().filter(|c| !c.is_whitespace()).count();
4003        total += chars;
4004        if line.bold {
4005            heavy += chars;
4006        }
4007        if line.font_size > 0.0 {
4008            sizes.extend(std::iter::repeat_n(line.font_size, chars.max(1)));
4009        }
4010    }
4011    sizes.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
4012    let size = sizes.get(sizes.len() / 2).copied().unwrap_or(0.0);
4013    (size, total > 0 && heavy * 2 > total)
4014}
4015
4016/// Where one column of lines ends and the next begins.
4017///
4018/// The XY-cut hands its regions over in reading order, so lines of one column
4019/// are consecutive. What marks the seam is that the next line does not descend
4020/// -- it is back at the top of the page -- or that it barely lies under the one
4021/// before it.
4022fn column_runs(metrics: &[LineMetrics]) -> Vec<(usize, usize)> {
4023    let mut runs = Vec::new();
4024    let mut start = 0usize;
4025    for i in 1..metrics.len() {
4026        let previous = &metrics[i - 1];
4027        let current = &metrics[i];
4028        let descends = previous.baseline - current.baseline
4029            > MIN_BASELINE_STEP_EM * current.font_size.max(previous.font_size);
4030        if !descends || overlap_fraction(previous, current) < COLUMN_OVERLAP_FRACTION {
4031            runs.push((start, i));
4032            start = i;
4033        }
4034    }
4035    if start < metrics.len() {
4036        runs.push((start, metrics.len()));
4037    }
4038    runs
4039}
4040
4041/// The left edge most of a column's lines are set to.
4042///
4043/// The tolerance comes from the column's MIDDLE type size and not from its
4044/// first line's: a column that opens with a heading would otherwise measure its
4045/// margin with the heading's em, and on a 24-point heading over 9-point body a
4046/// first-line indent of one em falls inside the tolerance and stops being an
4047/// indent at all.
4048fn body_left(metrics: &[LineMetrics]) -> f64 {
4049    let mut sizes: Vec<f64> = metrics.iter().map(|m| m.font_size).collect();
4050    sizes.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
4051    let tolerance = INDENT_MATCH_EM * sizes[sizes.len() / 2].max(1.0);
4052
4053    let mut lefts: Vec<f64> = metrics.iter().map(|m| m.indent_left).collect();
4054    lefts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
4055    let mut best = lefts.first().copied().unwrap_or(0.0);
4056    let mut best_count = 0usize;
4057    for (i, left) in lefts.iter().enumerate() {
4058        let count = lefts[i..]
4059            .iter()
4060            .take_while(|l| **l - left <= tolerance)
4061            .count();
4062        if count > best_count {
4063            best_count = count;
4064            best = *left;
4065        }
4066    }
4067    best
4068}
4069
4070/// Which way the page's indents point.
4071///
4072/// Told from what sits above each indented line, which is the only place the
4073/// two styles differ: the line above a first-line indent is the last line of a
4074/// paragraph and falls short of the measure; the line above a hanging
4075/// continuation is the entry's own first line and runs the full measure.
4076///
4077/// ONE VOTE FOR THE PAGE, counted over every column on it, because the style is
4078/// a decision the typesetter made once for the document and the evidence for it
4079/// is thin in any one column. A column of six lines holding one indent decides
4080/// on that indent alone, and on `tracemonkey` -- which is set first-line
4081/// indented throughout -- the short columns beside its figures came out
4082/// `Hanging` and read every paragraph opening as the end of the one above.
4083/// The MARGIN stays each column's own: two columns of a page sit at two left
4084/// edges, and an indent is a step away from the edge the column is set to.
4085fn indent_style(metrics: &[LineMetrics], runs: &[(usize, usize, f64)]) -> IndentStyle {
4086    let mut first_line = 0usize;
4087    let mut hanging = 0usize;
4088    for (start, end, margin) in runs {
4089        let measure = metrics[*start..*end]
4090            .iter()
4091            .map(|m| m.right)
4092            .fold(f64::NEG_INFINITY, f64::max);
4093        for i in *start + 1..*end {
4094            let line = &metrics[i];
4095            if line.font_size <= 0.0 {
4096                continue;
4097            }
4098            if line.indent_left - margin <= INDENT_MIN_EM * line.font_size {
4099                continue;
4100            }
4101            let above = &metrics[i - 1];
4102            if measure - above.right > SHORT_LINE_EM * above.font_size.max(line.font_size) {
4103                first_line += 1;
4104            } else {
4105                hanging += 1;
4106            }
4107        }
4108    }
4109    if first_line == 0 && hanging == 0 {
4110        IndentStyle::Flat
4111    } else if first_line >= hanging {
4112        IndentStyle::FirstLine
4113    } else {
4114        IndentStyle::Hanging
4115    }
4116}
4117
4118/// The entries of a list that marks them with nothing but its hang (#574).
4119///
4120/// A REFERENCE LIST SET AUTHOR-YEAR -- `Diehl, M., 2003, Phys. Rept. 388, 41.`
4121/// -- has no bullet and no number: each entry opens at the column's margin and
4122/// its continuation lines hang an indent in from it, which is how the hanging
4123/// indent above already keeps an entry together. What it did not do is call
4124/// the entries items, and DocLayNet marks every one of them a list item: on
4125/// the validation proxy of #632 they were 175 of the 284 unmarked items we
4126/// read as paragraphs.
4127///
4128/// AN ENTRY is a group of at least two lines whose first line stands at the
4129/// margin and whose other lines all hang at one indent from it. ONE such group
4130/// is a paragraph set with a hanging indent, and two can be a pair of
4131/// definitions; THREE in a row is the list, and a one-line entry between them
4132/// -- a reference short enough to fit its line -- belongs to it as long as the
4133/// hung entries are at least half of the run, which opens and closes on a
4134/// hung entry.
4135fn entries_of_a_list(metrics: &[LineMetrics], groups: &[Vec<usize>], margin: f64) -> Vec<bool> {
4136    let hung = |group: &Vec<usize>| -> bool {
4137        let first = &metrics[group[0]];
4138        let em = first.font_size;
4139        if group.len() < 2 || em <= 0.0 || first.indent_left - margin > INDENT_MIN_EM * em {
4140            return false;
4141        }
4142        let hang = metrics[group[1]].indent_left;
4143        hang - first.indent_left > INDENT_MIN_EM * em
4144            && group[1..]
4145                .iter()
4146                .all(|i| (metrics[*i].indent_left - hang).abs() <= INDENT_MATCH_EM * em)
4147    };
4148    let one_line_at_the_margin = |group: &Vec<usize>| -> bool {
4149        let first = &metrics[group[0]];
4150        group.len() == 1 && first.indent_left - margin <= INDENT_MIN_EM * first.font_size
4151    };
4152    let mut out = vec![false; groups.len()];
4153    let mut start = 0;
4154    while start < groups.len() {
4155        let mut end = start;
4156        let mut hung_count = 0;
4157        while end < groups.len() && (hung(&groups[end]) || one_line_at_the_margin(&groups[end])) {
4158            hung_count += usize::from(hung(&groups[end]));
4159            end += 1;
4160        }
4161        // Trimmed to its first and last hung entry: the one line at the margin
4162        // above a reference list is its heading, and the one below it the
4163        // next paragraph, not an entry.
4164        let (mut first, mut last) = (start, end);
4165        while first < last && !hung(&groups[first]) {
4166            first += 1;
4167        }
4168        while last > first && !hung(&groups[last - 1]) {
4169            last -= 1;
4170        }
4171        if hung_count >= 3 && 2 * hung_count >= last - first {
4172            out[first..last].fill(true);
4173        }
4174        start = end.max(start + 1);
4175    }
4176    out
4177}
4178
4179/// A ruling line the document drew between two lines of text.
4180fn rule_between(above: &LineMetrics, below: &LineMetrics, rules: &[HorizontalRule]) -> bool {
4181    rule_between_boxes(
4182        [above.left, above.bottom, above.right, above.top],
4183        [below.left, below.bottom, below.right, below.top],
4184        rules,
4185    )
4186}
4187
4188/// The same, on bare boxes in `[left, bottom, right, top]` order: the
4189/// narrow-column pair join reads lines the builder never measured
4190/// ([`JoinLine`]), and "between" is one definition.
4191pub(crate) fn rule_between_boxes(
4192    above: [f64; 4],
4193    below: [f64; 4],
4194    rules: &[HorizontalRule],
4195) -> bool {
4196    let left = above[0].min(below[0]);
4197    let right = above[2].max(below[2]);
4198    rules.iter().any(|rule| {
4199        rule.y >= below[3] && rule.y <= above[1] && rule.left < right && rule.right > left
4200    })
4201}
4202
4203/// The leading a column is set on: the middle of its own baseline steps.
4204///
4205/// Measured per column and not per page. `PageStats` takes the tightest
4206/// quartile of the gaps between the page's BANDS, and a band is a stripe across
4207/// the whole page: two columns whose baselines sit a few points apart produce a
4208/// band for each of them, so the page's figure on a two-column paper is the
4209/// offset between the columns rather than the leading of either. Every pair on
4210/// `tracemonkey` then read as a paragraph break, and the page came back one
4211/// block per line.
4212///
4213/// Steps that descend by more than four lines are left out of the middle: a
4214/// column ends with the gap above a footnote or a figure, and the median is
4215/// taken of what the type is set on, not of the page's furniture.
4216fn run_leading(metrics: &[LineMetrics], page_leading: f64) -> f64 {
4217    let em = metrics
4218        .iter()
4219        .map(|m| m.font_size)
4220        .fold(0.0_f64, f64::max)
4221        .max(1.0);
4222    let mut steps: Vec<f64> = metrics
4223        .windows(2)
4224        .map(|pair| pair[0].baseline - pair[1].baseline)
4225        .filter(|step| *step > 0.0 && *step < 4.0 * em)
4226        .collect();
4227    if steps.is_empty() {
4228        return if page_leading > 0.0 {
4229            page_leading
4230        } else {
4231            // The leading of solid setting, for a column that gave no second
4232            // line to measure one from.
4233            SOLID_PITCH_EM * em
4234        };
4235    }
4236    steps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
4237    steps[steps.len() / 2]
4238}
4239
4240/// The leading in force inside a paragraph: its own, once it has two lines to
4241/// measure, and the column's until then.
4242fn leading_of(metrics: &[LineMetrics], current: &[usize], run_leading: f64) -> f64 {
4243    if current.len() >= 2 {
4244        let mut steps: Vec<f64> = current
4245            .windows(2)
4246            .map(|pair| metrics[pair[0]].baseline - metrics[pair[1]].baseline)
4247            .collect();
4248        steps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
4249        return steps[steps.len() / 2];
4250    }
4251    run_leading
4252}
4253
4254/// Whether the merge says out loud which rail stopped each pair it refused.
4255///
4256/// Read once: this is asked on every rejected pair of every page, and an
4257/// environment lookup per pair is a cost paid by every caller for a switch
4258/// almost nobody turns on.
4259fn tracing() -> bool {
4260    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
4261    *ON.get_or_init(|| std::env::var_os("PDFLUENT_BLOCK_TRACE").is_some())
4262}
4263
4264/// Whether the line at `next` continues the paragraph `current` is building.
4265#[allow(clippy::too_many_arguments)]
4266/// Whether `line` starts under the text of the item `current` opens -- its
4267/// hang. Where the marker has a column of its own the hang is that column,
4268/// the edge the continuation rail in [`continues`] measures too; where the page drew it inside the first span there is no such
4269/// column, and the hang is any clear indent past the marker's edge, as the
4270/// width rail in [`continues`] reads it. Either way the next item starts at
4271/// the marker and the prose after the list at the marker's edge, so neither
4272/// starts here.
4273fn under_the_hang(metrics: &[LineMetrics], current: &[usize], line: &LineMetrics, em: f64) -> bool {
4274    let first = &metrics[current[0]];
4275    match first.item.map(|item| item.text_left) {
4276        Some(Some(text_left)) => {
4277            text_left - first.indent_left > INDENT_MIN_EM * em
4278                && line.indent_left >= text_left - INDENT_MATCH_EM * em
4279        }
4280        Some(None) => line.indent_left - first.indent_left > INDENT_MIN_EM * em,
4281        None => false,
4282    }
4283}
4284
4285/// Whether `step` is no wider than the list's own pitch: from this item down
4286/// to the next one, or -- for the last item, which has none -- from the line
4287/// above it down to it. Double-spaced references step two lines from one to
4288/// the next and one inside each, so a continuation is never further below its
4289/// item than the next item is. After a list set solid the next paragraph sits
4290/// a paragraph's white below the last item -- wider than the list's one-line
4291/// pitch -- and stays a paragraph even where it is set at the list's indent.
4292/// The next item is looked for within [`HANG_LOOKAHEAD_LINES`] lines, which is
4293/// as far as an item that runs to three lines and its hang reach.
4294fn within_the_lists_pitch(metrics: &[LineMetrics], current: &[usize], step: f64, em: f64) -> bool {
4295    let first = current[0];
4296    let top = metrics[first].baseline;
4297    let next_item = metrics
4298        .iter()
4299        .skip(first + 1)
4300        .take(HANG_LOOKAHEAD_LINES)
4301        .find(|m| m.item.is_some() && m.baseline < top);
4302    let pitch = match next_item {
4303        Some(next) => top - next.baseline,
4304        None => match first.checked_sub(1) {
4305            Some(before) => metrics[before].baseline - top,
4306            None => return true,
4307        },
4308    };
4309    pitch <= 0.0 || step <= pitch + SIZE_TOLERANCE * em
4310}
4311
4312/// How many lines below an item [`within_the_lists_pitch`] looks for the next.
4313const HANG_LOOKAHEAD_LINES: usize = 6;
4314
4315/// The column run a merge is asked within: the left margin it is set to, and
4316/// the right edge the longest line standing over it reaches.
4317#[derive(Debug, Clone, Copy)]
4318struct Column {
4319    margin: f64,
4320    right: f64,
4321}
4322
4323fn continues(
4324    metrics: &[LineMetrics],
4325    current: &[usize],
4326    next: usize,
4327    style: IndentStyle,
4328    column: Column,
4329    rules: &[HorizontalRule],
4330    leading: f64,
4331) -> bool {
4332    let margin = column.margin;
4333    let previous = &metrics[*current.last().expect("a paragraph holds a line")];
4334    let line = &metrics[next];
4335
4336    // Every refusal below goes out under `PDFLUENT_BLOCK_TRACE`, naming the pair
4337    // and the rail that stopped it. A merge that declines is invisible in its
4338    // output -- what comes back is a page of short blocks, and which of eight
4339    // rails produced each one is exactly what a reader of that page wants and
4340    // cannot get. Three of the corpus findings here were found with it on.
4341    macro_rules! no {
4342        ($reason:expr) => {{
4343            if tracing() {
4344                eprintln!("BREAK {} | {:?} -> {:?}", $reason, previous.text, line.text);
4345            }
4346            return false;
4347        }};
4348    }
4349    // A row of cells is not a line of prose, whichever side of the pair it is.
4350    if previous.tabular || line.tabular {
4351        no!("tabular");
4352    }
4353
4354    // A CONFIRMED LIST ITEM STARTS ITS OWN BLOCK (#569). Without this the
4355    // commonest list of all -- items of one line each, set solid at one left
4356    // edge -- differs from a paragraph in nothing the rails below measure, and
4357    // comes back as a single block with every bullet inside it. The marker is
4358    // not believed on its own: `mark_list_items` has already required a second
4359    // item in the same column to agree with this one, which is what keeps a
4360    // line of dialogue opening with an em dash from cutting a paragraph in two.
4361    if line.item.is_some() {
4362        no!("list item");
4363    }
4364
4365    let em = previous.font_size.max(line.font_size);
4366    if em <= 0.0 {
4367        no!("no type size");
4368    }
4369
4370    let step = previous.baseline - line.baseline;
4371    if step <= MIN_BASELINE_STEP_EM * em {
4372        no!("same line");
4373    }
4374    // UNDER AN ITEM'S HANG THE LIST'S OWN PITCH SPEAKS FOR IT, not the page's,
4375    // on both of the rails below: the column's measured leading (double-spaced
4376    // references in a column otherwise set solid, codex #1967) and the white
4377    // a line of prose never has (references in a paper set double-spaced).
4378    let hung = step <= HANG_MAX_LEADING_EM * em
4379        && under_the_hang(metrics, current, line, em)
4380        && within_the_lists_pitch(metrics, current, step, em);
4381    if step > leading_of(metrics, current, leading) * PARAGRAPH_LEADING_TOLERANCE && !hung {
4382        no!(format!(
4383            "leading {step:.2} over {:.2}",
4384            leading_of(metrics, current, leading)
4385        ));
4386    }
4387    if step > MAX_LEADING_EM * em && !hung {
4388        no!(format!("a line of white: {step:.2} on {em:.2} of type"));
4389    }
4390
4391    if (line.font_size - previous.font_size).abs() > SIZE_TOLERANCE * em {
4392        no!(format!(
4393            "type size {:.3} vs {:.3}",
4394            previous.font_size, line.font_size
4395        ));
4396    }
4397
4398    // A CHANGE OF WEIGHT IS A CHANGE OF TYPE, and it is the only one a document
4399    // set in a single size has left: a bold heading over a paragraph set solid
4400    // with it differs from the paragraph in nothing a measurement of geometry
4401    // can see, and merged into it there is no block for the classifier to call
4402    // a heading (#568). Line against line and by the characters, so a bold
4403    // phrase inside a line of prose does not end its paragraph and a document
4404    // set entirely in a heavy face never splits at all.
4405    if line.bold != previous.bold {
4406        no!(format!(
4407            "weight {} vs {}",
4408            if previous.bold { "bold" } else { "regular" },
4409            if line.bold { "bold" } else { "regular" }
4410        ));
4411    }
4412
4413    if overlap_fraction(previous, line) < COLUMN_OVERLAP_FRACTION {
4414        no!("column");
4415    }
4416
4417    if rule_between(previous, line, rules) {
4418        no!("rule");
4419    }
4420
4421    // A line that ends a sentence well short of the measure ends the paragraph.
4422    // The measure is taken from the paragraph itself rather than from the
4423    // column, so a paragraph set narrow inside a wider column is judged against
4424    // its own right edge.
4425    let measure = current
4426        .iter()
4427        .map(|i| metrics[*i].right)
4428        .chain(std::iter::once(line.right))
4429        .fold(f64::NEG_INFINITY, f64::max);
4430    if previous.ends_sentence() && measure - previous.right > SHORT_LINE_EM * em {
4431        no!("short line");
4432    }
4433
4434    // AN ITEM CARRIES ITS OWN HANG, and the page's paragraph shape does not
4435    // reach inside it. The item's continuation lines start at its TEXT, and a
4436    // line that starts at its MARKER is under nothing -- so it is the prose
4437    // after the list, whatever the item's first line measured.
4438    //
4439    // A line under the hang returns HERE rather than falling through to the
4440    // style below, because on a first-line-indented page that rail reads the
4441    // hang as an indent and starts a paragraph on it -- which splits every
4442    // conventionally hanging continuation out of its item (codex, round 2).
4443    // The hang is the list's own statement and it outranks the page's.
4444    if let Some(item) = metrics[current[0]].item {
4445        let marker_left = metrics[current[0]].indent_left;
4446        match item.text_left {
4447            Some(text_left) => {
4448                if line.indent_left >= text_left - INDENT_MATCH_EM * em {
4449                    return true;
4450                }
4451                // A NUMBER MAY SIT IN A FIRST-LINE INDENT. On a page set that
4452                // way the marker is inside the indent and the item's own body
4453                // returns to the margin, so its second line is further LEFT
4454                // than its first. The prose after such a list opens with the
4455                // same indent the marker sat in, which is not this.
4456                if !(style == IndentStyle::FirstLine
4457                    && line.indent_left < marker_left - INDENT_MIN_EM * em)
4458                {
4459                    no!("the line is back at the item's marker edge");
4460                }
4461            }
4462            // The page drew the marker inside the line's own first span, so
4463            // there is no column to measure. Fall back on the width: an item
4464            // that ran the full measure may be wrapping, one that stopped short
4465            // is finished. Asked only while the item is still one line, because
4466            // from the second line on the outdent rail below measures the same
4467            // hang.
4468            None if current.len() == 1 => {
4469                if line.indent_left - marker_left <= INDENT_MIN_EM * em
4470                    && measure - previous.right > SHORT_LINE_EM * em
4471                {
4472                    no!("the item ended short of the measure");
4473                }
4474                // A line that hangs under the item is the item's, as it is
4475                // where the marker has a column of its own. Left to the page's
4476                // rail below, a first-line-indented page reads the hang as an
4477                // indent and cuts the clause after its first line (#589).
4478                //
4479                // Only when the item has more to say: its first line ran to
4480                // the measure or stops mid-clause. `2. Pears` is finished, and
4481                // an indented line under it is the page's next paragraph
4482                // (codex, #1950).
4483                if line.indent_left - marker_left > INDENT_MIN_EM * em
4484                    && (measure - previous.right <= SHORT_LINE_EM * em
4485                        || runs_on(&previous.text)
4486                        || wrapped_before(previous, line, column.right))
4487                {
4488                    return true;
4489                }
4490            }
4491            // AND ONCE THE SECOND LINE HAS SET THE HANG, a line at that hang
4492            // is the item's too (#599). The width rail above is asked only of
4493            // the first pair; from there a first-line-indented page's own rail
4494            // read every further line at the hang as an indent and cut the
4495            // clause into one paragraph per line (`(i) Implementation of
4496            // training ...`, a Japanese ordinance in DocLayNet's validation
4497            // split). A finished line still ends the item: the short-line rail
4498            // is asked before this.
4499            None => {
4500                let hang = metrics[current[1]].indent_left;
4501                if hang - marker_left > INDENT_MIN_EM * em
4502                    && (line.indent_left - hang).abs() <= INDENT_MATCH_EM * em
4503                {
4504                    return true;
4505                }
4506            }
4507        }
4508    }
4509
4510    match style {
4511        // An indented line starts a paragraph, wherever the paragraph so far
4512        // happens to sit.
4513        IndentStyle::FirstLine => {
4514            if line.indent_left - margin > INDENT_MIN_EM * em {
4515                no!("first-line indent");
4516            }
4517            true
4518        }
4519        // A line that goes back to the margin after a hang is the next entry.
4520        IndentStyle::Hanging | IndentStyle::Flat => {
4521            let body = if current.len() >= 2 {
4522                current[1..]
4523                    .iter()
4524                    .map(|i| metrics[*i].indent_left)
4525                    .fold(f64::INFINITY, f64::min)
4526            } else {
4527                previous.indent_left
4528            };
4529            if line.indent_left - body < -INDENT_MIN_EM * em {
4530                no!("outdent");
4531            }
4532            true
4533        }
4534    }
4535}
4536
4537/// Join a paragraph's lines into the text it reads as: a space where the
4538/// typesetter put a line break, and nothing at all where it broke a word.
4539pub(crate) fn join_lines(lines: &[String]) -> String {
4540    let mut out = String::new();
4541    for line in lines {
4542        let trimmed = line.trim();
4543        if trimmed.is_empty() {
4544            continue;
4545        }
4546        if out.is_empty() {
4547            out.push_str(trimmed);
4548            continue;
4549        }
4550        if is_hyphen_wrap_candidate(&out, trimmed) {
4551            out.pop();
4552            out.push_str(trimmed);
4553        } else {
4554            out.push(' ');
4555            out.push_str(trimmed);
4556        }
4557    }
4558    out
4559}
4560
4561/// One block out of the lines that were merged into it.
4562///
4563/// `item` is the confirmed list marker the block's FIRST line opens with, when
4564/// it opens with one (#569): it decides the kind, and it comes off the front of
4565/// the text. The span that drew it stays in `spans`, which say what the page
4566/// drew and not what it reads as.
4567fn assemble(
4568    page: usize,
4569    index: usize,
4570    lines: &[&TextBlock],
4571    metrics: &[&LineMetrics],
4572    item: Option<ItemMark>,
4573) -> Block {
4574    let mut spans: Vec<TextSpan> = Vec::new();
4575    let mut texts: Vec<String> = Vec::new();
4576
4577    // An initial that belongs to a LATER line of the paragraph was set at the
4578    // front of it and drawn three lines down; the text says so even though the
4579    // geometry does not.
4580    //
4581    // Only where the line reads with the initial in front of it, which is the
4582    // one case the move can be undone in the text as well as in the spans: the
4583    // line's own text is assembled and reordered by `TextBlock::text`, and a
4584    // prefix that is not there is an initial that would be written twice --
4585    // once at the front and once where it already stood.
4586    let displaced = metrics
4587        .iter()
4588        .enumerate()
4589        .skip(1)
4590        .find_map(|(i, m)| m.initial.map(|s| (i, s)))
4591        .filter(|(i, s)| {
4592            metrics[*i]
4593                .text
4594                .starts_with(lines[*i].spans[*s].text.as_str())
4595        });
4596    if let Some((line, span)) = displaced {
4597        spans.push(lines[line].spans[span].clone());
4598        texts.push(lines[line].spans[span].text.clone());
4599    }
4600
4601    for (i, line) in lines.iter().enumerate() {
4602        for (s, span) in line.spans.iter().enumerate() {
4603            if displaced == Some((i, s)) {
4604                continue;
4605            }
4606            spans.push(span.clone());
4607        }
4608        let text = match displaced {
4609            Some((displaced_line, span)) if displaced_line == i => metrics[i]
4610                .text
4611                .strip_prefix(lines[i].spans[span].text.as_str())
4612                .expect("the prefix `displaced` was chosen for")
4613                .to_string(),
4614            _ => metrics[i].text.clone(),
4615        };
4616        texts.push(text);
4617    }
4618
4619    // Taken off the joined text rather than off the first line's, because
4620    // `join_lines` trims each line as it goes: the marker's own trailing space
4621    // is already gone by then, and the prefix that survives is the marker as it
4622    // reads.
4623    let mut text = join_lines(&texts);
4624    if let Some(item) = item {
4625        let marker = metrics
4626            .first()
4627            .and_then(|m| m.text.get(..item.len))
4628            .unwrap_or_default()
4629            .trim();
4630        if let Some(rest) = text.strip_prefix(marker) {
4631            text = rest.trim_start().to_string();
4632        }
4633    }
4634
4635    let mut block = Block {
4636        kind: match item {
4637            Some(item) => BlockKind::ListItem {
4638                level: item.level,
4639                ordinal: item.ordinal,
4640            },
4641            None => BlockKind::Paragraph,
4642        },
4643        page,
4644        bbox: [0.0; 4],
4645        text,
4646        spans,
4647        index,
4648        continues_previous: false,
4649        confidence: None,
4650        grid: None,
4651        table_origin: TableOrigin::Other,
4652        trace_learned: None,
4653    };
4654    recompute_bbox(&mut block);
4655    block
4656}
4657
4658/// What one stretch of a column turned out to be: lines the merge still has to
4659/// group, or a table `crate::tables` already read the grid of.
4660enum Group {
4661    /// Line indices the merge joined into one block.
4662    Lines(Vec<usize>),
4663    /// A table, its cells already placed in a grid.
4664    Table(tables::Table),
4665}
4666
4667/// The rows of one column run: which of its lines each row is drawn on.
4668///
4669/// A ROW IS NOT ALWAYS ONE LINE. `crate::text` hands back a row whose cell
4670/// wrapped as one block per cell, the continuation lines among them (#434), so
4671/// the lines of one row arrive consecutively and neither in one block nor in
4672/// one baseline. What holds them together is the run's own ROW PITCH: a
4673/// continuation sits at the leading, which is inside the pitch, and the next
4674/// row sits at the pitch itself.
4675///
4676/// The pitch is the middle of the run's own baseline steps, counted over the
4677/// DISTINCT baselines, because the cells of one row share theirs and would
4678/// otherwise vote for a pitch of nothing.
4679///
4680/// AND COUNTED OVER THE TABULAR LINES ONLY. A region holds a table and the
4681/// prose around it, and prose is the majority of almost every page that has
4682/// both: the median of every step is then the prose leading, which on a table
4683/// set on a wider pitch is smaller than the wrapped line it has to admit
4684/// (codex, round 1). A line with a gap wide enough to be a gutter is the one
4685/// thing here that says "table" before the grid is known, so the steps are
4686/// taken between those lines, and the region's own steps are the fallback for
4687/// a run that has none.
4688fn table_rows(metrics: &[LineMetrics], start: usize, end: usize) -> Vec<tables::Row> {
4689    let baselines_of = |tabular_only: bool| -> Vec<f64> {
4690        let mut baselines: Vec<f64> = (start..end)
4691            .filter(|i| !tabular_only || !metrics[*i].wide_gaps.is_empty())
4692            .map(|i| metrics[i].baseline)
4693            .collect();
4694        baselines.sort_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
4695        baselines.dedup_by(|a, b| (*a - *b).abs() < SAME_BASELINE);
4696        baselines
4697    };
4698    let steps_of = |baselines: Vec<f64>| -> Vec<f64> {
4699        let mut steps: Vec<f64> = baselines.windows(2).map(|pair| pair[0] - pair[1]).collect();
4700        steps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
4701        steps
4702    };
4703    let steps = match steps_of(baselines_of(true)) {
4704        tabular if !tabular.is_empty() => tabular,
4705        _ => steps_of(baselines_of(false)),
4706    };
4707    let pitch = match steps.len() {
4708        0 => f64::INFINITY,
4709        len => steps[len / 2] * ROW_PITCH_FRACTION,
4710    };
4711    // AND WHERE THE RUN HAS TWO KINDS OF STEP, THE SMALLER ONE IS THE RAIL.
4712    // A table whose cells hold sentences sets those sentences at the leading
4713    // and parts its rows by more, so its steps fall into two clusters and the
4714    // widest jump between them is the boundary a reader sees the rows by. The
4715    // rules below cannot find those rows on their own: every line of such a
4716    // cell has a gutter of its own (the cell beside it wrapped too) and sits
4717    // as far from the line above as from the one below, so each came out as a
4718    // row of one line and the table was read as a grid of fragments in the
4719    // wrong order (#576). A run whose steps are all alike has no wrapped cell
4720    // in it and answers `None`, which leaves the rules below exactly as they
4721    // were.
4722    //
4723    // OVER EVERY BASELINE, not over the tabular ones `pitch` is measured on.
4724    // Leaving the untabular lines out takes the wrapped lines themselves out
4725    // of the measure -- a table with a head set across each bank then shows
4726    // steps of one pitch and of two, and two of one thing is a jump this
4727    // would believe.
4728    let leading = wrapped_leading(&steps_of(baselines_of(false)));
4729
4730    // AND THE PITCH IS NOT THE ONLY RAIL. A pitch measured off a table set wide
4731    // swallows whatever the page put between its rows -- on `AlignmentTests` it
4732    // glued eleven separate field labels into one cell -- so a line joins the
4733    // row above only when it also sits CLOSER to it than to what follows, and
4734    // only when it has no gutter of its own. A line with a gutter in it is a
4735    // whole row; a cell of a row that arrived cell by cell (#434) has none, and
4736    // shares its row's baseline anyway.
4737    let mut rows: Vec<(usize, usize)> = Vec::new();
4738    let mut top = f64::NEG_INFINITY;
4739    // The line before this one, which is what the leading rail is measured
4740    // against: a cell four lines deep is four steps of the leading, and
4741    // measuring every one of them from the row's own first baseline turns the
4742    // fourth into a step of four leadings and ends the row there.
4743    let mut previous = f64::NEG_INFINITY;
4744    // Where the row puts its cells: every cell edge the lines of it have used
4745    // so far. A wrapped cell continues one of them and opens none of its own,
4746    // which is what tells a wrapped line from a line of a page that merely
4747    // sits close under another one. It grows with the row because a row
4748    // arrives cell by cell where one of its cells wrapped (#434): the first
4749    // line of it is one cell, not the whole row.
4750    let mut opener: Vec<f64> = Vec::new();
4751    for (i, line) in metrics.iter().enumerate().take(end).skip(start) {
4752        let gap_up = top - line.baseline;
4753        let step = previous - line.baseline;
4754        // Only where there is a rail to hold them against: every other run
4755        // pays nothing for this.
4756        let starts = if leading.is_some() {
4757            cell_starts(line)
4758        } else {
4759            Vec::new()
4760        };
4761        let in_the_same_cells = starts.iter().all(|at| {
4762            opener
4763                .iter()
4764                .any(|was| (at - was).abs() <= line.font_size * CELL_LEFT_EM)
4765        });
4766        let gap_down = (i + 1..end)
4767            .map(|j| line.baseline - metrics[j].baseline)
4768            .find(|step| *step > SAME_BASELINE)
4769            .unwrap_or(f64::INFINITY);
4770        let continues_the_row = gap_up <= SAME_BASELINE
4771            || match leading {
4772                Some(rail) => step <= rail + SAME_BASELINE && in_the_same_cells,
4773                None => gap_up < pitch && line.wide_gaps.is_empty() && gap_up < gap_down,
4774            };
4775        match rows.last_mut() {
4776            Some(row) if continues_the_row => {
4777                row.1 = i + 1;
4778                top = top.max(line.baseline);
4779                opener.extend(starts);
4780            }
4781            _ => {
4782                rows.push((i, i + 1));
4783                top = line.baseline;
4784                opener = starts;
4785            }
4786        }
4787        previous = line.baseline;
4788    }
4789
4790    // AND A ROW THE RAIL FOLDED HAS TO HOLD A LINE THAT IS NOT A WHOLE ROW.
4791    //
4792    // The rail is the smallest step the REGION has, and a compact table set at
4793    // the region's own line spacing, with wider gaps somewhere else in the
4794    // region, therefore has every one of its rows at the rail -- and its rows
4795    // do share cell edges, so the test above would fold the whole table into
4796    // one row and `find` would lose it for having fewer than two (codex, #576,
4797    // round one). What tells the two apart is what the folded lines hold: a
4798    // cell that wrapped puts only SOME of the row's cells on its second line,
4799    // and a row of a compact table puts all of them on every line. So a fold
4800    // whose every line opens every cell the row has is undone.
4801    let rows = match leading {
4802        None => rows,
4803        Some(_) => rows
4804            .into_iter()
4805            .flat_map(|(first, end)| unfold_whole_lines(metrics, first, end))
4806            .collect(),
4807    };
4808
4809    rows.into_iter()
4810        .map(|(first, end)| {
4811            let lines = &metrics[first..end];
4812            tables::Row {
4813                first,
4814                end,
4815                left: lines.iter().map(|m| m.left).fold(f64::INFINITY, f64::min),
4816                right: lines
4817                    .iter()
4818                    .map(|m| m.right)
4819                    .fold(f64::NEG_INFINITY, f64::max),
4820                top: lines
4821                    .iter()
4822                    .map(|m| m.top)
4823                    .fold(f64::NEG_INFINITY, f64::max),
4824                bottom: lines.iter().map(|m| m.bottom).fold(f64::INFINITY, f64::min),
4825                font_size: dominant_size(lines),
4826                bold: lines.iter().filter(|m| m.bold).count() * 2 > lines.len(),
4827            }
4828        })
4829        .collect()
4830}
4831
4832/// One row per baseline, where the baselines under the first are rows of
4833/// their own; the row unchanged where they are the wrapped lines of it.
4834///
4835/// The cells of one row share a baseline, so a baseline is the unit here and
4836/// not a line: a row that arrived cell by cell (#434) is several lines at one
4837/// baseline, and it is the baseline the test is about. A row of a compact
4838/// table holds every cell the table has; the second line of a table of
4839/// descriptions, where the shortest cell has already ended, does not.
4840fn unfold_whole_lines(metrics: &[LineMetrics], first: usize, end: usize) -> Vec<(usize, usize)> {
4841    let mut baselines: Vec<(usize, usize)> = Vec::new();
4842    for i in first..end {
4843        match baselines.last_mut() {
4844            Some(group)
4845                if (metrics[group.0].baseline - metrics[i].baseline).abs() <= SAME_BASELINE =>
4846            {
4847                group.1 = i + 1
4848            }
4849            _ => baselines.push((i, i + 1)),
4850        }
4851    }
4852    if baselines.len() < 2 {
4853        return vec![(first, end)];
4854    }
4855    let starts_of = |group: &(usize, usize)| -> Vec<f64> {
4856        (group.0..group.1)
4857            .flat_map(|i| cell_starts(&metrics[i]))
4858            .collect()
4859    };
4860    let em = metrics[first].font_size.max(f64::MIN_POSITIVE) * CELL_LEFT_EM;
4861    let whole: Vec<f64> = baselines.iter().flat_map(starts_of).collect();
4862    let is_whole = |group: &(usize, usize)| -> bool {
4863        let mine = starts_of(group);
4864        whole
4865            .iter()
4866            .all(|was| mine.iter().any(|at| (at - was).abs() <= em))
4867    };
4868    // WHAT A FOLD NEEDS IS EVIDENCE THAT A CELL WRAPPED, and a baseline
4869    // holding less than the row holds is that evidence -- with one exception,
4870    // which is the whole of the difference between the two cases this has to
4871    // tell apart. A baseline with one run of ink in the row's FIRST column
4872    // and nothing else is a sparse row of data as readily as it is a cell
4873    // carrying on, and no measurement here decides which; every other short
4874    // baseline leaves the first column empty, or holds cells side by side,
4875    // and a row does neither.
4876    //
4877    // Without the exception one sparse row kept a whole compact table folded
4878    // into a single row, which `find` then dropped for having fewer than two
4879    // (codex, #576). With it, a table of descriptions keeps its rows, because
4880    // the lines its cells wrap onto sit in the columns they belong to.
4881    let column_one = starts_of(&baselines[0])
4882        .into_iter()
4883        .fold(f64::INFINITY, f64::min);
4884    let a_sparse_row = |group: &(usize, usize)| -> bool {
4885        let mine = starts_of(group);
4886        mine.len() == 1 && (mine[0] - column_one).abs() <= em
4887    };
4888    let wrapped = baselines[1..]
4889        .iter()
4890        .any(|group| !is_whole(group) && !a_sparse_row(group));
4891    if wrapped {
4892        vec![(first, end)]
4893    } else {
4894        baselines
4895    }
4896}
4897
4898/// Where a line puts its cells: its own left edge, and the right edge of every
4899/// gap in it wide enough to be a gutter.
4900fn cell_starts(line: &LineMetrics) -> Vec<f64> {
4901    std::iter::once(line.left)
4902        .chain(line.wide_gaps.iter().map(|(_, to)| *to))
4903        .collect()
4904}
4905
4906/// The leading a wrapped cell sits at, where the run has two kinds of step.
4907///
4908/// `steps` are the run's baseline steps, ascending. The FIRST jump of
4909/// `ROW_PITCH_JUMP` or more is where the leading stops: the leading is the
4910/// smallest kind of step a run has, and everything above the first jump is
4911/// something else -- a row pitch, the space under a header, the gap to the
4912/// next bank. Taking the WIDEST jump instead reads the largest of those gaps
4913/// as the boundary and folds a header into the first row of its own table.
4914/// A run with no such jump has one kind of step, so it is one line per row --
4915/// the commoner table, and the one the pitch rail already reads correctly.
4916fn wrapped_leading(steps: &[f64]) -> Option<f64> {
4917    if steps.len() <= ROW_PITCH_CLUSTER {
4918        return None;
4919    }
4920    // AND THE PITCH SIDE HAS TO BE A CLUSTER. One step above the jump is a
4921    // gap -- the space under a header, the end of the table -- and reading it
4922    // as the row pitch makes every other step a leading and the whole run one
4923    // row, which is what `a_table_inside_a_cell_is_that_cell_s_text` showed
4924    // when the jump was believed on its own. The leading side needs no such
4925    // floor: one wrapped line in a table is a table with a wrapped line in
4926    // it, and that same test is the case.
4927    let last = steps.len() - ROW_PITCH_CLUSTER;
4928    steps
4929        .windows(2)
4930        .enumerate()
4931        .take_while(|(at, _)| *at < last)
4932        .find(|(_, pair)| pair[0] > 0.0 && pair[1] / pair[0] >= ROW_PITCH_JUMP)
4933        .map(|(_, pair)| pair[0])
4934}
4935
4936/// The type size most of a row's lines are set in.
4937fn dominant_size(lines: &[LineMetrics]) -> f64 {
4938    let mut sizes: Vec<f64> = lines
4939        .iter()
4940        .map(|m| m.font_size)
4941        .filter(|s| *s > 0.0)
4942        .collect();
4943    sizes.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
4944    sizes.get(sizes.len() / 2).copied().unwrap_or(0.0)
4945}
4946
4947/// One cell of a table as a block.
4948///
4949/// The cell's spans are taken in reading order -- line by line down, left to
4950/// right across -- rather than in the order they were drawn, because a cell
4951/// whose text wrapped arrives interleaved with the rest of its row (#434): its
4952/// two lines are two runs at two baselines inside one [`TextBlock`], and read
4953/// in draw order the second would come out before half of the first.
4954fn assemble_cell(
4955    page: usize,
4956    lines: &[&TextBlock],
4957    cell: &tables::Cell,
4958    doubt: GridDoubt,
4959    origin: TableOrigin,
4960    cell_path_words: bool,
4961) -> Block {
4962    let mut spans: Vec<TextSpan> = cell
4963        .spans
4964        .iter()
4965        .map(|(line, span)| lines[*line].spans[*span].clone())
4966        .collect();
4967    spans.sort_by(|a, b| {
4968        b.y.partial_cmp(&a.y)
4969            .unwrap_or(Ordering::Equal)
4970            .then(a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal))
4971    });
4972
4973    // Back into lines, so that each one is assembled by the same code that
4974    // assembles a line of prose -- the word spacing and the right-to-left
4975    // reordering included -- and the lines are then joined the way the lines of
4976    // a paragraph are.
4977    let mut rows: Vec<Vec<TextSpan>> = Vec::new();
4978    for span in &spans {
4979        let same_line = rows.last().is_some_and(|row: &Vec<TextSpan>| {
4980            let previous: &TextSpan = &row[0];
4981            (previous.y - span.y).abs() <= previous.font_size.max(span.font_size) * 0.5
4982        });
4983        match rows.last_mut() {
4984            Some(row) if same_line => row.push(span.clone()),
4985            _ => rows.push(vec![span.clone()]),
4986        }
4987    }
4988    let texts: Vec<String> = rows
4989        .into_iter()
4990        .map(|spans| TextBlock { spans }.text_with_cell_path_words(cell_path_words))
4991        .collect();
4992
4993    let mut block = Block {
4994        kind: BlockKind::TableCell {
4995            row: cell.row,
4996            column: cell.column,
4997            row_span: cell.row_span,
4998            column_span: cell.column_span,
4999            header: cell.header,
5000        },
5001        page,
5002        bbox: [0.0; 4],
5003        text: join_lines(&texts),
5004        spans,
5005        index: 0,
5006        continues_previous: false,
5007        confidence: None,
5008        grid: Some(doubt),
5009        table_origin: origin,
5010        trace_learned: None,
5011    };
5012    recompute_bbox(&mut block);
5013    block
5014}
5015
5016/// The block's box, from the spans it holds now.
5017///
5018/// Called again after a turned page's spans are put back in page space: the box
5019/// is the caller's frame, and the frame the paragraph was decided in is not it.
5020pub(crate) fn recompute_bbox(block: &mut Block) {
5021    let mut bbox = [
5022        f64::INFINITY,
5023        f64::INFINITY,
5024        f64::NEG_INFINITY,
5025        f64::NEG_INFINITY,
5026    ];
5027    for span in &block.spans {
5028        let (bottom, top) = span_extent(span);
5029        bbox[0] = bbox[0].min(span.x);
5030        bbox[1] = bbox[1].min(bottom);
5031        bbox[2] = bbox[2].max(span_right(span));
5032        bbox[3] = bbox[3].max(top);
5033    }
5034    block.bbox = if bbox.iter().all(|v| v.is_finite()) {
5035        bbox
5036    } else {
5037        [0.0; 4]
5038    };
5039}
5040
5041/// The face most of a line's characters are set in: the PostScript name the
5042/// page gave the font, or `None` where the line keeps no name (standard-14 and
5043/// Type3 faces). Counted per character, so a bold phrase inside a line of
5044/// prose does not speak for it.
5045fn dominant_face(line: &TextBlock) -> Option<&str> {
5046    dominant_face_of_spans(&line.spans)
5047}
5048
5049/// The same, on bare spans: the v2 release rejoin reads a released cell's
5050/// face the way the builder reads a line's, and the count is one definition.
5051pub(crate) fn dominant_face_of_spans(spans: &[TextSpan]) -> Option<&str> {
5052    let mut faces: Vec<(Option<&str>, usize)> = Vec::new();
5053    for span in spans {
5054        let face = span.font_name.as_deref();
5055        let chars = char_count(span);
5056        match faces.iter_mut().find(|(held, _)| *held == face) {
5057            Some(entry) => entry.1 += chars,
5058            None => faces.push((face, chars)),
5059        }
5060    }
5061    faces
5062        .into_iter()
5063        .max_by_key(|(_, count)| *count)
5064        .and_then(|(face, _)| face)
5065}
5066
5067/// Whether the line ends in terminal punctuation: `.`, `!` or `?`, trailing
5068/// closers aside. A colon or a semicolon is not terminal: it introduces what
5069/// follows or continues a clause, and a join that stopped at one would cut
5070/// every enumerated lead-in off its item.
5071fn ends_terminally(text: &str) -> bool {
5072    let trimmed = text
5073        .trim_end()
5074        .trim_end_matches(['"', '\'', '”', '’', ')', ']', '»']);
5075    matches!(
5076        trimmed.chars().next_back(),
5077        Some('.') | Some('!') | Some('?')
5078    )
5079}
5080
5081/// Whether the line opens in lowercase: its first letter, leading punctuation
5082/// and figures aside, is a lowercase one. A line that opens with a capital --
5083/// a new sentence -- joins the line above it; so does a line with no letter
5084/// at all, which has no case to refuse on.
5085fn opens_lowercase(text: &str) -> bool {
5086    text.chars()
5087        .find(|c| c.is_alphabetic())
5088        .is_some_and(|c| c.is_lowercase())
5089}
5090
5091/// The middle of a set of baseline steps, upper middle: the pitch of a block
5092/// that measured its own. Empty reads as none, for a column that gave no
5093/// second line to measure one from.
5094pub(crate) fn median_step(steps: &mut [f64]) -> f64 {
5095    if steps.is_empty() {
5096        return 0.0;
5097    }
5098    steps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
5099    steps[steps.len() / 2]
5100}
5101
5102/// One line as the narrow-column pair join reads it: the geometry the page
5103/// drew, the type it is set in, and the text as it reads.
5104///
5105/// [`LineMetrics`] is the builder's richer reading of the same line; this is
5106/// the pair join's whole input, so the builder ([`join_narrow_columns`]) and
5107/// the v2 release rejoin decide by one function
5108/// ([`narrow_column_pair_joins`]).
5109pub(crate) struct JoinLine<'a> {
5110    /// Leftmost edge of the line's ordinary text.
5111    pub left: f64,
5112    /// The left edge the indent is measured from: [`LineMetrics`]' own, which
5113    /// beside a drop cap is the cap's left rather than the text's.
5114    pub indent_left: f64,
5115    /// Rightmost edge of the line.
5116    pub right: f64,
5117    /// The baseline the line sits on.
5118    pub baseline: f64,
5119    /// Top of the line's glyphs.
5120    pub top: f64,
5121    /// Bottom of the line's glyphs.
5122    pub bottom: f64,
5123    /// The type size most of the line's characters are set in.
5124    pub font_size: f64,
5125    /// Whether most of the line's characters are set in a bold face.
5126    pub bold: bool,
5127    /// The face most of the line's characters are set in.
5128    pub face: Option<&'a str>,
5129    /// The line as it reads.
5130    pub text: &'a str,
5131    /// A list marker opens the line, or the line is a list item: either way
5132    /// it starts its own block. Read on the line below only.
5133    pub marker_or_item: bool,
5134    /// A second line confirmed a gutter inside it: a row of cells, not prose.
5135    pub tabular: bool,
5136}
5137
5138impl<'a> JoinLine<'a> {
5139    /// The builder's reading of one of its own lines: `face` from the line's
5140    /// spans, `marker_or_item` from its marker, its item, and the unmarked
5141    /// entries.
5142    fn of(metrics: &'a LineMetrics, face: Option<&'a str>, marker_or_item: bool) -> Self {
5143        Self {
5144            left: metrics.left,
5145            indent_left: metrics.indent_left,
5146            right: metrics.right,
5147            baseline: metrics.baseline,
5148            top: metrics.top,
5149            bottom: metrics.bottom,
5150            font_size: metrics.font_size,
5151            bold: metrics.bold,
5152            face,
5153            text: &metrics.text,
5154            marker_or_item,
5155            tabular: metrics.tabular,
5156        }
5157    }
5158}
5159
5160/// Whether the line `below` joins the block `above` ends: the narrow-column
5161/// pair join, one function for the builder and the v2 release rejoin.
5162///
5163/// `above_item` is whether the open block's FIRST line is a list item -- a
5164/// marker, a confirmed item, or an unmarked entry -- because nothing joins
5165/// onto an item either. `pitch` is the pitch of the block above: its own,
5166/// once the join has given it two lines to measure, and its column's until
5167/// then. `measure` is the column's own measure, over both runs the pair
5168/// stands in. `rules` are the horizontal lines the page drew, a boundary the
5169/// join never crosses.
5170///
5171/// The pair joins where the left edges agree to half an em (or both lines
5172/// are ragged inside the column they overlap), the step is the block's own
5173/// pitch, the size holds to half a point and the face and the weight do not
5174/// change. A sentence boundary joins too. What refuses is a marker on the
5175/// lower line, a table under either, a ruling line, a second line of white,
5176/// a lead-in ending in a colon, or a full-measure line ending in terminal
5177/// punctuation over a lowercase opening.
5178pub(crate) fn narrow_column_pair_joins(
5179    above: &JoinLine,
5180    above_item: bool,
5181    below: &JoinLine,
5182    pitch: f64,
5183    measure: f64,
5184    rules: &[HorizontalRule],
5185) -> bool {
5186    // A list item starts its own block, here as in the merge -- and on
5187    // the marker as written, without asking a second item to agree,
5188    // because agreeing markers in adjacent one-line groups are exactly
5189    // what this join would wrongly fuse.
5190    if below.marker_or_item {
5191        return false;
5192    }
5193    // And nothing joins onto an item either: the line after one at the
5194    // same edge is the prose after the list, not its second line. The
5195    // unmarked entries count as items too.
5196    if above_item {
5197        return false;
5198    }
5199    // A row of cells is not a line of prose, and a ruling line is a
5200    // boundary the builder never crosses.
5201    if above.tabular || below.tabular {
5202        return false;
5203    }
5204    if rule_between_boxes(
5205        [above.left, above.bottom, above.right, above.top],
5206        [below.left, below.bottom, below.right, below.top],
5207        rules,
5208    ) {
5209        return false;
5210    }
5211    let em = above.font_size.max(below.font_size);
5212    if em <= 0.0 {
5213        return false;
5214    }
5215    let step = above.baseline - below.baseline;
5216    if step <= MIN_BASELINE_STEP_EM * em
5217        || step > NARROW_PITCH_MULT * pitch
5218        || step > MAX_LEADING_EM * em
5219    {
5220        return false;
5221    }
5222    if (above.font_size - below.font_size).abs() > NARROW_SIZE_PT {
5223        return false;
5224    }
5225    if above.bold != below.bold || above.face != below.face {
5226        return false;
5227    }
5228    let ragged = |m: &JoinLine| measure - m.right > SHORT_LINE_EM * em;
5229    if (above.indent_left - below.indent_left).abs() > NARROW_X0_EM * em
5230        && !(ragged(above)
5231            && ragged(below)
5232            && overlap_fraction_of(above.left, above.right, below.left, below.right)
5233                >= COLUMN_OVERLAP_FRACTION)
5234    {
5235        return false;
5236    }
5237    // A sentence boundary joins: the annotation keeps one paragraph whole
5238    // across it. What refuses is a full-measure line ending in terminal
5239    // punctuation over a lowercase opening.
5240    if ends_terminally(above.text) && opens_lowercase(below.text) && !ragged(above) {
5241        return false;
5242    }
5243    // A line ending in a colon is a label or a lead-in, and its
5244    // disposition belongs to the heading logic: a `NOTE 12:` over its
5245    // title joins there, at that logic's level, and an early join here
5246    // would hand the classifier one block where it reads two.
5247    if above.text.trim_end().ends_with(':') {
5248        return false;
5249    }
5250    true
5251}
5252
5253/// One-line blocks back into the narrow column they were sliced out of (#574).
5254///
5255/// A paragraph set in a narrow column -- a sidebar, a caption under a photo,
5256/// an address block -- comes out of the merge as one block per line, and the
5257/// merge is not what cut it: the XY-cut put each line in a region of its own.
5258/// Two columns with offset baselines drag the cut's quartile down until every
5259/// line gap qualifies, and the leading floor only arms on a repeated step, so
5260/// a column the cut meets one line at a time is sliced one line at a time --
5261/// and regions never merge back. This joins them again, pair by pair, where
5262/// the pair reads as two lines of one column: the left edges agree to half an
5263/// em (or both lines are ragged inside the column they overlap), the step is
5264/// the block's own pitch, the size holds to half a point and the face and the
5265/// weight do not change. A sentence boundary joins too -- the annotation keeps
5266/// one paragraph whole across it. What refuses is a marker on the lower line,
5267/// a table between or under either, a ruling line, a second line of white, a
5268/// lead-in ending in a colon (the heading logic's to decide), or a
5269/// full-measure line ending in terminal punctuation over a lowercase opening:
5270/// the next paragraph starting where the last ended, not a wrap.
5271///
5272/// Only ever one line onto the block above it, and only onto a block of one
5273/// line or one these joins built: a paragraph the merge assembled was refused
5274/// its next line for a reason this repair does not overrule.
5275fn join_narrow_columns(
5276    groups: &mut Vec<(usize, Group)>,
5277    metrics: &[LineMetrics],
5278    lines: &[&TextBlock],
5279    runs: &[(usize, usize, f64)],
5280    shapes: &[(f64, f64)],
5281    rules: &[HorizontalRule],
5282    entries: &std::collections::HashSet<usize>,
5283) {
5284    // Whether the one-line group `next` joins the group `open` above it.
5285    // `open` is one line, or ends a run of these joins. The pair itself is
5286    // [`narrow_column_pair_joins`]' to decide, shared with the v2 release
5287    // rejoin; what stays here is the group shape it never sees, and the
5288    // pitch and the measure, which only the builder's runs can give it.
5289    let joinable = |open: &[usize], open_joined: bool, next: &[usize]| -> bool {
5290        if next.len() != 1 || (open.len() != 1 && !open_joined) {
5291            return false;
5292        }
5293        let (Some(&last), Some(&first), Some(&at)) = (open.last(), open.first(), next.first())
5294        else {
5295            return false;
5296        };
5297        let above = &metrics[last];
5298        let below = &metrics[at];
5299        let em = above.font_size.max(below.font_size);
5300        // The pitch of the block above: its own, once the join has given it
5301        // two lines to measure, and its run's until then -- floored at solid
5302        // setting, because a run of one line measures the page's quartile
5303        // instead, which on an interleaved page is the offset between two
5304        // columns. Whatever it measures, white the depth of most of another
5305        // line is a break.
5306        let pitch = if open.len() >= 2 {
5307            let mut steps: Vec<f64> = open
5308                .windows(2)
5309                .map(|pair| metrics[pair[0]].baseline - metrics[pair[1]].baseline)
5310                .collect();
5311            median_step(&mut steps)
5312        } else {
5313            runs.iter()
5314                .zip(shapes.iter())
5315                .find(|((start, end, _), _)| *start <= last && last < *end)
5316                .map(|(_, shape)| shape.0)
5317                .unwrap_or(0.0)
5318                .max(SOLID_PITCH_EM * em)
5319        };
5320        // The column's own measure, over both runs the pair stands in: a cut
5321        // that sliced every line left each run one line wide, which is no
5322        // measure at all.
5323        let measure = runs
5324            .iter()
5325            .zip(shapes.iter())
5326            .filter(|((start, end, _), _)| {
5327                (*start <= last && last < *end) || (*start <= at && at < *end)
5328            })
5329            .map(|(_, shape)| shape.1)
5330            .fold(f64::NEG_INFINITY, f64::max);
5331        let upper = JoinLine::of(
5332            above,
5333            dominant_face(lines[last]),
5334            // Read on the line below only; the block above stands already.
5335            false,
5336        );
5337        let lower = JoinLine::of(
5338            below,
5339            dominant_face(lines[at]),
5340            below.marker.is_some() || below.item.is_some() || entries.contains(&at),
5341        );
5342        let above_item = metrics[first].item.is_some() || entries.contains(&first);
5343        narrow_column_pair_joins(&upper, above_item, &lower, pitch, measure, rules)
5344    };
5345
5346    let mut out: Vec<(usize, Group)> = Vec::with_capacity(groups.len());
5347    // The group still open to a join: its key, its lines, and whether these
5348    // joins built it.
5349    let mut open: Option<(usize, Vec<usize>, bool)> = None;
5350    for (first, group) in std::mem::take(groups) {
5351        match group {
5352            Group::Table(table) => {
5353                if let Some((key, lines, _)) = open.take() {
5354                    out.push((key, Group::Lines(lines)));
5355                }
5356                out.push((first, Group::Table(table)));
5357            }
5358            Group::Lines(next) => {
5359                let join = match &open {
5360                    Some((_, open_lines, open_joined)) => joinable(open_lines, *open_joined, &next),
5361                    None => false,
5362                };
5363                if join {
5364                    if tracing() {
5365                        if let Some((_, open_lines, _)) = &open {
5366                            let a = open_lines
5367                                .last()
5368                                .map(|i| metrics[*i].text.as_str())
5369                                .unwrap_or("");
5370                            let b = next
5371                                .first()
5372                                .map(|i| metrics[*i].text.as_str())
5373                                .unwrap_or("");
5374                            eprintln!("JOIN narrow-column | {a:?} -> {b:?}");
5375                        }
5376                    }
5377                    let (_, open_lines, open_joined) =
5378                        open.as_mut().expect("a join has a block above it");
5379                    open_lines.extend(next);
5380                    *open_joined = true;
5381                } else {
5382                    if let Some((key, lines, _)) = open.take() {
5383                        out.push((key, Group::Lines(lines)));
5384                    }
5385                    open = Some((first, next, false));
5386                }
5387            }
5388        }
5389    }
5390    if let Some((key, lines, _)) = open.take() {
5391        out.push((key, Group::Lines(lines)));
5392    }
5393    *groups = out;
5394}
5395
5396/// The most white, in ems of the marker, between a lone bullet and the line
5397/// it opens. A marker column stands about an em from its text -- 0.2 to 1.3
5398/// em on the lists tuning pages -- and a table's gutter is three
5399/// (`TABULAR_GAP_EM`), so two keeps the fusion out of tables.
5400const FUSED_MARKER_GAP_EM: f64 = 2.0;
5401
5402/// How far apart, in points, a lone bullet and its line may be across the
5403/// baseline. The two are drawn on one baseline -- exactly 0.0 apart on the
5404/// tuning pages -- so a point is dust from the transforms, and the next line
5405/// down is a leading away.
5406const FUSED_BASELINE_PT: f64 = 1.0;
5407
5408/// The bullet a line holds and nothing else, if that is all it holds.
5409///
5410/// One glyph from [`lists::BULLET_CHARS`], and white space besides: the tab
5411/// run a producer draws after its bullets counts as white space, and a second
5412/// glyph of any kind -- a second bullet, a word -- says the line is complete
5413/// as it stands.
5414fn lone_marker_glyph(line: &TextBlock) -> Option<char> {
5415    let mut found = None;
5416    for span in &line.spans {
5417        for c in span.text.chars() {
5418            if c.is_whitespace() {
5419                continue;
5420            }
5421            if found.is_some() || !lists::BULLET_CHARS.contains(&c) {
5422                return None;
5423            }
5424            found = Some(c);
5425        }
5426    }
5427    found
5428}
5429
5430/// Each lone marker back onto the line it opens.
5431///
5432/// The cut parts a marker column from its text where the white between them
5433/// runs the region's height: the bullets come back as lines of their own,
5434/// ordered away from their text, and a line with no text after its marker is
5435/// no marker at all -- so a list set marker-out loses every item whose bullet
5436/// the cut lifted. The marker and its text are still drawn on ONE baseline,
5437/// which no two lines of a column share: a line that holds nothing but a
5438/// bullet joins the nearest line to its right on its own baseline.
5439///
5440/// A page's lines with each lone marker back on the line it opens: the fused
5441/// lines in the text's place, the markers gone, and the region bounds rebuilt
5442/// round what is left.
5443struct FusedPage {
5444    lines: Vec<TextBlock>,
5445    bounds: Vec<(usize, usize)>,
5446    table_first: Vec<bool>,
5447}
5448
5449/// Each lone marker back onto the line it opens.
5450///
5451/// The cut parts a marker column from its text where the white between them
5452/// runs the region's height: the bullets come back as lines of their own,
5453/// ordered away from their text, and a line with no text after its marker is
5454/// no marker at all -- so a list set marker-out loses every item whose bullet
5455/// the cut lifted. The marker and its text are still drawn on ONE baseline,
5456/// which no two lines of a column share: a line that holds nothing but a
5457/// bullet joins the nearest line to its right on its own baseline.
5458///
5459/// Page-wide and before everything else, because the cut may have ordered the
5460/// marker's sliver anywhere -- only the baseline says which line is its own.
5461/// A line that already opens with a marker takes no donation, and a fused
5462/// line takes no second one. Returns `None` when no line took one and the
5463/// page is untouched.
5464fn fuse_lone_markers(
5465    lines: &[&TextBlock],
5466    bounds: &[(usize, usize)],
5467    table_first: &[bool],
5468    cell_path_words: bool,
5469) -> Option<FusedPage> {
5470    if !lines.iter().any(|line| lone_marker_glyph(line).is_some()) {
5471        return None;
5472    }
5473    // The anchor span's baseline is the line's, the way `LineMetrics::of`
5474    // reads it; the edges are the spans'.
5475    let baseline = |line: &TextBlock| anchor_span(line.spans.iter()).map(|s| s.y).unwrap_or(0.0);
5476    let left = |line: &TextBlock| line.spans.iter().map(|s| s.x).fold(f64::INFINITY, f64::min);
5477    let right = |line: &TextBlock| {
5478        line.spans
5479            .iter()
5480            .map(span_right)
5481            .fold(f64::NEG_INFINITY, f64::max)
5482    };
5483    let mut donated: Vec<Option<Vec<TextSpan>>> = vec![None; lines.len()];
5484    let mut killed = vec![false; lines.len()];
5485    for (i, marker) in lines.iter().enumerate() {
5486        let Some(_) = lone_marker_glyph(marker) else {
5487            continue;
5488        };
5489        let em = anchor_span(marker.spans.iter())
5490            .map(|s| s.font_size.max(1.0))
5491            .unwrap_or(1.0);
5492        let base = baseline(marker);
5493        let edge = right(marker);
5494        let mut best: Option<(usize, f64)> = None;
5495        for (j, line) in lines.iter().enumerate() {
5496            if i == j || killed[j] || donated[j].is_some() || lone_marker_glyph(line).is_some() {
5497                continue;
5498            }
5499            if line
5500                .text_with_cell_path_words(cell_path_words)
5501                .trim()
5502                .is_empty()
5503            {
5504                continue;
5505            }
5506            // Complete as it stands: whatever sits left of it at its
5507            // baseline is not its marker. Read without a lead, which is
5508            // what a marker in running text is; a picture-font bullet or a
5509            // raised figure needs the spans `LineMetrics::of` still holds.
5510            if lists::marker_at(
5511                &line.text_with_cell_path_words(cell_path_words),
5512                lists::Lead::default(),
5513            )
5514            .is_some()
5515            {
5516                continue;
5517            }
5518            if (baseline(line) - base).abs() > FUSED_BASELINE_PT {
5519                continue;
5520            }
5521            let start = left(line);
5522            if start <= edge {
5523                continue;
5524            }
5525            let gap = start - edge;
5526            if gap > FUSED_MARKER_GAP_EM * em {
5527                continue;
5528            }
5529            if best.is_none_or(|(_, known): (usize, f64)| gap < known) {
5530                best = Some((j, gap));
5531            }
5532        }
5533        if let Some((j, _)) = best {
5534            let mut spans = marker.spans.clone();
5535            spans.extend(lines[j].spans.iter().cloned());
5536            spans.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
5537            donated[j] = Some(spans);
5538            killed[i] = true;
5539        }
5540    }
5541    if donated.iter().all(Option::is_none) {
5542        return None;
5543    }
5544    let mut region_of = vec![0usize; lines.len()];
5545    for (region, &(start, end)) in bounds.iter().enumerate() {
5546        for slot in region_of.iter_mut().take(end.min(lines.len())).skip(start) {
5547            *slot = region;
5548        }
5549    }
5550    let mut owned: Vec<TextBlock> = Vec::with_capacity(lines.len());
5551    let mut regions: Vec<usize> = Vec::with_capacity(lines.len());
5552    for (i, line) in lines.iter().enumerate() {
5553        if killed[i] {
5554            continue;
5555        }
5556        match donated[i].take() {
5557            Some(spans) => owned.push(TextBlock { spans }),
5558            None => owned.push((*line).clone()),
5559        }
5560        regions.push(region_of[i]);
5561    }
5562    let mut fused_bounds: Vec<(usize, usize)> = Vec::new();
5563    let mut fused_first: Vec<bool> = Vec::new();
5564    for (i, region) in regions.iter().enumerate() {
5565        if i > 0 && regions[i - 1] == *region {
5566            fused_bounds.last_mut().expect("a bound is open").1 = i + 1;
5567        } else {
5568            fused_bounds.push((i, i + 1));
5569            fused_first.push(table_first.get(*region).copied().unwrap_or(false));
5570        }
5571    }
5572    Some(FusedPage {
5573        lines: owned,
5574        bounds: fused_bounds,
5575        table_first: fused_first,
5576    })
5577}
5578
5579/// How many lines must show white at the same x before it counts as the
5580/// gutter between two columns of text rather than a wide word space.
5581///
5582/// Three: the narrowest fused list on the tuning pages shows five, and a
5583/// single-column page never aligns three table-wide gaps at one x -- with the
5584/// list-bearing rule below, nothing single-column ever splits at all.
5585const GUTTER_SUPPORT_LINES: usize = 3;
5586
5587/// How many characters each side of a gutter split must hold, not counting
5588/// white space. A contents page sets its page numbers apart from its entries
5589/// with white as wide as any gutter, and the number is a figure or two where
5590/// a column's line runs on.
5591const GUTTER_HALF_MIN_CHARS: usize = 8;
5592
5593/// Lines the page fused across its own column gutter, split back into their
5594/// columns: the lines, the region bounds round them, and which of them a
5595/// table holds. The table groups are renumbered in place, through `groups`.
5596struct SplitPage {
5597    lines: Vec<TextBlock>,
5598    bounds: Vec<(usize, usize)>,
5599    in_table: Vec<bool>,
5600}
5601
5602/// Each line the cut fused across a column gutter back into its two columns.
5603///
5604/// The cut parts columns where white runs the region's height, and a marker
5605/// hung into the gutter -- a bullet column standing between the text it marks
5606/// and the column beside it -- closes it: the page comes back read across both
5607/// columns, one fused line per row. The gutter is still there inside every
5608/// line, a wide gap at the same x row after row, which is what this splits on.
5609/// What comes out are the page's own columns: same-column items group under
5610/// the list rule, and no list ever continues across the gutter again.
5611///
5612/// AFTER the tables are found, and never through one: a table's rows are
5613/// fused lines too, and splitting them would unmake the grid. Only a line no
5614/// table holds is split, and only in a stack where two of the halves open
5615/// with a marker -- a list is the reason to cut, and prose with an aligned
5616/// gap of its own is left as the page wrote it. The tables keep their lines;
5617/// only their line numbers move to the longer lines.
5618/// One gap of one line: the line number, and the white from the end of the
5619/// span on its left to the start of the span on its right.
5620type LineGap = (usize, f64, f64);
5621
5622fn split_fused_lines(
5623    lines: &[&TextBlock],
5624    metrics: &[LineMetrics],
5625    bounds: &[(usize, usize)],
5626    in_table: &[bool],
5627    groups: &mut [(usize, Group)],
5628) -> Option<SplitPage> {
5629    // The wide gaps of the lines no table holds, clustered by overlap: gaps
5630    // that share an x are one gutter, and one gutter with few lines behind it
5631    // is a wide word space.
5632    let mut gaps: Vec<LineGap> = Vec::new();
5633    for (i, line) in metrics.iter().enumerate() {
5634        if in_table[i] {
5635            continue;
5636        }
5637        for &(end, start) in &line.wide_gaps {
5638            gaps.push((i, end, start));
5639        }
5640    }
5641    if gaps.len() < GUTTER_SUPPORT_LINES {
5642        return None;
5643    }
5644    gaps.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal));
5645    // Each cluster holds its members' line numbers, so the support counts
5646    // lines and not gaps: two gaps of one line in one cluster are one voice.
5647    let mut clusters: Vec<(Vec<usize>, Vec<LineGap>)> = Vec::new();
5648    for (line, end, start) in gaps {
5649        match clusters.last_mut() {
5650            Some((member_lines, members))
5651                if members.iter().any(|&(_, e, s)| end <= s && e <= start) =>
5652            {
5653                if !member_lines.contains(&line) {
5654                    member_lines.push(line);
5655                }
5656                members.push((line, end, start));
5657            }
5658            _ => clusters.push((vec![line], vec![(line, end, start)])),
5659        }
5660    }
5661    let gutters: Vec<&Vec<LineGap>> = clusters
5662        .iter()
5663        .filter(|(member_lines, _)| member_lines.len() >= GUTTER_SUPPORT_LINES)
5664        .map(|(_, members)| members)
5665        .collect();
5666    if gutters.is_empty() {
5667        return None;
5668    }
5669    let in_a_gutter = |line: usize, end: f64, start: f64| {
5670        gutters.iter().any(|members| {
5671            members
5672                .iter()
5673                .any(|&(l, e, s)| l == line && e == end && s == start)
5674        })
5675    };
5676
5677    // The halves of every line its gutter gaps cut, measured the way any line
5678    // is: the marker each half opens with is read off its own spans, so a
5679    // picture-font bullet counts like an inline one.
5680    let x_order = |line: &TextBlock| {
5681        let mut order: Vec<usize> = (0..line.spans.len()).collect();
5682        order.sort_by(|a, b| {
5683            line.spans[*a]
5684                .x
5685                .partial_cmp(&line.spans[*b].x)
5686                .unwrap_or(Ordering::Equal)
5687        });
5688        order
5689    };
5690    // Line by line, the halves it splits into: `None` where the line stays
5691    // whole -- no gutter gap, or a half too short to be a column's line.
5692    let mut halves: Vec<Option<Vec<TextBlock>>> = vec![None; lines.len()];
5693    let mut marks: Vec<Vec<bool>> = vec![Vec::new(); lines.len()];
5694    for (i, line) in lines.iter().enumerate() {
5695        if in_table[i] {
5696            continue;
5697        }
5698        let order = x_order(line);
5699        let mut cuts = vec![false; order.len().saturating_sub(1)];
5700        for (k, pair) in order.windows(2).enumerate() {
5701            let end = span_right(&line.spans[pair[0]]);
5702            let start = line.spans[pair[1]].x;
5703            cuts[k] = end < start && in_a_gutter(i, end, start);
5704        }
5705        if !cuts.iter().any(|cut| *cut) {
5706            continue;
5707        }
5708        let mut parts: Vec<TextBlock> = Vec::new();
5709        let mut at = 0usize;
5710        for (k, cut) in cuts.iter().enumerate() {
5711            if *cut {
5712                parts.push(TextBlock {
5713                    spans: order[at..=k]
5714                        .iter()
5715                        .map(|j| line.spans[*j].clone())
5716                        .collect(),
5717                });
5718                at = k + 1;
5719            }
5720        }
5721        parts.push(TextBlock {
5722            spans: order[at..].iter().map(|j| line.spans[*j].clone()).collect(),
5723        });
5724        let measured: Vec<LineMetrics> = parts.iter().map(LineMetrics::of).collect();
5725        let substantial = measured.iter().all(|half| {
5726            half.text.chars().filter(|c| !c.is_whitespace()).count() >= GUTTER_HALF_MIN_CHARS
5727        });
5728        if !substantial {
5729            continue;
5730        }
5731        marks[i] = measured.iter().map(|half| half.marker.is_some()).collect();
5732        halves[i] = Some(parts);
5733    }
5734
5735    // A stack is list-bearing when two of its halves open with a marker: the
5736    // run rule still confirms them afterwards, and anything less is prose.
5737    let mut bearing = vec![false; lines.len()];
5738    for (start, end) in column_stacks(metrics, bounds) {
5739        let count: usize = (start..end)
5740            .map(|i| marks[i].iter().filter(|marked| **marked).count())
5741            .sum();
5742        if count >= 2 {
5743            bearing[start..end].fill(true);
5744        }
5745    }
5746    if !halves
5747        .iter()
5748        .enumerate()
5749        .any(|(i, parts)| parts.is_some() && bearing[i])
5750    {
5751        return None;
5752    }
5753
5754    // The new lines, halves in the place of their line, and every line number
5755    // moved to them: the tables keep their lines, which never split.
5756    let mut region_of = vec![0usize; lines.len()];
5757    for (region, &(start, end)) in bounds.iter().enumerate() {
5758        for slot in region_of.iter_mut().take(end.min(lines.len())).skip(start) {
5759            *slot = region;
5760        }
5761    }
5762    let split: Vec<bool> = halves
5763        .iter()
5764        .enumerate()
5765        .map(|(i, parts)| parts.is_some() && bearing[i])
5766        .collect();
5767    let mut owned: Vec<TextBlock> = Vec::with_capacity(lines.len());
5768    let mut regions: Vec<usize> = Vec::with_capacity(lines.len());
5769    let mut tabled: Vec<bool> = Vec::with_capacity(lines.len());
5770    let mut moved: Vec<usize> = vec![0; lines.len()];
5771    for (i, line) in lines.iter().enumerate() {
5772        moved[i] = owned.len();
5773        match halves[i].take() {
5774            Some(parts) if split[i] => {
5775                for part in parts {
5776                    owned.push(part);
5777                    regions.push(region_of[i]);
5778                    tabled.push(false);
5779                }
5780            }
5781            _ => {
5782                owned.push((*line).clone());
5783                regions.push(region_of[i]);
5784                tabled.push(in_table[i]);
5785            }
5786        }
5787    }
5788    let mut split_bounds: Vec<(usize, usize)> = Vec::new();
5789    for (i, region) in regions.iter().enumerate() {
5790        if i > 0 && regions[i - 1] == *region {
5791            split_bounds.last_mut().expect("a bound is open").1 = i + 1;
5792        } else {
5793            split_bounds.push((i, i + 1));
5794        }
5795    }
5796    debug_assert_eq!(
5797        split_bounds.len(),
5798        bounds.len(),
5799        "a split adds lines to regions, never regions"
5800    );
5801    for (first, group) in groups.iter_mut() {
5802        *first = moved[*first];
5803        if let Group::Table(table) = group {
5804            for cell in table.cells.iter_mut() {
5805                for (line, _) in cell.spans.iter_mut() {
5806                    debug_assert!(!split[*line], "a table holds no split line");
5807                    *line = moved[*line];
5808                }
5809            }
5810        }
5811    }
5812    Some(SplitPage {
5813        lines: owned,
5814        bounds: split_bounds,
5815        in_table: tabled,
5816    })
5817}
5818
5819/// Every paragraph on one page, in reading order.
5820///
5821/// `regions` are the page's lines as [`crate::text`] produced them, grouped by
5822/// the leaf region of the XY-cut they came from and in the order the page is
5823/// read in. THE REGION IS THE COLUMN GAP: a page's two columns are two regions,
5824/// and a full-width title above them is a third -- so a paragraph that must
5825/// never cross a column gap is a paragraph that never crosses a region, and
5826/// nothing here has to rediscover from the text's own geometry what the cut
5827/// already decided. (A title left-aligned over a column, set in the body size,
5828/// is the same picture as the first line of a paragraph; no measurement of the
5829/// two lines tells them apart, and the cut is the only thing that knows.)
5830///
5831/// One repair crosses regions afterwards: [`join_narrow_columns`] joins
5832/// one-line groups back into the narrow column the cut sliced, pair by pair
5833/// and only where the pair reads as two lines of one column.
5834///
5835/// `rules` are the horizontal lines the page drew, in the same frame;
5836/// `page_leading` is the page's tight line-to-line spacing. `first_index` is
5837/// where this page's blocks start in the document's reading order.
5838/// `table_strategy` is which evidence may find a table: under
5839/// [`TableStrategy::Lines`] a grid no drawn rule crosses is left for the
5840/// paragraph merge below, exactly as a run the builder refused.
5841#[cfg(test)]
5842pub(crate) fn paragraphs(
5843    page: usize,
5844    first_index: usize,
5845    regions: &[Vec<TextBlock>],
5846    whole: &[bool],
5847    rules: &[HorizontalRule],
5848    page_leading: f64,
5849    table_strategy: TableStrategy,
5850) -> Vec<Block> {
5851    paragraphs_with_cell_path_words(
5852        page,
5853        first_index,
5854        regions,
5855        whole,
5856        rules,
5857        page_leading,
5858        table_strategy,
5859        true,
5860    )
5861}
5862
5863/// [`paragraphs`] with the cell-path word rules where `cell_path_words` is
5864/// true: line and cell texts read the quarter-em boundary as a space. The
5865/// bare entry keeps the shipped words for its unit tests; the extraction
5866/// path reads this one with the option.
5867#[allow(clippy::too_many_arguments)]
5868pub(crate) fn paragraphs_with_cell_path_words(
5869    page: usize,
5870    first_index: usize,
5871    regions: &[Vec<TextBlock>],
5872    whole: &[bool],
5873    rules: &[HorizontalRule],
5874    page_leading: f64,
5875    table_strategy: TableStrategy,
5876    cell_path_words: bool,
5877) -> Vec<Block> {
5878    let mut lines: Vec<&TextBlock> = Vec::new();
5879    let mut bounds: Vec<(usize, usize)> = Vec::new();
5880    let mut table_first: Vec<bool> = Vec::new();
5881    for (k, region) in regions.iter().enumerate() {
5882        let start = lines.len();
5883        lines.extend(region.iter().filter(|line| !line.spans.is_empty()));
5884        if lines.len() > start {
5885            bounds.push((start, lines.len()));
5886            table_first.push(whole.get(k).copied().unwrap_or(false));
5887        }
5888    }
5889    if lines.is_empty() {
5890        return Vec::new();
5891    }
5892    // Each lone marker back onto the line it opens, before anything reads
5893    // the lines: the cut may have ordered the marker's sliver anywhere, and
5894    // only its own baseline survives. Untouched pages keep their lines and
5895    // their bounds -- the fusion hands back `None` and nothing is rebuilt.
5896    let owned;
5897    if let Some(fused) = fuse_lone_markers(&lines, &bounds, &table_first, cell_path_words) {
5898        owned = fused.lines;
5899        lines = owned.iter().collect();
5900        bounds = fused.bounds;
5901        table_first = fused.table_first;
5902    }
5903
5904    let mut metrics: Vec<LineMetrics> = lines
5905        .iter()
5906        .map(|line| LineMetrics::of_with(line, true, cell_path_words))
5907        .collect();
5908    mark_tabular(&mut metrics);
5909    confirm_initials(&mut metrics, &lines, cell_path_words);
5910
5911    // The tables, before anything is cut into columns: a table is a statement
5912    // about a REGION, the way a list's levels are (#569, #570), and its rows
5913    // arrive one block per cell wherever a cell wrapped -- which is exactly the
5914    // seam `column_runs` cuts at. What is left of each region after its tables
5915    // are taken out is what the paragraph merge sees.
5916    //
5917    // Keyed by the line each group opens at, because the tables are taken out
5918    // of the regions and the runs are then built round them: sorting the two
5919    // back together by line is the reading order, since the regions and the
5920    // lines inside them are already in it.
5921    let mut groups: Vec<(usize, Group)> = Vec::new();
5922    let mut in_table: Vec<bool> = vec![false; lines.len()];
5923    for ((region_start, region_end), whole) in bounds.iter().zip(&table_first) {
5924        let rows = table_rows(&metrics, *region_start, *region_end);
5925        // The trace's page for vetoed candidates: `find` never learns it.
5926        let _trace_page = crate::table_trace::trace_page_guard(page);
5927        let found = if *whole {
5928            tables::find_whole(&lines, &rows, rules)
5929        } else {
5930            tables::find(&lines, &rows, rules)
5931        };
5932        // Under `Lines` the unruled grids never become groups: their lines
5933        // stay out of `in_table`, so the stretches below read them as the
5934        // paragraphs they would have been without the builder.
5935        let found = match table_strategy {
5936            TableStrategy::Lines => found
5937                .into_iter()
5938                .filter(|table| tables::table_has_ruling(&rows, table, rules))
5939                .collect(),
5940            TableStrategy::Text | TableStrategy::Both => found,
5941        };
5942        for table in found {
5943            let (first, end) = (rows[table.first].first, rows[table.end - 1].end);
5944            in_table[first..end].fill(true);
5945            groups.push((first, Group::Table(table)));
5946        }
5947    }
5948
5949    // Each line the cut fused across a column gutter back into its columns,
5950    // before anything is cut into columns itself: the merge and the list rule
5951    // below then see the page's own columns, and a list never continues
5952    // across the gutter again. Untouched pages keep their lines -- the split
5953    // hands back `None` where no gutter runs or no stack is list-bearing, and
5954    // nothing is rebuilt.
5955    let split_owned;
5956    if let Some(split) = split_fused_lines(&lines, &metrics, &bounds, &in_table, &mut groups) {
5957        split_owned = split.lines;
5958        lines = split_owned.iter().collect();
5959        bounds = split.bounds;
5960        in_table = split.in_table;
5961        metrics = lines
5962            .iter()
5963            .map(|line| LineMetrics::of_with(line, true, cell_path_words))
5964            .collect();
5965        mark_tabular(&mut metrics);
5966        confirm_initials(&mut metrics, &lines, cell_path_words);
5967    }
5968
5969    let mut stretches: Vec<(usize, usize)> = Vec::new();
5970    for (region_start, region_end) in &bounds {
5971        let mut start = *region_start;
5972        for (i, held) in in_table
5973            .iter()
5974            .enumerate()
5975            .take(*region_end)
5976            .skip(*region_start)
5977        {
5978            if *held {
5979                if start < i {
5980                    stretches.push((start, i));
5981                }
5982                start = i + 1;
5983            }
5984        }
5985        if start < *region_end {
5986            stretches.push((start, *region_end));
5987        }
5988    }
5989
5990    // Every column of the page, with the left edge it is set to.
5991    let runs: Vec<(usize, usize, f64)> = stretches
5992        .iter()
5993        .flat_map(|(stretch_start, stretch_end)| {
5994            column_runs(&metrics[*stretch_start..*stretch_end])
5995                .into_iter()
5996                .map(|(first, last)| (stretch_start + first, stretch_start + last))
5997                .collect::<Vec<_>>()
5998        })
5999        .map(|(start, end)| (start, end, body_left(&metrics[start..end])))
6000        .collect();
6001    // Before the merge, because a confirmed item is a boundary the merge has to
6002    // refuse to cross, and over the REGIONS rather than the column runs: a
6003    // list's own levels do not line up, which is what a run seam is made of
6004    // (#569).
6005    mark_list_items(&mut metrics, &bounds);
6006    let style = indent_style(&metrics, &runs);
6007    // Each run's leading and its column measure, for the merge below and the
6008    // narrow-column join after it. The measure is the COLUMN's -- every line
6009    // on the page that stands over this run -- and neither the run's nor its
6010    // region's: a list is a run seam and the cut can make a region of two
6011    // short items and the line after them, whose own edge is no measure at
6012    // all (codex, #1981). Too wide errs toward keeping lines apart, which is
6013    // the safe side.
6014    let run_shapes: Vec<(f64, f64)> = runs
6015        .iter()
6016        .map(|(start, end, _)| {
6017            let leading = run_leading(&metrics[*start..*end], page_leading);
6018            let run_left = metrics[*start..*end]
6019                .iter()
6020                .map(|m| m.indent_left)
6021                .fold(f64::INFINITY, f64::min);
6022            let run_right = metrics[*start..*end]
6023                .iter()
6024                .map(|m| m.right)
6025                .fold(f64::NEG_INFINITY, f64::max);
6026            let measure = metrics
6027                .iter()
6028                .filter(|m| m.indent_left < run_right && m.right > run_left)
6029                .map(|m| m.right)
6030                .fold(run_right, f64::max);
6031            (leading, measure)
6032        })
6033        .collect();
6034    let mut entries: std::collections::HashSet<usize> = std::collections::HashSet::new();
6035
6036    for (index, (start, end, margin)) in runs.iter().enumerate() {
6037        let (start, end, margin) = (*start, *end, *margin);
6038        let (leading, measure) = run_shapes[index];
6039        let column = Column {
6040            margin,
6041            right: measure,
6042        };
6043        let mut current = vec![start];
6044        let mut run_groups: Vec<Vec<usize>> = Vec::new();
6045        for i in start + 1..end {
6046            if continues(&metrics, &current, i, style, column, rules, leading) {
6047                current.push(i);
6048            } else {
6049                run_groups.push(std::mem::take(&mut current));
6050                current = vec![i];
6051            }
6052        }
6053        run_groups.push(current);
6054        for (group, entry) in
6055            run_groups
6056                .iter()
6057                .zip(entries_of_a_list(&metrics, &run_groups, margin))
6058        {
6059            if entry {
6060                entries.insert(group[0]);
6061            }
6062        }
6063        groups.extend(
6064            run_groups
6065                .into_iter()
6066                .map(|group| (group[0], Group::Lines(group))),
6067        );
6068    }
6069    groups.sort_by_key(|(first, _)| *first);
6070    join_narrow_columns(
6071        &mut groups,
6072        &metrics,
6073        &lines,
6074        &runs,
6075        &run_shapes,
6076        rules,
6077        &entries,
6078    );
6079
6080    // Numbered AFTER the blocks that hold nothing are gone. Numbering first left
6081    // a gap in the reading order wherever a group turned out to be whitespace,
6082    // and `PdfDocument::extract_blocks` starts each page at the count it has --
6083    // so the gap on one page became a REPEATED index on the next (codex,
6084    // round 2).
6085    let mut blocks: Vec<Block> = Vec::new();
6086    for (_, group) in groups {
6087        match group {
6088            Group::Lines(group) if !group.is_empty() => {
6089                let block_lines: Vec<&TextBlock> = group.iter().map(|i| lines[*i]).collect();
6090                let block_metrics: Vec<&LineMetrics> = group.iter().map(|i| &metrics[*i]).collect();
6091                let item = block_metrics.first().and_then(|m| m.item);
6092                let mut block = assemble(page, 0, &block_lines, &block_metrics, item);
6093                if item.is_none() && entries.contains(&group[0]) {
6094                    block.kind = BlockKind::ListItem {
6095                        level: 0,
6096                        ordinal: None,
6097                    };
6098                }
6099                blocks.push(block);
6100            }
6101            Group::Lines(_) => {}
6102            Group::Table(table) => {
6103                for cell in &table.cells {
6104                    // The same record on every cell of the table: see `GridDoubt`.
6105                    blocks.push(assemble_cell(
6106                        page,
6107                        &lines,
6108                        cell,
6109                        table.doubt,
6110                        table.origin,
6111                        cell_path_words,
6112                    ));
6113                }
6114            }
6115        }
6116    }
6117    blocks.retain(|block| !block.text.trim().is_empty());
6118    for (n, block) in blocks.iter_mut().enumerate() {
6119        block.index = first_index + n;
6120    }
6121    blocks
6122}
6123
6124#[cfg(test)]
6125mod tests {
6126    use super::*;
6127
6128    #[test]
6129    fn extract_options_defaults_pin_every_new_field() {
6130        let options = ExtractOptions::default();
6131        assert_eq!(options.ocr, OcrMode::Never);
6132        assert_eq!(options.ocr_language, "eng");
6133        assert_eq!(options.ocr_dpi, 150);
6134        assert!(matches!(options.quality, Quality::Fast));
6135        assert_eq!(options.model_dir, None);
6136        assert!(!options.keep_furniture);
6137        assert!(options.heading_overrides.is_empty());
6138        assert!(!options.include_hidden);
6139        assert!(!options.raw_order);
6140        assert!(matches!(options.table_strategy, TableStrategy::Both));
6141        assert!(matches!(options.table_render_mode, TableRenderMode::Auto));
6142    }
6143
6144    #[test]
6145    fn extract_options_still_implements_eq() {
6146        fn assert_is_eq<T: Eq>() {}
6147        assert_is_eq::<ExtractOptions>();
6148        assert_is_eq::<HeadingOverride>();
6149    }
6150
6151    #[test]
6152    fn extract_options_stays_eq() {
6153        // A caller may compare or key on the options: the derive above is
6154        // public API, and this fails to compile the day a field breaks it.
6155        fn assert_impl_eq<T: Eq>() {}
6156        assert_impl_eq::<ExtractOptions>();
6157    }
6158
6159    fn table_cell(row: u16, column: u16, text: &str) -> Block {
6160        Block {
6161            kind: BlockKind::TableCell {
6162                row,
6163                column,
6164                row_span: 1,
6165                column_span: if text.len() > 30 { 3 } else { 1 },
6166                header: row == 0,
6167            },
6168            page: 0,
6169            bbox: [
6170                72.0 + 100.0 * f64::from(column),
6171                700.0 - 12.0 * f64::from(row),
6172                160.0,
6173                709.0,
6174            ],
6175            text: text.into(),
6176            spans: Vec::new(),
6177            index: 0,
6178            continues_previous: false,
6179            confidence: None,
6180            grid: Some(GridDoubt::default()),
6181            table_origin: TableOrigin::Other,
6182            trace_learned: None,
6183        }
6184    }
6185
6186    /// A body of three rows and three columns, with `over` as a lone cell on
6187    /// the row above it and `under` on the row below.
6188    fn table_with(over: Option<&str>, under: Option<&str>) -> Vec<Block> {
6189        let mut blocks = Vec::new();
6190        let mut row = 0;
6191        if let Some(text) = over {
6192            blocks.push(table_cell(row, 0, text));
6193            row += 1;
6194        }
6195        for (label, a, b) in [
6196            ("Macrophages", "49.5", "50.5"),
6197            ("Granulocytes", "17.7", "15.5"),
6198            ("T lymphocytes", "5.0", "6.7"),
6199        ] {
6200            blocks.push(table_cell(row, 0, label));
6201            blocks.push(table_cell(row, 1, a));
6202            blocks.push(table_cell(row, 2, b));
6203            row += 1;
6204        }
6205        if let Some(text) = under {
6206            blocks.push(table_cell(row, 0, text));
6207        }
6208        blocks
6209    }
6210
6211    fn cells(blocks: &[Block]) -> usize {
6212        blocks
6213            .iter()
6214            .filter(|b| matches!(b.kind, BlockKind::TableCell { .. }))
6215            .count()
6216    }
6217
6218    #[test]
6219    fn extract_options_keeps_its_eq() {
6220        // `ExtractOptions` is public API a caller may compare or key on; the
6221        // bare float array cost it `Eq`, and the newtype below gives it back.
6222        // Compile-time: removing `Eq` fails this test's build, not a run.
6223        fn assert_eq<T: Eq>() {}
6224        assert_eq::<ExtractOptions>();
6225    }
6226
6227    #[test]
6228    fn tatr_options_default_to_four_threads() {
6229        // P4a: 8 threads is slower and fails the p95 table budget, 4 passes;
6230        // a caller that does not set the knob gets the passing value.
6231        assert_eq!(TatrOptions::default().intra_threads, 4);
6232    }
6233
6234    #[test]
6235    fn clip_margins_rejects_a_negative_side() {
6236        assert!(ClipMargins::new(0.0, 0.0, 0.0, 0.0).is_ok());
6237        assert!(ClipMargins::new(10.0, 20.0, 30.0, 40.0).is_ok());
6238        for (side, margins) in [
6239            ("left", (-1.0, 0.0, 0.0, 0.0)),
6240            ("top", (0.0, -1.0, 0.0, 0.0)),
6241            ("right", (0.0, 0.0, -1.0, 0.0)),
6242            ("bottom", (0.0, 0.0, 0.0, -0.5)),
6243        ] {
6244            let (left, top, right, bottom) = margins;
6245            let value = [left, top, right, bottom]
6246                .into_iter()
6247                .find(|v| *v < 0.0)
6248                .expect("one negative side");
6249            assert_eq!(
6250                ClipMargins::new(left, top, right, bottom),
6251                Err(OptionsError::InvalidClipMargins { side, value }),
6252                "{side} rejects its negative side",
6253            );
6254        }
6255    }
6256
6257    #[test]
6258    fn clip_margins_rejects_what_is_not_finite() {
6259        for (side, index) in [("left", 0), ("top", 1), ("right", 2), ("bottom", 3)] {
6260            for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
6261                let mut margins = [0.0; 4];
6262                margins[index] = value;
6263                let [left, top, right, bottom] = margins;
6264                assert_eq!(
6265                    ClipMargins::new(left, top, right, bottom),
6266                    Err(OptionsError::InvalidClipMargins { side, value }),
6267                    "{side} rejects {value:?}",
6268                );
6269            }
6270        }
6271    }
6272
6273    #[test]
6274    fn clip_margins_round_trips_through_its_accessor() {
6275        let margins = ClipMargins::new(10.0, 20.0, 30.0, 40.0).expect("valid");
6276        assert_eq!(margins.as_array(), [10.0, 20.0, 30.0, 40.0]);
6277    }
6278
6279    #[test]
6280    fn clip_margins_compares_float_equal_margins_equal() {
6281        let one = ClipMargins::new(1.0, 2.0, 3.0, 4.0).expect("valid");
6282        let same = ClipMargins::new(1.0, 2.0, 3.0, 4.0).expect("valid");
6283        assert_eq!(one, same);
6284        // `-0.0` is normalised to `0.0` at construction, so margins that
6285        // compare equal as floats are `Eq`-equal. Non-finite values never get
6286        // this far -- the constructor refuses them -- so every value here is
6287        // reflexive and `Eq` holds.
6288        let neg_zero = ClipMargins::new(-0.0, 0.0, 0.0, 0.0).expect("valid");
6289        let pos_zero = ClipMargins::new(0.0, 0.0, 0.0, 0.0).expect("valid");
6290        assert_eq!(neg_zero, pos_zero);
6291        // Bit-exact: `-0.0 == 0.0` as floats, so only the bits say the
6292        // normalisation happened.
6293        assert_eq!(neg_zero.as_array().map(f64::to_bits), [0.0f64.to_bits(); 4]);
6294        let mut seen = std::collections::HashSet::new();
6295        seen.insert(one);
6296        assert!(seen.contains(&same));
6297        seen.insert(neg_zero);
6298        assert!(seen.contains(&pos_zero));
6299    }
6300
6301    #[test]
6302    fn clip_margins_error_names_the_side() {
6303        let err = ClipMargins::new(-1.0, 0.0, 0.0, 0.0).expect_err("negative");
6304        assert_eq!(
6305            err.to_string(),
6306            "invalid clip margins: left must be finite and at least 0, got -1"
6307        );
6308        // `Display` plus `std::error::Error`, so bindings can map it.
6309        let _: &dyn std::error::Error = &err;
6310    }
6311
6312    fn hidden_span(text: &str) -> TextSpan {
6313        TextSpan {
6314            text: text.to_string(),
6315            render_mode: Some(3),
6316            ..Default::default()
6317        }
6318    }
6319
6320    fn visible_span(text: &str) -> TextSpan {
6321        TextSpan {
6322            text: text.to_string(),
6323            ..Default::default()
6324        }
6325    }
6326
6327    #[test]
6328    fn a_spanless_figure_does_not_mark_its_page_visible() {
6329        // A scanned page: its whole text layer hides in render mode 3 over
6330        // its own image, and the figure marker beside it carries no spans.
6331        // The marker must not vote the page visible, or the drop below takes
6332        // every word on the page.
6333        let figure = Block::new(
6334            BlockKind::Figure,
6335            0,
6336            [0.0, 0.0, 10.0, 10.0],
6337            String::new(),
6338            0,
6339        );
6340        let mut words = Block::new(
6341            BlockKind::Paragraph,
6342            0,
6343            [0.0, 0.0, 10.0, 10.0],
6344            "words".to_string(),
6345            1,
6346        );
6347        words.spans = vec![hidden_span("words")];
6348        let mut blocks = vec![figure, words];
6349        drop_hidden_text(&mut blocks);
6350        assert_eq!(blocks.len(), 2, "the hidden words went with the figure");
6351        assert_eq!(blocks[1].text, "words");
6352    }
6353
6354    #[test]
6355    fn visible_text_still_drops_hidden_spans() {
6356        // The other direction: where the page draws visible text, hidden
6357        // spans go -- the test above is not a drop that never fires.
6358        let mut block = Block::new(
6359            BlockKind::Paragraph,
6360            0,
6361            [0.0, 0.0, 10.0, 10.0],
6362            "seen gone".to_string(),
6363            0,
6364        );
6365        block.spans = vec![visible_span("seen"), hidden_span("gone")];
6366        let mut blocks = vec![block];
6367        drop_hidden_text(&mut blocks);
6368        assert_eq!(blocks.len(), 1);
6369        assert_eq!(blocks[0].spans.len(), 1);
6370        assert_eq!(blocks[0].text, "seen");
6371    }
6372
6373    #[test]
6374    fn a_caption_and_a_note_are_taken_out_of_the_table() {
6375        let mut blocks = table_with(
6376            Some("Table 1 Cellular composition of cells infiltrating synovial tissue"),
6377            Some("Values are percentages (mean \u{b1} standard deviation) of positive cells."),
6378        );
6379        peel_table_edges(&mut blocks, false);
6380        assert_eq!(
6381            blocks[0].kind,
6382            BlockKind::Paragraph,
6383            "the caption stayed a cell"
6384        );
6385        assert!(
6386            blocks[0].grid.is_none(),
6387            "a peeled caption kept its table's evidence"
6388        );
6389        assert_eq!(
6390            blocks.last().map(|b| b.kind),
6391            Some(BlockKind::Paragraph),
6392            "the note stayed a cell"
6393        );
6394        assert_eq!(cells(&blocks), 9);
6395        assert!(
6396            matches!(
6397                blocks[1].kind,
6398                BlockKind::TableCell {
6399                    row: 0,
6400                    column: 0,
6401                    ..
6402                }
6403            ),
6404            "the table's rows were not counted from its new first row: {:?}",
6405            blocks[1].kind
6406        );
6407    }
6408
6409    #[test]
6410    fn a_spanning_head_and_a_crystal_head_stay_in_the_table() {
6411        for head in [
6412            "Male Female",
6413            "x y z U iso*/ U eq",
6414            "Diplotype No. rs12979860 rs738409",
6415            // `table` inside a word is no caption (codex, #2048).
6416            "Stable isotope composition",
6417        ] {
6418            let mut blocks = table_with(Some(head), None);
6419            peel_table_edges(&mut blocks, false);
6420            assert_eq!(cells(&blocks), 10, "`{head}` was taken out of the table");
6421            // And under it: a row of symbols is not a sentence, however many
6422            // of them there are.
6423            // It may join the cell over it, where it reads as running on
6424            // from it; it stays in the table either way.
6425            let mut blocks = table_with(None, Some(head));
6426            peel_table_edges(&mut blocks, false);
6427            assert!(
6428                blocks
6429                    .iter()
6430                    .all(|b| { matches!(b.kind, BlockKind::TableCell { .. }) })
6431                    && blocks.iter().any(|b| b.text.contains(head)),
6432                "`{head}` under the table was taken out"
6433            );
6434        }
6435    }
6436
6437    /// A table set over a page break opens with the tail of the cell the page
6438    /// before began; read as a sentence it would be taken out.
6439    #[test]
6440    fn a_sentence_over_the_table_stays_in_it() {
6441        let mut blocks = table_with(
6442            Some("suitable for clinical trials for regulatory purposes and for the review."),
6443            None,
6444        );
6445        peel_table_edges(&mut blocks, false);
6446        assert_eq!(cells(&blocks), 10);
6447    }
6448
6449    /// A table's rows beside a column of the page's running text: `(label,
6450    /// figure, line of prose)` on every row.
6451    fn table_beside_prose(prose: &[&str]) -> Vec<Block> {
6452        let mut blocks = Vec::new();
6453        for (row, line) in prose.iter().enumerate() {
6454            let row = row as u16;
6455            let mut label = table_cell(row, 0, "Campbelltown");
6456            label.bbox = [
6457                72.0,
6458                700.0 - 12.0 * f64::from(row),
6459                150.0,
6460                709.0 - 12.0 * f64::from(row),
6461            ];
6462            let mut figure = table_cell(row, 1, "1,000");
6463            figure.bbox = [
6464                200.0,
6465                700.0 - 12.0 * f64::from(row),
6466                230.0,
6467                709.0 - 12.0 * f64::from(row),
6468            ];
6469            let mut text = table_cell(row, 2, line);
6470            text.kind = BlockKind::TableCell {
6471                row,
6472                column: 2,
6473                row_span: 1,
6474                column_span: 1,
6475                header: row == 0,
6476            };
6477            // A paragraph break where the page left a blank line.
6478            let drop = if row >= 3 { 12.0 } else { 0.0 };
6479            text.bbox = [
6480                320.0,
6481                700.0 - 12.0 * f64::from(row) - drop,
6482                460.0,
6483                709.0 - 12.0 * f64::from(row) - drop,
6484            ];
6485            blocks.extend([label, figure, text]);
6486        }
6487        blocks
6488    }
6489
6490    #[test]
6491    fn a_column_of_running_text_leaves_the_table() {
6492        let mut blocks = table_beside_prose(&[
6493            "During the year considerable effort was given to restocking",
6494            "our Residential Estates land portfolio across the country to",
6495            "meet our future growth requirements and our objectives.",
6496            "We successfully acquired future lots and we now control more",
6497            "residential allotments for development in the coming years,",
6498            "most of them in strategic growth areas within the states that",
6499            "are expected to lead demand for housing over the next decade.",
6500        ]);
6501        peel_table_edges(&mut blocks, false);
6502        let paragraphs: Vec<&str> = blocks
6503            .iter()
6504            .filter(|b| b.kind == BlockKind::Paragraph)
6505            .map(|b| b.text.as_str())
6506            .collect();
6507        assert_eq!(cells(&blocks), 14, "the running text stayed in the table");
6508        assert_eq!(paragraphs.len(), 2, "not two paragraphs: {paragraphs:?}");
6509        assert!(
6510            paragraphs[0].starts_with("During the year") && paragraphs[0].ends_with("objectives.")
6511        );
6512        assert!(
6513            blocks.iter().position(|b| b.kind == BlockKind::Paragraph)
6514                > blocks
6515                    .iter()
6516                    .rposition(|b| matches!(b.kind, BlockKind::TableCell { .. })),
6517            "the text was not set after the table"
6518        );
6519    }
6520
6521    /// A description that wraps inside its own cell carries on on a row the
6522    /// other columns leave empty, and stays in the table.
6523    #[test]
6524    fn a_wrapping_description_column_stays_in_the_table() {
6525        let mut blocks = Vec::new();
6526        for (row, (date, text)) in [
6527            (
6528                "18 December 2002",
6529                "Authorised the directors to approve the purchase of shares",
6530            ),
6531            (
6532                "",
6533                "in issue on that date by the company or any of its subsidiaries",
6534            ),
6535            (
6536                "18 December 2002",
6537                "Amended the articles of association in terms of which the maximum",
6538            ),
6539            (
6540                "",
6541                "number of directors allowed was increased from thirteen to fifteen.",
6542            ),
6543            (
6544                "18 December 2002",
6545                "Amended the articles of association to ensure that one-third",
6546            ),
6547            (
6548                "",
6549                "of the directors retire from office at every annual general meeting.",
6550            ),
6551            ("20 March 2003", "Approved the annual financial statements"),
6552        ]
6553        .iter()
6554        .enumerate()
6555        {
6556            if !date.is_empty() {
6557                blocks.push(table_cell(row as u16, 0, date));
6558            }
6559            let mut cell = table_cell(row as u16, 1, text);
6560            cell.kind = BlockKind::TableCell {
6561                row: row as u16,
6562                column: 1,
6563                row_span: 1,
6564                column_span: 1,
6565                header: row == 0,
6566            };
6567            blocks.push(cell);
6568        }
6569        peel_table_edges(&mut blocks, false);
6570        assert!(
6571            blocks
6572                .iter()
6573                .all(|b| matches!(b.kind, BlockKind::TableCell { .. })),
6574            "a wrapping description column left the table"
6575        );
6576        // And each description is one cell again, its line run on joined.
6577        assert!(
6578            blocks.iter().any(|b| b.text
6579                == "Amended the articles of association in terms of which the maximum \
6580                    number of directors allowed was increased from thirteen to fifteen."),
6581            "{:?}",
6582            blocks.iter().map(|b| &b.text).collect::<Vec<_>>()
6583        );
6584    }
6585
6586    fn span_at(text: &str, x: f64, width: f64) -> TextSpan {
6587        TextSpan {
6588            text: text.into(),
6589            x,
6590            width,
6591            font_size: 9.0,
6592            height: 9.0,
6593            ..TextSpan::default()
6594        }
6595    }
6596
6597    /// A one-column table whose cells hold `runs` (text, x, width), one row a
6598    /// cell.
6599    fn one_column(rows: &[&[(&str, f64, f64)]]) -> Vec<Block> {
6600        rows.iter()
6601            .enumerate()
6602            .map(|(row, runs)| {
6603                let mut cell = table_cell(row as u16, 0, "");
6604                cell.text = runs.iter().map(|r| r.0).collect::<Vec<_>>().join(" ");
6605                cell.spans = runs.iter().map(|&(t, x, w)| span_at(t, x, w)).collect();
6606                cell
6607            })
6608            .collect()
6609    }
6610
6611    #[test]
6612    fn a_table_read_as_one_column_gets_its_columns_back() {
6613        let mut blocks = one_column(&[
6614            &[("AUC score", 57.0, 40.0)],
6615            &[("CSF t-tau", 57.0, 29.0), ("0.616", 264.0, 17.0)],
6616            &[("CSF A\u{3b2}42", 57.0, 29.0), ("0.612", 264.0, 17.0)],
6617            &[("CSF p-tau", 57.0, 31.0), ("0.628", 264.0, 17.0)],
6618            &[("Biomarkers-15", 57.0, 45.0), ("0.830", 264.0, 17.0)],
6619            &[(
6620                "Note that the AUC score for Biosignature-15 reported here",
6621                57.0,
6622                260.0,
6623            )],
6624        ]);
6625        peel_table_edges(&mut blocks, false);
6626        let at = |text: &str| {
6627            blocks
6628                .iter()
6629                .find(|b| b.text == text)
6630                .map(|b| b.kind)
6631                .unwrap_or(BlockKind::Paragraph)
6632        };
6633        assert!(
6634            matches!(at("0.616"), BlockKind::TableCell { column: 1, .. }),
6635            "the figures did not get a column of their own: {:?}",
6636            blocks.iter().map(|b| (&b.text, b.kind)).collect::<Vec<_>>()
6637        );
6638        assert_eq!(
6639            blocks.last().map(|b| b.kind),
6640            Some(BlockKind::Paragraph),
6641            "the note that closed the white stayed in the table"
6642        );
6643    }
6644
6645    /// The cells a re-grid makes take their places in the reading order, and
6646    /// every block after them moves with them (codex, #2051).
6647    #[test]
6648    fn a_regridded_table_keeps_the_indices_in_reading_order() {
6649        let mut blocks = one_column(&[
6650            &[("CSF t-tau", 57.0, 29.0), ("0.616", 264.0, 17.0)],
6651            &[("CSF A\u{3b2}42", 57.0, 29.0), ("0.612", 264.0, 17.0)],
6652            &[("CSF p-tau", 57.0, 31.0), ("0.628", 264.0, 17.0)],
6653            &[("Biomarkers-15", 57.0, 45.0), ("0.830", 264.0, 17.0)],
6654        ]);
6655        let mut after = table_cell(0, 0, "The paragraph after the table.");
6656        after.kind = BlockKind::Paragraph;
6657        blocks.push(after);
6658        for (n, b) in blocks.iter_mut().enumerate() {
6659            b.index = n;
6660        }
6661        peel_table_edges(&mut blocks, false);
6662        let indices: Vec<usize> = blocks.iter().map(|b| b.index).collect();
6663        assert_eq!(indices, (0..blocks.len()).collect::<Vec<_>>());
6664    }
6665
6666    /// Spans with no white between them are one word in the rebuilt cell.
6667    #[test]
6668    fn a_regridded_cell_keeps_a_word_drawn_in_two_spans() {
6669        let mut blocks = one_column(&[
6670            &[("CSF t-tau", 57.0, 29.0), ("0.616", 264.0, 17.0)],
6671            &[
6672                ("CSF p-", 57.0, 24.0),
6673                ("tau", 81.0, 10.0),
6674                ("0.628", 264.0, 17.0),
6675            ],
6676            &[("CSF A\u{3b2}42", 57.0, 29.0), ("0.612", 264.0, 17.0)],
6677            &[("Biomarkers-15", 57.0, 45.0), ("0.830", 264.0, 17.0)],
6678        ]);
6679        peel_table_edges(&mut blocks, false);
6680        assert!(
6681            blocks.iter().any(|b| b.text == "CSF p-tau"),
6682            "{:?}",
6683            blocks.iter().map(|b| &b.text).collect::<Vec<_>>()
6684        );
6685    }
6686
6687    /// Two runs of a row that fall to one column are one cell, not a second
6688    /// table starting.
6689    #[test]
6690    fn two_runs_in_one_column_are_one_cell() {
6691        let mut blocks = one_column(&[
6692            &[("Label one", 57.0, 30.0), ("1.0", 200.0, 12.0)],
6693            &[("Label two", 57.0, 30.0), ("2.0", 200.0, 12.0)],
6694            &[("Label three", 57.0, 34.0), ("3.0", 200.0, 12.0)],
6695            &[
6696                ("Label", 57.0, 20.0),
6697                ("four", 110.0, 16.0),
6698                ("4.0", 200.0, 12.0),
6699            ],
6700        ]);
6701        peel_table_edges(&mut blocks, false);
6702        let end = crate::markdown::table_run(&blocks, 0);
6703        assert_eq!(end, Some(blocks.len()), "the table came apart into two");
6704    }
6705
6706    /// Prose read as a table of one column has no runs at shared places, and
6707    /// is left as it was.
6708    #[test]
6709    fn one_column_of_ragged_runs_is_left_alone() {
6710        let mut blocks = one_column(&[
6711            &[
6712                ("The committee met", 57.0, 70.0),
6713                ("on Monday", 180.0, 40.0),
6714            ],
6715            &[("and resolved", 57.0, 50.0), ("that the", 240.0, 30.0)],
6716            &[("officers report", 57.0, 60.0), ("before the", 300.0, 40.0)],
6717            &[("next meeting", 57.0, 50.0), ("of the board", 150.0, 40.0)],
6718        ]);
6719        peel_table_edges(&mut blocks, false);
6720        assert!(blocks
6721            .iter()
6722            .all(|b| matches!(b.kind, BlockKind::TableCell { column: 0, .. })));
6723    }
6724
6725    /// Cells at `(row, column, text)`, one column wide each.
6726    fn grid(cells: &[(u16, u16, &str)]) -> Vec<Block> {
6727        cells
6728            .iter()
6729            .map(|&(row, column, text)| {
6730                let mut cell = table_cell(row, column, text);
6731                cell.kind = BlockKind::TableCell {
6732                    row,
6733                    column,
6734                    row_span: 1,
6735                    column_span: 1,
6736                    header: row == 0,
6737                };
6738                cell
6739            })
6740            .collect()
6741    }
6742
6743    /// A bold caption the grid cut at the table's own gutters comes out whole
6744    /// (PMC1557522 on PubTables train, after #623 read its weight).
6745    #[test]
6746    fn a_caption_across_the_head_row_comes_out_whole() {
6747        let mut blocks = grid(&[
6748            (0, 0, "Table 1: Comparison methods"),
6749            (0, 1, "of the"),
6750            (0, 2, "logP values calculated"),
6751            (0, 3, "by several"),
6752            (1, 0, "Program"),
6753            (1, 1, "colchicine"),
6754            (1, 2, "paclitaxel"),
6755            (1, 3, "colchitaxel"),
6756            (2, 0, "ALOGP"),
6757            (2, 1, "1.59"),
6758            (2, 2, "3.20"),
6759            (2, 3, "4.64"),
6760            (3, 0, "CLOGP"),
6761            (3, 1, "1.20"),
6762            (3, 2, "4.95"),
6763            (3, 3, "6.50"),
6764        ]);
6765        peel_table_edges(&mut blocks, false);
6766        assert_eq!(blocks[0].kind, BlockKind::Paragraph);
6767        assert_eq!(
6768            blocks[0].text,
6769            "Table 1: Comparison methods of the logP values calculated by several"
6770        );
6771        assert!(
6772            matches!(
6773                blocks[1].kind,
6774                BlockKind::TableCell {
6775                    row: 0,
6776                    column: 0,
6777                    ..
6778                }
6779            ),
6780            "the table does not start at its own head: {:?}",
6781            blocks[1].kind
6782        );
6783        assert_eq!(cells(&blocks), 12);
6784    }
6785
6786    /// Beside column heads only the caption cell is the caption.
6787    #[test]
6788    fn a_caption_beside_column_heads_leaves_the_heads() {
6789        let mut blocks = grid(&[
6790            (0, 0, "Table 1 Descriptive statistics for publication rate"),
6791            (0, 1, "Statistics"),
6792            (0, 2, "PR P1"),
6793            (0, 3, "PR P2"),
6794            (1, 1, "Mean"),
6795            (1, 2, "0.41"),
6796            (1, 3, "0.52"),
6797            (2, 1, "SD"),
6798            (2, 2, "0.12"),
6799            (2, 3, "0.18"),
6800        ]);
6801        peel_table_edges(&mut blocks, false);
6802        assert_eq!(blocks[0].kind, BlockKind::Paragraph);
6803        assert!(blocks.iter().any(
6804            |b| b.text == "Statistics" && matches!(b.kind, BlockKind::TableCell { row: 0, .. })
6805        ));
6806    }
6807
6808    /// A running head with its page number is no table of one row; a contents
6809    /// line on its dot leaders is.
6810    #[test]
6811    fn a_table_of_one_row_is_a_line_unless_it_runs_on_leaders() {
6812        let mut blocks = grid(&[
6813            (
6814                0,
6815                0,
6816                "New records of the endemic Sicilian land snail species",
6817            ),
6818            (0, 1, "137"),
6819        ]);
6820        peel_table_edges(&mut blocks, false);
6821        assert_eq!(blocks.len(), 1);
6822        assert_eq!(blocks[0].kind, BlockKind::Paragraph);
6823
6824        let mut blocks = grid(&[
6825            (0, 0, "Letter from the Chairman . . . . . . . . 2"),
6826            (0, 1, "Feature Client Stories . . . . . . . . 6"),
6827            (0, 2, "Client Innovation Highlights . . . . 14"),
6828        ]);
6829        peel_table_edges(&mut blocks, false);
6830        assert_eq!(cells(&blocks), 3, "a contents line lost its table");
6831    }
6832
6833    /// `Table size` over lower-case heads is a head row, not a caption
6834    /// (codex, #2055).
6835    #[test]
6836    fn a_head_that_opens_with_the_word_table_is_no_caption() {
6837        let mut blocks = grid(&[
6838            (0, 0, "Table size"),
6839            (0, 1, "rows"),
6840            (0, 2, "columns"),
6841            (1, 0, "Small"),
6842            (1, 1, "12"),
6843            (1, 2, "4"),
6844            (2, 0, "Large"),
6845            (2, 1, "480"),
6846            (2, 2, "16"),
6847        ]);
6848        peel_table_edges(&mut blocks, false);
6849        assert_eq!(cells(&blocks), 9, "the head row was taken for a caption");
6850        assert!(is_numbered_caption("Table IV. Results"));
6851        assert!(is_numbered_caption("Table S2 Primers"));
6852        assert!(!is_numbered_caption("Table value"));
6853    }
6854
6855    /// A table the page broke may leave one row on the next page; that row is
6856    /// the table's, for `tables_across_pages` to join (codex, #2055).
6857    #[test]
6858    fn one_row_left_by_a_page_break_stays_a_table() {
6859        let mut blocks = grid(&[
6860            (0, 0, "Revenue"),
6861            (0, 1, "13,110"),
6862            (1, 0, "Cost of sales"),
6863            (1, 1, "(8,406)"),
6864            (2, 0, "Gross profit"),
6865            (2, 1, "4,704"),
6866        ]);
6867        let mut rest = grid(&[(0, 0, "Operating profit"), (0, 1, "1,559")]);
6868        for b in rest.iter_mut() {
6869            b.page = 1;
6870        }
6871        blocks.extend(rest);
6872        peel_table_edges(&mut blocks, false);
6873        assert_eq!(cells(&blocks), 8, "the page's one row became a line");
6874    }
6875
6876    /// A one-row grid after a page break with columns of its own is not the
6877    /// half of the table before it, and reads as the line it is (codex,
6878    /// #2058).
6879    #[test]
6880    fn one_row_with_columns_of_its_own_after_a_break_is_a_line() {
6881        let mut blocks = grid(&[
6882            (0, 0, "Revenue"),
6883            (0, 1, "13,110"),
6884            (1, 0, "Cost of sales"),
6885            (1, 1, "(8,406)"),
6886            (2, 0, "Gross profit"),
6887            (2, 1, "4,704"),
6888        ]);
6889        let mut head = grid(&[
6890            (0, 0, "New records of the endemic snail species"),
6891            (0, 1, "137"),
6892        ]);
6893        for (k, b) in head.iter_mut().enumerate() {
6894            b.page = 1;
6895            // Two columns, but standing where the table above has none.
6896            b.bbox[0] = [300.0, 540.0][k];
6897        }
6898        blocks.extend(head);
6899        peel_table_edges(&mut blocks, false);
6900        assert_eq!(
6901            cells(&blocks),
6902            6,
6903            "an unrelated one-row grid stayed a table"
6904        );
6905    }
6906
6907    /// The row a page break left at the foot of a page stays a table when
6908    /// the rest, on the next page, was read as one column: its columns are
6909    /// the ones it gets back (codex, #2065).
6910    #[test]
6911    fn one_row_before_a_table_read_as_one_column_stays_a_table() {
6912        let mut lead = table_cell(0, 0, "Notes to the statements.");
6913        lead.kind = BlockKind::Paragraph;
6914        let mut blocks = vec![lead];
6915        let mut row = grid(&[(0, 0, "Revenue"), (0, 1, "13,110")]);
6916        row[0].bbox[0] = 57.0;
6917        row[1].bbox[0] = 264.0;
6918        blocks.extend(row);
6919        let mut rest = one_column(&[
6920            &[("Cost of sales", 57.0, 50.0), ("(8,406)", 264.0, 30.0)],
6921            &[("Gross profit", 57.0, 45.0), ("4,704", 264.0, 20.0)],
6922            &[("Selling expenses", 57.0, 60.0), ("(3,145)", 264.0, 30.0)],
6923            &[("Operating profit", 57.0, 60.0), ("1,559", 264.0, 20.0)],
6924        ]);
6925        for b in rest.iter_mut() {
6926            b.page = 1;
6927        }
6928        blocks.extend(rest);
6929        peel_table_edges(&mut blocks, false);
6930        assert!(
6931            blocks
6932                .iter()
6933                .any(|b| b.text == "Revenue" && matches!(b.kind, BlockKind::TableCell { .. })),
6934            "the page's last row became a line: {:?}",
6935            blocks.iter().map(|b| (&b.text, b.kind)).collect::<Vec<_>>()
6936        );
6937    }
6938
6939    #[test]
6940    fn a_financial_row_label_under_the_table_stays_in_it() {
6941        let mut blocks = table_with(
6942            None,
6943            Some("Prima facie income tax (benefit)/expense calculated at 30%"),
6944        );
6945        peel_table_edges(&mut blocks, false);
6946        assert_eq!(cells(&blocks), 10);
6947    }
6948
6949    /// A line of text at a position, in a single span, as the extractor would
6950    /// have produced it.
6951    fn line(text: &str, x: f64, y: f64, size: f64) -> TextBlock {
6952        TextBlock {
6953            spans: vec![span(text, x, y, size)],
6954        }
6955    }
6956
6957    fn span(text: &str, x: f64, y: f64, size: f64) -> TextSpan {
6958        TextSpan {
6959            text: text.to_string(),
6960            x,
6961            y,
6962            width: size * 0.5 * text.chars().count() as f64,
6963            height: size,
6964            font_size: size,
6965            ..Default::default()
6966        }
6967    }
6968
6969    fn texts(blocks: &[Block]) -> Vec<String> {
6970        blocks.iter().map(|b| b.text.clone()).collect()
6971    }
6972
6973    /// A paragraph block of one-span lines, as the builder would have
6974    /// assembled it: `(text, x, y)` per line at `size`.
6975    fn para_block(lines: &[(&str, f64, f64)], size: f64) -> Block {
6976        let spans: Vec<TextSpan> = lines
6977            .iter()
6978            .map(|(text, x, y)| span(text, *x, *y, size))
6979            .collect();
6980        let mut block = Block {
6981            kind: BlockKind::Paragraph,
6982            page: 0,
6983            bbox: [0.0; 4],
6984            text: join_lines(
6985                &lines
6986                    .iter()
6987                    .map(|(text, _, _)| text.to_string())
6988                    .collect::<Vec<_>>(),
6989            ),
6990            spans,
6991            index: 0,
6992            continues_previous: false,
6993            confidence: None,
6994            grid: None,
6995            table_origin: TableOrigin::Other,
6996            trace_learned: None,
6997        };
6998        recompute_bbox(&mut block);
6999        block
7000    }
7001
7002    /// Six lines at a twelve-point pitch with a twenty-four-point step in the
7003    /// middle: the control every step-split test below carries, so each goes
7004    /// red with one block where it asks for two when the rule is out.
7005    fn merged_pair() -> Block {
7006        para_block(
7007            &[
7008                ("the first paragraph opens here and runs", 72.0, 700.0),
7009                ("the whole measure to its second line,", 72.0, 688.0),
7010                ("and closes on its third.", 72.0, 676.0),
7011                ("the second paragraph opens lower down", 72.0, 652.0),
7012                ("and runs the measure to its own end,", 72.0, 640.0),
7013                ("closing here.", 72.0, 628.0),
7014            ],
7015            10.0,
7016        )
7017    }
7018
7019    #[test]
7020    fn a_paragraph_sized_gap_inside_a_block_cuts_it() {
7021        let mut blocks = vec![merged_pair()];
7022        split_paragraphs_on_steps(&mut blocks);
7023        assert_eq!(
7024            texts(&blocks),
7025            vec![
7026                "the first paragraph opens here and runs the whole measure to its second line, and closes on its third.",
7027                "the second paragraph opens lower down and runs the measure to its own end, closing here.",
7028            ]
7029        );
7030        assert!(blocks
7031            .iter()
7032            .all(|b| matches!(b.kind, BlockKind::Paragraph)));
7033        assert_eq!(blocks[0].spans.len(), 3);
7034        assert_eq!(blocks[1].spans.len(), 3);
7035        assert!(blocks[0].bbox[1] > blocks[1].bbox[3]);
7036    }
7037
7038    #[test]
7039    fn an_evenly_double_spaced_paragraph_is_not_cut() {
7040        let even = para_block(
7041            &[
7042                ("a double-spaced line one", 72.0, 700.0),
7043                ("a double-spaced line two", 72.0, 676.0),
7044                ("a double-spaced line three", 72.0, 652.0),
7045                ("a double-spaced line four", 72.0, 628.0),
7046                ("a double-spaced line five", 72.0, 604.0),
7047                ("a double-spaced line six", 72.0, 580.0),
7048            ],
7049            10.0,
7050        );
7051        let mut blocks = vec![merged_pair(), even];
7052        split_paragraphs_on_steps(&mut blocks);
7053        assert_eq!(blocks.len(), 3, "{:?}", texts(&blocks));
7054        assert!(blocks[2].text.starts_with("a double-spaced line one"));
7055        assert!(blocks[2].text.ends_with("line six"));
7056        assert_eq!(blocks[2].spans.len(), 6);
7057    }
7058
7059    #[test]
7060    fn a_column_change_is_not_this_rules_business() {
7061        let shifted = para_block(
7062            &[
7063                ("the left column ends here and runs", 72.0, 700.0),
7064                ("the whole measure to its own end.", 72.0, 688.0),
7065                ("it closes on this third line.", 72.0, 676.0),
7066                ("the right column opens lower down", 300.0, 652.0),
7067                ("and runs to its own second line.", 300.0, 640.0),
7068                ("closing here at last.", 300.0, 628.0),
7069            ],
7070            10.0,
7071        );
7072        let mut blocks = vec![merged_pair(), shifted];
7073        split_paragraphs_on_steps(&mut blocks);
7074        assert_eq!(blocks.len(), 3, "{:?}", texts(&blocks));
7075        assert_eq!(blocks[2].spans.len(), 6);
7076    }
7077
7078    #[test]
7079    fn headings_and_list_items_are_untouched() {
7080        let mut heading = merged_pair();
7081        heading.kind = BlockKind::Heading { level: 1 };
7082        let mut item = merged_pair();
7083        item.kind = BlockKind::ListItem {
7084            level: 0,
7085            ordinal: None,
7086        };
7087        let heading_text = heading.text.clone();
7088        let mut blocks = vec![merged_pair(), heading, item];
7089        split_paragraphs_on_steps(&mut blocks);
7090        assert_eq!(blocks.len(), 4, "{:?}", texts(&blocks));
7091        assert_eq!(blocks[2].text, heading_text);
7092        assert!(matches!(blocks[2].kind, BlockKind::Heading { level: 1 }));
7093        assert!(matches!(
7094            blocks[3].kind,
7095            BlockKind::ListItem { level: 0, .. }
7096        ));
7097        assert_eq!(blocks[3].spans.len(), 6);
7098    }
7099
7100    #[test]
7101    fn a_vertical_word_is_not_cut_into_salad() {
7102        let word = para_block(
7103            &[
7104                ("T", 72.0, 700.0),
7105                ("h", 72.0, 688.0),
7106                ("i", 72.0, 676.0),
7107                ("c", 72.0, 664.0),
7108                ("k", 72.0, 652.0),
7109                ("n", 72.0, 628.0),
7110                ("e", 72.0, 616.0),
7111                ("s", 72.0, 604.0),
7112                ("s", 72.0, 592.0),
7113            ],
7114            10.0,
7115        );
7116        let text = word.text.clone();
7117        let mut blocks = vec![merged_pair(), word];
7118        split_paragraphs_on_steps(&mut blocks);
7119        assert_eq!(blocks.len(), 3, "{:?}", texts(&blocks));
7120        assert_eq!(blocks[2].text, text);
7121        assert_eq!(blocks[2].spans.len(), 9);
7122    }
7123
7124    #[test]
7125    fn a_rotated_stamp_is_not_cut() {
7126        let mut stamp = merged_pair();
7127        for span in &mut stamp.spans {
7128            let size = span.font_size;
7129            span.transform = Some([0.0, size, -size, 0.0, span.x, span.y]);
7130        }
7131        let text = stamp.text.clone();
7132        let mut blocks = vec![merged_pair(), stamp];
7133        split_paragraphs_on_steps(&mut blocks);
7134        assert_eq!(blocks.len(), 3, "{:?}", texts(&blocks));
7135        assert_eq!(blocks[2].text, text);
7136        assert_eq!(blocks[2].spans.len(), 6);
7137    }
7138
7139    #[test]
7140    fn a_number_block_is_not_split() {
7141        let figure = "1,234.5 (678) 12.3%";
7142        let mut rows: Vec<(&str, f64, f64)> = Vec::new();
7143        let mut y = 700.0;
7144        for row in 0..20 {
7145            rows.push((figure, 72.0, y));
7146            y -= if row % 5 == 4 { 24.0 } else { 12.0 };
7147        }
7148        let figures = para_block(&rows, 10.0);
7149        let text = figures.text.clone();
7150        let mut blocks = vec![merged_pair(), figures];
7151        split_paragraphs_on_steps(&mut blocks);
7152        assert_eq!(blocks.len(), 3, "{:?}", texts(&blocks));
7153        assert_eq!(blocks[2].text, text);
7154        assert_eq!(blocks[2].spans.len(), 20);
7155    }
7156
7157    #[test]
7158    fn prose_cuts_still_happen() {
7159        let parent = merged_pair();
7160        let mut blocks = vec![parent.clone()];
7161        split_paragraphs_on_steps(&mut blocks);
7162        assert_eq!(blocks.len(), 2, "{:?}", texts(&blocks));
7163        let plain = |text: &str| {
7164            text.replace("- ", "")
7165                .chars()
7166                .filter(|c| !c.is_whitespace())
7167                .collect::<Vec<_>>()
7168        };
7169        let mut got: Vec<char> = Vec::new();
7170        for piece in &blocks {
7171            got.extend(plain(&piece.text));
7172        }
7173        assert_eq!(got, plain(&parent.text));
7174    }
7175
7176    #[test]
7177    fn a_reversed_reassembly_refuses_the_whole_split() {
7178        let label = para_block(
7179            &[
7180                ("aaa", 72.0, 100.0),
7181                ("bbb", 72.0, 112.0),
7182                ("ccc", 72.0, 124.0),
7183                ("ddd", 72.0, 148.0),
7184            ],
7185            10.0,
7186        );
7187        let text = label.text.clone();
7188        let mut blocks = vec![merged_pair(), label];
7189        split_paragraphs_on_steps(&mut blocks);
7190        assert_eq!(blocks.len(), 3, "{:?}", texts(&blocks));
7191        assert_eq!(blocks[2].text, text);
7192        assert_eq!(blocks[2].spans.len(), 4);
7193    }
7194
7195    #[test]
7196    fn consecutive_lines_of_one_measure_become_one_paragraph() {
7197        let lines = vec![
7198            line("the first line of the paragraph runs", 72.0, 700.0, 10.0),
7199            line("the whole measure and the second one", 72.0, 688.0, 10.0),
7200            line("stops short of it.", 72.0, 676.0, 10.0),
7201        ];
7202        let blocks = paragraphs(
7203            0,
7204            0,
7205            std::slice::from_ref(&lines),
7206            &[],
7207            &[],
7208            12.0,
7209            TableStrategy::Both,
7210        );
7211        assert_eq!(
7212            texts(&blocks),
7213            vec![
7214                "the first line of the paragraph runs the whole measure and the second one stops short of it."
7215            ]
7216        );
7217    }
7218
7219    #[test]
7220    fn extra_leading_starts_a_new_paragraph() {
7221        let lines = vec![
7222            line("the first paragraph ends here and", 72.0, 700.0, 10.0),
7223            line("carries on to a second line.", 72.0, 688.0, 10.0),
7224            line("the second paragraph begins after a", 72.0, 664.0, 10.0),
7225            line("line of extra space.", 72.0, 652.0, 10.0),
7226        ];
7227        let blocks = paragraphs(
7228            0,
7229            0,
7230            std::slice::from_ref(&lines),
7231            &[],
7232            &[],
7233            12.0,
7234            TableStrategy::Both,
7235        );
7236        assert_eq!(blocks.len(), 2, "{:?}", texts(&blocks));
7237        assert!(blocks[0].text.ends_with("second line."));
7238        assert!(blocks[1].text.starts_with("the second paragraph"));
7239    }
7240
7241    #[test]
7242    fn a_word_broken_across_a_line_is_put_back_together() {
7243        let lines = vec![
7244            line("the typesetter broke this para-", 72.0, 700.0, 10.0),
7245            line("graph across two lines.", 72.0, 688.0, 10.0),
7246        ];
7247        let blocks = paragraphs(
7248            0,
7249            0,
7250            std::slice::from_ref(&lines),
7251            &[],
7252            &[],
7253            12.0,
7254            TableStrategy::Both,
7255        );
7256        assert_eq!(
7257            texts(&blocks),
7258            vec!["the typesetter broke this paragraph across two lines."]
7259        );
7260    }
7261
7262    /// A reference list set author-year, as `1002.4666` in DocLayNet sets it:
7263    /// each entry at the margin, its continuation hung an indent in.
7264    fn references(entries: usize) -> Vec<TextBlock> {
7265        let mut lines = vec![line("References", 72.0, 716.0, 12.0)];
7266        let mut y = 700.0;
7267        for n in 0..entries {
7268            lines.push(line(
7269                &format!("Author{n}, A. B., and C. D. Author, 2003, Phys. Rev. Lett. 88,"),
7270                72.0,
7271                y,
7272                10.0,
7273            ));
7274            lines.push(line(
7275                "014517, and the pages after it.",
7276                84.0,
7277                y - 12.0,
7278                10.0,
7279            ));
7280            y -= 24.0;
7281        }
7282        lines
7283    }
7284
7285    #[test]
7286    fn a_run_of_hung_entries_is_a_list_of_items() {
7287        // #574: DocLayNet marks every entry of a reference list a list item,
7288        // and an author-year list has no marker to say so -- only its hang.
7289        let blocks = paragraphs(
7290            0,
7291            0,
7292            std::slice::from_ref(&references(4)),
7293            &[],
7294            &[],
7295            12.0,
7296            TableStrategy::Both,
7297        );
7298        let kinds: Vec<bool> = blocks
7299            .iter()
7300            .map(|b| {
7301                matches!(
7302                    b.kind,
7303                    BlockKind::ListItem {
7304                        level: 0,
7305                        ordinal: None
7306                    }
7307                )
7308            })
7309            .collect();
7310        assert_eq!(
7311            kinds,
7312            vec![false, true, true, true, true],
7313            "{:?}",
7314            texts(&blocks)
7315        );
7316        assert!(
7317            blocks[1].text.starts_with("Author0, A. B."),
7318            "{:?}",
7319            texts(&blocks)
7320        );
7321        assert!(
7322            blocks[1].text.ends_with("the pages after it."),
7323            "{:?}",
7324            texts(&blocks)
7325        );
7326    }
7327
7328    #[test]
7329    fn two_hung_entries_are_not_yet_a_list() {
7330        // One paragraph set with a hanging indent is a paragraph, and two can
7331        // be a pair of definitions: the list starts at three.
7332        let blocks = paragraphs(
7333            0,
7334            0,
7335            std::slice::from_ref(&references(2)),
7336            &[],
7337            &[],
7338            12.0,
7339            TableStrategy::Both,
7340        );
7341        assert_eq!(blocks.len(), 3, "{:?}", texts(&blocks));
7342        assert!(
7343            blocks
7344                .iter()
7345                .all(|b| matches!(b.kind, BlockKind::Paragraph)),
7346            "{:?}",
7347            texts(&blocks)
7348        );
7349    }
7350
7351    #[test]
7352    fn a_first_line_indent_starts_a_paragraph_and_a_hanging_indent_does_not() {
7353        // First-line indent: every indented line follows a short line.
7354        let indented = vec![
7355            line("    the first paragraph opens with an", 90.0, 700.0, 10.0),
7356            line("indent and runs the full measure of", 72.0, 688.0, 10.0),
7357            line("the column.", 72.0, 676.0, 10.0),
7358            line("    the second one opens the same way", 90.0, 664.0, 10.0),
7359            line("and also runs the full measure here.", 72.0, 652.0, 10.0),
7360        ];
7361        let blocks = paragraphs(
7362            0,
7363            0,
7364            std::slice::from_ref(&indented),
7365            &[],
7366            &[],
7367            12.0,
7368            TableStrategy::Both,
7369        );
7370        assert_eq!(blocks.len(), 2, "{:?}", texts(&blocks));
7371
7372        // Hanging indent: every indented line follows a full line.
7373        let hanging = vec![
7374            line("Aho, A. and Ullman, J. Principles of", 72.0, 700.0, 10.0),
7375            line("    compiler design. Addison-Wesley,", 90.0, 688.0, 10.0),
7376            line("    Reading, 1977.", 90.0, 676.0, 10.0),
7377            line("Knuth, D. The art of computer program", 72.0, 664.0, 10.0),
7378            line("    ming. Addison-Wesley, Reading, 1968", 90.0, 652.0, 10.0),
7379        ];
7380        let blocks = paragraphs(
7381            0,
7382            0,
7383            std::slice::from_ref(&hanging),
7384            &[],
7385            &[],
7386            12.0,
7387            TableStrategy::Both,
7388        );
7389        assert_eq!(blocks.len(), 2, "{:?}", texts(&blocks));
7390        assert!(blocks[0].text.starts_with("Aho"));
7391        assert!(blocks[1].text.starts_with("Knuth"));
7392    }
7393
7394    #[test]
7395    fn a_heading_is_not_swallowed_by_the_paragraph_under_it() {
7396        let lines = vec![
7397            line("2 Scope", 72.0, 700.0, 16.0),
7398            line("the body of the section starts here", 72.0, 684.0, 10.0),
7399            line("and runs on for a second line.", 72.0, 672.0, 10.0),
7400        ];
7401        let blocks = paragraphs(
7402            0,
7403            0,
7404            std::slice::from_ref(&lines),
7405            &[],
7406            &[],
7407            12.0,
7408            TableStrategy::Both,
7409        );
7410        assert_eq!(blocks.len(), 2, "{:?}", texts(&blocks));
7411        assert_eq!(blocks[0].text, "2 Scope");
7412    }
7413
7414    #[test]
7415    fn a_ruled_row_never_joins_the_row_under_it() {
7416        // The Isartor appendix's pitch: two rows 18 pt apart with a hairline
7417        // between them, and the same two rows with the hairline withheld.
7418        let lines = vec![
7419            line("id-6-1-2-t01 the first message", 60.0, 700.0, 10.0),
7420            line("id-6-1-3-t01 the second message", 60.0, 682.0, 10.0),
7421        ];
7422        let rules = vec![HorizontalRule {
7423            left: 55.0,
7424            right: 540.0,
7425            y: 693.0,
7426        }];
7427        assert_eq!(
7428            paragraphs(
7429                0,
7430                0,
7431                std::slice::from_ref(&lines),
7432                &[],
7433                &[],
7434                18.0,
7435                TableStrategy::Both
7436            )
7437            .len(),
7438            1,
7439            "the unruled fixture no longer merges, so the pair below proves nothing"
7440        );
7441        assert_eq!(
7442            paragraphs(
7443                0,
7444                0,
7445                std::slice::from_ref(&lines),
7446                &[],
7447                &rules,
7448                18.0,
7449                TableStrategy::Both
7450            )
7451            .len(),
7452            2,
7453            "a rule between two rows did not keep them apart"
7454        );
7455    }
7456
7457    #[test]
7458    fn a_wide_word_gap_on_one_line_is_not_a_table() {
7459        // Justified setting stretches the spaces of a single line; nothing
7460        // lines up underneath it.
7461        let justified = vec![
7462            TextBlock {
7463                spans: vec![
7464                    span("the quick brown", 72.0, 700.0, 10.0),
7465                    span("fox jumps over it", 200.0, 700.0, 10.0),
7466                ],
7467            },
7468            line("and the second line is set solid.", 72.0, 688.0, 10.0),
7469        ];
7470        assert_eq!(
7471            paragraphs(
7472                0,
7473                0,
7474                std::slice::from_ref(&justified),
7475                &[],
7476                &[],
7477                12.0,
7478                TableStrategy::Both
7479            )
7480            .len(),
7481            1
7482        );
7483
7484        // Two lines with white running down the same x is a table nobody ruled.
7485        let tabular = vec![
7486            TextBlock {
7487                spans: vec![
7488                    span("Widget", 72.0, 700.0, 10.0),
7489                    span("12.00", 260.0, 700.0, 10.0),
7490                ],
7491            },
7492            TextBlock {
7493                spans: vec![
7494                    span("Gadget", 72.0, 688.0, 10.0),
7495                    span("8.50", 260.0, 688.0, 10.0),
7496                ],
7497            },
7498        ];
7499        // ... and a table is read as its cells, one block each, in row-major
7500        // order (#570): the two rows never join, and neither do the two
7501        // columns.
7502        let cells = paragraphs(
7503            0,
7504            0,
7505            std::slice::from_ref(&tabular),
7506            &[],
7507            &[],
7508            12.0,
7509            TableStrategy::Both,
7510        );
7511        assert_eq!(texts(&cells), vec!["Widget", "12.00", "Gadget", "8.50"]);
7512        assert!(
7513            cells
7514                .iter()
7515                .all(|block| matches!(block.kind, BlockKind::TableCell { .. })),
7516            "{:?}",
7517            cells.iter().map(|b| b.kind).collect::<Vec<_>>()
7518        );
7519    }
7520
7521    #[test]
7522    fn a_full_width_title_does_not_join_the_column_under_it() {
7523        // The cut gives a two-column page with a full-width title three
7524        // regions: the title, then each column. Set in the body size and
7525        // left-aligned on the left column, the title is the same picture as
7526        // the first line of a paragraph -- which is the whole reason the
7527        // region is what decides here and no measurement of the lines is.
7528        let title = vec![line(
7529            "A title that runs the whole width of the page and then some more",
7530            72.0,
7531            720.0,
7532            10.0,
7533        )];
7534        let left = vec![
7535            line("the left column", 72.0, 708.0, 10.0),
7536            line("starts here and", 72.0, 696.0, 10.0),
7537        ];
7538        let right = vec![
7539            line("the right column", 320.0, 708.0, 10.0),
7540            line("starts up here", 320.0, 696.0, 10.0),
7541        ];
7542        let blocks = paragraphs(
7543            0,
7544            0,
7545            &[title, left, right],
7546            &[],
7547            &[],
7548            12.0,
7549            TableStrategy::Both,
7550        );
7551        assert_eq!(blocks.len(), 3, "{:?}", texts(&blocks));
7552        assert!(blocks[0].text.starts_with("A title"));
7553        assert_eq!(blocks[1].text, "the left column starts here and");
7554        assert_eq!(blocks[2].text, "the right column starts up here");
7555    }
7556
7557    #[test]
7558    fn a_drop_cap_is_read_at_the_front_of_its_paragraph() {
7559        // Three-line initial: drawn on the baseline of the third line, at the
7560        // margin, with the first two lines set beside it.
7561        let lines = vec![
7562            line("he first line clears the", 100.0, 700.0, 10.0),
7563            line("initial, and so does the", 100.0, 688.0, 10.0),
7564            TextBlock {
7565                spans: vec![
7566                    span("T", 72.0, 676.0, 30.0),
7567                    span("second one; the third runs", 100.0, 676.0, 10.0),
7568                ],
7569            },
7570        ];
7571        let blocks = paragraphs(
7572            0,
7573            0,
7574            std::slice::from_ref(&lines),
7575            &[],
7576            &[],
7577            12.0,
7578            TableStrategy::Both,
7579        );
7580        assert_eq!(blocks.len(), 1, "{:?}", texts(&blocks));
7581        assert!(
7582            blocks[0].text.starts_with("T he first line"),
7583            "the initial is not at the front: {:?}",
7584            blocks[0].text
7585        );
7586    }
7587
7588    #[test]
7589    fn a_footnote_under_a_rule_stays_its_own_block() {
7590        let lines = vec![
7591            line("the last line of the body text sits", 72.0, 200.0, 10.0),
7592            line("above the footnote rule.", 72.0, 188.0, 10.0),
7593            line("1 The footnote itself is set smaller.", 72.0, 170.0, 8.0),
7594        ];
7595        let blocks = paragraphs(
7596            0,
7597            0,
7598            std::slice::from_ref(&lines),
7599            &[],
7600            &[],
7601            12.0,
7602            TableStrategy::Both,
7603        );
7604        assert_eq!(blocks.len(), 2, "{:?}", texts(&blocks));
7605        assert!(blocks[1].text.starts_with("1 The footnote"));
7606    }
7607
7608    #[test]
7609    fn a_block_carries_its_page_bbox_and_reading_order() {
7610        let lines = vec![
7611            line("one", 72.0, 700.0, 10.0),
7612            line("two", 72.0, 600.0, 10.0),
7613        ];
7614        let blocks = paragraphs(
7615            3,
7616            17,
7617            std::slice::from_ref(&lines),
7618            &[],
7619            &[],
7620            12.0,
7621            TableStrategy::Both,
7622        );
7623        assert_eq!(blocks.len(), 2);
7624        assert_eq!(blocks[0].page, 3);
7625        assert_eq!(blocks[0].index, 17);
7626        assert_eq!(blocks[1].index, 18);
7627        assert_eq!(blocks[0].kind, BlockKind::Paragraph);
7628        assert!(blocks[0].bbox[0] <= 72.0 && blocks[0].bbox[3] >= 700.0);
7629        assert!(blocks[0].bbox[1] < blocks[0].bbox[3]);
7630        assert_eq!(blocks[0].spans.len(), 1);
7631    }
7632
7633    #[test]
7634    fn an_oversized_marker_with_nothing_beside_it_is_not_a_drop_cap() {
7635        // A large bullet opening a line is the same shape as an initial and is
7636        // not one: nothing is set into the space it takes up.
7637        let lines = vec![
7638            line("the first line of the paragraph", 72.0, 700.0, 10.0),
7639            TextBlock {
7640                spans: vec![
7641                    span("\u{2022}", 72.0, 688.0, 20.0),
7642                    span("and the second one after a marker", 95.0, 688.0, 10.0),
7643                ],
7644            },
7645        ];
7646        let blocks = paragraphs(
7647            0,
7648            0,
7649            std::slice::from_ref(&lines),
7650            &[],
7651            &[],
7652            12.0,
7653            TableStrategy::Both,
7654        );
7655        assert_eq!(blocks.len(), 1, "{:?}", texts(&blocks));
7656        assert!(
7657            blocks[0].text.starts_with("the first line"),
7658            "the marker was hoisted to the front as if it were an initial: {:?}",
7659            blocks[0].text
7660        );
7661    }
7662
7663    #[test]
7664    fn the_reading_order_has_no_gap_where_a_block_held_only_space() {
7665        let lines = vec![
7666            line("the first paragraph", 72.0, 700.0, 10.0),
7667            line("   ", 72.0, 600.0, 10.0),
7668            line("the second paragraph", 72.0, 500.0, 10.0),
7669        ];
7670        let blocks = paragraphs(
7671            0,
7672            7,
7673            std::slice::from_ref(&lines),
7674            &[],
7675            &[],
7676            12.0,
7677            TableStrategy::Both,
7678        );
7679        assert_eq!(
7680            texts(&blocks),
7681            vec!["the first paragraph", "the second paragraph"]
7682        );
7683        assert_eq!(
7684            blocks.iter().map(|b| b.index).collect::<Vec<_>>(),
7685            vec![7, 8],
7686            "a block that held only space left a hole in the reading order, and \
7687             the next page starts at the count rather than at the last index"
7688        );
7689    }
7690
7691    /// A line set in a bold face at a size of its own.
7692    fn heavy(text: &str, x: f64, y: f64, size: f64) -> TextBlock {
7693        let mut span = span(text, x, y, size);
7694        span.is_bold = true;
7695        TextBlock { spans: vec![span] }
7696    }
7697
7698    #[test]
7699    fn a_numbered_heading_inside_a_column_is_not_a_list_item() {
7700        // The XY-cut usually puts a heading in a region of its own, and then
7701        // the run rule never sees a second marker to agree with. This is the
7702        // page where it does: two numbered headings and the prose between
7703        // them, all one region and all one column. What refuses them is the
7704        // type -- both are set above the body -- and nothing else here can.
7705        let mut lines = vec![heavy("3. Method", 72.0, 700.0, 14.0)];
7706        for (i, text) in [
7707            "The committee considered the proposals put before it in",
7708            "the order they had been received, and resolved that each",
7709        ]
7710        .iter()
7711        .enumerate()
7712        {
7713            lines.push(line(text, 72.0, 686.0 - i as f64 * 12.0, 10.0));
7714        }
7715        lines.push(heavy("4. Results", 72.0, 650.0, 14.0));
7716        lines.push(line(
7717            "of them be referred to the officers for a report.",
7718            72.0,
7719            636.0,
7720            10.0,
7721        ));
7722
7723        let blocks = paragraphs(
7724            0,
7725            0,
7726            std::slice::from_ref(&lines),
7727            &[],
7728            &[],
7729            12.0,
7730            TableStrategy::Both,
7731        );
7732        assert!(
7733            blocks
7734                .iter()
7735                .all(|block| !matches!(block.kind, BlockKind::ListItem { .. })),
7736            "a numbered heading was read as a list item: {:?}",
7737            blocks.iter().map(|b| (b.kind, &b.text)).collect::<Vec<_>>()
7738        );
7739    }
7740
7741    #[test]
7742    fn a_numbered_list_inside_the_same_column_is_one() {
7743        // The control for the case above: the same page, the same markers, the
7744        // same left edge -- set in the body instead of above it.
7745        let mut lines = vec![line(
7746            "The committee resolved that each of the proposals be referred",
7747            72.0,
7748            700.0,
7749            10.0,
7750        )];
7751        for (i, text) in ["3. to the officers", "4. to the auditors"]
7752            .iter()
7753            .enumerate()
7754        {
7755            lines.push(line(text, 72.0, 686.0 - i as f64 * 12.0, 10.0));
7756        }
7757
7758        let blocks = paragraphs(
7759            0,
7760            0,
7761            std::slice::from_ref(&lines),
7762            &[],
7763            &[],
7764            12.0,
7765            TableStrategy::Both,
7766        );
7767        let kinds: Vec<BlockKind> = blocks.iter().map(|block| block.kind).collect();
7768        assert_eq!(
7769            kinds,
7770            vec![
7771                BlockKind::Paragraph,
7772                BlockKind::ListItem {
7773                    level: 0,
7774                    ordinal: Some(3)
7775                },
7776                BlockKind::ListItem {
7777                    level: 0,
7778                    ordinal: Some(4)
7779                },
7780            ],
7781            "{:?}",
7782            texts(&blocks)
7783        );
7784        assert_eq!(blocks[1].text, "to the officers");
7785    }
7786
7787    #[test]
7788    fn a_page_of_nothing_produces_no_blocks() {
7789        assert!(paragraphs(0, 0, &[], &[], &[], 0.0, TableStrategy::Both).is_empty());
7790        assert!(paragraphs(
7791            0,
7792            0,
7793            &[vec![TextBlock { spans: Vec::new() }]],
7794            &[],
7795            &[],
7796            0.0,
7797            TableStrategy::Both
7798        )
7799        .is_empty());
7800    }
7801
7802    fn subtitle_span(size: f64) -> TextSpan {
7803        TextSpan {
7804            font_size: size,
7805            ..Default::default()
7806        }
7807    }
7808
7809    /// A block as the table passes hand it on: classified, boxed in `[x0, y0,
7810    /// x1, y1]` with y up, set in one size.
7811    fn subtitle_block(kind: BlockKind, text: &str, size: f64, bbox: [f64; 4]) -> Block {
7812        Block {
7813            kind,
7814            page: 0,
7815            bbox,
7816            text: text.into(),
7817            spans: vec![subtitle_span(size)],
7818            trace_learned: None,
7819            index: 0,
7820            continues_previous: false,
7821            confidence: None,
7822            grid: None,
7823            table_origin: TableOrigin::Other,
7824        }
7825    }
7826
7827    fn subtitle_heading(text: &str, size: f64, bbox: [f64; 4]) -> Block {
7828        subtitle_block(BlockKind::Heading { level: 1 }, text, size, bbox)
7829    }
7830
7831    /// The joined heading the first test joins, asserted whole: one block, the
7832    /// heading's level, both texts, both spans, the union box.
7833    #[test]
7834    fn a_title_keeps_its_subtitle() {
7835        let mut blocks = vec![
7836            subtitle_heading(
7837                "Statements of cash flows",
7838                18.0,
7839                [72.0, 700.0, 300.0, 718.0],
7840            ),
7841            subtitle_block(
7842                BlockKind::Paragraph,
7843                "for the year ended 30 June 2003",
7844                9.5,
7845                [72.0, 680.0, 280.0, 698.5],
7846            ),
7847        ];
7848        join_heading_subtitles(&mut blocks);
7849        assert_eq!(blocks.len(), 1, "the subtitle stayed apart: {blocks:?}");
7850        assert_eq!(blocks[0].kind, BlockKind::Heading { level: 1 });
7851        assert_eq!(
7852            blocks[0].text,
7853            "Statements of cash flows for the year ended 30 June 2003"
7854        );
7855        assert_eq!(blocks[0].spans.len(), 2);
7856        assert_eq!(blocks[0].bbox, [72.0, 680.0, 300.0, 718.0]);
7857        assert_eq!(blocks[0].index, 0);
7858    }
7859
7860    #[test]
7861    fn a_two_line_title_is_one_heading() {
7862        let mut blocks = vec![
7863            subtitle_heading(
7864                "WE DON'T PLAY GAMES WITH",
7865                32.0,
7866                [72.0, 700.0, 400.0, 732.0],
7867            ),
7868            subtitle_heading(
7869                "ELECTRICITY, OUR CUSTOMERS",
7870                32.0,
7871                [72.0, 660.0, 400.0, 693.6],
7872            ),
7873        ];
7874        join_heading_subtitles(&mut blocks);
7875        assert_eq!(blocks.len(), 1, "the title stayed in pieces: {blocks:?}");
7876        assert_eq!(blocks[0].kind, BlockKind::Heading { level: 1 });
7877        assert_eq!(
7878            blocks[0].text,
7879            "WE DON'T PLAY GAMES WITH ELECTRICITY, OUR CUSTOMERS"
7880        );
7881    }
7882
7883    #[test]
7884    fn a_heading_collects_two_tight_lines() {
7885        let mut blocks = vec![
7886            subtitle_heading("Annual report", 18.0, [72.0, 700.0, 200.0, 718.0]),
7887            subtitle_block(
7888                BlockKind::Paragraph,
7889                "for the year ended",
7890                9.5,
7891                [72.0, 690.0, 180.0, 698.5],
7892            ),
7893            subtitle_block(
7894                BlockKind::Paragraph,
7895                "30 June 2003",
7896                9.5,
7897                [72.0, 680.0, 150.0, 688.5],
7898            ),
7899            subtitle_block(
7900                BlockKind::Paragraph,
7901                "The year in review.",
7902                10.0,
7903                [72.0, 600.0, 300.0, 610.0],
7904            ),
7905        ];
7906        join_heading_subtitles(&mut blocks);
7907        assert_eq!(blocks.len(), 2, "the three lines did not join: {blocks:?}");
7908        assert_eq!(
7909            blocks[0].text,
7910            "Annual report for the year ended 30 June 2003"
7911        );
7912        assert_eq!(blocks[0].spans.len(), 3);
7913        assert_eq!((blocks[0].index, blocks[1].index), (0, 1));
7914    }
7915
7916    #[test]
7917    fn a_heading_over_a_sentence_stays_apart() {
7918        let mut blocks = vec![
7919            subtitle_heading("January 1989", 14.0, [72.0, 700.0, 200.0, 714.0]),
7920            subtitle_block(
7921                BlockKind::Paragraph,
7922                "Emperor Hirohito of Japan dead at 87.",
7923                10.0,
7924                [72.0, 680.0, 300.0, 698.0],
7925            ),
7926        ];
7927        join_heading_subtitles(&mut blocks);
7928        assert_eq!(
7929            blocks.len(),
7930            2,
7931            "the heading swallowed the sentence under it: {blocks:?}"
7932        );
7933    }
7934
7935    #[test]
7936    fn a_larger_line_under_a_heading_stays_apart() {
7937        let mut blocks = vec![
7938            subtitle_heading("Notes", 10.0, [72.0, 700.0, 200.0, 710.0]),
7939            subtitle_block(
7940                BlockKind::Paragraph,
7941                "A line set larger than the heading above it",
7942                14.0,
7943                [72.0, 680.0, 300.0, 698.5],
7944            ),
7945        ];
7946        join_heading_subtitles(&mut blocks);
7947        assert_eq!(
7948            blocks.len(),
7949            2,
7950            "the heading swallowed a larger line: {blocks:?}"
7951        );
7952    }
7953
7954    #[test]
7955    fn a_loose_line_under_a_heading_stays_apart() {
7956        let mut blocks = vec![
7957            subtitle_heading(
7958                "Statements of cash flows",
7959                18.0,
7960                [72.0, 700.0, 300.0, 718.0],
7961            ),
7962            subtitle_block(
7963                BlockKind::Paragraph,
7964                "for the year ended 30 June 2003",
7965                9.5,
7966                [72.0, 671.0, 280.0, 691.0],
7967            ),
7968        ];
7969        join_heading_subtitles(&mut blocks);
7970        assert_eq!(
7971            blocks.len(),
7972            2,
7973            "the heading reached half an em down: {blocks:?}"
7974        );
7975    }
7976
7977    #[test]
7978    fn an_indented_line_under_a_heading_stays_apart() {
7979        let mut blocks = vec![
7980            subtitle_heading(
7981                "Statements of cash flows",
7982                18.0,
7983                [72.0, 700.0, 300.0, 718.0],
7984            ),
7985            subtitle_block(
7986                BlockKind::Paragraph,
7987                "for the year ended 30 June 2003",
7988                9.5,
7989                [92.0, 680.0, 300.0, 698.5],
7990            ),
7991        ];
7992        join_heading_subtitles(&mut blocks);
7993        assert_eq!(
7994            blocks.len(),
7995            2,
7996            "the heading swallowed an indented line: {blocks:?}"
7997        );
7998    }
7999
8000    #[test]
8001    fn a_heading_over_a_list_like_line_stays_apart() {
8002        let mut blocks = vec![
8003            subtitle_heading("Contents", 14.0, [72.0, 700.0, 200.0, 714.0]),
8004            subtitle_block(
8005                BlockKind::Paragraph,
8006                "1. The first item of a list",
8007                10.0,
8008                [72.0, 680.0, 300.0, 698.0],
8009            ),
8010        ];
8011        join_heading_subtitles(&mut blocks);
8012        assert_eq!(
8013            blocks.len(),
8014            2,
8015            "the heading swallowed a list item: {blocks:?}"
8016        );
8017    }
8018
8019    #[test]
8020    fn a_subtitle_two_points_under_a_nine_point_head_joins() {
8021        // Two points under a nine-point head is 0.22 em: inside the quarter
8022        // em the join allows, past the two tenths it allowed before. The
8023        // SRB section head over its second line, on the same left edge.
8024        let mut blocks = vec![
8025            subtitle_heading(
8026                "Directors and Their Interests",
8027                9.0,
8028                [72.0, 700.0, 250.0, 709.0],
8029            ),
8030            subtitle_block(
8031                BlockKind::Paragraph,
8032                "Ordinary Shares and Options",
8033                9.0,
8034                [72.0, 689.0, 280.0, 698.0],
8035            ),
8036        ];
8037        join_heading_subtitles(&mut blocks);
8038        assert_eq!(blocks.len(), 1, "the subtitle stayed apart: {blocks:?}");
8039        assert_eq!(
8040            blocks[0].text,
8041            "Directors and Their Interests Ordinary Shares and Options"
8042        );
8043    }
8044
8045    #[test]
8046    fn a_line_three_points_under_a_nine_point_head_stays_apart() {
8047        // Three points under a nine-point head is a third of an em: past the
8048        // quarter em the join allows, so the line stays its own block.
8049        let mut blocks = vec![
8050            subtitle_heading(
8051                "Directors and Their Interests",
8052                9.0,
8053                [72.0, 700.0, 250.0, 709.0],
8054            ),
8055            subtitle_block(
8056                BlockKind::Paragraph,
8057                "Ordinary Shares and Options",
8058                9.0,
8059                [72.0, 688.0, 280.0, 697.0],
8060            ),
8061        ];
8062        join_heading_subtitles(&mut blocks);
8063        assert_eq!(
8064            blocks.len(),
8065            2,
8066            "the heading reached a third of an em down: {blocks:?}"
8067        );
8068    }
8069
8070    fn heading_line_span(size: f64, bold: bool) -> TextSpan {
8071        TextSpan {
8072            font_size: size,
8073            is_bold: bold,
8074            ..Default::default()
8075        }
8076    }
8077
8078    /// A block as the table passes hand it on: classified, boxed, set in one
8079    /// size and one weight.
8080    fn heading_line_block(
8081        kind: BlockKind,
8082        text: &str,
8083        size: f64,
8084        bold: bool,
8085        bbox: [f64; 4],
8086    ) -> Block {
8087        Block {
8088            kind,
8089            page: 0,
8090            bbox,
8091            text: text.into(),
8092            spans: vec![heading_line_span(size, bold)],
8093            trace_learned: None,
8094            index: 0,
8095            continues_previous: false,
8096            confidence: None,
8097            grid: None,
8098            table_origin: TableOrigin::Other,
8099        }
8100    }
8101
8102    #[test]
8103    fn two_heading_lines_in_one_style_become_one_heading() {
8104        let mut blocks = vec![
8105            heading_line_block(
8106                BlockKind::Heading { level: 1 },
8107                "ELECTRONIC DELIVERY OF",
8108                8.0,
8109                true,
8110                [72.0, 700.0, 220.0, 708.0],
8111            ),
8112            heading_line_block(
8113                BlockKind::Heading { level: 2 },
8114                "ANNUAL MEETING MATERIALS",
8115                8.0,
8116                true,
8117                [72.0, 689.0, 230.0, 697.0],
8118            ),
8119        ];
8120        join_heading_lines(&mut blocks);
8121        assert_eq!(blocks.len(), 1, "the title stayed in pieces: {blocks:?}");
8122        assert_eq!(blocks[0].kind, BlockKind::Heading { level: 1 });
8123        assert_eq!(
8124            blocks[0].text,
8125            "ELECTRONIC DELIVERY OF ANNUAL MEETING MATERIALS"
8126        );
8127        assert_eq!(blocks[0].spans.len(), 2);
8128        assert_eq!(blocks[0].bbox, [72.0, 689.0, 230.0, 708.0]);
8129        assert_eq!(blocks[0].index, 0);
8130    }
8131
8132    #[test]
8133    fn a_heading_and_its_same_style_paragraph_line_join_when_short() {
8134        // Without the abbreviation's dot: a b-side full stop is a sentence
8135        // the join refuses.
8136        let mut blocks = vec![
8137            heading_line_block(
8138                BlockKind::Paragraph,
8139                "Powell Power Electronics",
8140                10.0,
8141                true,
8142                [72.0, 700.0, 240.0, 710.0],
8143            ),
8144            heading_line_block(
8145                BlockKind::Heading { level: 2 },
8146                "Company, Inc",
8147                10.0,
8148                true,
8149                [72.0, 688.0, 180.0, 698.0],
8150            ),
8151        ];
8152        join_heading_lines(&mut blocks);
8153        assert_eq!(blocks.len(), 1, "the name stayed in pieces: {blocks:?}");
8154        assert_eq!(blocks[0].kind, BlockKind::Heading { level: 2 });
8155        assert_eq!(blocks[0].text, "Powell Power Electronics Company, Inc");
8156    }
8157
8158    #[test]
8159    fn a_heading_never_swallows_a_sentence() {
8160        let mut blocks = vec![
8161            heading_line_block(
8162                BlockKind::Heading { level: 2 },
8163                "Remuneration",
8164                10.0,
8165                true,
8166                [72.0, 700.0, 180.0, 710.0],
8167            ),
8168            heading_line_block(
8169                BlockKind::Paragraph,
8170                "The managing director's remuneration is made up of the following components.",
8171                10.0,
8172                true,
8173                [72.0, 685.0, 420.0, 698.0],
8174            ),
8175        ];
8176        join_heading_lines(&mut blocks);
8177        assert_eq!(
8178            blocks.len(),
8179            2,
8180            "the heading swallowed the sentence under it: {blocks:?}"
8181        );
8182    }
8183
8184    #[test]
8185    fn a_heading_piece_ending_in_a_colon_still_joins() {
8186        let mut blocks = vec![
8187            heading_line_block(
8188                BlockKind::Paragraph,
8189                "To the Board of Directors and Stockholders of",
8190                9.0,
8191                true,
8192                [72.0, 700.0, 300.0, 709.0],
8193            ),
8194            heading_line_block(
8195                BlockKind::Heading { level: 2 },
8196                "Powell Industries, Inc.:",
8197                9.0,
8198                true,
8199                [72.0, 689.2, 250.0, 698.2],
8200            ),
8201        ];
8202        join_heading_lines(&mut blocks);
8203        assert_eq!(blocks.len(), 1, "the name stayed in pieces: {blocks:?}");
8204        assert_eq!(blocks[0].kind, BlockKind::Heading { level: 2 });
8205        assert_eq!(
8206            blocks[0].text,
8207            "To the Board of Directors and Stockholders of Powell Industries, Inc.:"
8208        );
8209    }
8210
8211    #[test]
8212    fn different_sizes_do_not_join() {
8213        let mut blocks = vec![
8214            heading_line_block(
8215                BlockKind::Heading { level: 1 },
8216                "Notes",
8217                11.0,
8218                false,
8219                [72.0, 700.0, 150.0, 711.0],
8220            ),
8221            heading_line_block(
8222                BlockKind::Paragraph,
8223                "A line set smaller than the heading",
8224                9.0,
8225                false,
8226                [72.0, 690.0, 300.0, 699.0],
8227            ),
8228        ];
8229        join_heading_lines(&mut blocks);
8230        assert_eq!(
8231            blocks.len(),
8232            2,
8233            "the heading swallowed a line set in another size: {blocks:?}"
8234        );
8235    }
8236
8237    #[test]
8238    fn a_marker_line_is_not_joined() {
8239        let mut blocks = vec![
8240            heading_line_block(
8241                BlockKind::Heading { level: 2 },
8242                "Employee share plans",
8243                10.0,
8244                true,
8245                [72.0, 700.0, 260.0, 710.0],
8246            ),
8247            heading_line_block(
8248                BlockKind::Paragraph,
8249                "(a) The consolidated entity has implemented two plans",
8250                10.0,
8251                true,
8252                [72.0, 688.0, 420.0, 698.0],
8253            ),
8254        ];
8255        join_heading_lines(&mut blocks);
8256        assert_eq!(
8257            blocks.len(),
8258            2,
8259            "the heading swallowed a list item: {blocks:?}"
8260        );
8261    }
8262}