Skip to main content

pdf_engine/
balanced.rs

1//! The balanced tier's page assembly: detector boxes plus our own text, page in, markdown out (#641).
2//!
3//! Behind the `ort-layout` cargo feature (off by default), beside
4//! [`crate::layout`]. This is the Rust port of the balanced adapter --
5//! `scripts/extraction/adapters/teacher_page.py` with detector A (Heron-ft
6//! ep03) only, no union, and the chosen thresholds -- rule for rule:
7//!
8//! * regions from the detector's boxes, kept per class at
9//!   [`THRESHOLDS`] (section_header/title 0.25, list_item 0.10,
10//!   page_header/footer 0.10, table 0.75, everything else 0.50);
11//! * lines from the engine's own words before the learned pass (the same
12//!   [`crate::learned::features::page_features`] the `feature_dump` record
13//!   carries), grouped exactly as `align_labels.lines_of` groups them;
14//! * each line to the TIGHTEST kept region holding at least half its ink,
15//!   placed where its first line falls in the engine's reading order;
16//! * R1: a table region takes TATR's cells where the stage fires (only when
17//!   [`BalancedOptions::with_tatr`] opts in), else the grid of the engine's
18//!   own table in the same place (position filters, text ranks), else the
19//!   grid the engine builds inside the region, else its lines as paragraphs;
20//! * R2: a page with no words is the engine's own markdown for the page,
21//!   verbatim;
22//! * R3/R8/R9: bold/italic runs with the engine's markers, the engine's
23//!   sup/sub/underline tags on aligned words, one style run across a line
24//!   break;
25//! * R4: a taken grid's leftover lines as paragraphs around it;
26//! * R5: a short line (at most [`SHORT_HEADING_WORDS`] words) claimed by a
27//!   below-threshold section_header/title box at or above [`SHORT_THRESHOLD`]
28//!   is a heading;
29//! * R7: an engine table no region took is written at the reading position of
30//!   the lines it covers;
31//! * R10: ligatures folded, hyphen wraps joined, a list item's own bullet
32//!   dropped;
33//! * furniture (page_header/page_footer) dropped, unless
34//!   [`BalancedOptions::keep_furniture`] keeps it as paragraphs.
35//!
36//! [`balanced_page_markdown`] assembles one page from caller-held boxes --
37//! [`crate::layout::layout_boxes`] output or cached boxes injected for
38//! parity -- and [`balanced_markdown`] runs the detector per page. The
39//! table-structure stage hooks in per kept table region through
40//! [`BalancedOptions::with_tatr`]: TATR first, and only on a fire are its
41//! cells the region's table. There is no coarser switch here: a whole-page
42//! override would bypass the stage's own per-region invariants (no word
43//! lost, the coverage bar, the gap fill).
44//!
45//! Byte parity with the Python adapter (same boxes, same engine) is the
46//! contract: every helper below ports its Python original exactly, including
47//! `difflib.SequenceMatcher` (autojunk off, no junk) for the R8 alignment and
48//! the bench's `blocks_of`/`text_similarity` for the R1 match. Where the
49//! engine already computes the identical value in this crate (`ligatures`,
50//! `is_hyphen_wrap_candidate`, `BULLET_CHARS`), the port calls it rather than
51//! copying it.
52//!
53//! Model failures come back as [`EngineError::Io`](crate::EngineError)
54//! carrying the typed [`pdf_ocr::runtime::ModelError`] -- no new
55//! `EngineError` variants.
56
57// Copyright (c) 2026 Innovation Trigger B.V.
58//
59// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
60// the PDFluent Commercial Licence. See the LICENSE file in this repository --
61// that file travels with the copy you received, which a URL does not.
62
63use std::collections::{HashMap, HashSet};
64
65use crate::layout::{layout_boxes, LayoutBox, RuntimeConfig};
66use crate::learned::features::{page_features, PageWord};
67use crate::table_trace::TableOrigin;
68use crate::{Block, BlockKind, EngineError, ExtractOptions, PdfDocument, Result, TatrOptions};
69
70/// Detector A (Heron-ft ep03) per-class keep thresholds: the balanced tier's
71/// choices (T3's `chosen.json`: section_header/title 0.25, list_item 0.10,
72/// page_header/footer 0.10, table 0.75, default 0.50). A box is kept when its
73/// score reaches its class threshold; anything below the post threshold never
74/// reaches this table (see [`crate::layout`]).
75#[derive(Debug, Clone, Copy)]
76struct Thresholds {
77    /// Keep threshold for `section_header` and `title`.
78    heading: f32,
79    /// Keep threshold for `list_item`.
80    list_item: f32,
81    /// Keep threshold for `table`.
82    table: f32,
83    /// Keep threshold for `page_header` and `page_footer`.
84    furniture: f32,
85    /// Keep threshold for every other label.
86    default: f32,
87}
88
89/// The balanced tier's thresholds, from T3's `chosen.json`.
90const THRESHOLDS: Thresholds = Thresholds {
91    heading: 0.25,
92    list_item: 0.10,
93    table: 0.75,
94    furniture: 0.10,
95    default: 0.50,
96};
97
98/// R5: a below-threshold section_header/title box at or above this score still
99/// promotes the short lines it claims to headings.
100const SHORT_THRESHOLD: f32 = 0.05;
101
102/// R5: a line of at most this many words may become a heading on a weak
103/// detector-A signal.
104const SHORT_HEADING_WORDS: usize = 12;
105
106/// R1: how alike a table region's text and an engine table's text must be
107/// before the engine's grid is used.
108const GRID_FLOOR: f64 = 0.5;
109
110/// R1: an engine table is the region's when at least this share of its cells
111/// lie inside; a cell counts when [`POSITION_CELLS`] of its own area does.
112const POSITION_TABLE: f64 = 0.5;
113
114/// R1: the share of a cell's own area inside the region for the cell to count.
115const POSITION_CELLS: f64 = 0.5;
116
117/// R7: an orphan engine table covers a line when half the line's ink lies
118/// inside the table's cell-union box padded by this many points.
119const ORPHAN_PAD: f64 = 2.0;
120
121/// How wide a gap inside a line has to be, in ems of the line's own height,
122/// before it is a column boundary rather than a space (`align_labels`).
123const COLUMN_GAP_EM: f64 = 1.5;
124
125/// Options for [`balanced_page_markdown`] and [`balanced_markdown`].
126///
127/// The tier's own settings (thresholds, R5's bar, R7-R10 on, furniture
128/// dropped) are fixed; what varies per caller is whether furniture lines are
129/// kept, and whether the table-structure stage runs inside the kept table
130/// regions.
131#[derive(Debug, Clone, Default)]
132pub struct BalancedOptions {
133    /// Keep page_header/page_footer lines as paragraphs instead of dropping
134    /// them (the compat `to_markdown(header=, footer=)` mapping).
135    keep_furniture: bool,
136    /// TATR table structure inside the kept table regions. Default `None`:
137    /// R1 as it always ran. Set with [`Self::with_tatr`] only -- a private
138    /// field, so the opt-in stays a builder call.
139    tatr: Option<TatrOptions>,
140}
141
142impl BalancedOptions {
143    /// Keep furniture lines as paragraphs (`true`) or drop them (the default).
144    #[must_use]
145    pub fn keep_furniture(mut self, keep: bool) -> Self {
146        self.keep_furniture = keep;
147        self
148    }
149
150    /// Rebuild the kept table regions with TATR first, where it fires (track
151    /// B, P4).
152    ///
153    /// The only way to set the TATR opt-in: there is no public field. With
154    /// it set, R1 offers each kept table region to the Table Transformer
155    /// before the engine grid runs; where TATR fires its cells are the
156    /// region's table, and where it declines, fires under coverage, or the
157    /// region fails, R1 continues exactly as without the option. R7 orphan
158    /// engine tables never reach TATR: no detector region, no offer. A
159    /// missing model refuses up front, never a silent run without the stage
160    /// -- as does asking on a build without the `ort-tables` feature.
161    ///
162    /// # Example
163    ///
164    /// ```no_run
165    /// use pdf_engine::balanced::BalancedOptions;
166    /// use pdf_engine::TatrOptions;
167    ///
168    /// let options = BalancedOptions::default().with_tatr(TatrOptions::default());
169    /// # let _ = options;
170    /// ```
171    #[must_use]
172    pub fn with_tatr(mut self, tatr: TatrOptions) -> Self {
173        self.tatr = Some(tatr);
174        self
175    }
176}
177
178/// One page plus its layout boxes as markdown, the balanced tier's assembly.
179///
180/// `boxes` are detector A's boxes for `page` (from
181/// [`layout_boxes`](crate::layout::layout_boxes), or cached boxes injected
182/// for parity), each addressed against its own render size. `page` counts
183/// from zero. The markdown holds no page marker; [`balanced_markdown`] joins
184/// pages with a blank line.
185pub fn balanced_page_markdown(
186    doc: &PdfDocument,
187    page: usize,
188    boxes: &[LayoutBox],
189    opts: &BalancedOptions,
190) -> Result<String> {
191    let count = doc.page_count();
192    if page >= count {
193        return Err(EngineError::PageOutOfRange { index: page, count });
194    }
195    // The TATR context up front, before any early return: where the stage is
196    // asked for and the model is absent the page refuses here with the
197    // shared layer's `MODEL_NOT_AVAILABLE`, never a silent run without it --
198    // on a wordless page as on any other. `None` -- the stage not asked
199    // for -- assembles exactly as it always has: the R1 step below is gated
200    // on `Some`.
201    let tatr = doc.tatr_context(opts.tatr.as_ref())?;
202    let (width, height) = doc
203        .page_geometry(page)
204        .map(|g| g.effective_dimensions())
205        .unwrap_or((0.0, 0.0));
206    let (before, _) = doc.extract_blocks_and_rules_before_learning()?;
207    let on_page: Vec<Block> = before.into_iter().filter(|b| b.page == page).collect();
208    let refs: Vec<&Block> = on_page.iter().collect();
209    let words = page_features(&refs, width, height).words;
210
211    let options = ExtractOptions::default();
212    let (blocks, rules, withdrawn) = doc.extract_blocks_rules_and_withdrawn(&options)?;
213    // Rule A's inputs for the tables this tier builds itself (#636), and the
214    // tables the final blocks already lost to it.
215    let (page_rulings, page_graphics) = doc.page_rulings_and_graphics(page)?;
216    let withdrawn: Vec<Vec<[f64; 4]>> = withdrawn
217        .into_iter()
218        .filter(|table| table.page == page)
219        .map(|table| table.cells)
220        .collect();
221    let engine_blocks: Vec<Block> = blocks.into_iter().filter(|b| b.page == page).collect();
222    let engine_markdown = crate::markdown::to_markdown_with_rules(&engine_blocks, &rules);
223    if words.is_empty() {
224        return Ok(engine_markdown);
225    }
226
227    let (regions, short) = regions_of(boxes, width, height, opts.keep_furniture);
228    let mut matcher = GridMatcher::new(&engine_markdown, &engine_blocks);
229    let region_table = |bbox: [f64; 4]| -> Option<RegionGrid> {
230        match doc.table_in_region(page, bbox) {
231            Ok(cells) => {
232                let grid = crate::markdown::to_markdown(&cells);
233                if grid.is_empty() || grid.trim().is_empty() {
234                    None
235                } else {
236                    Some(RegionGrid { html: grid, cells })
237                }
238            }
239            Err(_) => None,
240        }
241    };
242    let tatr_table = |bbox: [f64; 4]| -> Option<RegionGrid> {
243        let context = tatr.as_ref()?;
244        match doc.tatr_cells_in_region(page, bbox, context) {
245            Ok(Some(cells)) => {
246                let grid = crate::markdown::to_markdown(&cells);
247                if grid.is_empty() || grid.trim().is_empty() {
248                    None
249                } else {
250                    Some(RegionGrid { html: grid, cells })
251                }
252            }
253            Ok(None) | Err(_) => None,
254        }
255    };
256    let tatr_table: Option<&dyn Fn([f64; 4]) -> Option<RegionGrid>> = if tatr.is_some() {
257        Some(&tatr_table)
258    } else {
259        None
260    };
261    Ok(assemble(
262        &words,
263        on_page.len(),
264        &regions,
265        &short,
266        &mut matcher,
267        &region_table,
268        tatr_table,
269        &engine_markdown,
270        page,
271        &engine_blocks,
272        &RuleAScope {
273            rulings: &page_rulings,
274            graphics: &page_graphics,
275            withdrawn: &withdrawn,
276        },
277    ))
278}
279
280/// What rule A reads for one page in [`assemble`] (#636).
281struct RuleAScope<'a> {
282    /// The page's lattice rulings.
283    rulings: &'a [crate::text::Ruling],
284    /// The page's drawn-cluster boxes.
285    graphics: &'a [[f64; 4]],
286    /// The cell boxes of each engine table the final blocks already lost to
287    /// rule A: a layout "table" box that holds one gets no rebuilt table.
288    withdrawn: &'a [Vec<[f64; 4]>],
289}
290
291impl RuleAScope<'_> {
292    /// Whether rule A keeps a table this tier built from `cells`.
293    fn keeps(&self, cells: &[Block]) -> bool {
294        let refs: Vec<&Block> = cells.iter().collect();
295        let origin = crate::table_trace::run_origin(&refs);
296        crate::table_trace::rule_a_keeps(cells, origin, self.rulings, self.graphics)
297    }
298
299    /// Whether `bbox` holds a withdrawn engine table: the matcher's own
300    /// position test, [`POSITION_CELLS`] of a cell inside the box for
301    /// [`POSITION_TABLE`] of the table's cells.
302    fn holds_withdrawn(&self, bbox: &[f64; 4]) -> bool {
303        self.withdrawn.iter().any(|cells| {
304            let inside = cells
305                .iter()
306                .filter(|c| area(c) > 0.0 && overlap(c, bbox) / area(c) >= POSITION_CELLS)
307                .count();
308            !cells.is_empty() && inside as f64 / cells.len() as f64 >= POSITION_TABLE
309        })
310    }
311}
312
313/// The document as markdown through the balanced tier: [`layout_boxes`](crate::layout::layout_boxes)
314/// per page, each page through [`balanced_page_markdown`].
315///
316/// Pages join with a blank line; empty pages contribute nothing. Needs the
317/// layout model (see [`crate::layout`]); without it the error is the shared
318/// layer's `MODEL_NOT_AVAILABLE`.
319pub fn balanced_markdown(
320    doc: &PdfDocument,
321    config: &RuntimeConfig,
322    opts: &BalancedOptions,
323) -> Result<String> {
324    let mut pages = Vec::new();
325    for page in 0..doc.page_count() {
326        let boxes = layout_boxes(doc, page, config)?;
327        let markdown = balanced_page_markdown(doc, page, &boxes, opts)?;
328        if !markdown.is_empty() {
329            pages.push(markdown);
330        }
331    }
332    Ok(pages.join("\n\n"))
333}
334
335/// What a region's lines become.
336#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
337enum RegionKind {
338    /// `section_header`/`title`: `## `.
339    Heading,
340    /// `list_item`: `- `.
341    ListItem,
342    /// `table`: a grid.
343    Table,
344    /// `page_header`/`page_footer`: dropped, unless kept as paragraphs.
345    Furniture,
346    /// Every other label: a paragraph.
347    Paragraph,
348}
349
350/// One kept detector region, in PDF user space (points, y up).
351#[derive(Debug, Clone)]
352struct Region {
353    /// What the region's lines become.
354    kind: RegionKind,
355    /// The detector's score (orders candidates; never printed).
356    score: f32,
357    /// `[x0, y0, x1, y1]` in PDF points, y up.
358    bbox: [f64; 4],
359}
360
361/// The keep threshold for one detector label.
362fn class_threshold(label: &str) -> f32 {
363    match label {
364        "section_header" | "title" => THRESHOLDS.heading,
365        "list_item" => THRESHOLDS.list_item,
366        "table" => THRESHOLDS.table,
367        "page_header" | "page_footer" => THRESHOLDS.furniture,
368        _ => THRESHOLDS.default,
369    }
370}
371
372/// One detector box (render px, top-left origin) in PDF user space (points,
373/// bottom-left origin).
374fn to_pdf_box(bbox: [f32; 4], render_w: u32, render_h: u32, page_w: f64, page_h: f64) -> [f64; 4] {
375    let (w, h) = (f64::from(render_w), f64::from(render_h));
376    let (sx, sy) = (page_w / w, page_h / h);
377    let bbox = [
378        f64::from(bbox[0]),
379        f64::from(bbox[1]),
380        f64::from(bbox[2]),
381        f64::from(bbox[3]),
382    ];
383    [
384        bbox[0] * sx,
385        page_h - bbox[3] * sy,
386        bbox[2] * sx,
387        page_h - bbox[1] * sy,
388    ]
389}
390
391/// The kept regions plus the R5 short regions, in input order.
392///
393/// Kept are the boxes at or above their class threshold; short are
394/// section_header/title boxes at or above [`SHORT_THRESHOLD`] but below it.
395/// With `keep_furniture`, furniture boxes read as paragraphs everywhere.
396fn regions_of(
397    boxes: &[LayoutBox],
398    page_w: f64,
399    page_h: f64,
400    keep_furniture: bool,
401) -> (Vec<Region>, Vec<Region>) {
402    let mut kept = Vec::new();
403    let mut short = Vec::new();
404    for b in boxes {
405        let bbox = to_pdf_box(b.bbox_px, b.render_w, b.render_h, page_w, page_h);
406        let threshold = class_threshold(&b.label);
407        if b.score >= threshold {
408            let mut kind = match b.label.as_str() {
409                "section_header" | "title" => RegionKind::Heading,
410                "list_item" => RegionKind::ListItem,
411                "table" => RegionKind::Table,
412                "page_header" | "page_footer" => RegionKind::Furniture,
413                _ => RegionKind::Paragraph,
414            };
415            if keep_furniture && kind == RegionKind::Furniture {
416                kind = RegionKind::Paragraph;
417            }
418            kept.push(Region {
419                kind,
420                score: b.score,
421                bbox,
422            });
423        } else if (b.label == "section_header" || b.label == "title") && b.score >= SHORT_THRESHOLD
424        {
425            short.push(Region {
426                kind: RegionKind::Heading,
427                score: b.score,
428                bbox,
429            });
430        }
431    }
432    (kept, short)
433}
434
435/// The area of one `[x0, y0, x1, y1]` box.
436fn area(box_: &[f64; 4]) -> f64 {
437    (box_[2] - box_[0]).max(0.0) * (box_[3] - box_[1]).max(0.0)
438}
439
440/// The overlap area of two `[x0, y0, x1, y1]` boxes.
441fn overlap(one: &[f64; 4], other: &[f64; 4]) -> f64 {
442    (one[2].min(other[2]) - one[0].max(other[0])).max(0.0)
443        * (one[3].min(other[3]) - one[1].max(other[1])).max(0.0)
444}
445
446/// The words of one block grouped into lines, top to bottom, as word indices.
447///
448/// Ports `align_labels.lines_of`: words join a line when their boxes overlap
449/// vertically by more than half of the shorter of the two (against the line's
450/// own span, not the word that opened it), lines sort left to right, split
451/// across a column gap, and come back in reading order.
452fn lines_of(block: usize, words: &[PageWord]) -> Vec<Vec<usize>> {
453    let mut ink: Vec<usize> = words
454        .iter()
455        .enumerate()
456        .filter(|(_, w)| w.block == block)
457        .map(|(i, _)| i)
458        .collect();
459    // Stable, as Python's sort is: identical boxes keep word order.
460    ink.sort_by(|&a, &b| {
461        words[b].bbox[3]
462            .total_cmp(&words[a].bbox[3])
463            .then_with(|| words[a].bbox[0].total_cmp(&words[b].bbox[0]))
464    });
465    let mut lines: Vec<Vec<usize>> = Vec::new();
466    for word in ink {
467        let box_ = words[word].bbox;
468        let mut placed = false;
469        for line in &mut lines {
470            let low = line
471                .iter()
472                .map(|&w| words[w].bbox[1])
473                .fold(f64::INFINITY, f64::min);
474            let high = line
475                .iter()
476                .map(|&w| words[w].bbox[3])
477                .fold(f64::NEG_INFINITY, f64::max);
478            let shorter = (box_[3] - box_[1]).min(high - low);
479            let shorter = if shorter == 0.0 { 1.0 } else { shorter };
480            let share = (box_[3].min(high) - box_[1].max(low)) / shorter;
481            if share > 0.5 {
482                line.push(word);
483                placed = true;
484                break;
485            }
486        }
487        if !placed {
488            lines.push(vec![word]);
489        }
490    }
491    for line in &mut lines {
492        line.sort_by(|&a, &b| words[a].bbox[0].total_cmp(&words[b].bbox[0]));
493    }
494    let pieces: Vec<Vec<usize>> = lines
495        .iter()
496        .flat_map(|line| across_a_column(line, words))
497        .collect();
498    in_reading_order(pieces, words)
499}
500
501/// One y-grouped line, cut where it crosses from one column into another.
502///
503/// A gap at or above [`COLUMN_GAP_EM`] ems of the line's own (median) height
504/// is a column boundary. The median is the upper middle, and a zero height
505/// reads as 1.0, as in `align_labels`.
506fn across_a_column(line: &[usize], words: &[PageWord]) -> Vec<Vec<usize>> {
507    if line.len() < 2 {
508        return vec![line.to_vec()];
509    }
510    let mut heights: Vec<f64> = line
511        .iter()
512        .map(|&w| words[w].bbox[3] - words[w].bbox[1])
513        .collect();
514    heights.sort_by(f64::total_cmp);
515    let mut height = heights[heights.len() / 2];
516    if height == 0.0 {
517        height = 1.0;
518    }
519    let mut out = Vec::new();
520    let mut run = vec![line[0]];
521    for window in line.windows(2) {
522        let (before, word) = (window[0], window[1]);
523        if (words[word].bbox[0] - words[before].bbox[2]) / height >= COLUMN_GAP_EM {
524            out.push(run);
525            run = vec![word];
526        } else {
527            run.push(word);
528        }
529    }
530    out.push(run);
531    out
532}
533
534/// The pieces of a block, column by column and top to bottom inside each.
535///
536/// A piece joins a column when it shares more than half of the narrower of
537/// the two x-ranges with it; columns sort by their left edge, pieces inside
538/// by their top (stable), as in `align_labels`.
539fn in_reading_order(pieces: Vec<Vec<usize>>, words: &[PageWord]) -> Vec<Vec<usize>> {
540    if pieces.len() < 2 {
541        return pieces;
542    }
543    struct Column {
544        left: f64,
545        right: f64,
546        pieces: Vec<Vec<usize>>,
547    }
548    let mut columns: Vec<Column> = Vec::new();
549    for piece in pieces {
550        let left = piece
551            .iter()
552            .map(|&w| words[w].bbox[0])
553            .fold(f64::INFINITY, f64::min);
554        let right = piece
555            .iter()
556            .map(|&w| words[w].bbox[2])
557            .fold(f64::NEG_INFINITY, f64::max);
558        let mut placed = false;
559        for column in &mut columns {
560            let width = (right - left).min(column.right - column.left);
561            let width = if width == 0.0 { 1.0 } else { width };
562            let shared = right.min(column.right) - left.max(column.left);
563            if shared / width > 0.5 {
564                column.left = column.left.min(left);
565                column.right = column.right.max(right);
566                column.pieces.push(piece.clone());
567                placed = true;
568                break;
569            }
570        }
571        if !placed {
572            columns.push(Column {
573                left,
574                right,
575                pieces: vec![piece],
576            });
577        }
578    }
579    columns.sort_by(|a, b| a.left.total_cmp(&b.left));
580    let mut out = Vec::new();
581    for column in columns {
582        let mut pieces = column.pieces;
583        pieces.sort_by(|a, b| {
584            let top = |piece: &[usize]| {
585                piece
586                    .iter()
587                    .map(|&w| words[w].bbox[3])
588                    .fold(f64::NEG_INFINITY, f64::max)
589            };
590            top(b).total_cmp(&top(a))
591        });
592        out.extend(pieces);
593    }
594    out
595}
596
597/// One placed line: its reading-order sequence, plain and shown text, box,
598/// and block index.
599struct PlacedLine {
600    /// Reading-order sequence across the page.
601    seq: usize,
602    /// The words joined by a space.
603    plain: String,
604    /// The words with their inline style.
605    styled: String,
606    /// The union of the words' boxes.
607    bbox: [f64; 4],
608    /// The block index (for grouping loose lines).
609    block: usize,
610}
611
612/// A region-built grid, with the cells it was written from: the trace reads
613/// the numbers off the cells, and the markdown is what the page prints.
614struct RegionGrid {
615    /// The grid as markdown.
616    html: String,
617    /// The cells the grid was written from (already stamped by their stage).
618    cells: Vec<Block>,
619}
620
621/// Our lines onto the regions, as markdown.
622///
623/// Ports `teacher_page.markdown` with the balanced settings: the tightest
624/// region holding at least half a line's ink wins; blocks stand where their
625/// first line falls in reading order; furniture drops. `table_grid` is the
626/// R1 matcher, `region_table` the region entry, `tatr_table` the TATR-only
627/// attempt per kept table region (`None` where the stage is off, and R1
628/// then runs exactly as without it), `engine_markdown` the page's own
629/// markdown (R2's fallback, R8's tags). `page` and `page_blocks` are the
630/// page's own number and engine blocks: the table trace's scope.
631#[allow(clippy::too_many_arguments)]
632fn assemble(
633    words: &[PageWord],
634    block_count: usize,
635    regions: &[Region],
636    short: &[Region],
637    table_grid: &mut GridMatcher,
638    region_table: &dyn Fn([f64; 4]) -> Option<RegionGrid>,
639    tatr_table: Option<&dyn Fn([f64; 4]) -> Option<RegionGrid>>,
640    engine_markdown: &str,
641    page: usize,
642    page_blocks: &[Block],
643    rule_a: &RuleAScope<'_>,
644) -> String {
645    let page_ctx = Some(crate::table_trace::PageCtx {
646        rulings: rule_a.rulings,
647        graphics: rule_a.graphics,
648    });
649    let pool: Vec<&Region> = regions.iter().chain(short.iter()).collect();
650    let n_kept = regions.len();
651    let styles = if engine_markdown.is_empty() {
652        None
653    } else {
654        Some(engine_styles_for(words, engine_markdown))
655    };
656    let mut items: HashMap<usize, Vec<PlacedLine>> = HashMap::new();
657    let mut order: Vec<usize> = Vec::new();
658    let mut loose: Vec<PlacedLine> = Vec::new();
659    let mut seq = 0usize;
660    for block in 0..block_count {
661        for piece in lines_of(block, words) {
662            if piece.is_empty() {
663                continue;
664            }
665            let mut ink: f64 = piece.iter().map(|&w| area(&words[w].bbox)).sum();
666            if ink == 0.0 {
667                ink = 1.0;
668            }
669            let mut cands: Vec<(f64, f32, usize)> = Vec::new();
670            for (ri, region) in pool.iter().enumerate() {
671                let held: f64 = piece
672                    .iter()
673                    .map(|&w| overlap(&words[w].bbox, &region.bbox))
674                    .sum();
675                if held / ink >= 0.5 && (ri < n_kept || piece.len() <= SHORT_HEADING_WORDS) {
676                    cands.push((area(&region.bbox), region.score, ri));
677                }
678            }
679            cands.sort_by(|a, b| {
680                a.0.total_cmp(&b.0)
681                    .then_with(|| b.1.total_cmp(&a.1))
682                    .then_with(|| a.2.cmp(&b.2))
683            });
684            let pick = cands.first().map(|(_, _, ri)| *ri);
685            let plain = piece
686                .iter()
687                .map(|&w| words[w].text.as_str())
688                .collect::<Vec<_>>()
689                .join(" ");
690            let piece_words: Vec<&PageWord> = piece.iter().map(|&w| &words[w]).collect();
691            let styled = match &styles {
692                Some(styles) => styled_line_engine(&piece, &piece_words, styles),
693                None => styled_line(&piece_words),
694            };
695            let bbox = [
696                piece
697                    .iter()
698                    .map(|&w| words[w].bbox[0])
699                    .fold(f64::INFINITY, f64::min),
700                piece
701                    .iter()
702                    .map(|&w| words[w].bbox[1])
703                    .fold(f64::INFINITY, f64::min),
704                piece
705                    .iter()
706                    .map(|&w| words[w].bbox[2])
707                    .fold(f64::NEG_INFINITY, f64::max),
708                piece
709                    .iter()
710                    .map(|&w| words[w].bbox[3])
711                    .fold(f64::NEG_INFINITY, f64::max),
712            ];
713            let line = PlacedLine {
714                seq,
715                plain,
716                styled,
717                bbox,
718                block,
719            };
720            seq += 1;
721            match pick {
722                Some(ri) => {
723                    if !items.contains_key(&ri) {
724                        order.push(ri);
725                    }
726                    items.entry(ri).or_default().push(line);
727                }
728                None => loose.push(line),
729            }
730        }
731    }
732
733    // What each claimed region becomes: TATR's cells where the stage fires,
734    // else the matched engine grid, else the region-built grid, else
735    // paragraphs.
736    let mut decided: HashMap<usize, (RegionKind, Option<String>, bool)> = HashMap::new();
737    for &ri in &order {
738        let lines = &items[&ri];
739        let text = lines
740            .iter()
741            .map(|l| l.plain.as_str())
742            .collect::<Vec<_>>()
743            .join(" ");
744        let mut kind = pool[ri].kind;
745        let mut grid: Option<String> = None;
746        let mut engine_grid = false;
747        // R1 step (1): TATR only, per kept table region. Only kept regions
748        // reach this with `Table` kind -- short regions are headings, and
749        // orphans never pass through here (R7 reads the leftovers below).
750        // On a fire its cells are the grid and the region's unclaimed lines
751        // stay for R4, exactly as with an engine grid; on a fallback R1
752        // continues below as without the stage.
753        // Rule A (#636) over every table this step could emit. A layout
754        // "table" box that holds an engine table rule A already withdrew gets
755        // no rebuilt grid either: the region and TATR rebuilds would read the
756        // same figure labels and put the table back.
757        let mut refused = kind == RegionKind::Table && rule_a.holds_withdrawn(&pool[ri].bbox);
758        if let (None, RegionKind::Table, Some(tatr), false) = (&grid, kind, tatr_table, refused) {
759            if let Some(built) = tatr(pool[ri].bbox) {
760                crate::table_trace::emit_run(page, &built.cells, page_blocks, page_ctx);
761                if rule_a.keeps(&built.cells) {
762                    grid = Some(built.html);
763                    engine_grid = true;
764                } else {
765                    refused = true;
766                }
767            }
768        }
769        if grid.is_none() && kind == RegionKind::Table {
770            grid = table_grid.table_grid(&text, &pool[ri].bbox);
771            if grid.is_some() {
772                engine_grid = true;
773                if let Some(cells) = table_grid.picked_cells() {
774                    let refs: Vec<&Block> = cells.iter().collect();
775                    crate::table_trace::emit_cells(
776                        page,
777                        &refs,
778                        page_blocks,
779                        TableOrigin::EngineGridMatch,
780                        page_ctx,
781                    );
782                    if !rule_a.keeps(cells) {
783                        grid = None;
784                        engine_grid = false;
785                        refused = true;
786                    }
787                }
788            }
789        }
790        if grid.is_none() && kind == RegionKind::Table {
791            if !refused {
792                if let Some(built) = region_table(pool[ri].bbox) {
793                    let html = built.html;
794                    if !html.is_empty() && !html.trim().is_empty() {
795                        crate::table_trace::emit_run(page, &built.cells, page_blocks, page_ctx);
796                        if rule_a.keeps(&built.cells) {
797                            grid = Some(html);
798                            engine_grid = true;
799                        }
800                    }
801                }
802            }
803            if grid.is_none() {
804                kind = RegionKind::Paragraph;
805            }
806        }
807        decided.insert(ri, (kind, grid, engine_grid));
808    }
809
810    type BlockRow = (
811        usize,
812        RegionKind,
813        String,
814        Option<String>,
815        Vec<String>,
816        Vec<String>,
817    );
818    let mut blocks: Vec<BlockRow> = Vec::new();
819    // R7: each engine table no region took, at the reading position of the
820    // first line it covers. Covered lines leave their blocks, except heading
821    // lines (a heading stays a heading); the table is skipped where a covered
822    // line sits in a decided table, where half the covered lines sit in
823    // heading or list_item regions, or where any sits in a list_item region.
824    for (html, tb, oi) in table_grid.orphans() {
825        // Rule A (#636): an orphan engine table it would withdraw stays text.
826        let orphan_cells = table_grid.orphan_cells(oi);
827        if !orphan_cells.is_empty() && !rule_a.keeps(orphan_cells) {
828            let refs: Vec<&Block> = orphan_cells.iter().collect();
829            crate::table_trace::emit_cells(
830                page,
831                &refs,
832                page_blocks,
833                TableOrigin::EngineGridMatch,
834                page_ctx,
835            );
836            continue;
837        }
838        let pad = [
839            tb[0] - ORPHAN_PAD,
840            tb[1] - ORPHAN_PAD,
841            tb[2] + ORPHAN_PAD,
842            tb[3] + ORPHAN_PAD,
843        ];
844        let inside = |box_: &[f64; 4]| -> bool {
845            let box_area = area(box_);
846            box_area > 0.0 && overlap(box_, &pad) / box_area >= 0.5
847        };
848        let hits: Vec<(usize, usize)> = items
849            .iter()
850            .flat_map(|(ri, lines)| {
851                lines
852                    .iter()
853                    .filter(|l| inside(&l.bbox))
854                    .map(|l| (*ri, l.seq))
855            })
856            .collect();
857        if hits
858            .iter()
859            .any(|(ri, _)| matches!(decided.get(ri), Some((RegionKind::Table, Some(_), _))))
860        {
861            continue;
862        }
863        let lhits: Vec<usize> = loose
864            .iter()
865            .filter(|l| inside(&l.bbox))
866            .map(|l| l.seq)
867            .collect();
868        let mut kinds: HashMap<RegionKind, usize> = HashMap::new();
869        for (ri, _) in &hits {
870            *kinds.entry(decided[ri].0).or_default() += 1;
871        }
872        let n_all = hits.len() + lhits.len();
873        let heading = kinds.get(&RegionKind::Heading).copied().unwrap_or(0);
874        let list_item = kinds.get(&RegionKind::ListItem).copied().unwrap_or(0);
875        if 2 * (heading + list_item) >= n_all || list_item > 0 {
876            continue;
877        }
878        let keep_hits: HashSet<usize> = hits
879            .iter()
880            .filter(|(ri, _)| !matches!(decided[ri].0, RegionKind::Heading | RegionKind::Furniture))
881            .map(|(_, seq)| *seq)
882            .collect();
883        if keep_hits.is_empty() && lhits.is_empty() {
884            continue;
885        }
886        let mut at = usize::MAX;
887        for seq in keep_hits.iter().chain(lhits.iter()) {
888            at = at.min(*seq);
889        }
890        let gone: HashSet<usize> = keep_hits.into_iter().chain(lhits).collect();
891        for lines in items.values_mut() {
892            lines.retain(|l| !gone.contains(&l.seq));
893        }
894        items.retain(|_, lines| !lines.is_empty());
895        order.retain(|ri| items.contains_key(ri));
896        loose.retain(|l| !gone.contains(&l.seq));
897        // The trace's line for the orphan grid, where it is written.
898        let refs: Vec<&Block> = orphan_cells.iter().collect();
899        crate::table_trace::emit_cells(
900            page,
901            &refs,
902            page_blocks,
903            TableOrigin::EngineGridMatch,
904            page_ctx,
905        );
906        blocks.push((
907            at,
908            RegionKind::Table,
909            "table".to_string(),
910            Some(html),
911            vec![],
912            vec![],
913        ));
914    }
915
916    for &ri in &order {
917        let lines = &items[&ri];
918        let (kind, grid, engine_grid) = decided.remove(&ri).unwrap_or((pool[ri].kind, None, false));
919        let mut shown = if kind == RegionKind::Heading {
920            join_lines(
921                &lines
922                    .iter()
923                    .map(|l| (l.plain.as_str(), l.plain.as_str()))
924                    .collect::<Vec<_>>(),
925            )
926        } else {
927            join_runs(&join_lines(
928                &lines
929                    .iter()
930                    .map(|l| (l.plain.as_str(), l.styled.as_str()))
931                    .collect::<Vec<_>>(),
932            ))
933        };
934        if kind == RegionKind::ListItem {
935            shown = strip_bullet(&shown);
936        }
937        shown = fold_ligatures(&shown).into_owned();
938        let (mut befores, mut afters) = (Vec::new(), Vec::new());
939        let mut grid = grid;
940        if kind == RegionKind::Table && grid.is_some() && engine_grid {
941            let (before, after, held) = leftover_split(lines, grid.unwrap_or_default());
942            befores = before;
943            afters = after;
944            grid = Some(held);
945        }
946        let at = lines.iter().map(|l| l.seq).min().unwrap_or(usize::MAX);
947        blocks.push((at, kind, shown, grid, befores, afters));
948    }
949    let mut by_block: HashMap<usize, Vec<(usize, &str, &str)>> = HashMap::new();
950    let mut block_order: Vec<usize> = Vec::new();
951    for line in &loose {
952        if !by_block.contains_key(&line.block) {
953            block_order.push(line.block);
954        }
955        by_block
956            .entry(line.block)
957            .or_default()
958            .push((line.seq, &line.styled, &line.plain));
959    }
960    for bi in block_order {
961        let lines = &by_block[&bi];
962        let at = lines.iter().map(|l| l.0).min().unwrap_or(usize::MAX);
963        let pairs: Vec<(&str, &str)> = lines
964            .iter()
965            .map(|(_, styled, plain)| (*plain, *styled))
966            .collect();
967        let shown = fold_ligatures(&join_runs(&join_lines(&pairs))).into_owned();
968        blocks.push((at, RegionKind::Paragraph, shown, None, vec![], vec![]));
969    }
970    blocks.sort_by_key(|b| b.0);
971
972    let mut out: Vec<String> = Vec::new();
973    for (_, kind, text, grid, befores, afters) in blocks {
974        for para in befores {
975            let para = collapse_ws(&para);
976            if !para.is_empty() {
977                out.push(para);
978            }
979        }
980        let text = collapse_ws(&text);
981        if !text.is_empty() && kind != RegionKind::Furniture {
982            out.push(match kind {
983                RegionKind::Heading => format!("## {text}"),
984                RegionKind::ListItem => format!("- {text}"),
985                RegionKind::Table => {
986                    grid.unwrap_or_else(|| format!("<table><tr><td>{text}</td></tr></table>"))
987                }
988                RegionKind::Paragraph | RegionKind::Furniture => text,
989            });
990        }
991        for para in afters {
992            let para = collapse_ws(&para);
993            if !para.is_empty() {
994                out.push(para);
995            }
996        }
997    }
998    out.join("\n\n")
999}
1000
1001/// Python's `str.isspace`: Unicode whitespace plus U+001C-U+001F, which Rust's
1002/// [`char::is_whitespace`] (White_Space) does not include.
1003fn is_py_space(ch: char) -> bool {
1004    ch.is_whitespace() || matches!(ch, '\u{1c}'..='\u{1f}')
1005}
1006
1007/// Python's `" ".join(text.split())`: collapse every whitespace run to one
1008/// space, dropping the ends.
1009fn collapse_ws(text: &str) -> String {
1010    text.split(is_py_space)
1011        .filter(|w| !w.is_empty())
1012        .collect::<Vec<_>>()
1013        .join(" ")
1014}
1015
1016/// Words whose own characters are Markdown or HTML syntax stay unmarked, as in
1017/// the engine's writer.
1018const SYNTAX_CHARS: [char; 8] = ['*', '_', '<', '>', '`', '[', ']', '\\'];
1019
1020/// Tilde joins the syntax set above (kept separate for readability).
1021const SYNTAX_TILDE: char = '~';
1022
1023/// Whether any character of `text` is Markdown or HTML syntax.
1024fn has_syntax(text: &str) -> bool {
1025    text.chars()
1026        .any(|ch| ch == SYNTAX_TILDE || SYNTAX_CHARS.contains(&ch))
1027}
1028
1029/// One style run with the engine's markers (`**`, `*`, `***`); plain where it
1030/// must stay readable.
1031fn marked(run: &str, bold: bool, italic: bool) -> String {
1032    if run.trim_matches(is_py_space).is_empty() || !(bold || italic) || has_syntax(run) {
1033        return run.to_string();
1034    }
1035    let mark = match (bold, italic) {
1036        (true, true) => "***",
1037        (true, false) => "**",
1038        (false, true) => "*",
1039        (false, false) => "",
1040    };
1041    format!("{mark}{run}{mark}")
1042}
1043
1044/// One line's words with consecutive bold/italic runs marked, as the engine's
1045/// writer marks them.
1046fn styled_line(piece: &[&PageWord]) -> String {
1047    let mut parts = Vec::new();
1048    let mut run: Vec<&str> = Vec::new();
1049    let mut setting = (false, false);
1050    for word in piece.iter().map(|w| Some(*w)).chain(std::iter::once(None)) {
1051        let key = word.map(|w| (w.bold, w.italic));
1052        if key != Some(setting) {
1053            parts.push(marked(&run.join(" "), setting.0, setting.1));
1054            run.clear();
1055            setting = key.unwrap_or((false, false));
1056        }
1057        if let Some(w) = word {
1058            run.push(w.text.as_str());
1059        }
1060    }
1061    parts
1062        .into_iter()
1063        .filter(|p| !p.is_empty())
1064        .collect::<Vec<_>>()
1065        .join(" ")
1066}
1067
1068/// One engine word's per-character sup/sub/underline, for R8.
1069type CharTags = (bool, bool, bool);
1070
1071/// Case-insensitive ASCII substring search from `from`; the byte index.
1072fn ci_find(text: &str, from: usize, needle: &str) -> Option<usize> {
1073    let bytes = text.as_bytes();
1074    let pat = needle.as_bytes();
1075    if pat.is_empty() || from + pat.len() > bytes.len() {
1076        return None;
1077    }
1078    (from..=bytes.len() - pat.len()).find(|&i| {
1079        bytes[i..i + pat.len()]
1080            .iter()
1081            .zip(pat.iter())
1082            .all(|(a, b)| a.to_ascii_lowercase() == *b)
1083    })
1084}
1085
1086/// `re.sub(r"<table.*?</table>", "\n", text, flags=re.I | re.S)`: each table
1087/// element (non-greedy to its first closer) becomes one newline.
1088fn strip_md_tables(text: &str) -> String {
1089    let mut out = String::new();
1090    let mut i = 0usize;
1091    while i < text.len() {
1092        let Some(p) = ci_find(text, i, "<table") else {
1093            out.push_str(&text[i..]);
1094            break;
1095        };
1096        let Some(q) = ci_find(text, p, "</table>") else {
1097            // No closer at or after this opener: none can match later either.
1098            out.push_str(&text[i..]);
1099            break;
1100        };
1101        out.push_str(&text[i..p]);
1102        out.push('\n');
1103        i = q + "</table>".len();
1104    }
1105    out
1106}
1107
1108/// One `<sup>`/`<sub>`/`<u>` tag at char `pos` (`st` as chars): the tag name
1109/// and the char index past it. Case-insensitive, no attributes, as the
1110/// adapter's `_STYLE_TAG`.
1111fn style_tag_at(chars: &[char], pos: usize) -> Option<(&'static str, bool, usize)> {
1112    if chars.get(pos) != Some(&'<') {
1113        return None;
1114    }
1115    let mut at = pos + 1;
1116    let closing = chars.get(at) == Some(&'/');
1117    if closing {
1118        at += 1;
1119    }
1120    for name in ["sup", "sub", "u"] {
1121        let end = at + name.len();
1122        if end < chars.len()
1123            && chars[at..end]
1124                .iter()
1125                .zip(name.chars())
1126                .all(|(a, b)| a.to_ascii_lowercase() == b)
1127            && chars[end] == '>'
1128        {
1129            return Some((name, closing, end + 1));
1130        }
1131    }
1132    None
1133}
1134
1135/// A `<br>` break at char `pos`: past the `>` when `</?br\s*/?>` matches
1136/// (case-insensitive), as the adapter reads it.
1137fn br_end_at(chars: &[char], pos: usize) -> Option<usize> {
1138    if chars.get(pos) != Some(&'<') {
1139        return None;
1140    }
1141    let mut at = pos + 1;
1142    if chars.get(at) == Some(&'/') {
1143        at += 1;
1144    }
1145    if at + 1 >= chars.len()
1146        || !chars[at].eq_ignore_ascii_case(&'b')
1147        || !chars[at + 1].eq_ignore_ascii_case(&'r')
1148    {
1149        return None;
1150    }
1151    at += 2;
1152    while chars.get(at).is_some_and(|ch| is_py_space(*ch)) {
1153        at += 1;
1154    }
1155    if chars.get(at) == Some(&'/') {
1156        at += 1;
1157    }
1158    if chars.get(at) == Some(&'>') {
1159        Some(at + 1)
1160    } else {
1161        None
1162    }
1163}
1164
1165/// The engine markdown's words outside tables, each with a per-character
1166/// style, read from its own markers (`***`/`**`/`*`, `<sup>`, `<sub>`, `<u>`).
1167///
1168/// A heading line's bold/italic read as unsettled: the `## ` carries the
1169/// weight there. Only the tags are kept per character -- bold and italic stay
1170/// the words' own flags downstream, as in the adapter.
1171fn engine_style_tokens(engine_markdown: &str) -> Vec<(String, Vec<CharTags>)> {
1172    let text = strip_md_tables(engine_markdown);
1173    let mut toks = Vec::new();
1174    for line in text.split('\n') {
1175        let stripped = line.trim_matches(is_py_space);
1176        if stripped.starts_with('|') {
1177            continue;
1178        }
1179        // A heading line's bold/italic would read as unsettled here; only the
1180        // tags below are kept, so the flag itself is unused (see docs).
1181        let _heading = stripped.starts_with('#');
1182        let mut st = stripped;
1183        // `^(#+ |- |\d+\. )`, once: the engine's own markers are ASCII.
1184        if let Some(rest) = st.strip_prefix("- ") {
1185            st = rest;
1186        } else if st.starts_with('#') {
1187            let hashes = st.chars().take_while(|ch| *ch == '#').count();
1188            let after: String = st.chars().skip(hashes).collect();
1189            if let Some(rest) = after.strip_prefix(' ') {
1190                st = &st[hashes + 1..];
1191                let _ = rest;
1192            }
1193        } else {
1194            let digits = st.chars().take_while(|ch| ch.is_ascii_digit()).count();
1195            let after: String = st.chars().skip(digits).collect();
1196            if digits > 0 && after.starts_with(". ") {
1197                st = &st[digits + 2..];
1198            }
1199        }
1200        let chars: Vec<char> = st.chars().collect();
1201        let mut tags = [false, false, false];
1202        let mut cur = String::new();
1203        let mut sty: Vec<CharTags> = Vec::new();
1204        let mut i = 0usize;
1205        let mut flush = |cur: &mut String, sty: &mut Vec<CharTags>| {
1206            if !cur.is_empty() {
1207                toks.push((std::mem::take(cur), std::mem::take(sty)));
1208            }
1209        };
1210        while i < chars.len() {
1211            if let Some((name, closing, end)) = style_tag_at(&chars, i) {
1212                let slot = match name {
1213                    "sup" => 0,
1214                    "sub" => 1,
1215                    _ => 2,
1216                };
1217                tags[slot] = !closing;
1218                i = end;
1219                continue;
1220            }
1221            if chars[i] == '<' {
1222                if let Some(end) = br_end_at(&chars, i) {
1223                    flush(&mut cur, &mut sty);
1224                    i = end;
1225                    continue;
1226                }
1227            }
1228            if chars[i] == '*' {
1229                let mut j = i;
1230                while j < chars.len() && chars[j] == '*' {
1231                    j += 1;
1232                }
1233                // Bold/italic toggles parsed as the adapter parses them; only
1234                // the tags below are kept (see the function docs).
1235                i = j;
1236                continue;
1237            }
1238            if is_py_space(chars[i]) {
1239                flush(&mut cur, &mut sty);
1240                i += 1;
1241                continue;
1242            }
1243            cur.push(chars[i]);
1244            sty.push((tags[0], tags[1], tags[2]));
1245            i += 1;
1246        }
1247        flush(&mut cur, &mut sty);
1248    }
1249    toks
1250}
1251
1252/// Word index -> per-character tags, for every word aligned to an engine word,
1253/// plus the glued words (no space before them).
1254struct EngineStyles {
1255    /// Per-character tags by word index.
1256    tags: HashMap<usize, Vec<CharTags>>,
1257    /// Word indices glued to the previous word (no space).
1258    glue: HashSet<usize>,
1259}
1260
1261/// `id(word)` -> per-character style, for every feature-dump word aligned to
1262/// an engine word: aligned by difflib on the word texts (autojunk off), same
1263/// text 1:1; where consecutive words concatenate to ONE engine token they
1264/// take that token's characters in order, and the later word is glued to the
1265/// earlier (no space) unless it is wholly raised or lowered.
1266fn engine_styles_for(words: &[PageWord], engine_markdown: &str) -> EngineStyles {
1267    let mut out = EngineStyles {
1268        tags: HashMap::new(),
1269        glue: HashSet::new(),
1270    };
1271    let eng = engine_style_tokens(engine_markdown);
1272    if eng.is_empty() || words.is_empty() {
1273        return out;
1274    }
1275    let a: Vec<&str> = words.iter().map(|w| w.text.as_str()).collect();
1276    let b: Vec<&str> = eng.iter().map(|(t, _)| t.as_str()).collect();
1277    for opcode in SequenceMatcher::new(&a, &b).opcodes() {
1278        match opcode.tag {
1279            OpTag::Equal => {
1280                for k in 0..opcode.a_end - opcode.a_start {
1281                    if eng[opcode.b_start + k].1.len() == a[opcode.a_start + k].chars().count() {
1282                        out.tags
1283                            .insert(opcode.a_start + k, eng[opcode.b_start + k].1.clone());
1284                    }
1285                }
1286            }
1287            OpTag::Replace => {
1288                let (mut i, mut j) = (opcode.a_start, opcode.b_start);
1289                while i < opcode.a_end && j < opcode.b_end {
1290                    let (tok, sty) = &eng[j];
1291                    let tok_len = tok.chars().count();
1292                    let (mut k, mut acc) = (i, String::new());
1293                    while k < opcode.a_end
1294                        && acc.chars().count() < tok_len
1295                        && tok.starts_with(&format!("{acc}{}", a[k]))
1296                    {
1297                        acc.push_str(a[k]);
1298                        k += 1;
1299                    }
1300                    if acc == *tok && k > i {
1301                        let mut at = 0usize;
1302                        for (off, word) in a[i..k].iter().enumerate() {
1303                            let m = i + off;
1304                            let len = word.chars().count();
1305                            out.tags.insert(m, sty[at..at + len].to_vec());
1306                            let raised = sty[at..at + len].iter().all(|t| t.0 || t.1);
1307                            at += len;
1308                            if m > i && !raised {
1309                                out.glue.insert(m);
1310                            }
1311                        }
1312                        i = k;
1313                        j += 1;
1314                    } else if a[i].chars().count() < tok_len && tok.starts_with(a[i]) {
1315                        i += 1;
1316                    } else {
1317                        j += 1;
1318                    }
1319                }
1320            }
1321            OpTag::Delete | OpTag::Insert => {}
1322        }
1323    }
1324    out
1325}
1326
1327/// One word with its sup/sub/u character runs wrapped in tags.
1328fn tagged(text: &str, styles: &[CharTags]) -> String {
1329    let chars: Vec<char> = text.chars().collect();
1330    let mut parts = Vec::new();
1331    let mut run = String::new();
1332    let mut key: Option<CharTags> = None;
1333    let flush = |run: &mut String, key: Option<CharTags>, parts: &mut Vec<String>| {
1334        if run.is_empty() {
1335            return;
1336        }
1337        let mut body = std::mem::take(run);
1338        if let Some((sup, sub, u)) = key {
1339            if sup {
1340                body = format!("<sup>{body}</sup>");
1341            } else if sub {
1342                body = format!("<sub>{body}</sub>");
1343            }
1344            if u {
1345                body = format!("<u>{body}</u>");
1346            }
1347        }
1348        parts.push(body);
1349    };
1350    // `zip(text, styles)`: the shorter side truncates, as in the adapter.
1351    for (index, ch) in chars.iter().enumerate().take(styles.len()) {
1352        let next = Some(styles[index]);
1353        if next != key && !run.is_empty() {
1354            flush(&mut run, key, &mut parts);
1355        }
1356        key = next;
1357        run.push(*ch);
1358    }
1359    flush(&mut run, key, &mut parts);
1360    parts.join("")
1361}
1362
1363/// `styled_line` where a word the engine wrote takes the engine's sup/sub/u
1364/// per character. Bold and italic stay the words' own; a glued word joins the
1365/// run's last word with no space, across a style boundary where it must.
1366fn styled_line_engine(piece: &[usize], words: &[&PageWord], styles: &EngineStyles) -> String {
1367    fn close(run: &[String], plain_run: &[String], setting: (bool, bool), parts: &mut Vec<String>) {
1368        let plain = plain_run.join(" ");
1369        let tagged_text = run.join(" ");
1370        if plain.trim_matches(is_py_space).is_empty() {
1371            return;
1372        }
1373        if !(setting.0 || setting.1) || has_syntax(&plain) {
1374            parts.push(tagged_text);
1375        } else {
1376            let mark = match setting {
1377                (true, true) => "***",
1378                (true, false) => "**",
1379                (false, true) => "*",
1380                (false, false) => "",
1381            };
1382            parts.push(format!("{mark}{tagged_text}{mark}"));
1383        }
1384    }
1385
1386    let mut parts: Vec<String> = Vec::new();
1387    let mut run: Vec<String> = Vec::new();
1388    let mut plain_run: Vec<String> = Vec::new();
1389    let mut setting = (false, false);
1390    let len = piece.len();
1391    for n in 0..=len {
1392        let key = if n < len {
1393            Some((words[n].bold, words[n].italic))
1394        } else {
1395            None
1396        };
1397        if key != Some(setting) {
1398            close(&run, &plain_run, setting, &mut parts);
1399            run.clear();
1400            plain_run.clear();
1401            setting = key.unwrap_or((false, false));
1402        }
1403        if n < len {
1404            let index = piece[n];
1405            let word = words[n];
1406            let tagged_text = match styles.tags.get(&index) {
1407                Some(st) if !has_syntax(&word.text) => tagged(&word.text, st),
1408                _ => word.text.clone(),
1409            };
1410            // `plain_run` mirrors `run`: both grow in every branch below.
1411            if styles.glue.contains(&index) && !run.is_empty() {
1412                if let (Some(last), Some(plain)) = (run.last_mut(), plain_run.last_mut()) {
1413                    last.push_str(&tagged_text);
1414                    plain.push_str(&word.text);
1415                }
1416            } else if styles.glue.contains(&index) && !parts.is_empty() {
1417                run.push(format!("\x00{tagged_text}"));
1418                plain_run.push(word.text.clone());
1419            } else {
1420                run.push(tagged_text);
1421                plain_run.push(word.text.clone());
1422            }
1423        }
1424    }
1425    // `re.sub(r" ?\x00", "", ...)`: the glue marker and one space before it.
1426    join_non_empty(&parts)
1427        .replace(" \x00", "")
1428        .replace('\x00', "")
1429}
1430
1431/// `" ".join(p for p in parts if p)`.
1432fn join_non_empty(parts: &[String]) -> String {
1433    parts
1434        .iter()
1435        .filter(|p| !p.is_empty())
1436        .cloned()
1437        .collect::<Vec<_>>()
1438        .join(" ")
1439}
1440
1441/// One opcode tag of [`SequenceMatcher::opcodes`].
1442#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1443enum OpTag {
1444    /// `a[i1:i2]` should be replaced by `b[j1:j2]`.
1445    Replace,
1446    /// `a[i1:i2]` should be deleted.
1447    Delete,
1448    /// `b[j1:j2]` should be inserted at `a[i1:i1]`.
1449    Insert,
1450    /// `a[i1:i2] == b[j1:j2]`.
1451    Equal,
1452}
1453
1454/// One opcode: how to turn `a[i1:i2]` into `b[j1:j2]`.
1455struct Opcode {
1456    /// What to do.
1457    tag: OpTag,
1458    /// Start in `a`.
1459    a_start: usize,
1460    /// End in `a`.
1461    a_end: usize,
1462    /// Start in `b`.
1463    b_start: usize,
1464    /// End in `b`.
1465    b_end: usize,
1466}
1467
1468/// `difflib.SequenceMatcher(None, a, b, autojunk=False)` over word texts.
1469///
1470/// Ports CPython's matcher exactly (junk never, no popular-element filter):
1471/// `b2j` maps each element to its ascending index list, `find_longest_match`
1472/// keeps the earliest maximal block (`k > bestsize`, strict), matching blocks
1473/// collapse adjacency, and opcodes read off them. The R8 alignment is its
1474/// only caller.
1475struct SequenceMatcher<'a> {
1476    /// The first sequence (feature-dump word texts).
1477    a: &'a [&'a str],
1478    /// The second sequence (engine word texts).
1479    b: &'a [&'a str],
1480    /// Each element of `b` to its ascending index list.
1481    b2j: HashMap<&'a str, Vec<usize>>,
1482}
1483
1484impl<'a> SequenceMatcher<'a> {
1485    /// Chain `b`: every element to every index it stands at, ascending.
1486    fn new(a: &'a [&'a str], b: &'a [&'a str]) -> Self {
1487        let mut b2j: HashMap<&str, Vec<usize>> = HashMap::new();
1488        for (i, elt) in b.iter().enumerate() {
1489            b2j.entry(elt).or_default().push(i);
1490        }
1491        Self { a, b, b2j }
1492    }
1493
1494    /// The longest matching block in `a[alo:ahi]` and `b[blo:bhi]`, as
1495    /// `(i, j, size)`: of all maximal blocks the one starting earliest in
1496    /// `a`, and of those the one starting earliest in `b`.
1497    fn find_longest_match(
1498        &self,
1499        alo: usize,
1500        ahi: usize,
1501        blo: usize,
1502        bhi: usize,
1503    ) -> (usize, usize, usize) {
1504        let (mut besti, mut bestj, mut bestsize) = (alo, blo, 0);
1505        let mut j2len: HashMap<usize, usize> = HashMap::new();
1506        for i in alo..ahi {
1507            let mut newj2len: HashMap<usize, usize> = HashMap::new();
1508            if let Some(js) = self.b2j.get(self.a[i]) {
1509                for &j in js {
1510                    if j < blo {
1511                        continue;
1512                    }
1513                    if j >= bhi {
1514                        break;
1515                    }
1516                    let k = j2len.get(&(j.wrapping_sub(1))).copied().unwrap_or(0) + 1;
1517                    newj2len.insert(j, k);
1518                    if k > bestsize {
1519                        besti = i + 1 - k;
1520                        bestj = j + 1 - k;
1521                        bestsize = k;
1522                    }
1523                }
1524            }
1525            j2len = newj2len;
1526        }
1527        // Nothing is junk, so the extension below sucks up every matching
1528        // element on both sides (the junk-only loops of the original match
1529        // nothing and are omitted).
1530        while besti > alo && bestj > blo && self.a[besti - 1] == self.b[bestj - 1] {
1531            besti -= 1;
1532            bestj -= 1;
1533            bestsize += 1;
1534        }
1535        while besti + bestsize < ahi
1536            && bestj + bestsize < bhi
1537            && self.a[besti + bestsize] == self.b[bestj + bestsize]
1538        {
1539            bestsize += 1;
1540        }
1541        (besti, bestj, bestsize)
1542    }
1543
1544    /// The matching blocks, monotonically increasing, adjacent blocks
1545    /// collapsed, with the `(len(a), len(b), 0)` dummy at the end.
1546    fn matching_blocks(&self) -> Vec<(usize, usize, usize)> {
1547        let (la, lb) = (self.a.len(), self.b.len());
1548        let mut queue = vec![(0, la, 0, lb)];
1549        let mut matching = Vec::new();
1550        while let Some((alo, ahi, blo, bhi)) = queue.pop() {
1551            let (i, j, k) = self.find_longest_match(alo, ahi, blo, bhi);
1552            if k > 0 {
1553                matching.push((i, j, k));
1554                if alo < i && blo < j {
1555                    queue.push((alo, i, blo, j));
1556                }
1557                if i + k < ahi && j + k < bhi {
1558                    queue.push((i + k, ahi, j + k, bhi));
1559                }
1560            }
1561        }
1562        matching.sort();
1563        let mut non_adjacent = Vec::new();
1564        let (mut i1, mut j1, mut k1) = (0, 0, 0);
1565        for (i2, j2, k2) in matching {
1566            if i1 + k1 == i2 && j1 + k1 == j2 {
1567                k1 += k2;
1568            } else {
1569                if k1 > 0 {
1570                    non_adjacent.push((i1, j1, k1));
1571                }
1572                (i1, j1, k1) = (i2, j2, k2);
1573            }
1574        }
1575        if k1 > 0 {
1576            non_adjacent.push((i1, j1, k1));
1577        }
1578        non_adjacent.push((la, lb, 0));
1579        non_adjacent
1580    }
1581
1582    /// How to turn `a` into `b`, one opcode per step.
1583    fn opcodes(&self) -> Vec<Opcode> {
1584        let (mut i, mut j) = (0, 0);
1585        let mut out = Vec::new();
1586        for (ai, bj, size) in self.matching_blocks() {
1587            let tag = if i < ai && j < bj {
1588                Some(OpTag::Replace)
1589            } else if i < ai {
1590                Some(OpTag::Delete)
1591            } else if j < bj {
1592                Some(OpTag::Insert)
1593            } else {
1594                None
1595            };
1596            if let Some(tag) = tag {
1597                out.push(Opcode {
1598                    tag,
1599                    a_start: i,
1600                    a_end: ai,
1601                    b_start: j,
1602                    b_end: bj,
1603                });
1604            }
1605            (i, j) = (ai + size, bj + size);
1606            if size > 0 {
1607                out.push(Opcode {
1608                    tag: OpTag::Equal,
1609                    a_start: ai,
1610                    a_end: i,
1611                    b_start: bj,
1612                    b_end: j,
1613                });
1614            }
1615        }
1616        out
1617    }
1618}
1619
1620/// One style run across a line break (R9): where two lines of one block carry
1621/// the same marker, `**a** **b**` becomes `**a b**`, as the engine writes a
1622/// wrapped bold sentence; likewise `</u> <u>` becomes one space.
1623///
1624/// Ports `re.sub(r"(?<=[^\s*])(\*{1,3}) \1(?=[^\s*])", " ", text)`: the seam
1625/// needs a non-space non-star on both sides, and the greedy star count tries
1626/// three first.
1627fn join_runs(text: &str) -> String {
1628    let chars: Vec<char> = text.chars().collect();
1629    let mut out = String::new();
1630    let mut i = 0usize;
1631    while i < chars.len() {
1632        let mut end = None;
1633        if chars[i] == '*' && i > 0 && !is_py_space(chars[i - 1]) && chars[i - 1] != '*' {
1634            let mut run = 0usize;
1635            while i + run < chars.len() && chars[i + run] == '*' {
1636                run += 1;
1637            }
1638            for n in (1..=run.min(3)).rev() {
1639                let mid = i + n;
1640                let second = mid + 1;
1641                if chars.get(mid) == Some(&' ')
1642                    && second + n <= chars.len()
1643                    && chars[second..second + n].iter().all(|ch| *ch == '*')
1644                    && chars
1645                        .get(second + n)
1646                        .is_some_and(|ch| !is_py_space(*ch) && *ch != '*')
1647                {
1648                    end = Some(second + n);
1649                    break;
1650                }
1651            }
1652        }
1653        match end {
1654            Some(stop) => {
1655                out.push(' ');
1656                i = stop;
1657            }
1658            None => {
1659                out.push(chars[i]);
1660                i += 1;
1661            }
1662        }
1663    }
1664    out.replace("</u> <u>", " ")
1665}
1666
1667/// Ligature glyphs (U+FB00-FB06, Armenian U+FB13-FB17) as their letters: the
1668/// engine's fold, whose table is NFKC's on exactly these code points (R10).
1669fn fold_ligatures(text: &str) -> std::borrow::Cow<'_, str> {
1670    crate::ligatures::fold(text)
1671}
1672
1673/// Whether `prev` ends in a hyphen wrap into `nxt` (R10): the engine's
1674/// `is_hyphen_wrap_candidate` -- at least three letters, `-`, the next line
1675/// opening in three ASCII lowercase -- over Python's left-strip.
1676fn hyphen_wrap(prev: &str, nxt: &str) -> bool {
1677    let trimmed: String = nxt.chars().skip_while(|ch| is_py_space(*ch)).collect();
1678    crate::text::is_hyphen_wrap_candidate(prev, &trimmed)
1679}
1680
1681/// A block's lines (plain, shown) joined by a space -- or, across an
1682/// end-of-line hyphen wrap whose next line opens in lowercase, without the
1683/// hyphen (R10).
1684fn join_lines(lines: &[(&str, &str)]) -> String {
1685    let mut out = String::new();
1686    let mut prev_plain: Option<&str> = None;
1687    for (plain, shown) in lines {
1688        let wrap = prev_plain.is_some_and(|prev| hyphen_wrap(prev, plain))
1689            && out.ends_with('-')
1690            && shown.chars().next().is_some_and(|ch| ch.is_lowercase());
1691        if prev_plain.is_none() {
1692            out.push_str(shown);
1693        } else if wrap {
1694            out.pop();
1695            out.push_str(shown);
1696        } else {
1697            out.push(' ');
1698            out.push_str(shown);
1699        }
1700        prev_plain = Some(plain);
1701    }
1702    out
1703}
1704
1705/// A list item's own bullet glyph (`lists.rs BULLET_CHARS`, which the `- `
1706/// replaces) is dropped where it stands before a space (R10).
1707fn strip_bullet(text: &str) -> String {
1708    let trimmed: String = text.chars().skip_while(|ch| is_py_space(*ch)).collect();
1709    let mut chars = trimmed.chars();
1710    match (chars.next(), chars.next()) {
1711        (Some(first), Some(' ')) if crate::lists::BULLET_CHARS.contains(&first) => trimmed
1712            [trimmed
1713                .char_indices()
1714                .nth(2)
1715                .map(|(i, _)| i)
1716                .unwrap_or(trimmed.len())..]
1717            .trim_start_matches(is_py_space)
1718            .to_string(),
1719        _ => text.to_string(),
1720    }
1721}
1722
1723/// Whether `ch` is a regex word character, as the bench's `[\w*]` reads it:
1724/// alphanumeric or underscore.
1725fn is_word_char(ch: char) -> bool {
1726    ch.is_alphanumeric() || ch == '_'
1727}
1728
1729/// `INLINE_TAG.sub("", line)`: inline HTML tags gone entirely.
1730fn strip_inline_tags(line: &str) -> String {
1731    // The regex's own alternation order.
1732    const TAGS: [&str; 9] = ["sub", "sup", "b", "i", "em", "strong", "s", "del", "u"];
1733    let chars: Vec<char> = line.chars().collect();
1734    let mut out = String::new();
1735    let mut i = 0usize;
1736    while i < chars.len() {
1737        let mut end = None;
1738        if chars[i] == '<' {
1739            let mut at = i + 1;
1740            if chars.get(at) == Some(&'/') {
1741                at += 1;
1742            }
1743            for name in TAGS {
1744                let stop = at + name.len();
1745                if stop < chars.len()
1746                    && chars[at..stop]
1747                        .iter()
1748                        .zip(name.chars())
1749                        .all(|(a, b)| a.to_ascii_lowercase() == b)
1750                    && chars[stop] == '>'
1751                {
1752                    end = Some(stop + 1);
1753                    break;
1754                }
1755            }
1756        }
1757        match end {
1758            Some(stop) => i = stop,
1759            None => {
1760                out.push(chars[i]);
1761                i += 1;
1762            }
1763        }
1764    }
1765    out
1766}
1767
1768/// One branch of an `unmarked` delimiter pass at `i`: the content span and
1769/// the match end, for `delim` runs around `\S(?:.*?\S)?` content. The lazy
1770/// tail tries present-first, shortest first; then the bare `\S`. The tail's
1771/// `.` matches every line char, so the span always qualifies -- only its end
1772/// must be a non-space with the closer.
1773fn delimited_at(chars: &[char], i: usize, delim: &[char]) -> Option<(usize, usize, usize)> {
1774    if !chars[i..].starts_with(delim) {
1775        return None;
1776    }
1777    let p = i + delim.len();
1778    if chars.get(p).is_none_or(|ch| is_py_space(*ch)) {
1779        return None;
1780    }
1781    let mut at = p + 1;
1782    while at < chars.len() {
1783        if !is_py_space(chars[at]) && chars[at + 1..].starts_with(delim) {
1784            return Some((p, at + 1, at + 1 + delim.len()));
1785        }
1786        at += 1;
1787    }
1788    if chars[p + 1..].starts_with(delim) {
1789        return Some((p, p + 1, p + 1 + delim.len()));
1790    }
1791    None
1792}
1793
1794/// `INLINE_STRONG`: one scan, both branches at each position -- the `***`
1795/// branch first, then `**` -- as the regex alternation tries them. Two
1796/// passes would let `***` consume text the `**` branch matches from an
1797/// earlier position.
1798fn strip_strong(line: &str) -> String {
1799    let chars: Vec<char> = line.chars().collect();
1800    let triple: Vec<char> = "***".chars().collect();
1801    let double: Vec<char> = "**".chars().collect();
1802    let mut out = String::new();
1803    let mut i = 0usize;
1804    while i < chars.len() {
1805        let matched = delimited_at(&chars, i, &triple).or_else(|| delimited_at(&chars, i, &double));
1806        match matched {
1807            Some((from, to, stop)) => {
1808                out.extend(chars[from..to].iter());
1809                i = stop;
1810            }
1811            None => {
1812                out.push(chars[i]);
1813                i += 1;
1814            }
1815        }
1816    }
1817    out
1818}
1819
1820/// `INLINE_STRIKE.sub(...)`: `~~` runs around `\S(?:.*?\S)?` content become
1821/// the content. Leftmost, non-overlapping, as `re.sub` scans.
1822fn strip_strike(line: &str) -> String {
1823    let chars: Vec<char> = line.chars().collect();
1824    let wide: Vec<char> = "~~".chars().collect();
1825    let mut out = String::new();
1826    let mut i = 0usize;
1827    while i < chars.len() {
1828        match delimited_at(&chars, i, &wide) {
1829            Some((from, to, stop)) => {
1830                out.extend(chars[from..to].iter());
1831                i = stop;
1832            }
1833            None => {
1834                out.push(chars[i]);
1835                i += 1;
1836            }
1837        }
1838    }
1839    out
1840}
1841
1842/// `INLINE_EM.sub(r"\1", line)`: single-star runs around
1843/// `\S(?:[^*\n]*?\S)?` content become the content, with a non-word non-star
1844/// required on both sides. The tail's span holds no star (its end may be
1845/// one); tried present-first, shortest first, then the bare `\S`.
1846fn strip_em(line: &str) -> String {
1847    let chars: Vec<char> = line.chars().collect();
1848    let closed = |after: usize| -> bool {
1849        chars.get(after) == Some(&'*')
1850            && chars
1851                .get(after + 1)
1852                .is_none_or(|ch| !is_word_char(*ch) && *ch != '*')
1853    };
1854    let mut out = String::new();
1855    let mut i = 0usize;
1856    while i < chars.len() {
1857        let mut matched: Option<(usize, usize)> = None;
1858        if chars[i] == '*' && (i == 0 || (!is_word_char(chars[i - 1]) && chars[i - 1] != '*')) {
1859            let p = i + 1;
1860            if chars.get(p).is_some_and(|ch| !is_py_space(*ch)) {
1861                let mut at = p + 1;
1862                // A star in the span ends the growth, but still stands as a
1863                // tail end itself (the tail's `\S` allows it).
1864                while at < chars.len() && chars[p + 1..at].iter().all(|ch| *ch != '*') {
1865                    if !is_py_space(chars[at]) && closed(at + 1) {
1866                        matched = Some((p, at + 1));
1867                        break;
1868                    }
1869                    at += 1;
1870                }
1871                if matched.is_none() && closed(p + 1) {
1872                    matched = Some((p, p + 1));
1873                }
1874            }
1875        }
1876        match matched {
1877            Some((from, to)) => {
1878                out.extend(chars[from..to].iter());
1879                i = to + 1;
1880            }
1881            None => {
1882                out.push(chars[i]);
1883                i += 1;
1884            }
1885        }
1886    }
1887    out
1888}
1889
1890/// A line's text without inline emphasis, for the R1 match: inline tags gone,
1891/// `***`/`**`/`*` runs unwrapped, `~~` unwrapped -- in that order, as the
1892/// bench's `unmarked` reads them.
1893fn unmarked(line: &str) -> String {
1894    let line = strip_inline_tags(line);
1895    let line = strip_strong(&line);
1896    let line = strip_em(&line);
1897    strip_strike(&line)
1898}
1899
1900/// `COMMENT.sub(" ", text)`: `<!--...-->` (across lines) becomes one space.
1901fn strip_comments(text: &str) -> String {
1902    let mut out = String::new();
1903    let mut i = 0usize;
1904    while i < text.len() {
1905        let Some(p) = ci_find(text, i, "<!--") else {
1906            out.push_str(&text[i..]);
1907            break;
1908        };
1909        // The closer search is case-sensitive (`-->` has no letters).
1910        let Some(q) = text[p..].find("-->").map(|k| p + k) else {
1911            out.push_str(&text[i..]);
1912            break;
1913        };
1914        out.push_str(&text[i..p]);
1915        out.push(' ');
1916        i = q + "-->".len();
1917    }
1918    out
1919}
1920
1921/// `IMAGE.sub(" ", text)`: a Markdown image becomes one space.
1922fn strip_images(text: &str) -> String {
1923    let chars: Vec<char> = text.chars().collect();
1924    let mut out = String::new();
1925    let mut i = 0usize;
1926    while i < chars.len() {
1927        let mut end = None;
1928        if chars[i] == '!' && chars.get(i + 1) == Some(&'[') {
1929            let mut j = i + 2;
1930            while j < chars.len() && chars[j] != ']' && chars[j] != '\n' {
1931                j += 1;
1932            }
1933            if chars.get(j) == Some(&']') && chars.get(j + 1) == Some(&'(') {
1934                let mut k = j + 2;
1935                while k < chars.len() && chars[k] != ')' && !is_py_space(chars[k]) {
1936                    k += 1;
1937                }
1938                // The optional ` \s+"..."`, greedy, then the closer.
1939                let mut title_end = None;
1940                let mut s = k;
1941                while s < chars.len() && is_py_space(chars[s]) {
1942                    s += 1;
1943                }
1944                if s > k && chars.get(s) == Some(&'"') {
1945                    // Greedy `[^"]*"`: the last quote that still closes.
1946                    let mut quotes = Vec::new();
1947                    let mut q = s + 1;
1948                    while q < chars.len() {
1949                        if chars[q] == '"' {
1950                            quotes.push(q);
1951                        }
1952                        q += 1;
1953                    }
1954                    for q in quotes.iter().rev() {
1955                        if chars.get(q + 1) == Some(&')') {
1956                            title_end = Some(q + 2);
1957                            break;
1958                        }
1959                    }
1960                }
1961                if let Some(stop) = title_end {
1962                    end = Some(stop);
1963                } else if chars.get(k) == Some(&')') {
1964                    end = Some(k + 1);
1965                }
1966            }
1967        }
1968        match end {
1969            Some(stop) => {
1970                out.push(' ');
1971                i = stop;
1972            }
1973            None => {
1974                out.push(chars[i]);
1975                i += 1;
1976            }
1977        }
1978    }
1979    out
1980}
1981
1982/// `FENCE.sub("", text)`: a code fence's marker line becomes empty (its
1983/// content is kept). `^[ \t]*```[\w+-]*[ \t]*$` per line; the word class is
1984/// ASCII (info strings the engine writes are).
1985fn strip_fences(text: &str) -> String {
1986    text.split('\n')
1987        .map(|line| {
1988            let fenced = line
1989                .trim_matches([' ', '\t'])
1990                .strip_prefix("```")
1991                .is_some_and(|rest| {
1992                    rest.chars()
1993                        .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '+' || ch == '-')
1994                });
1995            if fenced {
1996                ""
1997            } else {
1998                line
1999            }
2000        })
2001        .collect::<Vec<_>>()
2002        .join("\n")
2003}
2004
2005/// Whether the (unmarked) line is a pipe-table row: `^\s*\|.*\|\s*$`.
2006fn is_table_row(line: &str) -> bool {
2007    let trimmed = line.trim_matches(is_py_space);
2008    let mut chars = trimmed.chars();
2009    match (chars.next(), trimmed.chars().count()) {
2010        (Some('|'), n) if n >= 2 => trimmed.ends_with('|'),
2011        _ => false,
2012    }
2013}
2014
2015/// Whether the (unmarked) line is a delimiter row: `^\s*\|[\s:|-]+\|\s*$`.
2016fn is_table_rule(line: &str) -> bool {
2017    let trimmed = line.trim_matches(is_py_space);
2018    let chars: Vec<char> = trimmed.chars().collect();
2019    chars.len() >= 3
2020        && chars[0] == '|'
2021        && chars[chars.len() - 1] == '|'
2022        && chars[1..chars.len() - 1]
2023            .iter()
2024            .all(|ch| is_py_space(*ch) || matches!(*ch, ':' | '|' | '-'))
2025}
2026
2027/// A markdown pipe table as the words it holds, in reading order.
2028fn pipe_to_text(rows: &[String]) -> String {
2029    let mut cells = Vec::new();
2030    for row in rows {
2031        for cell in row.trim_matches(is_py_space).trim_matches('|').split('|') {
2032            let cell = cell.trim_matches(is_py_space);
2033            if !cell.is_empty() {
2034                cells.push(cell);
2035            }
2036        }
2037    }
2038    cells.join(" ")
2039}
2040
2041/// A markdown pipe table as the HTML that TEDS reads: rows above the
2042/// delimiter as `<th>`, the rest as `<td>`, unescaped.
2043fn pipe_to_html(rows: &[String], header: usize) -> String {
2044    let mut out = String::from("<table>");
2045    for (index, row) in rows.iter().enumerate() {
2046        let cells: Vec<&str> = row
2047            .trim_matches(is_py_space)
2048            .trim_matches('|')
2049            .split('|')
2050            .map(|c| c.trim_matches(is_py_space))
2051            .collect();
2052        let tag = if index < header { "th" } else { "td" };
2053        out.push_str("<tr>");
2054        for cell in cells {
2055            out.push_str(&format!("<{tag}>{cell}</{tag}>"));
2056        }
2057        out.push_str("</tr>");
2058    }
2059    out.push_str("</table>");
2060    out
2061}
2062
2063/// Strip tags (`<[^>]+>`: at least one char) for an HTML table's text.
2064fn strip_tags_plus(text: &str) -> String {
2065    let chars: Vec<char> = text.chars().collect();
2066    let mut out = String::new();
2067    let mut i = 0usize;
2068    while i < chars.len() {
2069        if chars[i] == '<' {
2070            let mut j = i + 1;
2071            while j < chars.len() && chars[j] != '>' {
2072                j += 1;
2073            }
2074            if j < chars.len() && j > i + 1 {
2075                out.push(' ');
2076                i = j + 1;
2077                continue;
2078            }
2079        }
2080        out.push(chars[i]);
2081        i += 1;
2082    }
2083    out
2084}
2085
2086/// The engine markdown's tables, as (text, html): ports the table half of the
2087/// bench's `blocks_of` (headings, items and paragraphs are never consumed
2088/// downstream). Comments and images become a space, fence markers vanish,
2089/// every line is unmarked, and a table is a run of pipe rows or an HTML
2090/// table.
2091fn engine_tables_of_markdown(markdown: &str) -> Vec<(String, String)> {
2092    let prepared = strip_fences(&strip_images(&strip_comments(markdown)));
2093    let mut tables = Vec::new();
2094    let mut rows: Vec<String> = Vec::new();
2095    let mut header_rows: Vec<usize> = Vec::new();
2096    let mut inside_html: Vec<String> = Vec::new();
2097    let flush_table = |rows: &mut Vec<String>,
2098                       header_rows: &mut Vec<usize>,
2099                       tables: &mut Vec<(String, String)>| {
2100        if !rows.is_empty() {
2101            let header = header_rows.first().copied().unwrap_or(0);
2102            tables.push((pipe_to_text(rows), pipe_to_html(rows, header)));
2103            rows.clear();
2104            header_rows.clear();
2105        }
2106    };
2107    for line in prepared.split('\n') {
2108        let line = unmarked(line);
2109        if !inside_html.is_empty() || line.to_lowercase().contains("<table") {
2110            inside_html.push(line.clone());
2111            if line.to_lowercase().contains("</table>") {
2112                let html = inside_html.join("\n");
2113                tables.push((collapse_ws(&strip_tags_plus(&html)), html));
2114                inside_html.clear();
2115            }
2116            continue;
2117        }
2118        if is_table_row(&line) {
2119            if is_table_rule(&line) {
2120                if header_rows.is_empty() {
2121                    header_rows.push(rows.len());
2122                }
2123            } else {
2124                rows.push(line);
2125            }
2126            continue;
2127        }
2128        flush_table(&mut rows, &mut header_rows, &mut tables);
2129    }
2130    flush_table(&mut rows, &mut header_rows, &mut tables);
2131    tables
2132}
2133
2134/// Levenshtein, exactly: two rows over characters.
2135fn edit_distance(left: &str, right: &str) -> usize {
2136    if left == right {
2137        return 0;
2138    }
2139    let left: Vec<char> = left.chars().collect();
2140    let right: Vec<char> = right.chars().collect();
2141    if left.is_empty() {
2142        return right.len();
2143    }
2144    if right.is_empty() {
2145        return left.len();
2146    }
2147    let (long, short) = if left.len() >= right.len() {
2148        (left, right)
2149    } else {
2150        (right, left)
2151    };
2152    let mut prev: Vec<usize> = (0..=short.len()).collect();
2153    let mut cur = vec![0usize; short.len() + 1];
2154    for (i, lc) in long.iter().enumerate() {
2155        cur[0] = i + 1;
2156        for (j, rc) in short.iter().enumerate() {
2157            cur[j + 1] = (prev[j + 1] + 1)
2158                .min(cur[j] + 1)
2159                .min(prev[j] + usize::from(lc != rc));
2160        }
2161        std::mem::swap(&mut prev, &mut cur);
2162    }
2163    prev[short.len()]
2164}
2165
2166/// The edit distance read as a score: 1 minus the distance over the longer
2167/// text in characters.
2168fn text_similarity(left: &str, right: &str) -> f64 {
2169    if left.is_empty() && right.is_empty() {
2170        return 1.0;
2171    }
2172    let max_len = left.chars().count().max(right.chars().count());
2173    1.0 - edit_distance(left, right) as f64 / max_len as f64
2174}
2175
2176/// `table_grid(text, bbox)` over the engine's tables on the page: position
2177/// filters, text ranks. Each table is given out once; the tables no region
2178/// took come back from [`GridMatcher::orphans`] for R7.
2179struct GridMatcher {
2180    /// The engine tables' (text, html), in markdown order.
2181    tables: Vec<(String, String)>,
2182    /// The engine tables' cell boxes, aligned with `tables` (empty where the
2183    /// runs do not line up with the markdown's tables, switching geometry
2184    /// off).
2185    geos: Vec<Vec<[f64; 4]>>,
2186    /// The engine tables' cells, aligned with `tables`: the trace's numbers
2187    /// for matched and orphan grids, and rule A's input (#636).
2188    snaps: Vec<Vec<Block>>,
2189    /// Tables already given out.
2190    used: HashSet<usize>,
2191    /// The table the last `table_grid` call gave out, for the trace.
2192    last_pick: Option<usize>,
2193}
2194
2195impl GridMatcher {
2196    /// The matcher over one page's engine markdown and blocks.
2197    fn new(engine_markdown: &str, blocks: &[Block]) -> Self {
2198        let tables = engine_tables_of_markdown(engine_markdown);
2199        let mut geos = engine_tables_of(blocks);
2200        let mut snaps = engine_table_cells(blocks);
2201        if geos.len() != tables.len() {
2202            geos = Vec::new();
2203            snaps = Vec::new();
2204        } else if !snaps.is_empty() && snaps.len() != geos.len() {
2205            // The snapshot runs drifted from the geometry runs: no trace
2206            // numbers rather than the wrong table's.
2207            snaps = Vec::new();
2208        }
2209        Self {
2210            tables,
2211            geos,
2212            snaps,
2213            used: HashSet::new(),
2214            last_pick: None,
2215        }
2216    }
2217
2218    /// The cells of the table the last `table_grid` call gave out, if the
2219    /// trace recorded them.
2220    fn picked_cells(&self) -> Option<&[Block]> {
2221        self.last_pick
2222            .and_then(|i| self.snaps.get(i))
2223            .map(Vec::as_slice)
2224    }
2225
2226    /// The cells of one engine table, for an orphan line.
2227    fn orphan_cells(&self, index: usize) -> &[Block] {
2228        self.snaps.get(index).map(Vec::as_slice).unwrap_or(&[])
2229    }
2230
2231    /// The HTML of the engine table most like `text` among those whose cells
2232    /// lie inside `bbox` -- and only where no table overlaps that way, the
2233    /// HTML of the table whose text is most like `text` (at least
2234    /// [`GRID_FLOOR`]). None when nothing matches.
2235    fn table_grid(&mut self, text: &str, bbox: &[f64; 4]) -> Option<String> {
2236        self.last_pick = None;
2237        if !self.geos.is_empty() {
2238            let mut eligible: Vec<(f64, f64, usize, usize)> = Vec::new();
2239            for (i, cells) in self.geos.iter().enumerate() {
2240                if self.used.contains(&i) || cells.is_empty() {
2241                    continue;
2242                }
2243                let inside = cells
2244                    .iter()
2245                    .filter(|c| area(c) > 0.0 && overlap(c, bbox) / area(c) >= POSITION_CELLS)
2246                    .count();
2247                let score = inside as f64 / cells.len() as f64;
2248                if score < POSITION_TABLE {
2249                    continue;
2250                }
2251                eligible.push((text_similarity(text, &self.tables[i].0), score, inside, i));
2252            }
2253            if !eligible.is_empty() {
2254                // POSITION FILTERS, TEXT RANKS: the most similar text wins,
2255                // position only breaking ties; the first of equals.
2256                let mut pick = 0usize;
2257                let mut best: Option<(f64, f64, usize)> = None;
2258                for (sim, pos, inside, i) in &eligible {
2259                    let key = (*sim, *pos, *inside);
2260                    let better = match &best {
2261                        None => true,
2262                        Some(b) => {
2263                            key.0 > b.0
2264                                || (key.0 == b.0 && (key.1 > b.1 || (key.1 == b.1 && key.2 > b.2)))
2265                        }
2266                    };
2267                    if better {
2268                        best = Some(key);
2269                        pick = *i;
2270                    }
2271                }
2272                self.used.insert(pick);
2273                self.last_pick = Some(pick);
2274                return Some(collapse_ws(&self.tables[pick].1));
2275            }
2276        }
2277        // Ties break to the higher index: `max` over `(similarity, i)`.
2278        let mut best: Option<(f64, usize)> = None;
2279        for (i, table) in self.tables.iter().enumerate() {
2280            if self.used.contains(&i) {
2281                continue;
2282            }
2283            let sim = text_similarity(text, &table.0);
2284            let better = match &best {
2285                None => true,
2286                Some((s, j)) => sim > *s || (sim == *s && i > *j),
2287            };
2288            if better {
2289                best = Some((sim, i));
2290            }
2291        }
2292        match best {
2293            Some((sim, i)) if sim >= GRID_FLOOR => {
2294                self.used.insert(i);
2295                self.last_pick = Some(i);
2296                Some(collapse_ws(&self.tables[i].1))
2297            }
2298            _ => None,
2299        }
2300    }
2301
2302    /// The engine tables no region took, as (html, union bbox of their
2303    /// cells, table index for the trace's cells).
2304    fn orphans(&self) -> Vec<(String, [f64; 4], usize)> {
2305        let mut out = Vec::new();
2306        for (i, cells) in self.geos.iter().enumerate() {
2307            if self.used.contains(&i) || cells.is_empty() {
2308                continue;
2309            }
2310            let box_ = [
2311                cells.iter().map(|c| c[0]).fold(f64::INFINITY, f64::min),
2312                cells.iter().map(|c| c[1]).fold(f64::INFINITY, f64::min),
2313                cells.iter().map(|c| c[2]).fold(f64::NEG_INFINITY, f64::max),
2314                cells.iter().map(|c| c[3]).fold(f64::NEG_INFINITY, f64::max),
2315            ];
2316            out.push((collapse_ws(&self.tables[i].1), box_, i));
2317        }
2318        out
2319    }
2320}
2321
2322/// The page's engine tables as their cells, run for run the way
2323/// [`engine_tables_of`] reads them: the trace's numbers for matched and
2324/// orphan grids, aligned with the markdown's tables in the same order.
2325/// Only built with the trace on.
2326fn engine_table_cells(blocks: &[Block]) -> Vec<Vec<Block>> {
2327    let mut tables = Vec::new();
2328    let mut cells: Vec<Block> = Vec::new();
2329    let mut last: Option<(u16, u16)> = None;
2330    for block in blocks {
2331        match block.kind {
2332            BlockKind::TableCell { row, column, .. } => {
2333                let at = (row, column);
2334                if last.is_none_or(|prev| at <= prev) && !cells.is_empty() {
2335                    tables.push(std::mem::take(&mut cells));
2336                }
2337                cells.push(block.clone());
2338                last = Some(at);
2339            }
2340            _ => {
2341                if !cells.is_empty() {
2342                    tables.push(std::mem::take(&mut cells));
2343                }
2344                last = None;
2345            }
2346        }
2347    }
2348    if !cells.is_empty() {
2349        tables.push(std::mem::take(&mut cells));
2350    }
2351    tables
2352}
2353
2354/// The page's engine tables as their cells' boxes, from the same blocks
2355/// `structured_markdown --json` prints: a table is a run of adjacent
2356/// `tableCell` blocks in row-major order -- the writer's own `table_run`
2357/// rule, so the runs line up with the markdown's tables in order.
2358fn engine_tables_of(blocks: &[Block]) -> Vec<Vec<[f64; 4]>> {
2359    let mut tables = Vec::new();
2360    let mut cells: Vec<[f64; 4]> = Vec::new();
2361    let mut last: Option<(u16, u16)> = None;
2362    let flush = |cells: &mut Vec<[f64; 4]>, tables: &mut Vec<Vec<[f64; 4]>>| {
2363        if !cells.is_empty() {
2364            tables.push(std::mem::take(cells));
2365        }
2366    };
2367    for block in blocks {
2368        match block.kind {
2369            BlockKind::TableCell { row, column, .. } => {
2370                let at = (row, column);
2371                if last.is_none_or(|prev| at <= prev) {
2372                    flush(&mut cells, &mut tables);
2373                }
2374                cells.push(block.bbox);
2375                last = Some(at);
2376            }
2377            _ => {
2378                flush(&mut cells, &mut tables);
2379                last = None;
2380            }
2381        }
2382    }
2383    flush(&mut cells, &mut tables);
2384    tables
2385}
2386
2387/// Strip ASCII punctuation off both ends and lowercase, as the adapter's
2388/// `_norm` reads a word.
2389fn norm_word(word: &str) -> String {
2390    word.trim_matches(|ch: char| ch.is_ascii_punctuation())
2391        .to_lowercase()
2392}
2393
2394/// The grid's words, tags gone, for the leftover test.
2395fn grid_words(grid: &str) -> HashSet<String> {
2396    strip_tags_star(grid)
2397        .split(is_py_space)
2398        .map(norm_word)
2399        .filter(|w| !w.is_empty())
2400        .collect()
2401}
2402
2403/// Strip tags (`<[^>]*>`: possibly empty) for the leftover vocabulary.
2404fn strip_tags_star(text: &str) -> String {
2405    let chars: Vec<char> = text.chars().collect();
2406    let mut out = String::new();
2407    let mut i = 0usize;
2408    while i < chars.len() {
2409        if chars[i] == '<' {
2410            let mut j = i + 1;
2411            while j < chars.len() && chars[j] != '>' {
2412                j += 1;
2413            }
2414            if j < chars.len() {
2415                out.push(' ');
2416                i = j + 1;
2417                continue;
2418            }
2419        }
2420        out.push(chars[i]);
2421        i += 1;
2422    }
2423    out
2424}
2425
2426/// A leftover line as a paragraph: a bare rule line is escaped so it stays
2427/// words, not structure.
2428fn safe_para(text: &str) -> String {
2429    // `(---+|\*+)`: three or more dashes, or one or more stars.
2430    let collapsed = collapse_ws(text);
2431    let dashes = collapsed.len() >= 3 && collapsed.chars().all(|ch| ch == '-');
2432    let stars = !collapsed.is_empty() && collapsed.chars().all(|ch| ch == '*');
2433    if dashes || stars {
2434        format!("\\{collapsed}")
2435    } else {
2436        collapsed
2437    }
2438}
2439
2440/// A taken grid's leftover lines: the region's lines the grid does not hold,
2441/// in reading order. A line is held when every one of its words is among the
2442/// grid's (case- and punctuation-blind); the rest become paragraphs before
2443/// the table (lines above the held ones) or after it. Returns the
2444/// before/after paragraphs and the grid.
2445fn leftover_split(lines: &[PlacedLine], grid: String) -> (Vec<String>, Vec<String>, String) {
2446    let vocab = grid_words(&grid);
2447    let held = |line: &PlacedLine| -> bool {
2448        let words: Vec<String> = line
2449            .plain
2450            .split(is_py_space)
2451            .map(norm_word)
2452            .filter(|w| !w.is_empty())
2453            .collect();
2454        !words.is_empty() && words.iter().all(|w| vocab.contains(w))
2455    };
2456    let flags: Vec<bool> = lines.iter().map(held).collect();
2457    let left: Vec<&PlacedLine> = lines
2458        .iter()
2459        .zip(flags.iter())
2460        .filter(|(_, held)| !**held)
2461        .map(|(line, _)| line)
2462        .collect();
2463    if left.is_empty() {
2464        return (Vec::new(), Vec::new(), grid);
2465    }
2466    let kept: Vec<&PlacedLine> = lines
2467        .iter()
2468        .zip(flags.iter())
2469        .filter(|(_, held)| **held)
2470        .map(|(line, _)| line)
2471        .collect();
2472    let (before, after): (Vec<&PlacedLine>, Vec<&PlacedLine>) = if kept.is_empty() {
2473        (Vec::new(), left)
2474    } else {
2475        let top = kept
2476            .iter()
2477            .map(|line| line.bbox[3])
2478            .fold(f64::NEG_INFINITY, f64::max);
2479        left.into_iter().partition(|line| line.bbox[1] >= top)
2480    };
2481    (
2482        before.iter().map(|line| safe_para(&line.styled)).collect(),
2483        after.iter().map(|line| safe_para(&line.styled)).collect(),
2484        grid,
2485    )
2486}
2487
2488#[cfg(test)]
2489mod tests {
2490    use super::*;
2491
2492    /// A `difflib` opcode as a readable tuple.
2493    fn opcode_strings(a: &[&str], b: &[&str]) -> Vec<(String, usize, usize, usize, usize)> {
2494        SequenceMatcher::new(a, b)
2495            .opcodes()
2496            .iter()
2497            .map(|o| {
2498                let tag = match o.tag {
2499                    OpTag::Replace => "replace",
2500                    OpTag::Delete => "delete",
2501                    OpTag::Insert => "insert",
2502                    OpTag::Equal => "equal",
2503                };
2504                (tag.to_string(), o.a_start, o.a_end, o.b_start, o.b_end)
2505            })
2506            .collect()
2507    }
2508
2509    #[test]
2510    fn sequence_matcher_ports_difflib() {
2511        // The docstring's cases, plus repeats, empties and nesting.
2512        let opcodes = |a: &[&str], b: &[&str]| opcode_strings(a, b);
2513        assert_eq!(
2514            opcodes(&["a", "b", "x", "c", "d"], &["a", "b", "c", "d"]),
2515            [
2516                ("equal".to_string(), 0, 2, 0, 2),
2517                ("delete".to_string(), 2, 3, 2, 2),
2518                ("equal".to_string(), 3, 5, 2, 4),
2519            ]
2520        );
2521        assert_eq!(
2522            opcodes(
2523                &["q", "a", "b", "x", "c", "d"],
2524                &["a", "b", "y", "c", "d", "f"]
2525            ),
2526            [
2527                ("delete".to_string(), 0, 1, 0, 0),
2528                ("equal".to_string(), 1, 3, 0, 2),
2529                ("replace".to_string(), 3, 4, 2, 3),
2530                ("equal".to_string(), 4, 6, 3, 5),
2531                ("insert".to_string(), 6, 6, 5, 6),
2532            ]
2533        );
2534        assert_eq!(
2535            opcodes(&["x", "x", "x", "x"], &["x", "x", "x", "x"]),
2536            [("equal".to_string(), 0, 4, 0, 4)]
2537        );
2538        assert_eq!(opcodes(&["a"], &[]), [("delete".to_string(), 0, 1, 0, 0)]);
2539        assert_eq!(
2540            opcodes(&[], &["a", "b"]),
2541            [("insert".to_string(), 0, 0, 0, 2)]
2542        );
2543        // A repeated element aligns to its earliest index: the `>` is
2544        // strict, so the first maximal block wins, never the last.
2545        assert_eq!(
2546            opcodes(&["c"], &["c", "c", "b"]),
2547            [
2548                ("equal".to_string(), 0, 1, 0, 1),
2549                ("insert".to_string(), 1, 1, 1, 3),
2550            ]
2551        );
2552        // Earliest maximal block wins, as `find_longest_match` promises.
2553        assert_eq!(
2554            opcodes(&["a", "a", "b", "a"], &["a", "b", "a", "a"]),
2555            [
2556                ("delete".to_string(), 0, 1, 0, 0),
2557                ("equal".to_string(), 1, 4, 0, 3),
2558                ("insert".to_string(), 4, 4, 3, 4),
2559            ]
2560        );
2561    }
2562
2563    #[test]
2564    fn unmarked_unwraps_tags_strong_em_and_strike_in_order() {
2565        // The alternation tries `***` before `**` at each position: the `**`
2566        // branch may span what a `***`-first pass would have consumed.
2567        assert_eq!(unmarked("**a** ***a***"), "a** *a***");
2568        assert_eq!(unmarked("H<sub>2</sub>O and **net**"), "H2O and net");
2569        assert_eq!(unmarked("*days* gone"), "days gone");
2570        assert_eq!(unmarked("~~gone~~"), "gone");
2571        assert_eq!(unmarked("***x***"), "x");
2572        assert_eq!(unmarked("**a**b"), "ab");
2573        assert_eq!(unmarked("a ** b"), "a ** b");
2574        assert_eq!(unmarked("*a**"), "a*");
2575        assert_eq!(unmarked("**"), "**");
2576        assert_eq!(unmarked("text"), "text");
2577    }
2578
2579    #[test]
2580    fn join_runs_merges_only_true_seams() {
2581        assert_eq!(join_runs("**a** **b**"), "**a b**");
2582        assert_eq!(join_runs("*a* *b*"), "*a b*");
2583        assert_eq!(join_runs("***a*** ***b***"), "***a b***");
2584        // The `</u> <u>` seam is one space; the emission collapses the rest.
2585        assert_eq!(join_runs("keep </u> <u> one"), "keep   one");
2586        // No seam without the marker on both sides, or at the string's edge.
2587        assert_eq!(join_runs("**a** b"), "**a** b");
2588        assert_eq!(join_runs("**a**"), "**a**");
2589        assert_eq!(join_runs("a** **b"), "a b");
2590        assert_eq!(join_runs("** **"), "** **");
2591        assert_eq!(join_runs("plain"), "plain");
2592    }
2593
2594    #[test]
2595    fn join_lines_joins_only_hyphen_wraps() {
2596        let plain = |lines: &[(&str, &str)]| join_lines(lines);
2597        assert_eq!(
2598            plain(&[("docu-", "docu-"), ("ments", "ments")]),
2599            "documents"
2600        );
2601        assert_eq!(
2602            plain(&[("docu-", "**docu-**"), ("ments", "**ments**")]),
2603            "**docu-** **ments**"
2604        );
2605        // The next line must open in three ASCII lowercase.
2606        assert_eq!(plain(&[("docu-", "docu-"), ("Flow", "Flow")]), "docu- Flow");
2607        assert_eq!(plain(&[("docu-", "docu-"), ("me", "me")]), "docu- me");
2608        // Three letters must stand before the hyphen.
2609        assert_eq!(plain(&[("co-", "co-"), ("op", "op")]), "co- op");
2610        assert_eq!(
2611            plain(&[("plain", "plain"), ("words", "words")]),
2612            "plain words"
2613        );
2614    }
2615
2616    #[test]
2617    fn strip_bullet_drops_only_a_spaced_bullet() {
2618        assert_eq!(strip_bullet("\u{2022} item"), "item");
2619        assert_eq!(strip_bullet("- item"), "item");
2620        assert_eq!(strip_bullet("  \u{00b7} spaced"), "spaced");
2621        assert_eq!(strip_bullet("-x"), "-x");
2622        assert_eq!(strip_bullet("-- x"), "-- x");
2623        assert_eq!(strip_bullet("plain"), "plain");
2624        assert_eq!(strip_bullet(""), "");
2625    }
2626
2627    #[test]
2628    fn safe_para_escapes_only_bare_rule_lines() {
2629        assert_eq!(safe_para("---"), "\\---");
2630        assert_eq!(safe_para("***"), "\\***");
2631        assert_eq!(safe_para("  ***  "), "\\***");
2632        assert_eq!(safe_para("-"), "-");
2633        assert_eq!(safe_para("*"), "\\*");
2634        assert_eq!(safe_para("--- x"), "--- x");
2635        assert_eq!(safe_para("words"), "words");
2636    }
2637
2638    #[test]
2639    fn grid_words_reads_the_tagless_vocab() {
2640        let words = grid_words("<table><tr><td>Hello, World!</td></tr></table>");
2641        assert_eq!(
2642            words,
2643            HashSet::from(["hello".to_string(), "world".to_string()])
2644        );
2645        assert!(grid_words("<>").is_empty());
2646        // No entity decoding: only tags go.
2647        assert_eq!(
2648            grid_words("A&amp;B"),
2649            HashSet::from(["a&amp;b".to_string()])
2650        );
2651    }
2652
2653    #[test]
2654    fn text_similarity_is_one_minus_normalised_distance() {
2655        assert_eq!(text_similarity("same", "same"), 1.0);
2656        assert_eq!(text_similarity("", ""), 1.0);
2657        assert_eq!(text_similarity("abc", ""), 0.0);
2658        assert_eq!(edit_distance("kitten", "sitting"), 3);
2659        let sim = text_similarity("kitten", "sitting");
2660        assert!((sim - (1.0 - 3.0 / 7.0)).abs() < 1e-12, "got {sim}");
2661    }
2662
2663    #[test]
2664    fn engine_tables_of_markdown_reads_pipe_and_html() {
2665        let tables = engine_tables_of_markdown(
2666            "# Title\n\n| Name | Songs |\n| --- | --- |\n| **Abba** | *Waterloo* |\n",
2667        );
2668        assert_eq!(tables.len(), 1);
2669        assert_eq!(tables[0].0, "Name Songs Abba Waterloo");
2670        assert_eq!(
2671            tables[0].1,
2672            "<table><tr><th>Name</th><th>Songs</th></tr>\
2673             <tr><td>Abba</td><td>Waterloo</td></tr></table>"
2674        );
2675        let tables = engine_tables_of_markdown(
2676            "<!-- page 1 -->\n\n<table>\n<tr><td>H<sub>2</sub>O</td></tr>\n</table>\n",
2677        );
2678        assert_eq!(tables.len(), 1);
2679        assert_eq!(tables[0].0, "H2O");
2680        assert!(tables[0].1.contains("<td>H2O</td>"), "got {}", tables[0].1);
2681        // A fence marker and an image are syntax, not content.
2682        let tables = engine_tables_of_markdown("```text\n![](a.png)\n```\n");
2683        assert!(tables.is_empty());
2684    }
2685
2686    /// A word for the line tests: geometry and flags only, no PDF.
2687    fn word(text: &str, bbox: [f64; 4], block: usize, bold: bool, italic: bool) -> PageWord {
2688        PageWord {
2689            text: text.to_string(),
2690            bbox,
2691            block,
2692            span: 0,
2693            size: 10.0,
2694            bold,
2695            italic,
2696            font: None,
2697            color: None,
2698        }
2699    }
2700
2701    #[test]
2702    fn lines_of_groups_splits_and_orders() {
2703        // Two columns, two rows: one y-grouped line per row, cut at the gap,
2704        // read column by column.
2705        let words = [
2706            word("left-one", [72.0, 700.0, 130.0, 710.0], 0, false, false),
2707            word("right-one", [360.0, 700.0, 430.0, 710.0], 0, false, false),
2708            word("left-two", [72.0, 686.0, 130.0, 696.0], 0, false, false),
2709            word("right-two", [360.0, 686.0, 430.0, 696.0], 0, false, false),
2710        ];
2711        let lines = lines_of(0, &words);
2712        let texts: Vec<Vec<&str>> = lines
2713            .iter()
2714            .map(|line| line.iter().map(|&w| words[w].text.as_str()).collect())
2715            .collect();
2716        assert_eq!(
2717            texts,
2718            vec![
2719                vec!["left-one"],
2720                vec!["left-two"],
2721                vec!["right-one"],
2722                vec!["right-two"],
2723            ]
2724        );
2725        // A raised word stays on its line: overlap against the line's span.
2726        let words = [
2727            word("body", [72.0, 700.0, 110.0, 710.0], 0, false, false),
2728            word("mark", [112.0, 706.0, 130.0, 712.0], 0, false, false),
2729        ];
2730        assert_eq!(lines_of(0, &words).len(), 1);
2731    }
2732
2733    #[test]
2734    fn regions_of_applies_thresholds_furniture_and_short() {
2735        fn box_of(label: &str, score: f32) -> LayoutBox {
2736            LayoutBox {
2737                label: label.to_string(),
2738                score,
2739                bbox_px: [0.0, 0.0, 100.0, 100.0],
2740                render_w: 612,
2741                render_h: 792,
2742            }
2743        }
2744        // At the bar: kept. Under it: ignored -- except a weak heading
2745        // signal, which reads as a short region.
2746        let (kept, short) = regions_of(
2747            &[
2748                box_of("table", 0.75),
2749                box_of("table", 0.74),
2750                box_of("list_item", 0.10),
2751                box_of("list_item", 0.09),
2752                box_of("section_header", 0.25),
2753                box_of("title", 0.10),
2754                box_of("title", 0.04),
2755                box_of("text", 0.50),
2756            ],
2757            612.0,
2758            792.0,
2759            false,
2760        );
2761        assert_eq!(kept.len(), 4);
2762        assert_eq!(kept[0].kind, RegionKind::Table);
2763        assert_eq!(kept[1].kind, RegionKind::ListItem);
2764        assert_eq!(kept[2].kind, RegionKind::Heading);
2765        assert_eq!(kept[3].kind, RegionKind::Paragraph);
2766        assert_eq!(short.len(), 1);
2767        assert_eq!(short[0].kind, RegionKind::Heading);
2768        // Furniture reads as furniture -- unless kept, when it is a paragraph.
2769        let (kept, _) = regions_of(&[box_of("page_header", 0.5)], 612.0, 792.0, false);
2770        assert_eq!(kept[0].kind, RegionKind::Furniture);
2771        let (kept, _) = regions_of(&[box_of("page_footer", 0.5)], 612.0, 792.0, true);
2772        assert_eq!(kept[0].kind, RegionKind::Paragraph);
2773    }
2774
2775    // -- The page tests below run through `balanced_page_markdown` on
2776    // -- operator-built PDFs, with injected detector boxes.
2777
2778    /// One drawn run: text, Type1 font, size, and position.
2779    struct Piece {
2780        /// The text.
2781        text: String,
2782        /// The Type1 base font.
2783        font: String,
2784        /// The font size.
2785        size: f64,
2786        /// The x position.
2787        x: f64,
2788        /// The y position.
2789        y: f64,
2790    }
2791
2792    /// A one-page PDF drawing `pieces` at their positions (612x792 pt).
2793    fn pdf_of(pieces: &[Piece]) -> Vec<u8> {
2794        use lopdf::content::{Content, Operation};
2795        use lopdf::{dictionary, Document, Object, Stream, StringFormat};
2796
2797        /// WinAnsi bytes: ASCII as itself, the bullet as 0x95.
2798        fn encode(text: &str) -> Vec<u8> {
2799            let mut out = Vec::new();
2800            for ch in text.chars() {
2801                if ch == '\u{2022}' {
2802                    out.push(0x95);
2803                } else if ch.is_ascii() {
2804                    out.push(ch as u8);
2805                } else {
2806                    out.push(b'?');
2807                }
2808            }
2809            out
2810        }
2811
2812        let mut doc = Document::with_version("1.7");
2813        let mut fonts: std::collections::HashMap<String, lopdf::ObjectId> =
2814            std::collections::HashMap::new();
2815        for piece in pieces {
2816            fonts.entry(piece.font.clone()).or_insert_with(|| {
2817                doc.add_object(Object::Dictionary(dictionary! {
2818                    "Type" => Object::Name(b"Font".to_vec()),
2819                    "Subtype" => Object::Name(b"Type1".to_vec()),
2820                    "BaseFont" => Object::Name(piece.font.as_bytes().to_vec()),
2821                    "Encoding" => Object::Name(b"WinAnsiEncoding".to_vec()),
2822                }))
2823            });
2824        }
2825        let mut names: Vec<String> = fonts.keys().cloned().collect();
2826        names.sort();
2827        let mut operations = Vec::new();
2828        for piece in pieces {
2829            let index = names.iter().position(|n| n == &piece.font).unwrap_or(0);
2830            let name = format!("F{}", index + 1);
2831            operations.push(Operation::new("BT", vec![]));
2832            operations.push(Operation::new(
2833                "Tf",
2834                vec![Object::Name(name.as_bytes().to_vec()), piece.size.into()],
2835            ));
2836            operations.push(Operation::new(
2837                "Tm",
2838                vec![
2839                    1.into(),
2840                    0.into(),
2841                    0.into(),
2842                    1.into(),
2843                    piece.x.into(),
2844                    piece.y.into(),
2845                ],
2846            ));
2847            operations.push(Operation::new(
2848                "Tj",
2849                vec![Object::String(encode(&piece.text), StringFormat::Literal)],
2850            ));
2851            operations.push(Operation::new("ET", vec![]));
2852        }
2853        let content = Content { operations }.encode().expect("encode content");
2854        let content_id = doc.add_object(Object::Stream(Stream::new(dictionary! {}, content)));
2855        let mut fontdict = dictionary! {};
2856        for (i, name) in names.iter().enumerate() {
2857            fontdict.set(format!("F{}", i + 1), Object::Reference(fonts[name]));
2858        }
2859        let page_id = doc.add_object(Object::Dictionary(dictionary! {
2860            "Type" => Object::Name(b"Page".to_vec()),
2861            "MediaBox" => Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()]),
2862            "Contents" => Object::Reference(content_id),
2863            "Resources" => Object::Dictionary(dictionary! {
2864                "Font" => Object::Dictionary(fontdict),
2865            }),
2866        }));
2867        let pages_id = doc.add_object(Object::Dictionary(dictionary! {
2868            "Type" => Object::Name(b"Pages".to_vec()),
2869            "Kids" => Object::Array(vec![Object::Reference(page_id)]),
2870            "Count" => Object::Integer(1),
2871        }));
2872        if let Ok(Object::Dictionary(page)) = doc.get_object_mut(page_id) {
2873            page.set("Parent", Object::Reference(pages_id));
2874        }
2875        let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id });
2876        doc.trailer.set("Root", catalog);
2877        let mut bytes = Vec::new();
2878        doc.save_to(&mut bytes).expect("save");
2879        bytes
2880    }
2881
2882    /// Shorthand for a drawn run.
2883    fn at(text: &str, font: &str, size: f64, x: f64, y: f64) -> Piece {
2884        Piece {
2885            text: text.to_string(),
2886            font: font.to_string(),
2887            size,
2888            x,
2889            y,
2890        }
2891    }
2892
2893    /// An injected detector box over a PDF-points bbox: the render is
2894    /// addressed 1:1 with the 612x792 page, flipped vertically.
2895    fn layout_box(label: &str, score: f32, bbox: [f64; 4]) -> LayoutBox {
2896        LayoutBox {
2897            label: label.to_string(),
2898            score,
2899            bbox_px: [
2900                bbox[0] as f32,
2901                (792.0 - bbox[3]) as f32,
2902                bbox[2] as f32,
2903                (792.0 - bbox[1]) as f32,
2904            ],
2905            render_w: 612,
2906            render_h: 792,
2907        }
2908    }
2909
2910    /// The page through the public function.
2911    fn page_of(bytes: Vec<u8>, boxes: &[LayoutBox], opts: &BalancedOptions) -> String {
2912        let doc = PdfDocument::open(bytes).expect("open");
2913        balanced_page_markdown(&doc, 0, boxes, opts).expect("balanced")
2914    }
2915
2916    #[test]
2917    fn tightest_covering_box_wins() {
2918        let bytes = pdf_of(&[
2919            at("Opening line here", "Helvetica", 10.0, 72.0, 740.0),
2920            at("Following line here", "Helvetica", 10.0, 72.0, 726.0),
2921        ]);
2922        // The outer box holds both lines; the inner heading box holds only
2923        // the first -- and wins it despite the lower score.
2924        let boxes = [
2925            layout_box("text", 0.9, [60.0, 700.0, 540.0, 760.0]),
2926            layout_box("section_header", 0.3, [60.0, 735.0, 540.0, 755.0]),
2927        ];
2928        assert_eq!(
2929            page_of(bytes, &boxes, &BalancedOptions::default()),
2930            "## Opening line here\n\nFollowing line here"
2931        );
2932    }
2933
2934    #[test]
2935    fn below_threshold_boxes_are_ignored() {
2936        let bytes = pdf_of(&[
2937            at(
2938                "A first paragraph line with more than twelve words in it here yes",
2939                "Helvetica",
2940                10.0,
2941                72.0,
2942                740.0,
2943            ),
2944            at(
2945                "A second paragraph line with more than twelve words in it here too",
2946                "Helvetica",
2947                10.0,
2948                72.0,
2949                726.0,
2950            ),
2951            at(
2952                "\u{2022} A bullet line here with more than twelve words in it too yes",
2953                "Helvetica",
2954                10.0,
2955                72.0,
2956                700.0,
2957            ),
2958        ]);
2959        // A table signal under 0.75 is no table; a weak heading signal on
2960        // long lines is no heading either (short lines would still turn).
2961        // Unclaimed, the lines stay in their one block as one paragraph.
2962        let boxes = [
2963            layout_box("table", 0.74, [60.0, 690.0, 540.0, 760.0]),
2964            layout_box("section_header", 0.2, [60.0, 690.0, 540.0, 760.0]),
2965            layout_box("list_item", 0.09, [60.0, 690.0, 540.0, 710.0]),
2966        ];
2967        let opts = BalancedOptions::default();
2968        assert_eq!(
2969            page_of(bytes, &boxes, &opts),
2970            "A first paragraph line with more than twelve words in it here yes \
2971             A second paragraph line with more than twelve words in it here too\n\n\u{2022} A bullet line here with more than twelve words in it too yes"
2972        );
2973    }
2974
2975    /// A stub plus three figure columns over four rows: the engine reads it
2976    /// as a table.
2977    fn table_pieces() -> Vec<Piece> {
2978        let rows = [
2979            ("Revenue", "100", "200", "300"),
2980            ("Costs", "10", "20", "30"),
2981            ("Profit", "90", "180", "270"),
2982            ("Margin", "9", "18", "27"),
2983        ];
2984        let mut pieces = Vec::new();
2985        for (i, (a, b, c, d)) in rows.iter().enumerate() {
2986            let y = 700.0 - i as f64 * 14.0;
2987            pieces.push(at(a, "Helvetica", 9.0, 72.0, y));
2988            pieces.push(at(b, "Helvetica", 9.0, 360.0, y));
2989            pieces.push(at(c, "Helvetica", 9.0, 430.0, y));
2990            pieces.push(at(d, "Helvetica", 9.0, 500.0, y));
2991        }
2992        pieces
2993    }
2994
2995    /// The engine's grid for [`table_pieces`]: the engine reads the first row
2996    /// (three figures in four cells) as body, so since the header switches
2997    /// shipped default-on this table is headerless HTML, all `<td>`, collapsed
2998    /// to one line by the grid matcher.
2999    const TABLE_GRID: &str =
3000        "<table> <tr><td>Revenue</td><td>100</td><td>200</td><td>300</td></tr> \
3001         <tr><td>Costs</td><td>10</td><td>20</td><td>30</td></tr> \
3002         <tr><td>Profit</td><td>90</td><td>180</td><td>270</td></tr> \
3003         <tr><td>Margin</td><td>9</td><td>18</td><td>27</td></tr> </table>";
3004
3005    #[test]
3006    fn table_region_takes_the_engine_grid() {
3007        let bytes = pdf_of(&table_pieces());
3008        let boxes = [layout_box("table", 0.8, [60.0, 640.0, 540.0, 720.0])];
3009        assert_eq!(
3010            page_of(bytes, &boxes, &BalancedOptions::default()),
3011            TABLE_GRID
3012        );
3013    }
3014
3015    #[test]
3016    fn table_region_without_a_grid_is_paragraphs() {
3017        let bytes = pdf_of(&[at(
3018            "Only prose on this page",
3019            "Helvetica",
3020            10.0,
3021            72.0,
3022            740.0,
3023        )]);
3024        // A confident table box over prose: no engine grid matches, the
3025        // region entry finds fewer than two lines, and the line stays text.
3026        let boxes = [layout_box("table", 0.9, [60.0, 700.0, 540.0, 760.0])];
3027        assert_eq!(
3028            page_of(bytes, &boxes, &BalancedOptions::default()),
3029            "Only prose on this page"
3030        );
3031    }
3032
3033    #[test]
3034    fn orphan_tables_come_from_the_engine() {
3035        // No boxes at all: the engine table is an orphan, written as the
3036        // engine's own grid.
3037        let bytes = pdf_of(&table_pieces());
3038        assert_eq!(page_of(bytes, &[], &BalancedOptions::default()), TABLE_GRID);
3039    }
3040
3041    #[test]
3042    fn wordless_page_is_the_engine_page() {
3043        use lopdf::{dictionary, Document, Object, Stream};
3044        // A figure and no words: the engine's own markdown, verbatim.
3045        let mut doc = Document::with_version("1.7");
3046        let img = doc.add_object(Object::Stream(Stream::new(
3047            dictionary! {
3048                "Type" => Object::Name(b"XObject".to_vec()),
3049                "Subtype" => Object::Name(b"Image".to_vec()),
3050                "Width" => 64,
3051                "Height" => 64,
3052                "ColorSpace" => Object::Name(b"DeviceGray".to_vec()),
3053                "BitsPerComponent" => 8,
3054            },
3055            vec![128u8; 64 * 64],
3056        )));
3057        let content = doc.add_object(Stream::new(
3058            dictionary! {},
3059            b"q 100 0 0 100 72 600 cm /Im1 Do Q".to_vec(),
3060        ));
3061        let page = doc.add_object(dictionary! {
3062            "Type" => "Page",
3063            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
3064            "Contents" => content,
3065            "Resources" => dictionary! { "XObject" => dictionary! { "Im1" => img } },
3066        });
3067        let pages = doc.new_object_id();
3068        doc.objects.insert(
3069            pages,
3070            Object::Dictionary(dictionary! {
3071                "Type" => "Pages",
3072                "Kids" => vec![page.into()],
3073                "Count" => 1,
3074            }),
3075        );
3076        if let Ok(Object::Dictionary(p)) = doc.get_object_mut(page) {
3077            p.set("Parent", Object::Reference(pages));
3078        }
3079        let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages });
3080        doc.trailer.set("Root", catalog);
3081        let mut bytes = Vec::new();
3082        doc.save_to(&mut bytes).expect("save");
3083        let engine = crate::structured_batch::markdown_of_bytes(bytes.clone()).expect("engine");
3084        assert!(!engine.is_empty(), "the engine marks the figure");
3085        assert_eq!(
3086            page_of(bytes, &[], &BalancedOptions::default()),
3087            engine,
3088            "R2 returns the engine page verbatim"
3089        );
3090    }
3091
3092    #[test]
3093    fn bold_and_italic_runs_carry_markers() {
3094        let bytes = pdf_of(&[
3095            at("The quick ", "Helvetica", 10.0, 72.0, 700.0),
3096            at("brown fox", "Helvetica-Bold", 10.0, 130.0, 700.0),
3097            at(" jumps", "Helvetica", 10.0, 190.0, 700.0),
3098            at("over the", "Helvetica", 10.0, 72.0, 686.0),
3099            at("lazy dog", "Helvetica-Oblique", 10.0, 125.0, 686.0),
3100        ]);
3101        assert_eq!(
3102            page_of(bytes, &[], &BalancedOptions::default()),
3103            "The quick **brown fox** jumps over the *lazy dog*"
3104        );
3105    }
3106
3107    #[test]
3108    fn headings_stay_plain() {
3109        let bytes = pdf_of(&[at("Title Words Here", "Helvetica-Bold", 14.0, 72.0, 740.0)]);
3110        // The detector calls it a heading: `## ` carries the weight, and no
3111        // markers are written beside it.
3112        let boxes = [layout_box("title", 0.9, [60.0, 730.0, 540.0, 760.0])];
3113        assert_eq!(
3114            page_of(bytes, &boxes, &BalancedOptions::default()),
3115            "## Title Words Here"
3116        );
3117    }
3118
3119    #[test]
3120    fn leftover_label_lines_stay_paragraphs() {
3121        let mut pieces = vec![at("Annual figures", "Helvetica", 10.0, 72.0, 724.0)];
3122        pieces.extend(table_pieces());
3123        let bytes = pdf_of(&pieces);
3124        // The region holds the label and the grid: the grid does not hold
3125        // the label, so it stands before the table.
3126        let boxes = [layout_box("table", 0.8, [60.0, 640.0, 540.0, 740.0])];
3127        assert_eq!(
3128            page_of(bytes, &boxes, &BalancedOptions::default()),
3129            format!("Annual figures\n\n{TABLE_GRID}")
3130        );
3131    }
3132
3133    #[test]
3134    fn short_line_on_weak_signal_is_a_heading() {
3135        let bytes = pdf_of(&[
3136            at("A short line", "Helvetica", 10.0, 72.0, 740.0),
3137            at(
3138                "A much longer paragraph line with more than twelve words in it here",
3139                "Helvetica",
3140                10.0,
3141                72.0,
3142                726.0,
3143            ),
3144        ]);
3145        // Below the 0.25 bar but at the 0.05 one: only the short line turns.
3146        let boxes = [
3147            layout_box("section_header", 0.1, [60.0, 735.0, 540.0, 755.0]),
3148            layout_box("section_header", 0.1, [60.0, 715.0, 540.0, 735.0]),
3149        ];
3150        assert_eq!(
3151            page_of(bytes, &boxes, &BalancedOptions::default()),
3152            "## A short line\n\nA much longer paragraph line with more than twelve words in it here"
3153        );
3154    }
3155
3156    #[test]
3157    fn orphan_engine_table_is_written_at_its_lines() {
3158        let mut pieces = vec![at("Opening words first", "Helvetica", 10.0, 72.0, 740.0)];
3159        pieces.extend(table_pieces());
3160        pieces.push(at("Closing words last", "Helvetica", 10.0, 72.0, 620.0));
3161        let bytes = pdf_of(&pieces);
3162        // No boxes: the engine table is written where its lines stood, and
3163        // its lines leave their paragraphs.
3164        assert_eq!(
3165            page_of(bytes, &[], &BalancedOptions::default()),
3166            format!("Opening words first\n\n{TABLE_GRID}\n\nClosing words last")
3167        );
3168    }
3169
3170    #[test]
3171    fn aligned_words_take_engine_tags() {
3172        // The raised "2" stands apart: the engine writes it as its own token.
3173        let bytes = pdf_of(&[
3174            at("See the documents.", "Helvetica", 10.0, 72.0, 700.0),
3175            at("2", "Helvetica", 7.0, 168.0, 705.0),
3176            at("for details here.", "Helvetica", 10.0, 176.0, 700.0),
3177        ]);
3178        assert_eq!(
3179            page_of(bytes, &[], &BalancedOptions::default()),
3180            "See the documents. <sup>2</sup> for details here."
3181        );
3182        // The raised "2" touches its neighbours: one engine token, glued.
3183        let bytes = pdf_of(&[
3184            at("See documents.", "Helvetica", 10.0, 72.0, 700.0),
3185            at("2", "Helvetica", 7.0, 145.0, 705.0),
3186            at("here follows.", "Helvetica", 10.0, 150.0, 700.0),
3187        ]);
3188        // "here" glues (its chars take the token's tagless tail); the
3189        // raised "2" never glues, so its space stands.
3190        assert_eq!(
3191            page_of(bytes, &[], &BalancedOptions::default()),
3192            "See documents. <sup>2</sup>here follows."
3193        );
3194    }
3195
3196    #[test]
3197    fn style_run_merges_across_lines() {
3198        let bytes = pdf_of(&[
3199            at("The brown fox", "Helvetica-Bold", 10.0, 72.0, 700.0),
3200            at("jumps high", "Helvetica-Bold", 10.0, 72.0, 686.0),
3201        ]);
3202        assert_eq!(
3203            page_of(bytes, &[], &BalancedOptions::default()),
3204            "**The brown fox jumps high**"
3205        );
3206    }
3207
3208    #[test]
3209    fn hyphen_wrap_joins_and_bullet_drops() {
3210        let bytes = pdf_of(&[
3211            at("A docu-", "Helvetica", 10.0, 72.0, 700.0),
3212            at("ments word", "Helvetica", 10.0, 72.0, 686.0),
3213            at("\u{2022} Item one text", "Helvetica", 10.0, 72.0, 660.0),
3214        ]);
3215        let boxes = [layout_box("list_item", 0.9, [60.0, 650.0, 540.0, 670.0])];
3216        assert_eq!(
3217            page_of(bytes, &boxes, &BalancedOptions::default()),
3218            "A documents word\n\n- Item one text"
3219        );
3220    }
3221
3222    #[test]
3223    fn ligatures_fold_when_the_engine_keeps_them() {
3224        use lopdf::{dictionary, Document, Object, Stream};
3225        // MacRoman 0xDE is the fi ligature; with the engine's own fold off,
3226        // the adapter's still folds it.
3227        let mut doc = Document::with_version("1.7");
3228        let font = doc.add_object(Object::Dictionary(dictionary! {
3229            "Type" => Object::Name(b"Font".to_vec()),
3230            "Subtype" => Object::Name(b"Type1".to_vec()),
3231            "BaseFont" => Object::Name(b"Helvetica".to_vec()),
3232            "Encoding" => Object::Name(b"MacRomanEncoding".to_vec()),
3233        }));
3234        let content = doc.add_object(Stream::new(
3235            dictionary! {},
3236            b"BT /F1 10 Tf 72 700 Td <6FDE6365> Tj ET".to_vec(),
3237        ));
3238        let page = doc.add_object(dictionary! {
3239            "Type" => "Page",
3240            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
3241            "Contents" => content,
3242            "Resources" => dictionary! { "Font" => dictionary! { "F1" => font } },
3243        });
3244        let pages = doc.new_object_id();
3245        doc.objects.insert(
3246            pages,
3247            Object::Dictionary(dictionary! {
3248                "Type" => "Pages",
3249                "Kids" => vec![page.into()],
3250                "Count" => 1,
3251            }),
3252        );
3253        if let Ok(Object::Dictionary(p)) = doc.get_object_mut(page) {
3254            p.set("Parent", Object::Reference(pages));
3255        }
3256        let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages });
3257        doc.trailer.set("Root", catalog);
3258        let mut bytes = Vec::new();
3259        doc.save_to(&mut bytes).expect("save");
3260        let mut doc = PdfDocument::open(bytes).expect("open");
3261        doc.set_keep_ligatures(true);
3262        assert_eq!(
3263            balanced_page_markdown(&doc, 0, &[], &BalancedOptions::default()).expect("balanced"),
3264            "ofice"
3265        );
3266    }
3267
3268    #[test]
3269    fn furniture_drops_unless_kept() {
3270        let bytes = pdf_of(&[
3271            at("Running head words", "Helvetica", 9.0, 72.0, 760.0),
3272            at("Body words here", "Helvetica", 10.0, 72.0, 700.0),
3273        ]);
3274        let boxes = [layout_box("page_header", 0.9, [60.0, 750.0, 540.0, 775.0])];
3275        assert_eq!(
3276            page_of(bytes.clone(), &boxes, &BalancedOptions::default()),
3277            "Body words here"
3278        );
3279        assert_eq!(
3280            page_of(
3281                bytes,
3282                &boxes,
3283                &BalancedOptions::default().keep_furniture(true)
3284            ),
3285            "Running head words\n\nBody words here"
3286        );
3287    }
3288
3289    #[test]
3290    fn page_out_of_range_is_an_error() {
3291        let bytes = pdf_of(&[at("words", "Helvetica", 10.0, 72.0, 700.0)]);
3292        let doc = PdfDocument::open(bytes).expect("open");
3293        let err = balanced_page_markdown(&doc, 3, &[], &BalancedOptions::default())
3294            .expect_err("page 3 of one");
3295        assert!(matches!(
3296            err,
3297            crate::EngineError::PageOutOfRange { index: 3, count: 1 }
3298        ));
3299    }
3300
3301    #[test]
3302    fn balanced_markdown_needs_the_model() {
3303        let bytes = pdf_of(&[at("words", "Helvetica", 10.0, 72.0, 700.0)]);
3304        let doc = PdfDocument::open(bytes).expect("open");
3305        let mut dir = std::env::temp_dir();
3306        dir.push("pdfluent-balanced-no-model");
3307        let config = RuntimeConfig::new().with_model_dir(dir);
3308        let err =
3309            balanced_markdown(&doc, &config, &BalancedOptions::default()).expect_err("no model");
3310        assert!(
3311            matches!(err, crate::EngineError::Io(_)),
3312            "a model failure rides Io: {err:?}"
3313        );
3314    }
3315}