Skip to main content

pdf_engine/
markdown.rs

1// Copyright (c) 2026 Innovation Trigger B.V.
2//
3// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
4// the PDFluent Commercial Licence. See the LICENSE file in this repository --
5// that file travels with the copy you received, which a URL does not.
6
7//! The block model as markdown (#568, #569, #570, #571).
8//!
9//! The heading classifier next door is only worth what a consumer can read off
10//! it, and what a RAG pipeline reads is markdown: Docling, Marker and every
11//! tool this milestone is measured against publish in it, and the benchmark
12//! harness grades every column through one markdown reader. So a heading that
13//! never reaches a `#` is a heading nobody downstream has.
14//!
15//! DELIBERATELY THE SMALLEST THING THAT IS TRUE. Headings become `#`..`###`,
16//! list items become `-` and `1.`, tables become a table, paragraphs become
17//! paragraphs, and nothing else claims a syntax it has not earned. The
18//! serializers ticket (#572) owns what grows here -- page-break markers, the
19//! other formats, and the golden tests over all of it. What this file owes that
20//! ticket is the mapping and a deterministic byte for byte output, which it can
21//! then extend rather than replace.
22//!
23//! WHY A TABLE IS SOMETIMES HTML. A markdown table is a rectangle: every row
24//! the same number of cells, none reaching across two. A table that is not one
25//! has to be written in something that can say `colspan`, and that is HTML --
26//! which every markdown reader this milestone is measured through passes along
27//! untouched, and which is the rule Docling follows for the same reason.
28//!
29//! AND WHY THE FIRST ROW IS THE HEADER ROW EVEN WHERE THE DOCUMENT DID NOT SAY
30//! SO: a markdown table has no shape without a delimiter row, and the row above
31//! it is the header whatever it holds. `BlockKind::TableCell` carries `header`
32//! for the tables that DID say so, and the HTML form writes it as `th` against
33//! `td`. The markdown form cannot, and says the only thing the format lets it.
34//!
35//! WHY A NESTED ITEM IS INDENTED BY ITS PARENT'S MARKER AND NOT BY A CONSTANT.
36//! CommonMark reads a list inside an item only when the nested line starts at
37//! or past the parent item's own CONTENT column, and that column is as wide as
38//! the parent's marker: two for `- `, three for `1. `, five for `100. `. A
39//! constant wide enough for the widest is a constant that opens an indented
40//! code block under the narrowest, and a constant narrow enough for `- ` drops
41//! out of `100. ` -- which the detector produces, because it accepts three
42//! digits (codex, round 2). So the items still open are kept on a stack with
43//! the column each of them opened, and a child is written at its parent's.
44//!
45//! AND WHY THE LEVEL IS NOT TAKEN AT FACE VALUE. A level is a rank among the
46//! indent steps of a REGION, so a page whose outer list the detector missed can
47//! open with an item at level 1 and no level 0 above it. Written out indented
48//! with nothing to nest into, that line is an indented code block, and every
49//! word of it leaves the text layer. So the depth actually written is never
50//! more than one past the deepest item still open -- which is what the stack
51//! answers anyway.
52//!
53//! WHY THE TEXT IS ESCAPED AT ALL. A paragraph that opens `# `, `- ` or `<`
54//! is prose the document set, and emitting it raw would hand the reader a
55//! heading, a list item or a block of HTML the document does not have -- the
56//! same mistake in the other direction from missing one. Only the markers that
57//! start a line are escaped, because those are the only ones the benchmark
58//! reader (and CommonMark) reads at the front of a line.
59//!
60//! A FENCE IS THE WORST OF THEM, and it is why this is not a nicety. A
61//! paragraph opening ``` -- a code listing, a manual, anything that quotes
62//! markdown -- opens a fenced block that nothing here ever closes, and every
63//! heading and paragraph after it on the page is swallowed into it (codex,
64//! round 1). One backslash ends that.
65
66//! WHAT DOES NOT REACH THE MARKDOWN AT ALL is the furniture: the running head
67//! and the page number a document repeats are marked as such (#571) and are
68//! not written, because standing between two paragraphs is exactly what makes
69//! them a defect for anything that chunks the text. They are still in the
70//! block model, which is where a caller that wants them reads them -- or the
71//! caller asks [`MarkdownOptions::include_headers`] and
72//! [`MarkdownOptions::include_footers`], and they are written as plain
73//! paragraphs at their reading-order position.
74//!
75//! AND WHAT REACHES IT AS ONE PARAGRAPH is the sentence a page break cut in
76//! half: `Block::continues_previous` says so, and the second half is written
77//! without a break, without an escape -- it is not at the start of a line any
78//! more -- and with the word the break was made inside of put back together.
79//!
80//! WHERE THE PAGE DID CHANGE, an HTML COMMENT says so (#572). A chunker that
81//! cites the document needs the page a chunk came from, and markdown has no
82//! syntax for one. A comment is the only thing CommonMark carries through
83//! untouched and shows to nobody: every reader this milestone is measured
84//! through passes it along, no renderer prints it, and the benchmark's own
85//! reader strips it before a single axis sees it -- so the marker cannot buy
86//! or cost a point on any of them. A caller that wants no marker asks
87//! [`PageSeparators::None`], and the paragraphs stand separated by a blank
88//! line.
89//!
90//! THE NUMBER IN IT IS `Block::page`, which is 0-BASED, and it is the same
91//! number the JSON form carries under `page`. A reader's chrome counts pages
92//! from one and this does not: a caller joining the two serialisations of one
93//! document must find the same page in both, and a marker that agreed with
94//! Acrobat's status bar instead would disagree with our own model.
95//!
96//! AND IT NEVER STANDS INSIDE A PARAGRAPH. A sentence the break cut in two is
97//! one paragraph (above), and a marker between the halves would cut it again
98//! -- which is the defect #571 removed, rebuilt out of comments. The marker is
99//! therefore DEFERRED and not dropped: the new page is owed one, and the next
100//! block on it opens with it. A paragraph whose second half ends the document
101//! is the one case that leaves no marker at all, because there is nothing
102//! after it to attribute to the new page.
103//!
104//! THE SYNTAX IS RESERVED, which is what makes the marker readable at all. A
105//! document that QUOTES an HTML comment -- a manual, a style guide, a page of
106//! markdown about markdown -- would otherwise put `<!-- page 7 -->` at the
107//! start of a line as body text, byte for byte what this writes, and a
108//! chunker following the convention would file the rest of the document under
109//! page 7 (codex, round 1). So a `<` that opens a line is escaped like every
110//! other marker below, and a marker at the start of a line is always ours.
111//!
112//! MONOSPACE READS AS CODE (#636). A run of spans the document set in a
113//! monospace face is written `` `like this` `` inside a paragraph or a list
114//! item, and a paragraph set wholly in one over two lines or more as a fenced
115//! block with its line breaks kept -- the way pymupdf4llm renders what its
116//! `ignore_code` switch reads. Tables, headings and blocks of tabular figures
117//! never are, and [`ExtractOptions::code`] switches the whole of it off.
118//!
119//! [`ExtractOptions::code`]: crate::blocks::ExtractOptions::code
120
121use crate::blocks::{Block, BlockKind, ExtractOptions, PageSeparators, TableRenderMode};
122#[cfg(test)]
123use crate::table_trace::TableOrigin;
124use crate::text::{HorizontalRule, TextSpan};
125
126/// What one cell of a table says about itself.
127struct CellAt<'a> {
128    row: usize,
129    column: usize,
130    row_span: usize,
131    column_span: usize,
132    header: bool,
133    text: &'a str,
134}
135
136/// The same cell with its text owned, so the currency join can rewrite it.
137struct OwnedCell {
138    row: usize,
139    column: usize,
140    row_span: usize,
141    column_span: usize,
142    header: bool,
143    text: String,
144}
145
146/// The cell a block is, or `None` where it is not one.
147fn cell_of(block: &Block) -> Option<CellAt<'_>> {
148    match block.kind {
149        BlockKind::TableCell {
150            row,
151            column,
152            row_span,
153            column_span,
154            header,
155        } => Some(CellAt {
156            row: row as usize,
157            column: column as usize,
158            row_span: (row_span as usize).max(1),
159            column_span: (column_span as usize).max(1),
160            header,
161            text: block.text.trim(),
162        }),
163        _ => None,
164    }
165}
166
167/// One past the last block of the table that starts at `from`, or `None` where
168/// no table starts there.
169///
170/// A table is a run of adjacent cells in ROW-MAJOR order, so `(row, column)`
171/// climbs with every block of it and the run ends where that stops: where the
172/// blocks stop being cells, where the page changes, or where the coordinates go
173/// backwards, which is a second table starting.
174///
175/// NOT "the next cell at row 0, column 0". A table whose first row opens with
176/// an empty cell has no block there at all, so the run ran straight on into it
177/// and the two grids came out as one -- with duplicate coordinates, which the
178/// serializer resolves by taking the first and dropping the second table's
179/// cells (codex, round 2).
180pub(crate) fn table_run(blocks: &[Block], from: usize) -> Option<usize> {
181    let first = cell_of(&blocks[from])?;
182    let mut last = (first.row, first.column);
183    let mut end = from + 1;
184    while end < blocks.len() && blocks[end].page == blocks[from].page {
185        match cell_of(&blocks[end]) {
186            Some(cell) if (cell.row, cell.column) > last => {
187                last = (cell.row, cell.column);
188                end += 1;
189            }
190            _ => break,
191        }
192    }
193    Some(end)
194}
195
196/// [`table_run`], and on across a page break where the next page's first cell
197/// is marked as the same table's continuation ([`Block::continues_previous`],
198/// set by `crate::blocks::tables_across_pages`, #574).
199fn table_run_across(blocks: &[Block], from: usize) -> Option<usize> {
200    let mut end = table_run(blocks, from)?;
201    while end < blocks.len()
202        && blocks[end].continues_previous
203        && blocks[end].page != blocks[end - 1].page
204        && cell_of(&blocks[end]).is_some()
205    {
206        end = table_run(blocks, end)?;
207    }
208    Some(end)
209}
210
211/// The cells of a run that may span pages, with every continuation's rows
212/// numbered on from the rows before it -- and its first row left out where it
213/// repeats the table's head, which is what a table set over two pages does.
214fn cells_across(blocks: &[Block]) -> Vec<CellAt<'_>> {
215    let mut out: Vec<CellAt> = Vec::new();
216    let mut offset = 0usize;
217    let mut segment_head: Option<usize> = None; // the row that heads this segment, if it repeats
218    let head: Vec<&str> = blocks
219        .iter()
220        .filter_map(cell_of)
221        .take_while(|c| c.row == 0)
222        .map(|c| c.text)
223        .collect();
224    for (k, block) in blocks.iter().enumerate() {
225        let Some(mut cell) = cell_of(block) else {
226            continue;
227        };
228        if k > 0 && block.continues_previous && block.page != blocks[k - 1].page {
229            let rows_so_far = out.iter().map(|c| c.row + c.row_span).max().unwrap_or(0);
230            let first_row = cell.row;
231            let this_head: Vec<&str> = blocks[k..]
232                .iter()
233                .take_while(|b| b.page == block.page)
234                .filter_map(cell_of)
235                .take_while(|c| c.row == first_row)
236                .map(|c| c.text)
237                .collect();
238            segment_head = (!head.is_empty() && this_head == head).then_some(first_row);
239            offset = rows_so_far - first_row.min(rows_so_far);
240            if segment_head.is_some() {
241                offset = rows_so_far.saturating_sub(first_row + 1);
242            }
243        }
244        if segment_head == Some(cell.row) {
245            continue;
246        }
247        cell.row += offset;
248        out.push(cell);
249    }
250    out
251}
252
253/// A table's cells as markdown, or as HTML where markdown cannot hold them --
254/// or in the one form `mode` asks for, whatever the table's shape.
255fn table(blocks: &[Block], switches: crate::learned::Switches, mode: TableRenderMode) -> String {
256    let mut owned: Vec<OwnedCell> = cells_across(blocks)
257        .iter()
258        .map(|cell| OwnedCell {
259            row: cell.row,
260            column: cell.column,
261            row_span: cell.row_span,
262            column_span: cell.column_span,
263            header: cell.header,
264            text: cell.text.to_string(),
265        })
266        .collect();
267    join_currency_columns(&mut owned);
268    let mut cells: Vec<CellAt> = owned
269        .iter()
270        .map(|cell| CellAt {
271            row: cell.row,
272            column: cell.column,
273            row_span: cell.row_span,
274            column_span: cell.column_span,
275            header: cell.header,
276            text: &cell.text,
277        })
278        .collect();
279    let head = header_block(&cells, switches);
280    for cell in cells.iter_mut().filter(|cell| cell.row < head) {
281        cell.header = true;
282    }
283    let columns = cells
284        .iter()
285        .map(|cell| cell.column + cell.column_span)
286        .max()
287        .unwrap_or(0);
288    let rows = cells
289        .iter()
290        .map(|cell| cell.row + cell.row_span)
291        .max()
292        .unwrap_or(0);
293    if columns == 0 || rows == 0 {
294        return String::new();
295    }
296
297    let rectangular = cells
298        .iter()
299        .all(|cell| cell.row_span == 1 && cell.column_span == 1)
300        && (0..rows).all(|r| {
301            let mut seen: Vec<usize> = cells
302                .iter()
303                .filter(|cell| cell.row == r)
304                .map(|cell| cell.column)
305                .collect();
306            seen.sort_unstable();
307            seen == (0..columns).collect::<Vec<usize>>()
308        });
309
310    // A pipe table holds ONE header row (the one above its delimiter); a header
311    // of two lines needs HTML to say both are headers. And with no header
312    // row at all a pipe table is a lie -- its first row reads as a header
313    // the table does not have -- so under `markdown_headerless_html` such
314    // a table is HTML with every cell as the decoder marked it. A caller
315    // that names a mode gets that form for every table instead (#659).
316    let pipe = match mode {
317        TableRenderMode::Markdown => true,
318        TableRenderMode::Html => false,
319        TableRenderMode::Auto => {
320            rectangular && head <= 1 && (head == 1 || !switches.markdown_headerless_html)
321        }
322    };
323    if pipe {
324        markdown_table(&cells, rows, columns)
325    } else {
326        html_table(&cells, rows, columns)
327    }
328}
329
330/// How many leading rows are the table's column header: the rows above the first
331/// row whose cells are mostly figures.
332///
333/// A two-line header (`Issue` over `Age`, `December 31,` over `2009`) came out
334/// as body rows, so a reader keying columns by their header -- ParseBench's
335/// record match keys each column by its leading `<th>` rows -- found no header
336/// at all and fell back to `col_0, col_1, ...`: record match 0 on tables whose
337/// cells were 97% right. Marked as header, ParseBench's table score on the
338/// tuning half went 0.573 -> 0.600 (13 pages up, 6 down) and FinTabNet TEDS
339/// 0.526 -> 0.531 (#641, 01-10-2026).
340///
341/// 0 -- nothing is marked -- when the first row is already figures, when no
342/// row is (a table of words, unless `markdown_header_block_word_table` heads
343/// its first row by position), when the block would be more than four rows
344/// (that is a list of labels, not a header), or when a cell spans rows (the
345/// header is not a band of whole rows then -- unless
346/// `markdown_header_block_rowspan_crossing` narrows the veto to a span
347/// straddling the head boundary).
348///
349/// A ROW OF YEARS IS NOT A FIGURES ROW. A statement heads its columns with the
350/// year each holds (`2012 | 2011 | 2010` under `Year Ended December 31,`), and
351/// a year reads as a figure, so the header stopped above it and the years came
352/// out as body: FinTabNet TEDS 0.5302 -> 0.5362 (43 tables up, 0 down, #574).
353///
354/// A MIXED ROW IS A BODY ROW under `markdown_header_block_mixed_body`: a row
355/// that already holds values beside its labels has turned from label to value,
356/// even below the majority line, so the header ends above it. Years are not
357/// values here: a stubbed year row (`Contractual Obligation | 2008 | 2009`)
358/// heads its columns, so only a non-year figure mixes.
359fn header_block(cells: &[CellAt], switches: crate::learned::Switches) -> usize {
360    let rows = cells.iter().map(|cell| cell.row + 1).max().unwrap_or(0);
361    if rows == 0 {
362        return 0;
363    }
364    let figures = |r: usize| {
365        if is_year_row(cells, r, switches.markdown_header_block_stub_years) {
366            return false;
367        }
368        let texts: Vec<&str> = cells
369            .iter()
370            .filter(|cell| cell.row == r)
371            .map(|cell| cell.text.trim())
372            .filter(|text| !text.is_empty())
373            .collect();
374        if texts.is_empty() {
375            return false;
376        }
377        let counted = texts
378            .iter()
379            .filter(|t| {
380                is_figure(t)
381                    || (switches.markdown_header_block_figures && is_stacked_figure_cell(t))
382            })
383            .count();
384        let valued = texts
385            .iter()
386            .filter(|t| {
387                (is_figure(t)
388                    || (switches.markdown_header_block_figures && is_stacked_figure_cell(t)))
389                    && !is_year_cell(t)
390            })
391            .count();
392        let mixed = valued > 0 && valued < texts.len();
393        2 * counted >= texts.len() || (switches.markdown_header_block_mixed_body && mixed)
394    };
395    let mut head = (0..rows).find(|&r| figures(r)).unwrap_or(rows);
396    if head == rows && switches.markdown_header_block_word_table && rows > 1 {
397        let first_has_text = cells
398            .iter()
399            .filter(|cell| cell.row == 0)
400            .any(|cell| !cell.text.trim().is_empty());
401        if first_has_text {
402            head = 1;
403        }
404    }
405    if head == rows || head > 4 {
406        return 0;
407    }
408    if cells.iter().any(|cell| cell.row_span > 1) {
409        if !switches.markdown_header_block_rowspan_crossing {
410            return 0;
411        }
412        let crosses = cells
413            .iter()
414            .any(|cell| cell.row < head && cell.row + cell.row_span > head);
415        if crosses {
416            return 0;
417        }
418    }
419    head
420}
421
422/// Whether a row is a row of years: every non-empty cell a year, at least two.
423///
424/// Under `markdown_header_block_stub_years` the stub column does not vote: a
425/// row whose value cells are all years is a year row whatever its first cell
426/// holds -- `($ in millions) | 2010 | 2009` heads a statement's columns, and
427/// counting its years as figures ended the header above an empty block (#636).
428fn is_year_row(cells: &[CellAt], row: usize, stub_years: bool) -> bool {
429    let mut all_years = true;
430    let mut years = 0usize;
431    for cell in cells.iter().filter(|cell| cell.row == row) {
432        let text = cell.text.trim();
433        if text.is_empty() {
434            continue;
435        }
436        if !is_year_cell(text) {
437            all_years = false;
438            break;
439        }
440        years += 1;
441    }
442    if all_years && years >= 2 {
443        return true;
444    }
445    if !stub_years {
446        return false;
447    }
448    let mut valued = 0usize;
449    for cell in cells
450        .iter()
451        .filter(|cell| cell.row == row && cell.column != 0)
452    {
453        let text = cell.text.trim();
454        if text.is_empty() {
455            continue;
456        }
457        if !is_year_cell(text) {
458            return false;
459        }
460        valued += 1;
461    }
462    valued >= 2
463}
464
465/// Whether a cell holds a year: `19xx`/`20xx`, optionally followed by a
466/// footnote mark -- `(1)`/`( 1)` with or without the space before it, or `*`.
467fn is_year_cell(text: &str) -> bool {
468    let mut rest = text.trim();
469    if rest.is_empty() {
470        return false;
471    }
472    if let Some(without_star) = rest.strip_suffix('*') {
473        rest = without_star.trim_end();
474        if rest.is_empty() {
475            return false;
476        }
477    }
478    if let Some(open) = rest.find('(') {
479        let (year, note) = rest.split_at(open);
480        if !is_year(year.trim_end()) {
481            return false;
482        }
483        let note = note.trim();
484        if !(note.starts_with('(') && note.ends_with(')')) {
485            return false;
486        }
487        let inner = note[1..note.len() - 1].trim();
488        return !inner.is_empty() && inner.bytes().all(|b| b.is_ascii_digit());
489    }
490    if rest.contains(['(', ')']) {
491        return false;
492    }
493    is_year(rest)
494}
495
496/// Whether a cell holds exactly a four-digit year of the last two centuries.
497fn is_year(text: &str) -> bool {
498    text.len() == 4
499        && (text.starts_with("19") || text.starts_with("20"))
500        && text.bytes().all(|b| b.is_ascii_digit())
501}
502
503/// Whether a cell holds exactly one currency symbol.
504fn is_currency_symbol(text: &str) -> bool {
505    matches!(text.trim(), "$" | "€" | "£" | "¥")
506}
507
508/// Whether a spanning cell covers both the currency column and the one it
509/// joins onto: such a span is a header over the join, not a grid beside it.
510fn contains_the_join(cell: &OwnedCell, join: usize) -> bool {
511    cell.column_span > 1 && cell.column <= join && cell.column + cell.column_span >= join + 2
512}
513
514/// The first column that is only currency symbols, if there is one.
515///
516/// A CURRENCY COLUMN holds nothing but empty cells and exactly one symbol per
517/// cell (`$`, `€`, `£`, `¥`), at least one symbol in the column, and no cell
518/// only PARTLY overlapping it and the column to its right spans columns: a
519/// span that ends at the currency column or starts at the next one means the
520/// grid beside it is not one cell per row, so there is nothing to join onto.
521/// A span that CONTAINS both columns is a header over the join and does not
522/// block it; it shrinks by one with the column it covers (#574).
523fn find_currency_column(cells: &[OwnedCell]) -> Option<usize> {
524    let columns = cells
525        .iter()
526        .map(|cell| cell.column + cell.column_span)
527        .max()
528        .unwrap_or(0);
529    if columns < 2 {
530        return None;
531    }
532    for join in 0..columns - 1 {
533        if cells.iter().any(|cell| {
534            cell.column_span > 1
535                && cell.column < join + 2
536                && cell.column + cell.column_span > join
537                && !contains_the_join(cell, join)
538        }) {
539            continue;
540        }
541        let mut symbol = false;
542        let mut clean = true;
543        for cell in cells
544            .iter()
545            .filter(|cell| cell.column == join && !contains_the_join(cell, join))
546        {
547            let text = cell.text.trim();
548            if text.is_empty() {
549                continue;
550            }
551            if is_currency_symbol(text) {
552                symbol = true;
553            } else {
554                clean = false;
555                break;
556            }
557        }
558        if clean && symbol {
559            return Some(join);
560        }
561    }
562    None
563}
564
565/// Join every currency column onto the column to its right.
566///
567/// A statement sets its currency in a column of its own (`$` beside `115`),
568/// and written as its own column the symbol reads as a column with no header
569/// and the figure as a figure with no unit. Each symbol is prefixed to the
570/// cell to its right (`$ 115`), the column is removed, and the columns after
571/// it shift down by one, until no currency column remains: FinTabNet TEDS
572/// 0.5302 -> 0.5406 (33 up, 0 down) and ParseBench 0.6001 -> 0.6022 (#574).
573///
574/// A header span over the join keeps covering the same figures: its
575/// `column_span` shrinks by one with the column it loses.
576fn join_currency_columns(cells: &mut Vec<OwnedCell>) {
577    while let Some(join) = find_currency_column(cells) {
578        let symbols: Vec<(usize, String, bool)> = cells
579            .iter()
580            .filter(|cell| cell.column == join)
581            .filter_map(|cell| {
582                let text = cell.text.trim();
583                if is_currency_symbol(text) {
584                    Some((cell.row, text.to_string(), cell.header))
585                } else {
586                    None
587                }
588            })
589            .collect();
590        for (row, symbol, header) in symbols {
591            match cells
592                .iter_mut()
593                .find(|cell| cell.row == row && cell.column == join + 1)
594            {
595                Some(right) => {
596                    let text = right.text.trim();
597                    right.text = if text.is_empty() {
598                        symbol
599                    } else {
600                        format!("{symbol} {text}")
601                    };
602                }
603                None => cells.push(OwnedCell {
604                    row,
605                    column: join + 1,
606                    row_span: 1,
607                    column_span: 1,
608                    header,
609                    text: symbol,
610                }),
611            }
612        }
613        cells.retain(|cell| cell.column != join || contains_the_join(cell, join));
614        for cell in cells
615            .iter_mut()
616            .filter(|cell| contains_the_join(cell, join))
617        {
618            cell.column_span -= 1;
619        }
620        for cell in cells.iter_mut().filter(|cell| cell.column > join) {
621            cell.column -= 1;
622        }
623    }
624}
625
626/// Whether a cell holds a figure: an amount, a percentage, a year, a dash for
627/// nil, or `n/a`.
628pub(crate) fn is_figure(text: &str) -> bool {
629    let t = text.trim();
630    if t.eq_ignore_ascii_case("n/a") || t.eq_ignore_ascii_case("na") {
631        return true;
632    }
633    if !t.is_empty() && t.chars().all(|c| matches!(c, '-' | '–' | '—')) {
634        return true;
635    }
636    let core = t
637        .trim_start_matches(|c: char| c.is_whitespace() || "$€£(-–—+".contains(c))
638        .trim_end_matches(|c: char| c.is_whitespace() || "%*)".contains(c));
639    !core.is_empty()
640        && core.chars().any(|c| c.is_ascii_digit())
641        && core
642            .chars()
643            .all(|c| c.is_ascii_digit() || c == '.' || c == ',')
644}
645
646/// Whether a cell holds figures run together: every whitespace-separated
647/// token a figure or a nil dash, with or without a currency symbol --
648/// `86,090 (4,305)`, `$—`, `$ —`, `$ 990`
649/// (`markdown_header_block_figures`, #574).
650///
651/// Two text lines fused into one row read as one cell, and `is_figure` fails
652/// on the inner space -- so the fused body rows read as header. A bare
653/// currency glyph is an amount's half, not a figure on its own: `$` alone is
654/// no figure here, as under `is_figure`.
655fn is_stacked_figure_cell(text: &str) -> bool {
656    let mut substance = false;
657    for token in text.split_whitespace() {
658        if is_figure(token) {
659            substance = true;
660            continue;
661        }
662        // A currency symbol plus a nil dash, which `is_figure` strips to
663        // empty: `$—`, `€ -`'s `-` beside its `€`.
664        let rest = token.trim_start_matches(['$', '€', '£', '¥', '¢', '₹']);
665        if rest.len() < token.len()
666            && !rest.is_empty()
667            && rest.chars().all(|c| matches!(c, '-' | '–' | '—'))
668        {
669            substance = true;
670            continue;
671        }
672        // A bare currency glyph beside the figures: `$` in `$ —`, `$ 990`.
673        if !token.is_empty() && token.chars().all(|c| "$€£¥¢₹".contains(c)) {
674            continue;
675        }
676        return false;
677    }
678    substance
679}
680
681/// The rectangular case: a pipe table, its first row the header row.
682fn markdown_table(cells: &[CellAt], rows: usize, columns: usize) -> String {
683    let mut out = String::new();
684    for r in 0..rows {
685        out.push('|');
686        for c in 0..columns {
687            let text = cells
688                .iter()
689                .find(|cell| cell.row == r && cell.column == c)
690                .map(|cell| cell.text)
691                .unwrap_or_default();
692            out.push(' ');
693            out.push_str(&escape_cell(&cell_text(text, columns)));
694            out.push_str(" |");
695        }
696        out.push('\n');
697        if r == 0 {
698            out.push('|');
699            for _ in 0..columns {
700                out.push_str(" --- |");
701            }
702            out.push('\n');
703        }
704    }
705    out
706}
707
708/// Everything else: an HTML table, which can say `colspan`, `rowspan` and the
709/// difference between a header cell and a body cell.
710///
711/// WALKED COLUMN BY COLUMN, not cell by cell. An HTML row has no way to say
712/// "this cell is in the third column"; position is counted from the cells
713/// before it. So a row whose second cell is missing -- which is the commonest
714/// thing a grid that is not a rectangle has in it -- would put its third cell
715/// in the second column of every reader, and the `27%` under `Amount` would
716/// come out under `Region` (codex, round 1). An empty `td` holds the place, and
717/// a column a cell above reaches down into holds it without one.
718fn html_table(cells: &[CellAt], rows: usize, columns: usize) -> String {
719    let mut taken = vec![vec![false; columns]; rows];
720    let mut out = String::from("<table>\n");
721    for r in 0..rows {
722        out.push_str("<tr>");
723        let mut c = 0usize;
724        while c < columns {
725            if taken[r][c] {
726                c += 1;
727                continue;
728            }
729            let Some(cell) = cells.iter().find(|cell| cell.row == r && cell.column == c) else {
730                out.push_str("<td></td>");
731                c += 1;
732                continue;
733            };
734            for row in taken.iter_mut().take((r + cell.row_span).min(rows)).skip(r) {
735                for held in row
736                    .iter_mut()
737                    .take((c + cell.column_span).min(columns))
738                    .skip(c)
739                {
740                    *held = true;
741                }
742            }
743            let tag = if cell.header { "th" } else { "td" };
744            out.push('<');
745            out.push_str(tag);
746            if cell.column_span > 1 {
747                out.push_str(&format!(" colspan=\"{}\"", cell.column_span));
748            }
749            if cell.row_span > 1 {
750                out.push_str(&format!(" rowspan=\"{}\"", cell.row_span));
751            }
752            out.push('>');
753            out.push_str(&escape_html(&cell_text(cell.text, columns)));
754            out.push_str(&format!("</{tag}>"));
755            c += cell.column_span.max(1);
756        }
757        out.push_str("</tr>\n");
758    }
759    out.push_str("</table>");
760    out
761}
762
763/// A cell's text as the table writes it: leaders out of a statement, kept in a
764/// contents list.
765///
766/// A table of two columns is a label and its number -- a table of contents
767/// (`Dell . . . . 16`), a citation list -- and there the dots are what the
768/// line is made of: the DocLayNet truth keeps them, and stripping them cost
769/// three such tables their match on the proxy (tables 0.657 -> 0.636). A
770/// statement has a label and several figures, and there they are layout.
771fn cell_text(text: &str, columns: usize) -> String {
772    if columns >= 3 {
773        without_leaders(text)
774    } else {
775        text.to_string()
776    }
777}
778
779/// A cell's text without its dot leaders.
780///
781/// A financial statement runs a row label out to its figures on dots --
782/// `United States . . . . ........ $11,772` -- and the leaders land in the
783/// label's cell. They are layout, not content: on 614 FinTabNet tables (train
784/// side, companies no engine model saw) they cost 0.093 TEDS, and 82 tables
785/// were not matched to their truth at all because the dots outweighed the
786/// words (01-10-2026, #641). A run of four or more dots with at most one
787/// whitespace between them is a leader -- the rule `judge::has_leader` uses --
788/// so an ellipsis (`...`) and a decimal point stay.
789fn without_leaders(text: &str) -> String {
790    let chars: Vec<char> = text.chars().collect();
791    let mut out = String::with_capacity(text.len());
792    let mut i = 0usize;
793    while i < chars.len() {
794        if chars[i] == '.' {
795            // the run: dots, each followed by at most one whitespace
796            let mut j = i;
797            let mut dots = 0usize;
798            while j < chars.len() && chars[j] == '.' {
799                dots += 1;
800                j += 1;
801                if j < chars.len() && chars[j].is_whitespace() && chars.get(j + 1) == Some(&'.') {
802                    j += 1;
803                }
804            }
805            if dots >= 4 {
806                out.push(' ');
807                i = j;
808                continue;
809            }
810        }
811        out.push(chars[i]);
812        i += 1;
813    }
814    out.split_whitespace().collect::<Vec<&str>>().join(" ")
815}
816
817/// A cell's text inside a pipe table: a pipe of its own would end the cell, and
818/// a line break would end the row.
819fn escape_cell(text: &str) -> String {
820    let mut out = String::with_capacity(text.len());
821    for c in text.chars() {
822        match c {
823            '|' => out.push_str("\\|"),
824            '\n' | '\r' => out.push(' '),
825            _ => out.push(c),
826        }
827    }
828    out
829}
830
831/// A cell's text inside an HTML table.
832fn escape_html(text: &str) -> String {
833    let mut out = String::with_capacity(text.len());
834    for c in text.chars() {
835        match c {
836            '&' => out.push_str("&amp;"),
837            '<' => out.push_str("&lt;"),
838            '>' => out.push_str("&gt;"),
839            _ => out.push(c),
840        }
841    }
842    out
843}
844
845/// How many digits an ordered list marker may open with before it stops being
846/// one. Nine, which is what CommonMark reads: `1234567890.` is a figure and a
847/// full stop.
848const ORDINAL_MAX_DIGITS: usize = 9;
849
850/// Whether a line opening with `marker` really opens a marker.
851///
852/// A bullet, an ATX heading and an ordered item are all the character AND a
853/// space after it -- or the end of the line, which is an empty item and an
854/// empty heading. Without that clause every figure a document sets at the head
855/// of a line (`-3,503`, `0.1`, `#4`) is written with a backslash in front of
856/// it, and the backslash is in the text layer of everything downstream: the
857/// benchmark's reader, a chunker, an embedding.
858///
859/// THE RULE OF THREE IS THE OTHER HALF. `---` and `***` are a thematic break
860/// with no space anywhere in them, and `---` alone under a paragraph is a
861/// setext heading; a line that is nothing but three or more of the marker is
862/// escaped whatever follows it.
863fn opens_a_marker(line: &str, marker: char) -> bool {
864    // AN ATX HEADING OPENS WITH A RUN, and the space is asked for after the
865    // run rather than after the first hash: `## Terms` is a level-two heading
866    // and has to keep its backslash. Six is where CommonMark stops counting,
867    // so a seventh hash makes the line prose and no escape is owed.
868    let run = if marker == '#' {
869        line.chars().take_while(|c| *c == '#').count().min(6)
870    } else {
871        marker.len_utf8()
872    };
873    let rest = &line[run..];
874    if matches!(rest.chars().next(), None | Some(' ') | Some('\t')) {
875        return true;
876    }
877    matches!(marker, '-' | '*')
878        && line.chars().filter(|c| !c.is_whitespace()).count() >= 3
879        && line.chars().all(|c| c == marker || c.is_whitespace())
880}
881
882/// A paragraph's own text, with anything that would read as a marker at the
883/// start of a line backslashed.
884fn escape_leading_marker(text: &str) -> String {
885    let trimmed = text.trim_start();
886    let indent = &text[..text.len() - trimmed.len()];
887    // A fence opener first: three or more of the same character, which the
888    // single-character arms below would leave as a fence of two.
889    for fence in ['`', '~'] {
890        if trimmed.starts_with([fence, fence, fence].iter().collect::<String>().as_str()) {
891            return format!("{indent}\\{trimmed}");
892        }
893    }
894    let mut chars = trimmed.chars();
895    let escaped = match chars.next() {
896        // A blockquote, a table row and our own page marker need no space
897        // after them: the character alone is the syntax.
898        Some(c @ ('>' | '|' | '`' | '~' | '<')) => format!("\\{c}{}", chars.as_str()),
899        // A bullet and an ATX heading do. `- hire purchase` is a list item and
900        // `-3,503` is a figure; `# 4` is a heading and `#4` is not one in any
901        // reader this milestone is measured through. An escape that changes
902        // nothing is a backslash in the text layer, and the round of #579
903        // counted 79 of them over 200 pages.
904        Some(c @ ('#' | '-' | '*' | '+')) if opens_a_marker(trimmed, c) => {
905            format!("\\{c}{}", chars.as_str())
906        }
907        Some(c) if c.is_ascii_digit() => {
908            // `1. Introduction` is a list item; `1984 was` and `0.1` are not.
909            let rest = chars.as_str();
910            let digits = trimmed.chars().take_while(|c| c.is_ascii_digit()).count();
911            let after = trimmed[digits..].chars().next();
912            let delimited = matches!(after, Some('.') | Some(')'))
913                && opens_a_marker(&trimmed[digits..], after.unwrap_or('.'));
914            if digits <= ORDINAL_MAX_DIGITS && delimited {
915                format!("{}\\{}", &trimmed[..digits], &trimmed[digits..])
916            } else {
917                format!("{c}{rest}")
918            }
919        }
920        Some(c) => format!("{c}{}", chars.as_str()),
921        None => String::new(),
922    };
923    format!("{indent}{escaped}")
924}
925
926/// The marker a page change writes, `page` being the 0-based `Block::page` of
927/// the page that OPENS -- the same number the JSON form carries.
928///
929/// A comment and not a rule (`---`): a thematic break is content, and a reader
930/// that renders the markdown would print a line the document does not have.
931fn page_marker(page: usize) -> String {
932    format!("<!-- page {page} -->")
933}
934
935/// Write the page marker where the page has changed, and remember the page.
936///
937/// The open list items are dropped with it: an HTML block ends a list in
938/// CommonMark whatever follows, so the items after the marker open a list of
939/// their own, and an item still indented under a parent that no longer exists
940/// is an indented code block -- every word of it out of the text layer.
941///
942/// With [`PageSeparators::None`] nothing is written -- the caller separates
943/// the paragraphs with a blank line as it does within a page -- but the page
944/// is still remembered, and the list stays open: with no HTML block between
945/// them the items on either side are one list.
946fn note_page(
947    out: &mut String,
948    open: &mut Vec<(usize, usize)>,
949    last: &mut Option<usize>,
950    page: usize,
951    separators: PageSeparators,
952) {
953    if *last == Some(page) {
954        return;
955    }
956    if last.is_some() && !out.is_empty() && separators == PageSeparators::Comment {
957        out.push_str("\n\n");
958        out.push_str(&page_marker(page));
959        open.clear();
960    }
961    *last = Some(page);
962}
963
964/// A character of a right-to-left script (Hebrew, Arabic and their
965/// presentation forms).
966fn is_right_to_left(c: char) -> bool {
967    matches!(c as u32, 0x0590..=0x08FF | 0xFB1D..=0xFDFF | 0xFE70..=0xFEFF)
968}
969
970/// The first place from `from` where `run` stands in `text` as whole words: no
971/// letter or digit touching it on either side.
972fn whole_at(text: &str, run: &str, from: usize) -> Option<usize> {
973    let mut at = from;
974    while let Some(found) = text[at..].find(run) {
975        let start = at + found;
976        let end = start + run.len();
977        let before = text[..start].chars().next_back();
978        let after = text[end..].chars().next();
979        if !before.is_some_and(char::is_alphanumeric) && !after.is_some_and(char::is_alphanumeric) {
980            return Some(start);
981        }
982        at = start + run.chars().next().map_or(1, char::len_utf8);
983    }
984    None
985}
986
987/// How a run of text was set, as far as Markdown can say it (#628, ParseBench's
988/// formatting axis): weight, slant, and a raised or lowered baseline.
989#[derive(Clone, Copy, PartialEq, Eq)]
990enum Set {
991    Bold,
992    Italic,
993    BoldItalic,
994    Sup,
995    Sub,
996}
997
998impl Set {
999    fn marks(self) -> (&'static str, &'static str) {
1000        match self {
1001            Set::Bold => ("**", "**"),
1002            Set::Italic => ("*", "*"),
1003            Set::BoldItalic => ("***", "***"),
1004            Set::Sup => ("<sup>", "</sup>"),
1005            Set::Sub => ("<sub>", "</sub>"),
1006        }
1007    }
1008}
1009
1010/// A code span: its byte range in the block's text.
1011type CodeSpan = (usize, usize);
1012
1013/// A style mark: its byte range, the setting, and whether it is underlined.
1014type StyleMark = (usize, usize, Option<Set>, bool);
1015
1016/// The block's text with its bold, italic, superscript and subscript runs
1017/// marked, from the spans the engine already reads (#628).
1018///
1019/// The text is the logical reading, with lines joined and broken words put
1020/// back; the spans are the page's runs. So each marked span is FOUND in the
1021/// text, in order, and a span that cannot be found (a word the line break cut)
1022/// is left unmarked rather than guessed at. A run whose own characters are
1023/// Markdown or HTML syntax is left unmarked too: marking it would need escapes
1024/// the rest of this serializer does not write. Strikethrough is not here -- the
1025/// spans carry no drawn line to read it from.
1026///
1027/// A raised or lowered run is one of at most four characters, set at most 0.85
1028/// of the size of the run before it on the same line, whose baseline moved by
1029/// at least a fifth (up) or a tenth (down) of that run's size.
1030/// A body block's line: escaped where it opens like a marker, styled otherwise.
1031///
1032/// NOT BOTH (#628). A paragraph whose own words open `3. ` is escaped (`3\. `)
1033/// so that no reader takes it for a list item -- and a bold `3.` wrapped as
1034/// `**3.** ...` escapes nothing, because the line no longer opens with the
1035/// digit, while any reader that reads through the emphasis sees `3. ...` again:
1036/// on nine DocLayNet pages of the round of 27-09 such lines read as list items;
1037/// our lists fell 0.627 -> 0.618. A line that needs its escape keeps it, unstyled.
1038///
1039/// CODE IS THE ONE MARK THAT STANDS IN FRONT OF AN ESCAPE (#636). A line the
1040/// document set in monospace opens with OUR fence after marking, and a `#` or
1041/// a digit inside backticks is literal text no reader takes for a heading or
1042/// an item: the span neutralizes the marker, so no escape is owed. Escaping it
1043/// instead would break the span it opens.
1044fn body_of(block: &Block, text: &str, rules: &[HorizontalRule], code: bool) -> String {
1045    let (spans, styles) = rich_marks(block, text, rules, code);
1046    let rich = emit_rich(text, &styles, &spans);
1047    if spans.first().is_some_and(|(start, _)| *start == 0) {
1048        return rich;
1049    }
1050    let escaped = escape_leading_marker(text);
1051    if escaped != text {
1052        escaped
1053    } else {
1054        escape_leading_marker(&rich)
1055    }
1056}
1057
1058/// `text` with its style and code marks, for a line that continues a paragraph
1059/// rather than opening one: the second half of a paragraph the page broke in
1060/// two. No escape is owed mid-line.
1061fn rich_text(block: &Block, text: &str, rules: &[HorizontalRule], code: bool) -> String {
1062    let (spans, styles) = rich_marks(block, text, rules, code);
1063    emit_rich(text, &styles, &spans)
1064}
1065
1066/// The code spans of `text` where they are read, and its style marks outside
1067/// them: code wins every overlap, because a bold marker inside backticks is
1068/// literal text, not emphasis.
1069fn rich_marks(
1070    block: &Block,
1071    text: &str,
1072    rules: &[HorizontalRule],
1073    code: bool,
1074) -> (Vec<CodeSpan>, Vec<StyleMark>) {
1075    let spans = if code && code_here(block, text) {
1076        code_ranges(block, text)
1077    } else {
1078        Vec::new()
1079    };
1080    let mut styles = styled_ranges(block, text, rules);
1081    styles.retain(|(start, end, _, _)| !spans.iter().any(|(cs, ce)| *start < *ce && *cs < *end));
1082    (spans, styles)
1083}
1084
1085/// Whether inline code is read in this block at all: a paragraph or a list
1086/// item, of words rather than figures, set left to right (#636). Headings and
1087/// table cells never are -- their arms do not call here -- and neither is a
1088/// caption or a right-to-left block, whose spans are not in the text's order.
1089fn code_here(block: &Block, text: &str) -> bool {
1090    matches!(
1091        block.kind,
1092        BlockKind::Paragraph | BlockKind::ListItem { .. }
1093    ) && !mostly_digits(text)
1094        && !text.chars().any(is_right_to_left)
1095}
1096
1097/// Whether `text` is tabular figures rather than words: more than half its
1098/// non-space characters are digits or punctuation (#636). Financial reports set
1099/// their figures in monospace faces, and a column of amounts is not code.
1100fn mostly_digits(text: &str) -> bool {
1101    let mut total = 0usize;
1102    let mut figures = 0usize;
1103    for c in text.chars() {
1104        if c.is_whitespace() {
1105            continue;
1106        }
1107        total += 1;
1108        if c.is_ascii_digit() || c.is_ascii_punctuation() {
1109            figures += 1;
1110        }
1111    }
1112    total > 0 && 2 * figures > total
1113}
1114
1115/// The byte ranges of `text` set in a monospace face, in order (#636).
1116///
1117/// Like [`styled_ranges`], each span is FOUND in the text in draw order, and a
1118/// span the line breaks cut apart is left unmarked rather than guessed at.
1119/// Unlike it, the match is not asked to be whole words: a backtick opens
1120/// anywhere, where an asterisk mid-word opens nothing. A backtick of the
1121/// document's own is fenced rather than skipped, and neighbouring monospace
1122/// spans apart by white space only are one span, the way neighbouring bold
1123/// runs are one mark.
1124fn code_ranges(block: &Block, text: &str) -> Vec<CodeSpan> {
1125    let mut ranges: Vec<CodeSpan> = Vec::new();
1126    let mut cursor = 0usize;
1127    for span in &block.spans {
1128        let run = span.text.trim();
1129        if run.is_empty() {
1130            continue;
1131        }
1132        let Some(found) = text[cursor..].find(run) else {
1133            continue;
1134        };
1135        let start = cursor + found;
1136        let end = start + run.len();
1137        cursor = end;
1138        if span.is_monospace != Some(true) {
1139            continue;
1140        }
1141        match ranges.last_mut() {
1142            Some(last) if text[last.1..start].trim().is_empty() => last.1 = end,
1143            _ => ranges.push((start, end)),
1144        }
1145    }
1146    // Like a style mark, a code span never closes after a trailing hyphen: the
1147    // next block may continue this paragraph across the page, and the join
1148    // looks at the last character written.
1149    if let Some(last) = ranges.last() {
1150        if last.1 == text.len() && text.ends_with('-') {
1151            ranges.pop();
1152        }
1153    }
1154    ranges
1155}
1156
1157/// The longest run of `` ` `` in `text`.
1158fn longest_backtick_run(text: &str) -> usize {
1159    let mut best = 0usize;
1160    let mut run = 0usize;
1161    for c in text.chars() {
1162        if c == '`' {
1163            run += 1;
1164            best = best.max(run);
1165        } else {
1166            run = 0;
1167        }
1168    }
1169    best
1170}
1171
1172/// `content` as an inline code span: a backtick fence one longer than any run
1173/// inside it, as CommonMark wants, with a space of padding where the content
1174/// itself opens or closes with a backtick.
1175fn inline_code(content: &str) -> String {
1176    let fence = "`".repeat(longest_backtick_run(content) + 1);
1177    if content.starts_with('`') || content.ends_with('`') {
1178        format!("{fence} {content} {fence}")
1179    } else {
1180        format!("{fence}{content}{fence}")
1181    }
1182}
1183
1184/// A paragraph set wholly in a monospace face over two lines or more, as its
1185/// lines, top first (#636). `None` everywhere else: a list item keeps inline
1186/// spans, tables and headings never read as code, and neither does tabular
1187fn fenced_lines(block: &Block) -> Option<Vec<String>> {
1188    if !matches!(block.kind, BlockKind::Paragraph) {
1189        return None;
1190    }
1191    if block.text.chars().any(is_right_to_left) || mostly_digits(&block.text) {
1192        return None;
1193    }
1194    if !block.spans.iter().any(|s| !s.text.trim().is_empty())
1195        || block
1196            .spans
1197            .iter()
1198            .any(|s| !s.text.trim().is_empty() && s.is_monospace != Some(true))
1199    {
1200        return None;
1201    }
1202    let lines: Vec<String> = crate::text::lines_of_block(block)
1203        .iter()
1204        .map(|line| crate::text::line_text(block, line))
1205        .map(|line| line.trim_end().to_string())
1206        .filter(|line| !line.trim().is_empty())
1207        .collect();
1208    (lines.len() >= 2).then_some(lines)
1209}
1210
1211/// Those lines as a fenced block: the fence on its own lines, one backtick
1212/// longer than any run the lines hold.
1213fn fenced_block(lines: &[String]) -> String {
1214    let fence = "`".repeat(
1215        lines
1216            .iter()
1217            .map(|line| longest_backtick_run(line))
1218            .max()
1219            .unwrap_or(0)
1220            .max(2)
1221            + 1,
1222    );
1223    format!("{fence}\n{}\n{fence}", lines.join("\n"))
1224}
1225
1226/// Whether a drawn rule runs under `span` as its underline (#623, ParseBench's
1227/// `is_underline`: we wrote none, 0.000 against pymupdf4llm's 0.335).
1228///
1229/// The rule sits just under the baseline -- up to a third of an em below it,
1230/// or a twentieth above for a rule drawn through the descenders -- and covers
1231/// most of the run. And it is no wider than the run's own LINE, give or take
1232/// an em: a table border or a separator under a heading runs past the words
1233/// above it, an underline stops with them.
1234fn underlined(span: &TextSpan, line: (f64, f64), rules: &[HorizontalRule]) -> bool {
1235    let size = span.font_size;
1236    if size <= 0.0 || span.width <= 0.0 {
1237        return false;
1238    }
1239    let (left, right) = (span.x, span.x + span.width);
1240    rules.iter().any(|r| {
1241        r.y <= span.y + 0.05 * size
1242            && r.y >= span.y - 0.35 * size
1243            && r.left <= left + 0.1 * span.width
1244            && r.right >= right - 0.1 * span.width
1245            && r.left >= line.0 - size
1246            && r.right <= line.1 + size
1247    })
1248}
1249
1250/// The horizontal extent of the spans on `span`'s baseline in this block.
1251fn line_of(block: &Block, span: &TextSpan) -> (f64, f64) {
1252    block
1253        .spans
1254        .iter()
1255        .filter(|s| (s.y - span.y).abs() <= 0.2 * span.font_size.max(1.0))
1256        .fold((span.x, span.x + span.width), |(l, r), s| {
1257            (l.min(s.x), r.max(s.x + s.width))
1258        })
1259}
1260
1261fn page_rules<'a>(rules: &'a [Vec<HorizontalRule>], block: &Block) -> &'a [HorizontalRule] {
1262    rules.get(block.page).map_or(&[], Vec::as_slice)
1263}
1264
1265/// The style marks alone, for the tests that pin them: production writes
1266/// styles beside code through [`rich_marks`].
1267#[cfg(test)]
1268fn styled(block: &Block, text: &str, rules: &[HorizontalRule]) -> String {
1269    emit_rich(text, &styled_ranges(block, text, rules), &[])
1270}
1271
1272/// The byte ranges of `text` carrying a style mark, in order: the setting and
1273/// whether a drawn rule underlines it. [`styled`] writes these alone; the body
1274/// writes them beside the code spans, outside every one of them.
1275fn styled_ranges(block: &Block, text: &str, rules: &[HorizontalRule]) -> Vec<StyleMark> {
1276    // RIGHT-TO-LEFT TEXT IS LEFT UNMARKED (codex, #1999): the block's text is
1277    // reordered into logical order and its spans are not, so matching one
1278    // against the other in sequence would drop or misplace marks.
1279    if text.chars().any(is_right_to_left) {
1280        return Vec::new();
1281    }
1282    let mut ranges: Vec<StyleMark> = Vec::new();
1283    let mut cursor = 0usize;
1284    let mut previous: Option<&crate::text::TextSpan> = None;
1285    for (index, span) in block.spans.iter().enumerate() {
1286        let run = span.text.trim();
1287        let before = previous;
1288        previous = Some(span);
1289        if run.is_empty() || run.contains(['*', '_', '<', '>', '`', '[', ']', '\\', '~']) {
1290            continue;
1291        }
1292        let raised = before.and_then(|prev| {
1293            let small = span.font_size > 0.0
1294                && prev.font_size > 0.0
1295                && span.font_size <= 0.85 * prev.font_size
1296                && run.chars().count() <= 4;
1297            if !small {
1298                return None;
1299            }
1300            let shift = span.y - prev.y;
1301            // ON THE SAME LINE (codex, #1999): the run before it may end the
1302            // line above, and a line's own step is larger than any shift.
1303            if shift.abs() >= 0.8 * prev.font_size || span.x < prev.x {
1304                return None;
1305            }
1306            if shift >= 0.2 * prev.font_size {
1307                Some(Set::Sup)
1308            } else if shift <= -0.1 * prev.font_size {
1309                Some(Set::Sub)
1310            } else {
1311                None
1312            }
1313        });
1314        let set = raised.or(match (span.is_bold, span.is_italic) {
1315            (true, true) => Some(Set::BoldItalic),
1316            (true, false) => Some(Set::Bold),
1317            (false, true) => Some(Set::Italic),
1318            (false, false) => None,
1319        });
1320        // FOUND WHOLE: a run that matches only inside a word (`hyphen-` in
1321        // `hyphen-ated`, which the line break made) would mark half a word.
1322        // A raised or lowered run sits inside its word (`H2O`, `m2`) by nature.
1323        //
1324        // JOINED WHOLE comes first: the block rebuild joins spans the shared
1325        // word rule joins, so a sub-word span (`2` in `2025`) never matches
1326        // whole -- but it still sits exactly at the cursor, past the blanks
1327        // the whitespace spans left behind, when the spans and the text
1328        // agree. Placing it there never jumps the cursor the way a later
1329        // whole match (`.` in `U.S.` finding `office .`) would; the same
1330        // rule that joined it tells half a word (`hyphen-` off its line)
1331        // from a word the page drew in pieces.
1332        let place = if matches!(set, Some(Set::Sup | Set::Sub)) {
1333            text[cursor..].find(run).map(|found| cursor + found)
1334        } else {
1335            let rest = &text[cursor..];
1336            let skipped = rest
1337                .find(|c: char| !c.is_whitespace())
1338                .unwrap_or(rest.len());
1339            let at = cursor + skipped;
1340            let joined = text[at..].starts_with(run)
1341                && (before.is_some_and(|prev| crate::words_join::spans_join(prev, span))
1342                    || block
1343                        .spans
1344                        .get(index + 1)
1345                        .is_some_and(|next| crate::words_join::spans_join(span, next)));
1346            if joined {
1347                Some(at)
1348            } else {
1349                whole_at(text, run, cursor)
1350            }
1351        };
1352        let Some(start) = place else {
1353            continue;
1354        };
1355        let end = start + run.len();
1356        cursor = end;
1357        let under = !rules.is_empty() && underlined(span, line_of(block, span), rules);
1358        if set.is_some() || under {
1359            match ranges.last_mut() {
1360                // Neighbouring runs of one setting, apart by white space only,
1361                // are one run: `**two words**`, not `**two** **words**`.
1362                Some(last)
1363                    if last.2 == set
1364                        && last.3 == under
1365                        && text[last.1..start].trim().is_empty() =>
1366                {
1367                    last.1 = end
1368                }
1369                _ => ranges.push((start, end, set, under)),
1370            }
1371        }
1372    }
1373    // A MARK NEVER CLOSES AFTER A TRAILING HYPHEN (codex, #1999): the next
1374    // block may continue this paragraph across the page, and the join looks at
1375    // the last character written -- `**hyphen-**` hides the hyphen it needs.
1376    if let Some(last) = ranges.last() {
1377        if last.1 == text.len() && text.ends_with('-') {
1378            ranges.pop();
1379        }
1380    }
1381    ranges
1382}
1383
1384/// `text` with its style marks and its code spans, both in byte order. The two
1385/// never overlap -- code wins every overlap before this is called -- so one
1386/// walk writes both.
1387fn emit_rich(text: &str, styles: &[StyleMark], code: &[CodeSpan]) -> String {
1388    if styles.is_empty() && code.is_empty() {
1389        return text.to_string();
1390    }
1391    let mut out = String::with_capacity(text.len() + styles.len() * 6 + code.len() * 2);
1392    let mut at = 0usize;
1393    let mut styles = styles;
1394    let mut code = code;
1395    loop {
1396        let take_style = match (styles.first(), code.first()) {
1397            (Some((s, _, _, _)), Some((c, _))) => *s < *c,
1398            (Some(_), None) => true,
1399            (None, Some(_)) => false,
1400            (None, None) => break,
1401        };
1402        if take_style {
1403            let (start, end, set, under) = styles[0];
1404            styles = &styles[1..];
1405            let (open, close) = set.map_or(("", ""), Set::marks);
1406            out.push_str(&text[at..start]);
1407            out.push_str(open);
1408            if under {
1409                out.push_str("<u>");
1410            }
1411            out.push_str(&text[start..end]);
1412            if under {
1413                out.push_str("</u>");
1414            }
1415            out.push_str(close);
1416            at = end;
1417        } else {
1418            let (start, end) = code[0];
1419            code = &code[1..];
1420            out.push_str(&text[at..start]);
1421            out.push_str(&inline_code(&text[start..end]));
1422            at = end;
1423        }
1424    }
1425    out.push_str(&text[at..]);
1426    out
1427}
1428
1429/// Which blocks reach the Markdown, and how a change of page is marked.
1430///
1431/// The switches [`ExtractOptions`](crate::ExtractOptions) carries for
1432/// [`PdfDocument::extract_markdown`](crate::PdfDocument::extract_markdown),
1433/// as the `to_markdown_with_*_options` functions below take them. Default is
1434/// today's output: no furniture, and every page change a comment.
1435///
1436/// `#[non_exhaustive]`, so a switch added here cannot break a caller that
1437/// never named it: start from [`Default`] and assign the fields you want.
1438#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1439#[non_exhaustive]
1440pub struct MarkdownOptions {
1441    /// Whether [`BlockKind::Header`] blocks are written as plain paragraphs
1442    /// at their reading-order position. Default false: they are left out,
1443    /// exactly as without the option. Never as headings: a running head is
1444    /// not a section title.
1445    pub include_headers: bool,
1446    /// Whether [`BlockKind::Footer`] blocks are written as plain paragraphs
1447    /// at their reading-order position. Default false: they are left out,
1448    /// exactly as without the option. Never as headings either.
1449    pub include_footers: bool,
1450    /// How a change of page is marked. Default
1451    /// [`PageSeparators::Comment`]: today's `<!-- page N -->` marker.
1452    pub page_separators: PageSeparators,
1453    /// Whether monospace runs reach the Markdown as code. Default true:
1454    /// `` `like this` `` in a paragraph, a fenced block for a paragraph
1455    /// set wholly in one. False restores the output from before code
1456    /// existed, byte for byte.
1457    pub code: bool,
1458    /// Whether figures are written as image references. Default true.
1459    ///
1460    /// False writes no reference at all, the way the furniture reads
1461    /// without its switches.
1462    pub include_figures: bool,
1463    /// How tables reach the Markdown. Default [`TableRenderMode::Auto`]:
1464    /// pipes where Markdown holds the table's shape, HTML elsewhere --
1465    /// today's output, byte for byte.
1466    pub table_render_mode: TableRenderMode,
1467}
1468
1469impl Default for MarkdownOptions {
1470    /// Today's output: no furniture, every page change a comment, and
1471    /// monospace as code.
1472    fn default() -> Self {
1473        Self {
1474            include_headers: false,
1475            include_footers: false,
1476            page_separators: PageSeparators::Comment,
1477            code: default_code(),
1478            include_figures: true,
1479            table_render_mode: TableRenderMode::Auto,
1480        }
1481    }
1482}
1483
1484impl From<&ExtractOptions> for MarkdownOptions {
1485    /// The markdown half of an [`ExtractOptions`](crate::ExtractOptions):
1486    /// what `extract_markdown` serializes with.
1487    fn from(options: &ExtractOptions) -> Self {
1488        Self {
1489            include_headers: options.include_headers,
1490            include_footers: options.include_footers,
1491            page_separators: options.page_separators,
1492            code: options.code,
1493            include_figures: options.include_figures,
1494            table_render_mode: options.table_render_mode,
1495        }
1496    }
1497}
1498
1499/// Every block as markdown, blocks separated by a blank line.
1500///
1501/// Preview: output quality is still improving and the API may change in a minor release.
1502///
1503/// Headings become `#`..`###`, list items `-` and `1.`, a table a pipe table
1504/// or an HTML one, a page change a comment, and the furniture nothing at all.
1505/// [`to_markdown_with_options`] is the same serialisation with the
1506/// switches: furniture as plain paragraphs, and no page marker at all.
1507/// Monospace runs become code, as [`ExtractOptions::code`] says by default
1508/// (see [`to_markdown_with_code`] for the switch).
1509///
1510/// Deterministic: the same blocks produce the same bytes, which is what makes a
1511/// golden test over it possible at all (#572).
1512///
1513/// [`ExtractOptions::code`]: crate::blocks::ExtractOptions::code
1514pub fn to_markdown(blocks: &[Block]) -> String {
1515    to_markdown_with_options(blocks, &MarkdownOptions::default())
1516}
1517
1518/// [`to_markdown`], with the monospace runs as code or as plain text (#636).
1519///
1520/// Preview: output quality is still improving and the API may change in a minor release.
1521///
1522/// False writes what [`to_markdown`] wrote before code existed: the runs are
1523/// still found, they are just not marked. The switch
1524/// [`ExtractOptions::code`] reads.
1525///
1526/// [`ExtractOptions::code`]: crate::blocks::ExtractOptions::code
1527pub fn to_markdown_with_code(blocks: &[Block], code: bool) -> String {
1528    let options = MarkdownOptions {
1529        code,
1530        ..MarkdownOptions::default()
1531    };
1532    to_markdown_with_options(blocks, &options)
1533}
1534
1535/// Whether monospace reads as code where no caller said otherwise: the one
1536/// default, [`ExtractOptions::default`], read here so the serializer and the
1537/// option cannot drift apart.
1538///
1539/// [`ExtractOptions::default`]: crate::blocks::ExtractOptions::default
1540fn default_code() -> bool {
1541    crate::blocks::ExtractOptions::default().code
1542}
1543
1544/// [`to_markdown`], with the horizontal rules each page drew, in page order,
1545/// as [`crate::PdfDocument::extract_blocks_and_rules`] gives them: a run with
1546/// a rule drawn under it is written `<u>underlined</u>` (#623).
1547#[doc(hidden)]
1548pub fn to_markdown_with_rules(blocks: &[Block], rules: &[Vec<HorizontalRule>]) -> String {
1549    render_markdown(
1550        blocks,
1551        rules,
1552        crate::learned::SWITCHES,
1553        &MarkdownOptions::default(),
1554    )
1555}
1556
1557/// [`to_markdown`], with the learned switches given, so a test names the
1558/// header-block rule its table is read with: the shipped switches read the
1559/// shipped rule.
1560#[doc(hidden)]
1561pub fn to_markdown_with_switches(blocks: &[Block], switches: crate::learned::Switches) -> String {
1562    render_markdown(blocks, &[], switches, &MarkdownOptions::default())
1563}
1564
1565/// [`to_markdown`], with the furniture and the page marker as `options` says.
1566///
1567/// Preview: output quality is still improving and the API may change in a minor release.
1568///
1569/// With [`MarkdownOptions::include_headers`] the running heads, and with
1570/// [`MarkdownOptions::include_footers`] the page numbers, are written as
1571/// plain paragraphs at their reading-order position; with
1572/// [`PageSeparators::None`] a change of page writes no marker, and the
1573/// paragraphs on either side stand separated by a blank line.
1574pub fn to_markdown_with_options(blocks: &[Block], options: &MarkdownOptions) -> String {
1575    render_markdown(blocks, &[], crate::learned::SWITCHES, options)
1576}
1577
1578/// Whether every span in the document is monospaced (lane L1).
1579///
1580/// Empty of spans counts as set in monospace, which changes nothing: with
1581/// no spans there are no code runs to mark either way.
1582fn document_is_set_in_monospace(blocks: &[Block]) -> bool {
1583    blocks
1584        .iter()
1585        .flat_map(|block| block.spans.iter())
1586        .all(|span| span.is_monospace == Some(true))
1587}
1588
1589/// The walker the entry points share: [`to_markdown`] is this with no
1590/// rules and [`MarkdownOptions::default`].
1591fn render_markdown(
1592    blocks: &[Block],
1593    rules: &[Vec<HorizontalRule>],
1594    switches: crate::learned::Switches,
1595    options: &MarkdownOptions,
1596) -> String {
1597    // Lane L1: a document set wholly in monospace is prose, not code.
1598    // Fences would mark the whole document, so only the fenced form is
1599    // suppressed; a lone monospaced line still reads inline (SPEC G10).
1600    let code = options.code;
1601    let fence_code = code && !document_is_set_in_monospace(blocks);
1602    let mut out = String::new();
1603    // The items still open in the run being written, innermost last: the column
1604    // each was written at, and how wide its marker is. A child is written at
1605    // the last one's content column, which is the sum of the two, and a list
1606    // that has closed leaves nothing behind.
1607    let mut open: Vec<(usize, usize)> = Vec::new();
1608    // Whether the last thing written was a paragraph, which is the only thing
1609    // the half of a paragraph on the next page can be joined onto (#571).
1610    let mut after_paragraph = false;
1611    // The page the last written block was drawn on, or `None` before the first
1612    // one: the marker stands at a CHANGE, so there is none above the opening
1613    // block however far into the document that block's page is.
1614    let mut last_page: Option<usize> = None;
1615    // Figures rendered so far per page: a figure's name is its number among
1616    // its page's figures in block order, the same enumeration the document
1617    // pass resolves embeds and files by.
1618    let mut figure_ordinals: std::collections::BTreeMap<usize, usize> =
1619        std::collections::BTreeMap::new();
1620    let mut at = 0usize;
1621    while at < blocks.len() {
1622        // A table first, because it is the one thing here made of several
1623        // blocks: the run is written whole and `at` steps over all of it.
1624        if let Some(end) = table_run_across(blocks, at) {
1625            let page = blocks[at].page;
1626            let written = table(&blocks[at..end], switches, options.table_render_mode);
1627            at = end;
1628            if written.is_empty() {
1629                continue;
1630            }
1631            note_page(
1632                &mut out,
1633                &mut open,
1634                &mut last_page,
1635                page,
1636                options.page_separators,
1637            );
1638            open.clear();
1639            after_paragraph = false;
1640            if !out.is_empty() {
1641                out.push_str("\n\n");
1642            }
1643            out.push_str(written.trim_end_matches('\n'));
1644            continue;
1645        }
1646        let block = &blocks[at];
1647        at += 1;
1648        // The furniture is out of the flow: a running head between two
1649        // paragraphs is the break this milestone is about (#571). It is still
1650        // in the block model, which is where a caller that wants it reads it --
1651        // or the caller asks `include_headers` / `include_footers`, and it is
1652        // written below as a plain paragraph, never as a heading.
1653        let wanted = match block.kind {
1654            BlockKind::Header => options.include_headers,
1655            BlockKind::Footer => options.include_footers,
1656            BlockKind::Figure => options.include_figures,
1657            _ => true,
1658        };
1659        if !wanted {
1660            continue;
1661        }
1662        // A figure is its own paragraph: an image reference, never prose.
1663        // The target is the block's text where the document pass resolved
1664        // one -- an embed or a written file -- else the figure's derived
1665        // name. Figures carry no text of their own, so this stands before
1666        // the empty-text skip below.
1667        if matches!(block.kind, BlockKind::Figure) {
1668            let ordinal = figure_ordinals.get(&block.page).copied().unwrap_or(0);
1669            let target = block.text.trim();
1670            let marker;
1671            let target = if target.is_empty() {
1672                marker = crate::markdown_images::figure_name(block.page, ordinal);
1673                marker.as_str()
1674            } else {
1675                target
1676            };
1677            figure_ordinals.insert(block.page, ordinal + 1);
1678            note_page(
1679                &mut out,
1680                &mut open,
1681                &mut last_page,
1682                block.page,
1683                options.page_separators,
1684            );
1685            if !out.is_empty() {
1686                out.push_str("\n\n");
1687            }
1688            after_paragraph = false;
1689            open.clear();
1690            out.push_str("![](");
1691            out.push_str(target);
1692            out.push(')');
1693            continue;
1694        }
1695        let is_furniture = matches!(block.kind, BlockKind::Header | BlockKind::Footer);
1696        let text = block.text.trim();
1697        if text.is_empty() {
1698            continue;
1699        }
1700        // A paragraph set wholly in monospace over two lines or more is a
1701        // fenced block, and a fence joins onto nothing: it opens its own
1702        // paragraph even where the page broke one across it (#636).
1703        let fence = if fence_code {
1704            fenced_lines(block)
1705        } else {
1706            None
1707        };
1708        // A paragraph the page broke in two is written as one. The second half
1709        // is not at the start of a line any more, so it is neither separated
1710        // nor escaped -- and a word the break was made inside of is put back
1711        // together, the way `crate::blocks` puts back a word broken across a
1712        // line. Furniture never joins: a running head is its own paragraph,
1713        // not the second half of the one above it.
1714        if block.continues_previous
1715            && after_paragraph
1716            && fence.is_none()
1717            && !out.is_empty()
1718            && !is_furniture
1719        {
1720            // The same test the merge inside a page applies, and for the same
1721            // reason: a trailing hyphen is a broken word only where the letters
1722            // on both sides of it say so, and `x-` before `ray` is a compound
1723            // the page broke at the hyphen it already had (codex, round 1).
1724            if crate::text::is_hyphen_wrap_candidate(&out, text) {
1725                out.pop();
1726            } else {
1727                out.push(' ');
1728            }
1729            out.push_str(&rich_text(block, text, page_rules(rules, block), code));
1730            // AND THE PAGE IS LEFT UNMARKED RATHER THAN MARKED OFF. The half
1731            // written here is on the new page, but no marker may stand in
1732            // front of it, so the page it moved to is still owed one -- and
1733            // `last_page` is what owes it. Recording the move here paid the
1734            // debt with nothing: the heading and the paragraphs that followed
1735            // on that page came out under the page before it, and a chunker
1736            // reading the markers filed the lot one page early (codex, round
1737            // 2). Left alone, the next block on the new page opens with the
1738            // marker, which is where it belongs.
1739            continue;
1740        }
1741        note_page(
1742            &mut out,
1743            &mut open,
1744            &mut last_page,
1745            block.page,
1746            options.page_separators,
1747        );
1748        if !out.is_empty() {
1749            // One newline between items of the same list, two everywhere else.
1750            // A blank line between items makes the list LOOSE in CommonMark,
1751            // which wraps every item in a paragraph of its own and puts the
1752            // list's own text a level further down for any reader that walks
1753            // the tree.
1754            let tight = !open.is_empty() && matches!(block.kind, BlockKind::ListItem { .. });
1755            out.push_str(if tight { "\n" } else { "\n\n" });
1756        }
1757        after_paragraph = matches!(block.kind, BlockKind::Paragraph);
1758        match block.kind {
1759            BlockKind::Heading { level } => {
1760                open.clear();
1761                let level = level.clamp(1, 6) as usize;
1762                out.push_str(&"#".repeat(level));
1763                out.push(' ');
1764                out.push_str(text);
1765            }
1766            BlockKind::ListItem { level, ordinal } => {
1767                let depth = (level as usize).min(open.len());
1768                open.truncate(depth);
1769                let indent = open.last().map_or(0, |(column, marker)| column + marker);
1770                let marker = match ordinal {
1771                    Some(number) => format!("{number}. "),
1772                    None => "- ".to_string(),
1773                };
1774                out.push_str(&" ".repeat(indent));
1775                out.push_str(&marker);
1776                open.push((indent, marker.chars().count()));
1777                // Escaped like any other body text: an item whose own words
1778                // open `# ` or ``` would otherwise close the item and open a
1779                // heading or a fence inside the list.
1780                out.push_str(&body_of(block, text, page_rules(rules, block), code));
1781            }
1782            // A CAPTION IS SET APART AS ITALIC, the way a reader sees it under
1783            // its picture: its own paragraph, emphasised, so a chunker keeps it
1784            // beside the figure and never reads it as body (#636).
1785            BlockKind::Caption => {
1786                open.clear();
1787                out.push('*');
1788                out.push_str(body_of(block, text, page_rules(rules, block), code).trim());
1789                out.push('*');
1790            }
1791            _ => {
1792                open.clear();
1793                if let Some(lines) = &fence {
1794                    // A fence is not a paragraph for the join above: the half
1795                    // of a sentence on the next page opens something new rather
1796                    // than writing onto a code block.
1797                    after_paragraph = false;
1798                    out.push_str(&fenced_block(lines));
1799                } else {
1800                    out.push_str(&body_of(block, text, page_rules(rules, block), code));
1801                }
1802            }
1803        }
1804    }
1805    if !out.is_empty() {
1806        out.push('\n');
1807    }
1808    out
1809}
1810
1811#[cfg(test)]
1812mod tests {
1813    #[test]
1814    fn a_cell_loses_its_dot_leaders_but_keeps_an_ellipsis_and_decimals() {
1815        use super::without_leaders;
1816        assert_eq!(
1817            without_leaders("United States . . . . ........ $11,772"),
1818            "United States $11,772"
1819        );
1820        assert_eq!(
1821            without_leaders("2005 Plan ......................"),
1822            "2005 Plan"
1823        );
1824        assert_eq!(without_leaders("Net sales"), "Net sales");
1825        assert_eq!(
1826            without_leaders("to be determined..."),
1827            "to be determined..."
1828        );
1829        assert_eq!(without_leaders("1.25 . 3.5"), "1.25 . 3.5");
1830        assert_eq!(without_leaders("....."), "");
1831        assert_eq!(
1832            super::cell_text("Dell . . . . . 16", 2),
1833            "Dell . . . . . 16"
1834        );
1835        assert_eq!(super::cell_text("Revenues ...... 954", 3), "Revenues 954");
1836    }
1837
1838    fn cell_at(row: usize, column: usize, text: &str) -> super::CellAt<'_> {
1839        super::CellAt {
1840            row,
1841            column,
1842            row_span: 1,
1843            column_span: 1,
1844            header: false,
1845            text,
1846        }
1847    }
1848
1849    /// Every header switch off, whatever the shipped defaults become: these
1850    /// tests pin the rule each switch changes, not the shipping state.
1851    fn off() -> crate::learned::Switches {
1852        crate::learned::Switches {
1853            markdown_header_block_figures: false,
1854            markdown_header_block_rowspan_crossing: false,
1855            markdown_header_block_word_table: false,
1856            markdown_header_block_mixed_body: false,
1857            markdown_headerless_html: false,
1858            markdown_header_block_stub_years: false,
1859            ..crate::learned::SWITCHES
1860        }
1861    }
1862
1863    #[test]
1864    fn a_two_line_header_is_marked_and_a_table_of_words_is_not() {
1865        use super::header_block;
1866        let two = [
1867            cell_at(0, 0, ""),
1868            cell_at(0, 1, "Issue"),
1869            cell_at(1, 0, "Class"),
1870            cell_at(1, 1, "Age"),
1871            cell_at(2, 0, "MN"),
1872            cell_at(2, 1, "35"),
1873            cell_at(3, 0, "MN"),
1874            cell_at(3, 1, "$1,500.00"),
1875        ];
1876        assert_eq!(header_block(&two, off()), 2);
1877        let words = [
1878            cell_at(0, 0, "Name"),
1879            cell_at(0, 1, "Role"),
1880            cell_at(1, 0, "Ann"),
1881            cell_at(1, 1, "Chair"),
1882        ];
1883        assert_eq!(header_block(&words, off()), 0);
1884        let figures_first = [
1885            cell_at(0, 0, "Total"),
1886            cell_at(0, 1, "12"),
1887            cell_at(1, 0, "Net"),
1888            cell_at(1, 1, "(3)"),
1889        ];
1890        assert_eq!(header_block(&figures_first, off()), 0);
1891        assert!(super::is_figure("(1,234.5)") && super::is_figure("12%") && super::is_figure("—"));
1892        assert!(!super::is_figure("Fair Value") && !super::is_figure("December 31,"));
1893    }
1894
1895    /// Figures run together read as figures with the switch
1896    /// (`markdown_header_block_figures`, #574): every token a figure or a nil
1897    /// dash, with or without a currency symbol. A bare `$` is an amount's
1898    /// half, not a figure, and prose is prose.
1899    #[test]
1900    fn stacked_figure_cells_count_as_figures_under_the_switch() {
1901        for text in [
1902            "86,090 (4,305)",
1903            "12,000 (1,000)",
1904            "$—",
1905            "$ —",
1906            "$ 990",
1907            "€ -",
1908            "— —",
1909            "(4,305)",
1910        ] {
1911            assert!(
1912                super::is_stacked_figure_cell(text),
1913                "{text:?} reads as figures"
1914            );
1915        }
1916        for text in [
1917            "$",
1918            "",
1919            "December 31,",
1920            "Fair Value",
1921            "Contributions Distributions",
1922            "86,090 and more",
1923        ] {
1924            assert!(
1925                !super::is_stacked_figure_cell(text),
1926                "{text:?} reads as text"
1927            );
1928        }
1929    }
1930
1931    /// DLR p61's fused rows in miniature: the header, a nil row, a fused
1932    /// figures row, a plain figures row. Without the switch the fused rows
1933    /// read as text and the header block covers rows 0-2; with it the nil
1934    /// row ends the header. Red with the rule removed: 3 either way.
1935    #[test]
1936    fn fused_figure_rows_end_the_header_block_under_the_switch() {
1937        use super::header_block;
1938        let cells = [
1939            cell_at(0, 0, ""),
1940            cell_at(0, 1, "Shares"),
1941            cell_at(0, 2, "Amount"),
1942            cell_at(1, 0, "Balance at December 31, 2001"),
1943            cell_at(1, 1, "— —"),
1944            cell_at(1, 2, "— —"),
1945            cell_at(2, 0, "Contributions Distributions"),
1946            cell_at(2, 1, "86,090 (4,305)"),
1947            cell_at(2, 2, "12,000 (1,000)"),
1948            cell_at(3, 0, "Net loss"),
1949            cell_at(3, 1, "(5,000)"),
1950            cell_at(3, 2, "(6,000)"),
1951        ];
1952        assert_eq!(header_block(&cells, off()), 3);
1953        assert_eq!(
1954            header_block(
1955                &cells,
1956                crate::learned::Switches {
1957                    markdown_header_block_figures: true,
1958                    ..off()
1959                }
1960            ),
1961            1
1962        );
1963    }
1964
1965    #[test]
1966    fn a_row_of_years_does_not_end_the_header() {
1967        use super::header_block;
1968        fn spanned(row: usize, column: usize, column_span: usize, text: &str) -> super::CellAt<'_> {
1969            super::CellAt {
1970                row,
1971                column,
1972                row_span: 1,
1973                column_span,
1974                header: false,
1975                text,
1976            }
1977        }
1978        let years = [
1979            cell_at(0, 0, ""),
1980            spanned(0, 1, 3, "Year Ended December 31,"),
1981            cell_at(1, 0, ""),
1982            cell_at(1, 1, "2012"),
1983            cell_at(1, 2, "2011"),
1984            cell_at(1, 3, "2010"),
1985            cell_at(2, 0, "Revenue"),
1986            cell_at(2, 1, "1"),
1987            cell_at(2, 2, "2"),
1988            cell_at(2, 3, "3"),
1989        ];
1990        assert_eq!(header_block(&years, off()), 2);
1991        let footnote = [
1992            cell_at(0, 0, ""),
1993            spanned(0, 1, 2, "Year Ended December 31,"),
1994            cell_at(1, 0, ""),
1995            cell_at(1, 1, "2012 (1)"),
1996            cell_at(1, 2, "2011"),
1997            cell_at(2, 0, "Revenue"),
1998            cell_at(2, 1, "1"),
1999            cell_at(2, 2, "2"),
2000        ];
2001        assert_eq!(header_block(&footnote, off()), 2);
2002        let not_all_years = [
2003            cell_at(0, 0, "Item"),
2004            cell_at(0, 1, "Value"),
2005            cell_at(0, 2, "Share"),
2006            cell_at(1, 0, "Total"),
2007            cell_at(1, 1, "2012"),
2008            cell_at(1, 2, "35.0%"),
2009            cell_at(2, 0, "Net"),
2010            cell_at(2, 1, "3"),
2011            cell_at(2, 2, "4%"),
2012        ];
2013        assert_eq!(header_block(&not_all_years, off()), 1);
2014    }
2015
2016    /// A rowspan inside the header or the body keeps the header under the
2017    /// switch (`markdown_header_block_rowspan_crossing`, #636): only a span
2018    /// straddling the head boundary vetoes. Red with the rule removed: 0
2019    /// wherever a cell spans rows.
2020    #[test]
2021    fn a_rowspan_clear_of_the_boundary_keeps_the_header_under_the_switch() {
2022        use super::header_block;
2023        fn tall(row: usize, column: usize, row_span: usize, text: &str) -> super::CellAt<'_> {
2024            super::CellAt {
2025                row,
2026                column,
2027                row_span,
2028                column_span: 1,
2029                header: false,
2030                text,
2031            }
2032        }
2033        let on = || crate::learned::Switches {
2034            markdown_header_block_rowspan_crossing: true,
2035            ..off()
2036        };
2037        // A row group in the body: the stub spans rows 3-4, the head is 2.
2038        let grouped = [
2039            cell_at(0, 0, "Region"),
2040            cell_at(0, 1, "Sales"),
2041            cell_at(1, 0, "North"),
2042            cell_at(1, 1, "Q1"),
2043            cell_at(2, 0, "East"),
2044            cell_at(2, 1, "12"),
2045            tall(3, 0, 2, "West"),
2046            cell_at(3, 1, "9"),
2047            cell_at(4, 1, "7"),
2048        ];
2049        assert_eq!(header_block(&grouped, off()), 0);
2050        assert_eq!(header_block(&grouped, on()), 2);
2051        // A group stub two rows high inside the header: no veto either.
2052        let stub = [
2053            tall(0, 0, 2, "Test"),
2054            cell_at(0, 1, "Low"),
2055            cell_at(1, 1, "High"),
2056            cell_at(2, 0, "A"),
2057            cell_at(2, 1, "3"),
2058        ];
2059        assert_eq!(header_block(&stub, off()), 0);
2060        assert_eq!(header_block(&stub, on()), 2);
2061        // But a span across the boundary still vetoes: row 1 runs on into
2062        // the body, so the header is not a band of whole rows.
2063        let straddling = [
2064            cell_at(0, 0, "Region"),
2065            cell_at(0, 1, "Sales"),
2066            tall(1, 0, 2, "North"),
2067            cell_at(1, 1, "Q1"),
2068            cell_at(2, 1, "12"),
2069        ];
2070        assert_eq!(header_block(&straddling, off()), 0);
2071        assert_eq!(header_block(&straddling, on()), 0);
2072    }
2073
2074    /// A table of words heads its first row under the switch
2075    /// (`markdown_header_block_word_table`, #636): with no figures row to
2076    /// end it, the first row is the header by position. Red with the rule
2077    /// removed: 0, the old word-table veto.
2078    #[test]
2079    fn a_word_table_heads_its_first_row_under_the_switch() {
2080        use super::header_block;
2081        let on = || crate::learned::Switches {
2082            markdown_header_block_word_table: true,
2083            ..off()
2084        };
2085        let words = [
2086            cell_at(0, 0, ""),
2087            cell_at(0, 1, "Enablers"),
2088            cell_at(0, 2, "Barriers"),
2089            cell_at(1, 0, "Hiring"),
2090            cell_at(1, 1, "Referrals"),
2091            cell_at(1, 2, "Costs"),
2092            cell_at(2, 0, "Keeping"),
2093            cell_at(2, 1, "Pay"),
2094            cell_at(2, 2, "Hours"),
2095        ];
2096        assert_eq!(header_block(&words, off()), 0);
2097        assert_eq!(header_block(&words, on()), 1);
2098        // One row is no table with a header: the block would be the table.
2099        let single = [cell_at(0, 0, "Name"), cell_at(0, 1, "Role")];
2100        assert_eq!(header_block(&single, on()), 0);
2101        // And a first row with no text in it heads nothing.
2102        let blank = [
2103            cell_at(0, 0, ""),
2104            cell_at(0, 1, " "),
2105            cell_at(1, 0, "Ann"),
2106            cell_at(1, 1, "Chair"),
2107        ];
2108        assert_eq!(header_block(&blank, on()), 0);
2109    }
2110
2111    /// A row mixing labels and figures ends the header under the switch
2112    /// (`markdown_header_block_mixed_body`, #636): it has already turned
2113    /// from label to value. Red with the rule removed: the mixed row reads
2114    /// as labels and the header runs past it.
2115    #[test]
2116    fn a_mixed_row_ends_the_header_under_the_switch() {
2117        use super::header_block;
2118        let on = || crate::learned::Switches {
2119            markdown_header_block_mixed_body: true,
2120            ..off()
2121        };
2122        let over = [
2123            cell_at(0, 0, "Region"),
2124            cell_at(0, 1, "North"),
2125            cell_at(0, 2, "South"),
2126            cell_at(0, 3, "Share"),
2127            cell_at(1, 0, "Age (years)"),
2128            cell_at(1, 1, "North"),
2129            cell_at(1, 2, "South"),
2130            cell_at(1, 3, "0.73"),
2131            cell_at(2, 0, "Female"),
2132            cell_at(2, 1, "26"),
2133            cell_at(2, 2, "20"),
2134            cell_at(2, 3, "0.22"),
2135        ];
2136        assert_eq!(header_block(&over, off()), 2);
2137        assert_eq!(header_block(&over, on()), 1);
2138        // A headerless grid, label beside value from the first row on:
2139        // nothing is marked.
2140        let headerless = [
2141            cell_at(0, 0, "Genome size"),
2142            cell_at(0, 1, "North"),
2143            cell_at(0, 2, "South"),
2144            cell_at(0, 3, "14.6"),
2145            cell_at(1, 0, "Assembled"),
2146            cell_at(1, 1, "13"),
2147            cell_at(1, 2, "12"),
2148            cell_at(1, 3, "11"),
2149        ];
2150        assert_eq!(header_block(&headerless, off()), 1);
2151        assert_eq!(header_block(&headerless, on()), 0);
2152        // But a stubbed year row is no mixed row: years head columns, so a
2153        // bare year beside labels does not turn the row to values. Red with
2154        // the year guard removed: 1, the header demoted to one row.
2155        let stubbed_years = [
2156            cell_at(0, 0, "Payments due by period"),
2157            cell_at(0, 1, ""),
2158            cell_at(0, 2, ""),
2159            cell_at(1, 0, "Contractual Obligation"),
2160            cell_at(1, 1, "2008"),
2161            cell_at(1, 2, "2009 and 2010"),
2162            cell_at(2, 0, "Long-term debt"),
2163            cell_at(2, 1, "$ 629"),
2164            cell_at(2, 2, "$ 2,334"),
2165        ];
2166        assert_eq!(header_block(&stubbed_years, off()), 2);
2167        assert_eq!(header_block(&stubbed_years, on()), 2);
2168    }
2169
2170    /// A stubbed year row heads under the switch
2171    /// (`markdown_header_block_stub_years`, #636): the years in the value
2172    /// columns head them, whatever the stub cell holds. Red with the rule
2173    /// removed: 0, the years read as figures in the first row.
2174    #[test]
2175    fn a_stubbed_year_row_heads_under_the_switch() {
2176        use super::header_block;
2177        let on = || crate::learned::Switches {
2178            markdown_header_block_stub_years: true,
2179            ..off()
2180        };
2181        let stubbed = [
2182            cell_at(0, 0, "($ in millions)"),
2183            cell_at(0, 1, "2010"),
2184            cell_at(0, 2, "2009"),
2185            cell_at(1, 0, "Sales"),
2186            cell_at(1, 1, "$ 914"),
2187            cell_at(1, 2, "$ 850"),
2188        ];
2189        assert_eq!(header_block(&stubbed, off()), 0);
2190        assert_eq!(header_block(&stubbed, on()), 1);
2191        // One year is no year row: the stub variant keeps the old bar of two.
2192        let single = [
2193            cell_at(0, 0, "As of"),
2194            cell_at(0, 1, "December"),
2195            cell_at(0, 2, "2010"),
2196            cell_at(1, 0, "Cash"),
2197            cell_at(1, 1, "1"),
2198            cell_at(1, 2, "2"),
2199        ];
2200        assert_eq!(header_block(&single, on()), 1);
2201        // And a plain all-years row heads with or without the switch.
2202        let plain = [
2203            cell_at(0, 0, "2010"),
2204            cell_at(0, 1, "2009"),
2205            cell_at(1, 0, "Sales"),
2206            cell_at(1, 1, "914"),
2207        ];
2208        assert_eq!(header_block(&plain, off()), 1);
2209        assert_eq!(header_block(&plain, on()), 1);
2210    }
2211
2212    use super::*;
2213
2214    fn block(kind: BlockKind, text: &str) -> Block {
2215        Block {
2216            kind,
2217            page: 0,
2218            bbox: [0.0; 4],
2219            text: text.to_string(),
2220            spans: Vec::new(),
2221            index: 0,
2222            continues_previous: false,
2223            confidence: None,
2224            grid: None,
2225            table_origin: TableOrigin::Other,
2226            trace_learned: None,
2227        }
2228    }
2229
2230    /// A caption is set apart as an italic paragraph of its own (#636).
2231    #[test]
2232    fn a_caption_is_an_italic_paragraph_of_its_own() {
2233        let md = to_markdown(&[
2234            block(BlockKind::Paragraph, "The plant runs day and night."),
2235            block(BlockKind::Caption, "Figure 1. The plant at night."),
2236        ]);
2237        assert_eq!(
2238            md,
2239            "The plant runs day and night.\n\n*Figure 1. The plant at night.*\n"
2240        );
2241    }
2242
2243    fn run(text: &str, y: f64, size: f64, bold: bool, italic: bool) -> crate::text::TextSpan {
2244        crate::text::TextSpan {
2245            text: text.to_string(),
2246            y,
2247            font_size: size,
2248            is_bold: bold,
2249            is_italic: italic,
2250            ..Default::default()
2251        }
2252    }
2253
2254    fn set_block(text: &str, spans: Vec<crate::text::TextSpan>) -> Block {
2255        Block {
2256            spans,
2257            ..block(BlockKind::Paragraph, text)
2258        }
2259    }
2260
2261    fn placed(text: &str, x: f64, width: f64, bold: bool) -> crate::text::TextSpan {
2262        crate::text::TextSpan {
2263            x,
2264            width,
2265            ..run(text, 700.0, 10.0, bold, false)
2266        }
2267    }
2268
2269    fn rule(left: f64, right: f64, y: f64) -> HorizontalRule {
2270        HorizontalRule { left, right, y }
2271    }
2272
2273    #[test]
2274    fn a_rule_under_a_run_underlines_it_and_a_border_does_not() {
2275        let b = set_block(
2276            "See the terms of service before signing.",
2277            vec![
2278                placed("See the", 100.0, 35.0, false),
2279                placed("terms of service", 140.0, 80.0, true),
2280                placed("before signing.", 225.0, 75.0, false),
2281            ],
2282        );
2283        // Under the bold run, just below the baseline: both marks, nested.
2284        assert_eq!(
2285            styled(&b, &b.text, &[rule(140.0, 220.0, 698.5)]),
2286            "See the **<u>terms of service</u>** before signing."
2287        );
2288        // A table border runs far past the line: not an underline.
2289        assert_eq!(
2290            styled(&b, &b.text, &[rule(40.0, 560.0, 698.5)]),
2291            "See the **terms of service** before signing."
2292        );
2293        // A rule a line below is the next line's business.
2294        assert_eq!(
2295            styled(&b, &b.text, &[rule(140.0, 220.0, 688.0)]),
2296            "See the **terms of service** before signing."
2297        );
2298        // An underline over the plain run marks it alone.
2299        assert_eq!(
2300            styled(&b, &b.text, &[rule(225.0, 300.0, 699.0)]),
2301            "See the **terms of service** <u>before signing.</u>"
2302        );
2303    }
2304
2305    #[test]
2306    fn bold_and_italic_runs_are_marked_from_the_spans() {
2307        let b = set_block(
2308            "Pay the net amount within thirty days of the invoice.",
2309            vec![
2310                run("Pay the", 700.0, 10.0, false, false),
2311                run("net amount", 700.0, 10.0, true, false),
2312                run("within", 700.0, 10.0, false, false),
2313                run("thirty days", 700.0, 10.0, false, true),
2314                run("of the invoice.", 700.0, 10.0, false, false),
2315            ],
2316        );
2317        assert_eq!(
2318            to_markdown(&[b]),
2319            "Pay the **net amount** within *thirty days* of the invoice.\n"
2320        );
2321    }
2322
2323    #[test]
2324    fn sub_word_spans_the_rebuild_joined_are_marked_whole() {
2325        // The rebuild joins `2 0 2 5` to `2025`; the marks follow the same
2326        // boundary, so the joined word reads bold (#668).
2327        let b = set_block(
2328            "2025",
2329            vec![
2330                placed("2", 100.0, 3.0, true),
2331                placed("0", 103.0, 3.0, true),
2332                placed("2", 106.0, 3.0, true),
2333                placed("5", 109.0, 3.0, true),
2334            ],
2335        );
2336        assert_eq!(styled(&b, &b.text, &[]), "**2025**");
2337    }
2338
2339    #[test]
2340    fn a_hyphen_off_its_line_is_still_half_a_word() {
2341        // The joined-word mark needs the shared boundary: `hyphen-` sits a
2342        // line above `ated`, so it stays unmarked the way it always was.
2343        let b = set_block(
2344            "hyphen-ated",
2345            vec![
2346                placed("hyphen-", 100.0, 35.0, true),
2347                crate::text::TextSpan {
2348                    x: 100.0,
2349                    width: 20.0,
2350                    ..run("ated", 688.0, 10.0, false, false)
2351                },
2352            ],
2353        );
2354        assert_eq!(styled(&b, &b.text, &[]), "hyphen-ated");
2355    }
2356
2357    #[test]
2358    fn neighbouring_runs_of_one_setting_are_one_mark() {
2359        let b = set_block(
2360            "Two bold words here.",
2361            vec![
2362                run("Two", 700.0, 10.0, true, false),
2363                run("bold", 700.0, 10.0, true, false),
2364                run("words here.", 700.0, 10.0, false, false),
2365            ],
2366        );
2367        assert_eq!(to_markdown(&[b]), "**Two bold** words here.\n");
2368    }
2369
2370    #[test]
2371    fn raised_and_lowered_runs_are_sup_and_sub() {
2372        let b = set_block(
2373            "Water is H2O and the area is 4 m2 here.",
2374            vec![
2375                run("Water is H", 700.0, 10.0, false, false),
2376                run("2", 697.0, 7.0, false, false),
2377                run("O and the area is 4 m", 700.0, 10.0, false, false),
2378                run("2", 703.5, 7.0, false, false),
2379                run("here.", 700.0, 10.0, false, false),
2380            ],
2381        );
2382        assert_eq!(
2383            to_markdown(&[b]),
2384            "Water is H<sub>2</sub>O and the area is 4 m<sup>2</sup> here.\n"
2385        );
2386    }
2387
2388    #[test]
2389    fn a_line_that_opens_like_a_list_item_keeps_its_escape_and_no_style() {
2390        let b = set_block(
2391            "3. The parties agree.",
2392            vec![
2393                run("3.", 700.0, 10.0, true, false),
2394                run("The parties agree.", 700.0, 10.0, false, false),
2395            ],
2396        );
2397        assert_eq!(to_markdown(&[b]), "3\\. The parties agree.\n");
2398    }
2399
2400    #[test]
2401    fn a_small_run_opening_the_next_line_is_not_a_subscript() {
2402        let b = set_block(
2403            "the end of a line and 12 more words",
2404            vec![
2405                run("the end of a line and", 700.0, 10.0, false, false),
2406                run("12", 688.0, 8.0, false, false),
2407                run("more words", 688.0, 10.0, false, false),
2408            ],
2409        );
2410        assert_eq!(to_markdown(&[b]), "the end of a line and 12 more words\n");
2411    }
2412
2413    #[test]
2414    fn a_bold_run_ending_in_a_page_break_hyphen_still_joins() {
2415        let mut first = set_block(
2416            "the word is hyphen-",
2417            vec![
2418                run("the word is", 700.0, 10.0, false, false),
2419                run("hyphen-", 700.0, 10.0, true, false),
2420            ],
2421        );
2422        first.page = 0;
2423        let mut second = block(BlockKind::Paragraph, "ated across the page.");
2424        second.page = 1;
2425        second.continues_previous = true;
2426        assert_eq!(
2427            to_markdown(&[first, second]),
2428            "the word is hyphenated across the page.\n"
2429        );
2430    }
2431
2432    #[test]
2433    fn right_to_left_text_is_left_unmarked() {
2434        let b = set_block(
2435            "שלום עולם",
2436            vec![
2437                run("עולם", 700.0, 10.0, true, false),
2438                run("שלום", 700.0, 10.0, false, false),
2439            ],
2440        );
2441        assert_eq!(to_markdown(&[b]), "שלום עולם\n");
2442    }
2443
2444    #[test]
2445    fn a_run_that_holds_markup_or_cannot_be_found_is_left_alone() {
2446        let b = set_block(
2447            "Use a*b and the hyphen-ated word.",
2448            vec![
2449                run("Use a*b", 700.0, 10.0, true, false),
2450                run("hyphen-", 700.0, 10.0, true, false),
2451            ],
2452        );
2453        assert_eq!(to_markdown(&[b]), "Use a*b and the hyphen-ated word.\n");
2454    }
2455
2456    fn mono(text: &str, x: f64, y: f64) -> crate::text::TextSpan {
2457        crate::text::TextSpan {
2458            text: text.to_string(),
2459            x,
2460            y,
2461            width: text.len().max(1) as f64 * 6.0,
2462            font_size: 10.0,
2463            is_monospace: Some(true),
2464            ..Default::default()
2465        }
2466    }
2467
2468    fn plain(text: &str, x: f64, y: f64) -> crate::text::TextSpan {
2469        crate::text::TextSpan {
2470            text: text.to_string(),
2471            x,
2472            y,
2473            width: text.len().max(1) as f64 * 6.0,
2474            font_size: 10.0,
2475            ..Default::default()
2476        }
2477    }
2478
2479    #[test]
2480    fn a_monospace_word_in_a_sentence_is_inline_code() {
2481        let b = set_block(
2482            "Use foo here.",
2483            vec![
2484                plain("Use ", 100.0, 700.0),
2485                mono("foo", 130.0, 700.0),
2486                plain(" here.", 150.0, 700.0),
2487            ],
2488        );
2489        assert_eq!(to_markdown(&[b]), "Use `foo` here.\n");
2490    }
2491
2492    #[test]
2493    fn neighbouring_monospace_runs_are_one_span() {
2494        let b = set_block(
2495            "Run foo bar now.",
2496            vec![
2497                plain("Run ", 100.0, 700.0),
2498                mono("foo", 130.0, 700.0),
2499                mono("bar", 155.0, 700.0),
2500                plain(" now.", 180.0, 700.0),
2501            ],
2502        );
2503        assert_eq!(to_markdown(&[b]), "Run `foo bar` now.\n");
2504    }
2505
2506    #[test]
2507    fn a_backtick_in_code_lengthens_the_fence() {
2508        let b = set_block(
2509            "Use a`b here.",
2510            vec![
2511                plain("Use ", 100.0, 700.0),
2512                mono("a`b", 130.0, 700.0),
2513                plain(" here.", 155.0, 700.0),
2514            ],
2515        );
2516        assert_eq!(to_markdown(&[b]), "Use ``a`b`` here.\n");
2517    }
2518
2519    #[test]
2520    fn code_wins_over_bold_on_the_same_run() {
2521        let mut code = mono("foo", 130.0, 700.0);
2522        code.is_bold = true;
2523        let b = set_block(
2524            "Use foo here.",
2525            vec![
2526                plain("Use ", 100.0, 700.0),
2527                code,
2528                plain(" here.", 150.0, 700.0),
2529            ],
2530        );
2531        assert_eq!(to_markdown(&[b]), "Use `foo` here.\n");
2532    }
2533
2534    #[test]
2535    fn code_at_the_opening_needs_no_escape() {
2536        let b = set_block("# foo", vec![mono("# foo", 100.0, 700.0)]);
2537        assert_eq!(to_markdown(&[b]), "`# foo`\n");
2538    }
2539
2540    #[test]
2541    fn a_list_item_reads_its_monospace_inline() {
2542        let item = block(
2543            BlockKind::ListItem {
2544                level: 0,
2545                ordinal: None,
2546            },
2547            "Run foo now.",
2548        );
2549        let item = Block {
2550            spans: vec![
2551                plain("Run ", 100.0, 700.0),
2552                mono("foo", 130.0, 700.0),
2553                plain(" now.", 150.0, 700.0),
2554            ],
2555            ..item
2556        };
2557        assert_eq!(to_markdown(&[item]), "- Run `foo` now.\n");
2558    }
2559
2560    #[test]
2561    fn an_all_monospace_paragraph_over_two_lines_reads_inline() {
2562        // Lane L1: the whole-document typewriter switch reads a wholly
2563        // monospaced document as prose, so this block is inline code now,
2564        // fenced only against a proportional face.
2565        let b = set_block(
2566            "let x = 1; let y = 2;",
2567            vec![
2568                mono("let x = 1;", 100.0, 700.0),
2569                mono("let y = 2;", 100.0, 688.0),
2570            ],
2571        );
2572        assert_eq!(to_markdown(&[b]), "`let x = 1; let y = 2;`\n");
2573    }
2574
2575    #[test]
2576    fn a_fence_inside_an_unfenced_block_stays_inline() {
2577        // Lane L1: as above, the typewriter switch leaves a wholly
2578        // monospaced block unfenced; the backticks read inline.
2579        let b = set_block(
2580            "``` code",
2581            vec![mono("```", 100.0, 700.0), mono("code", 100.0, 688.0)],
2582        );
2583        assert_eq!(
2584            to_markdown(std::slice::from_ref(&b)),
2585            "```` ``` code ````\n"
2586        );
2587        // Against a proportional face the block still fences, and the
2588        // fence still lengthens past the backticks it holds.
2589        let prose = set_block("words", vec![plain("words", 100.0, 712.0)]);
2590        assert_eq!(to_markdown(&[prose, b]), "words\n\n````\n```\ncode\n````\n");
2591    }
2592
2593    #[test]
2594    fn a_single_monospace_line_stays_inline() {
2595        let b = set_block("print(x)", vec![mono("print(x)", 100.0, 700.0)]);
2596        assert_eq!(to_markdown(&[b]), "`print(x)`\n");
2597    }
2598
2599    #[test]
2600    fn a_paragraph_only_partly_monospace_stays_inline() {
2601        let b = set_block(
2602            "code_x words here",
2603            vec![
2604                mono("code_x", 100.0, 700.0),
2605                plain("words here", 100.0, 688.0),
2606            ],
2607        );
2608        assert_eq!(to_markdown(&[b]), "`code_x` words here\n");
2609    }
2610
2611    #[test]
2612    fn monospace_figures_are_not_code() {
2613        let b = set_block(
2614            "1,234 5,678 9,012",
2615            vec![mono("1,234 5,678 9,012", 100.0, 700.0)],
2616        );
2617        assert_eq!(to_markdown(&[b]), "1,234 5,678 9,012\n");
2618        // Neither fenced over two lines: the guard skips the block, not the span.
2619        let b = set_block(
2620            "1,234 5,678",
2621            vec![mono("1,234", 100.0, 700.0), mono("5,678", 100.0, 688.0)],
2622        );
2623        assert_eq!(to_markdown(&[b]), "1,234 5,678\n");
2624    }
2625
2626    #[test]
2627    fn tables_headings_and_captions_never_carry_code() {
2628        let mut code = cell(1, 0, false, "a1");
2629        code.spans = vec![mono("a1", 100.0, 700.0)];
2630        let table = vec![
2631            cell(0, 0, true, "Code"),
2632            cell(0, 1, true, "Means"),
2633            code,
2634            cell(1, 1, false, "either"),
2635        ];
2636        assert_eq!(
2637            to_markdown(&table),
2638            "| Code | Means |\n| --- | --- |\n| a1 | either |\n"
2639        );
2640        let heading = block(BlockKind::Heading { level: 1 }, "Setup foo.");
2641        let heading = Block {
2642            spans: vec![
2643                plain("Setup ", 100.0, 700.0),
2644                mono("foo", 150.0, 700.0),
2645                plain(".", 170.0, 700.0),
2646            ],
2647            ..heading
2648        };
2649        assert_eq!(to_markdown(&[heading]), "# Setup foo.\n");
2650        let caption = block(BlockKind::Caption, "Figure 1 shows foo.");
2651        let caption = Block {
2652            spans: vec![
2653                plain("Figure 1 shows ", 100.0, 700.0),
2654                mono("foo", 200.0, 700.0),
2655                plain(".", 220.0, 700.0),
2656            ],
2657            ..caption
2658        };
2659        assert_eq!(to_markdown(&[caption]), "*Figure 1 shows foo.*\n");
2660    }
2661
2662    #[test]
2663    fn code_false_writes_todays_bytes() {
2664        let b = set_block(
2665            "Use foo here.",
2666            vec![
2667                plain("Use ", 100.0, 700.0),
2668                mono("foo", 130.0, 700.0),
2669                plain(" here.", 150.0, 700.0),
2670            ],
2671        );
2672        assert_eq!(
2673            to_markdown_with_code(std::slice::from_ref(&b), false),
2674            "Use foo here.\n"
2675        );
2676        let fenced = set_block(
2677            "let x = 1; let y = 2;",
2678            vec![
2679                mono("let x = 1;", 100.0, 700.0),
2680                mono("let y = 2;", 100.0, 688.0),
2681            ],
2682        );
2683        assert_eq!(
2684            to_markdown_with_code(&[fenced], false),
2685            "let x = 1; let y = 2;\n"
2686        );
2687        assert_eq!(to_markdown(&[b]), "Use `foo` here.\n");
2688    }
2689
2690    #[test]
2691    fn a_code_span_never_closes_after_a_page_break_hyphen() {
2692        let mut first = set_block(
2693            "the setter is config-",
2694            vec![
2695                plain("the setter is ", 100.0, 700.0),
2696                mono("config-", 190.0, 700.0),
2697            ],
2698        );
2699        first.page = 0;
2700        let mut second = block(BlockKind::Paragraph, "file on the next page.");
2701        second.page = 1;
2702        second.continues_previous = true;
2703        assert_eq!(
2704            to_markdown(&[first, second]),
2705            "the setter is configfile on the next page.\n"
2706        );
2707    }
2708
2709    #[test]
2710    fn heading_levels_become_one_to_three_hashes() {
2711        let blocks = vec![
2712            block(BlockKind::Heading { level: 1 }, "Scope"),
2713            block(BlockKind::Paragraph, "This document describes."),
2714            block(BlockKind::Heading { level: 2 }, "Terms"),
2715            block(BlockKind::Heading { level: 3 }, "Party"),
2716        ];
2717        assert_eq!(
2718            to_markdown(&blocks),
2719            "# Scope\n\nThis document describes.\n\n## Terms\n\n### Party\n"
2720        );
2721    }
2722
2723    #[test]
2724    fn a_paragraph_that_opens_like_a_marker_is_escaped() {
2725        let blocks = vec![
2726            block(BlockKind::Paragraph, "# not a heading"),
2727            block(BlockKind::Paragraph, "- not an item"),
2728            block(BlockKind::Paragraph, "1. not an item"),
2729            block(BlockKind::Paragraph, "1984 was a year"),
2730        ];
2731        assert_eq!(
2732            to_markdown(&blocks),
2733            "\\# not a heading\n\n\\- not an item\n\n1\\. not an item\n\n1984 was a year\n"
2734        );
2735    }
2736
2737    #[test]
2738    fn a_figure_at_the_head_of_a_line_is_not_escaped() {
2739        // A marker is the character AND the space after it. Without that
2740        // clause every figure a document sets at the head of a line carries a
2741        // backslash into the text layer of everything downstream -- 79 of them
2742        // over the 200 pages of run structured-doclaynet-20260921T000905Z,
2743        // against a ground truth that has none (#579).
2744        let blocks = vec![
2745            block(BlockKind::Paragraph, "-3,503 6,200"),
2746            block(BlockKind::Paragraph, "0.1 0.01 0.001"),
2747            block(BlockKind::Paragraph, "#4 of the series"),
2748            block(BlockKind::Paragraph, "+3HnN is the rate"),
2749            block(BlockKind::Paragraph, "2.94 0.98 0.88"),
2750            // Seven hashes is prose in CommonMark, which stops counting at six.
2751            block(BlockKind::Paragraph, "####### not a heading"),
2752        ];
2753        assert_eq!(
2754            to_markdown(&blocks),
2755            "-3,503 6,200\n\n0.1 0.01 0.001\n\n#4 of the series\n\n\
2756             +3HnN is the rate\n\n2.94 0.98 0.88\n\n####### not a heading\n"
2757        );
2758    }
2759
2760    #[test]
2761    fn a_heading_that_opens_with_a_run_of_hashes_is_still_escaped() {
2762        // The mutation the case above pays for: asking for the space after the
2763        // FIRST hash rather than after the run lets `## Terms` out as a
2764        // level-two heading the document does not have.
2765        let blocks = vec![
2766            block(BlockKind::Paragraph, "## Terms"),
2767            block(BlockKind::Paragraph, "###### Sixth"),
2768            block(BlockKind::Paragraph, "###"),
2769        ];
2770        assert_eq!(
2771            to_markdown(&blocks),
2772            "\\## Terms\n\n\\###### Sixth\n\n\\###\n"
2773        );
2774    }
2775
2776    #[test]
2777    fn a_rule_of_three_is_escaped_whatever_follows_it() {
2778        // The other half of the same clause: `---` and `***` are a thematic
2779        // break with no space in them anywhere, and a document that sets one
2780        // as prose would otherwise draw a line across the page.
2781        let blocks = vec![
2782            block(BlockKind::Paragraph, "---"),
2783            block(BlockKind::Paragraph, "***"),
2784            block(BlockKind::Paragraph, "-- an em rule"),
2785        ];
2786        assert_eq!(to_markdown(&blocks), "\\---\n\n\\***\n\n-- an em rule\n");
2787    }
2788
2789    #[test]
2790    fn a_paragraph_that_opens_a_fence_cannot_swallow_the_document() {
2791        let blocks = vec![
2792            block(BlockKind::Paragraph, "```rust"),
2793            block(BlockKind::Heading { level: 1 }, "Scope"),
2794            block(BlockKind::Paragraph, "~~~ a tilde fence"),
2795            block(BlockKind::Paragraph, "`inline code` is not a fence"),
2796        ];
2797        let markdown = to_markdown(&blocks);
2798        assert!(
2799            markdown.starts_with("\\```rust"),
2800            "the fence opens a block nothing closes: {markdown}"
2801        );
2802        assert!(
2803            markdown.contains("\n# Scope\n"),
2804            "the heading after the fence was swallowed: {markdown}"
2805        );
2806        assert!(markdown.contains("\\~~~ a tilde fence"), "{markdown}");
2807        assert!(markdown.contains("\\`inline code`"), "{markdown}");
2808    }
2809
2810    fn cell(row: u16, column: u16, header: bool, text: &str) -> Block {
2811        block(
2812            BlockKind::TableCell {
2813                row,
2814                column,
2815                row_span: 1,
2816                column_span: 1,
2817                header,
2818            },
2819            text,
2820        )
2821    }
2822
2823    /// A rectangle with no header row is HTML under the switch
2824    /// (`markdown_headerless_html`, #636): the pipe form would force its
2825    /// first row into a header it does not have. Red with the rule removed:
2826    /// a pipe table either way.
2827    #[test]
2828    fn a_headerless_rectangle_is_html_under_the_switch() {
2829        let blocks = vec![
2830            cell(0, 0, false, "Genome size"),
2831            cell(0, 1, false, "14.6"),
2832            cell(1, 0, false, "Assembled"),
2833            cell(1, 1, false, "13"),
2834        ];
2835        assert_eq!(
2836            super::to_markdown_with_switches(&blocks, off()),
2837            "| Genome size | 14.6 |\n| --- | --- |\n| Assembled | 13 |\n"
2838        );
2839        let on = crate::learned::Switches {
2840            markdown_headerless_html: true,
2841            ..off()
2842        };
2843        let html = super::to_markdown_with_switches(&blocks, on);
2844        assert!(
2845            html.starts_with("<table>"),
2846            "a headerless table is HTML, not pipes: {html:?}"
2847        );
2848        assert!(
2849            !html.contains("<th"),
2850            "no cell is promoted to a header: {html:?}"
2851        );
2852    }
2853
2854    #[test]
2855    fn a_pipe_in_a_cell_does_not_open_a_column() {
2856        let blocks = vec![
2857            cell(0, 0, true, "Code"),
2858            cell(0, 1, true, "Means"),
2859            cell(1, 0, false, "a|b"),
2860            cell(1, 1, false, "either"),
2861        ];
2862        assert_eq!(
2863            to_markdown(&blocks),
2864            "| Code | Means |\n| --- | --- |\n| a\\|b | either |\n"
2865        );
2866    }
2867
2868    #[test]
2869    fn a_cell_that_holds_markup_is_escaped_in_the_html_form() {
2870        let mut blocks = vec![
2871            block(
2872                BlockKind::TableCell {
2873                    row: 0,
2874                    column: 0,
2875                    row_span: 1,
2876                    column_span: 2,
2877                    header: true,
2878                },
2879                "a < b & c",
2880            ),
2881            cell(1, 0, false, "left"),
2882            cell(1, 1, false, "right"),
2883        ];
2884        blocks.push(cell(2, 0, false, "under"));
2885        blocks.push(cell(2, 1, false, "it"));
2886        assert_eq!(
2887            to_markdown(&blocks),
2888            "<table>\n\
2889             <tr><th colspan=\"2\">a &lt; b &amp; c</th></tr>\n\
2890             <tr><td>left</td><td>right</td></tr>\n\
2891             <tr><td>under</td><td>it</td></tr>\n\
2892             </table>\n"
2893        );
2894    }
2895
2896    #[test]
2897    fn a_currency_column_joins_the_column_to_its_right() {
2898        let joined = vec![
2899            cell(0, 0, false, "Service cost"),
2900            cell(0, 1, false, "$"),
2901            cell(0, 2, false, "115"),
2902            cell(1, 0, false, "Interest"),
2903            cell(1, 1, false, ""),
2904            cell(1, 2, false, "82"),
2905        ];
2906        let markdown = to_markdown(&joined);
2907        assert!(markdown.contains("$ 115"), "{markdown}");
2908        // HTML, not pipes: both rows are label beside value, so the grid has
2909        // no header row and the pipe form would force one (#636).
2910        assert!(markdown.starts_with("<table>"), "{markdown}");
2911        assert!(!markdown.contains("<th"), "{markdown}");
2912        assert!(!markdown.contains("<td>$</td>"), "{markdown}");
2913        let not_currency = vec![
2914            cell(0, 0, false, "A"),
2915            cell(0, 1, false, "$"),
2916            cell(0, 2, false, "1"),
2917            cell(1, 0, false, "B"),
2918            cell(1, 1, false, "12"),
2919            cell(1, 2, false, "2"),
2920        ];
2921        let markdown = to_markdown(&not_currency);
2922        assert!(markdown.contains("<td>$</td>"), "{markdown}");
2923        assert!(!markdown.contains("$ 1"), "{markdown}");
2924        let spanning = vec![
2925            cell(0, 0, false, "Service"),
2926            cell(0, 1, false, "$"),
2927            block(
2928                BlockKind::TableCell {
2929                    row: 0,
2930                    column: 2,
2931                    row_span: 1,
2932                    column_span: 2,
2933                    header: false,
2934                },
2935                "115",
2936            ),
2937            cell(1, 0, false, "Interest"),
2938            cell(1, 1, false, ""),
2939            cell(1, 2, false, "82"),
2940            cell(1, 3, false, "83"),
2941        ];
2942        let markdown = to_markdown(&spanning);
2943        assert!(!markdown.contains("$ 115"), "{markdown}");
2944    }
2945
2946    #[test]
2947    fn a_header_span_over_the_join_shrinks_with_it_and_a_partial_span_blocks() {
2948        fn spanned(row: u16, column: u16, column_span: u16, text: &str) -> Block {
2949            block(
2950                BlockKind::TableCell {
2951                    row,
2952                    column,
2953                    row_span: 1,
2954                    column_span,
2955                    header: false,
2956                },
2957                text,
2958            )
2959        }
2960        // CMG_2010 p56 t0 as cells: a header over columns 1..6, three `$`
2961        // columns at 1, 3, 5 each beside its figures. The span contains every
2962        // join, so all three fire and it shrinks 6 -> 3 over columns 1..3.
2963        let under_span = vec![
2964            cell(0, 0, false, "Item"),
2965            spanned(0, 1, 6, "For the years ended December 31"),
2966            cell(1, 0, false, "Net sales"),
2967            cell(1, 1, false, "$"),
2968            cell(1, 2, false, "114,750"),
2969            cell(1, 3, false, "$"),
2970            cell(1, 4, false, "101,029"),
2971            cell(1, 5, false, "$"),
2972            cell(1, 6, false, "90,547"),
2973            cell(2, 0, false, "Cost of sales"),
2974            cell(2, 1, false, ""),
2975            cell(2, 2, false, "86,133"),
2976            cell(2, 3, false, ""),
2977            cell(2, 4, false, "74,920"),
2978            cell(2, 5, false, ""),
2979            cell(2, 6, false, "66,610"),
2980        ];
2981        let markdown = to_markdown(&under_span);
2982        assert!(
2983            markdown.contains("<th colspan=\"3\">For the years ended December 31</th>"),
2984            "the span shrinks with the three columns it covers: {markdown}"
2985        );
2986        assert!(markdown.contains("$ 114,750"), "{markdown}");
2987        assert!(
2988            !markdown.contains(">$<"),
2989            "no bare currency column survives: {markdown}"
2990        );
2991        // A span ending exactly at the currency column still blocks: the grid
2992        // beside it is not one cell per row.
2993        let partial = vec![
2994            spanned(0, 0, 2, "Sales"),
2995            cell(0, 2, false, "Amount"),
2996            cell(1, 0, false, "Net"),
2997            cell(1, 1, false, "$"),
2998            cell(1, 2, false, "1"),
2999        ];
3000        let markdown = to_markdown(&partial);
3001        assert!(
3002            !markdown.contains("$ 1"),
3003            "a partial span still blocks the join: {markdown}"
3004        );
3005        assert!(
3006            markdown.contains(">$<"),
3007            "the blocked currency column survives: {markdown}"
3008        );
3009    }
3010
3011    /// Two tables with nothing between them are two tables: the second opens
3012    /// at row 0, column 0, which is the only thing that says so.
3013    #[test]
3014    fn a_second_table_starts_where_the_grid_starts_again() {
3015        let blocks = vec![
3016            cell(0, 0, false, "one"),
3017            cell(0, 1, false, "two"),
3018            cell(1, 0, false, "three"),
3019            cell(1, 1, false, "four"),
3020            cell(0, 0, false, "five"),
3021            cell(0, 1, false, "six"),
3022            cell(1, 0, false, "seven"),
3023            cell(1, 1, false, "eight"),
3024        ];
3025        let markdown = to_markdown(&blocks);
3026        assert_eq!(markdown.matches("| --- | --- |").count(), 2, "{markdown}");
3027        assert!(markdown.contains("| four |\n\n| five |"), "{markdown}");
3028    }
3029
3030    /// A table whose first row opens with an empty cell has no block at row 0,
3031    /// column 0 at all, so a run that ended only there ran straight into it and
3032    /// read two grids as one -- with duplicate coordinates, and the second
3033    /// table's cells dropped (codex, round 2).
3034    #[test]
3035    fn a_second_table_that_opens_with_a_hole_is_still_a_second_table() {
3036        let blocks = vec![
3037            cell(0, 0, false, "one"),
3038            cell(0, 1, false, "two"),
3039            cell(1, 0, false, "three"),
3040            cell(1, 1, false, "four"),
3041            cell(0, 1, false, "six"),
3042            cell(1, 0, false, "seven"),
3043            cell(1, 1, false, "eight"),
3044        ];
3045        let markdown = to_markdown(&blocks);
3046        assert_eq!(
3047            markdown.matches("<table>").count()
3048                + markdown
3049                    .lines()
3050                    .filter(|line| line.starts_with("| ---"))
3051                    .count(),
3052            2,
3053            "{markdown}"
3054        );
3055        for word in ["one", "four", "six", "eight"] {
3056            assert!(markdown.contains(word), "{word} was dropped: {markdown}");
3057        }
3058    }
3059
3060    fn on_page(kind: BlockKind, text: &str, page: usize) -> Block {
3061        Block {
3062            page,
3063            ..block(kind, text)
3064        }
3065    }
3066
3067    /// A document that quotes an HTML comment must not be able to write our
3068    /// own page marker as body text: a chunker following the convention would
3069    /// file everything after it under a page the document never had.
3070    #[test]
3071    fn a_paragraph_that_quotes_the_page_marker_is_not_one() {
3072        let blocks = vec![
3073            block(BlockKind::Paragraph, "<!-- page 7 -->"),
3074            block(BlockKind::Paragraph, "<table> opens a block too"),
3075        ];
3076        let markdown = to_markdown(&blocks);
3077        assert_eq!(
3078            markdown,
3079            "\\<!-- page 7 -->\n\n\\<table> opens a block too\n"
3080        );
3081        assert!(
3082            !markdown.lines().any(|line| line == "<!-- page 7 -->"),
3083            "document text came out as a marker: {markdown}"
3084        );
3085    }
3086
3087    #[test]
3088    fn a_page_change_is_marked_and_the_first_page_is_not() {
3089        let blocks = vec![
3090            on_page(BlockKind::Paragraph, "The first page ends here.", 0),
3091            on_page(BlockKind::Paragraph, "The second opens here.", 1),
3092        ];
3093        assert_eq!(
3094            to_markdown(&blocks),
3095            "The first page ends here.\n\n<!-- page 1 -->\n\nThe second opens here.\n"
3096        );
3097    }
3098
3099    /// The marker counts the page the way the block model does, from zero, so
3100    /// that a caller holding both serialisations of one document finds the same
3101    /// page in each.
3102    #[test]
3103    fn the_number_in_the_marker_is_the_block_s_own_page() {
3104        let blocks = vec![
3105            on_page(BlockKind::Paragraph, "Opening.", 4),
3106            on_page(BlockKind::Paragraph, "Next.", 7),
3107        ];
3108        assert!(to_markdown(&blocks).contains("<!-- page 7 -->"));
3109    }
3110
3111    /// The half of a sentence on the next page is written onto the first half,
3112    /// and a marker between them would cut the paragraph the joining exists to
3113    /// keep whole (#571). The page still MOVED, which the marker after it has
3114    /// to say: measured from the first page it would name a page two behind.
3115    #[test]
3116    fn a_paragraph_the_break_cut_in_two_is_not_cut_again_by_the_marker() {
3117        let mut middle = on_page(BlockKind::Paragraph, "to its end.", 1);
3118        middle.continues_previous = true;
3119        let blocks = vec![
3120            on_page(BlockKind::Paragraph, "The sentence runs on", 0),
3121            middle,
3122            on_page(BlockKind::Paragraph, "A new one.", 2),
3123        ];
3124        assert_eq!(
3125            to_markdown(&blocks),
3126            "The sentence runs on to its end.\n\n<!-- page 2 -->\n\nA new one.\n"
3127        );
3128    }
3129
3130    /// The half of the paragraph on the next page cannot have a marker in
3131    /// front of it, but the page it is on still has to be named: the block
3132    /// AFTER it opens with the marker. Without that the whole of the new page
3133    /// reads as the page before it (codex, round 2).
3134    #[test]
3135    fn the_deferred_marker_stands_after_the_joined_paragraph() {
3136        let mut middle = on_page(BlockKind::Paragraph, "to its end.", 1);
3137        middle.continues_previous = true;
3138        let blocks = vec![
3139            on_page(BlockKind::Paragraph, "The sentence runs on", 0),
3140            middle,
3141            on_page(BlockKind::Heading { level: 1 }, "Finance", 1),
3142            on_page(BlockKind::Paragraph, "Revenue was ahead of budget.", 1),
3143        ];
3144        assert_eq!(
3145            to_markdown(&blocks),
3146            "The sentence runs on to its end.\n\n<!-- page 1 -->\n\n# Finance\n\n\
3147             Revenue was ahead of budget.\n"
3148        );
3149    }
3150
3151    /// An HTML block ends a list in CommonMark, so the items after the marker
3152    /// are a list of their own -- and an item still written at its old parent's
3153    /// content column would be an indented code block with nothing to nest in.
3154    #[test]
3155    fn the_marker_closes_the_list_it_stands_after() {
3156        let blocks = vec![
3157            on_page(
3158                BlockKind::ListItem {
3159                    level: 0,
3160                    ordinal: None,
3161                },
3162                "first",
3163                0,
3164            ),
3165            on_page(
3166                BlockKind::ListItem {
3167                    level: 1,
3168                    ordinal: None,
3169                },
3170                "under it",
3171                1,
3172            ),
3173        ];
3174        assert_eq!(
3175            to_markdown(&blocks),
3176            "- first\n\n<!-- page 1 -->\n\n- under it\n"
3177        );
3178    }
3179
3180    /// The furniture is written nowhere, so it moves nothing: the running heads
3181    /// stand after the body in the reading order (#571), and a marker taken off
3182    /// their pages would put one at the end of every document.
3183    #[test]
3184    fn the_running_head_does_not_mark_a_page() {
3185        let blocks = vec![
3186            on_page(BlockKind::Paragraph, "Body one.", 0),
3187            on_page(BlockKind::Paragraph, "Body two.", 1),
3188            on_page(BlockKind::Header, "Annual Report 2026", 0),
3189            on_page(BlockKind::Header, "Annual Report 2026", 1),
3190        ];
3191        assert_eq!(
3192            to_markdown(&blocks),
3193            "Body one.\n\n<!-- page 1 -->\n\nBody two.\n"
3194        );
3195    }
3196
3197    /// Asked for, the furniture is written where it stands, as plain
3198    /// paragraphs: a running head is not a section title, and a page number
3199    /// not a heading either.
3200    #[test]
3201    fn furniture_asked_for_is_plain_paragraphs_where_they_stand() {
3202        let blocks = vec![
3203            on_page(BlockKind::Paragraph, "Body one.", 0),
3204            on_page(BlockKind::Paragraph, "Body two.", 1),
3205            on_page(BlockKind::Header, "Annual Report 2026", 0),
3206            on_page(BlockKind::Footer, "Page 1 of 2", 0),
3207        ];
3208        let options = MarkdownOptions {
3209            include_headers: true,
3210            include_footers: true,
3211            ..Default::default()
3212        };
3213        let markdown = to_markdown_with_options(&blocks, &options);
3214        assert_eq!(
3215            markdown,
3216            "Body one.\n\n<!-- page 1 -->\n\nBody two.\n\n<!-- page 0 -->\n\n\
3217             Annual Report 2026\n\nPage 1 of 2\n"
3218        );
3219        assert!(
3220            !markdown.lines().any(|line| line.starts_with('#')),
3221            "no heading syntax for furniture:\n{markdown}"
3222        );
3223    }
3224
3225    /// Headers on and footers off keeps only the running head; the reverse
3226    /// keeps only the footer. Each switch reads only its own furniture role.
3227    #[test]
3228    fn headers_and_footers_are_asked_for_separately() {
3229        let blocks = vec![
3230            on_page(BlockKind::Paragraph, "Body one.", 0),
3231            on_page(BlockKind::Header, "Annual Report 2026", 0),
3232            on_page(BlockKind::Footer, "Page 1 of 2", 0),
3233        ];
3234        let heads = MarkdownOptions {
3235            include_headers: true,
3236            ..Default::default()
3237        };
3238        let heads_only = to_markdown_with_options(&blocks, &heads);
3239        assert!(
3240            heads_only.contains("Annual Report 2026"),
3241            "the head is written:\n{heads_only}"
3242        );
3243        assert!(
3244            !heads_only.contains("Page 1 of 2"),
3245            "the footer is not:\n{heads_only}"
3246        );
3247        let feet = MarkdownOptions {
3248            include_footers: true,
3249            ..Default::default()
3250        };
3251        let feet_only = to_markdown_with_options(&blocks, &feet);
3252        assert!(
3253            !feet_only.contains("Annual Report 2026"),
3254            "the head is not:\n{feet_only}"
3255        );
3256        assert!(
3257            feet_only.contains("Page 1 of 2"),
3258            "the footer is written:\n{feet_only}"
3259        );
3260    }
3261
3262    /// With the separators off there is no marker: the paragraphs on either
3263    /// side of a page change stand separated by a blank line, like two
3264    /// paragraphs of one page.
3265    #[test]
3266    fn without_separators_pages_stand_a_blank_line_apart() {
3267        let blocks = vec![
3268            on_page(BlockKind::Paragraph, "Body one.", 0),
3269            on_page(BlockKind::Paragraph, "Body two.", 1),
3270        ];
3271        let options = MarkdownOptions {
3272            page_separators: PageSeparators::None,
3273            ..Default::default()
3274        };
3275        assert_eq!(
3276            to_markdown_with_options(&blocks, &options),
3277            "Body one.\n\nBody two.\n"
3278        );
3279    }
3280
3281    #[test]
3282    fn a_table_the_page_broke_is_two_tables_with_the_marker_between_them() {
3283        let mut blocks = vec![cell(0, 0, true, "Code"), cell(0, 1, true, "Means")];
3284        blocks.push(on_page(
3285            BlockKind::TableCell {
3286                row: 0,
3287                column: 0,
3288                row_span: 1,
3289                column_span: 1,
3290                header: true,
3291            },
3292            "Code",
3293            1,
3294        ));
3295        blocks.push(on_page(
3296            BlockKind::TableCell {
3297                row: 0,
3298                column: 1,
3299                row_span: 1,
3300                column_span: 1,
3301                header: true,
3302            },
3303            "Means",
3304            1,
3305        ));
3306        // HTML tables, not pipes: one row is no header block, so the form
3307        // follows the decoder's own marks rather than forcing a header (#636).
3308        assert_eq!(
3309            to_markdown(&blocks),
3310            "<table>\n<tr><th>Code</th><th>Means</th></tr>\n</table>\n\n<!-- page 1 -->\n\n<table>\n<tr><th>Code</th><th>Means</th></tr>\n</table>\n"
3311        );
3312    }
3313
3314    #[test]
3315    fn nothing_in_means_nothing_out() {
3316        assert_eq!(to_markdown(&[]), "");
3317        assert_eq!(to_markdown(&[block(BlockKind::Paragraph, "   ")]), "");
3318    }
3319}