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 `` 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], ®ridded)
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, ¤t, 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}