pdf-engine 1.2.0

Unified PDF rendering engine — page rendering, text extraction, thumbnails.
Documentation
//! A line that wraps under its entry in a two-column index is the entry's
//! (#714).
//!
//! An exhibit index sets `10.53` in its first column and a description in
//! its second that runs to three lines; the number stands beside the first
//! line and the lines under it have nothing in the first column. The grid
//! read each line as a row, so the index came out with two rows of empty
//! stubs under every long entry, and TEDS charged each as a row and the
//! entry's text as three cells. FinTabNet writes the entry as one row
//! with the three lines in one cell, and so does any reader.
//!
//! THE RULE, ON THE FINAL CELLS. In a table of two columns with no
//! spanning cell, a row whose first cell is empty and whose second holds
//! prose -- a letter in it, so not a figure or a page number -- joins the
//! row above it when that row's second cell holds prose too, AND the line
//! stands where a wrapped line stands: its left edge at the entry's left
//! edge (within [`WRAP_ALIGN`] of the line's height) and no more than a
//! line's height of white under the entry. The texts join with a space
//! between, the boxes as one box, the spans in order. A row with a figure
//! in its second cell is a row: `Total | 12` under an empty stub is a sum,
//! not a continuation. A glossary whose terms are set apart, or a
//! key-value table whose value is indented, keeps its rows: the white or
//! the edge says so. A table of three or more columns is left alone; its
//! empty stubs mean other things. A row with two cells in one column is
//! not a grid this reads and is left as it is.

// Copyright (c) 2026 Innovation Trigger B.V.
//
// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
// the PDFluent Commercial Licence. See the LICENSE file in this repository --
// that file travels with the copy you received, which a URL does not.

use crate::blocks::{Block, BlockKind};
use crate::header_rows::tables_in;

/// How far a wrapped line's left edge may stand from the entry's, as a
/// share of the line's height.
const WRAP_ALIGN: f64 = 0.5;

/// How much white may stand between an entry and the line wrapped under
/// it, as a share of the line's height: a line's pitch, not a paragraph's.
const WRAP_GAP: f64 = 1.0;

/// Whether `line` stands under `entry` the way a wrapped line does: left
/// edges together, and no more than a line of white between (y up).
fn wraps_under(entry: [f64; 4], line: [f64; 4]) -> bool {
    let height = (line[3] - line[1]).max(1.0);
    (line[0] - entry[0]).abs() <= WRAP_ALIGN * height
        && entry[1] - line[3] <= WRAP_GAP * height
        && line[3] <= entry[3]
}

/// Join the wrapped lines of every two-column index in place.
pub(crate) fn join_continuation_rows(blocks: &mut Vec<Block>) {
    let mut drop = vec![false; blocks.len()];
    let mut changed = false;
    for (first, end) in tables_in(blocks) {
        let table = &blocks[first..end];
        let two_columns = table.iter().all(|b| match b.kind {
            BlockKind::TableCell {
                column,
                row_span,
                column_span,
                ..
            } => column <= 1 && row_span == 1 && column_span == 1,
            _ => true,
        });
        let columns = table
            .iter()
            .filter_map(|b| match b.kind {
                BlockKind::TableCell { column, .. } => Some(column + 1),
                _ => None,
            })
            .max()
            .unwrap_or(0);
        if !two_columns || columns != 2 {
            continue;
        }
        let rows = table
            .iter()
            .filter_map(|b| match b.kind {
                BlockKind::TableCell { row, .. } => Some(row + 1),
                _ => None,
            })
            .max()
            .unwrap_or(0);
        // The block of each row's stub and entry, where present; a row with
        // two cells in one column is not read.
        let mut stub: Vec<Option<usize>> = vec![None; rows as usize];
        let mut entry: Vec<Option<usize>> = vec![None; rows as usize];
        let mut doubled: Vec<bool> = vec![false; rows as usize];
        for (i, b) in table.iter().enumerate() {
            if let BlockKind::TableCell { row, column, .. } = b.kind {
                let slot = if column == 0 { &mut stub } else { &mut entry };
                if slot[row as usize].replace(first + i).is_some() {
                    doubled[row as usize] = true;
                }
            }
        }
        // The row each row joins, after the joins above it.
        let mut target: Vec<u16> = (0..rows).collect();
        for r in 1..rows as usize {
            let stub_empty = stub[r].is_none_or(|i| blocks[i].text.trim().is_empty());
            let Some(here) = entry[r] else {
                continue;
            };
            if doubled[r] || !stub_empty || !is_prose(&blocks[here].text) {
                continue;
            }
            let above = target[r - 1] as usize;
            let Some(keep) = entry[above] else {
                continue;
            };
            if doubled[above]
                || !is_prose(&blocks[keep].text)
                || !wraps_under(blocks[keep].bbox, blocks[here].bbox)
            {
                continue;
            }
            let (text, bbox, spans) = {
                let b = &blocks[here];
                (b.text.clone(), b.bbox, b.spans.clone())
            };
            let kept = &mut blocks[keep];
            if !text.trim().is_empty() {
                if !kept.text.is_empty() {
                    kept.text.push(' ');
                }
                kept.text.push_str(text.trim());
            }
            kept.bbox = [
                kept.bbox[0].min(bbox[0]),
                kept.bbox[1].min(bbox[1]),
                kept.bbox[2].max(bbox[2]),
                kept.bbox[3].max(bbox[3]),
            ];
            kept.spans.extend(spans);
            drop[here] = true;
            if let Some(s) = stub[r] {
                drop[s] = true;
            }
            target[r] = above as u16;
            changed = true;
        }
        // The rows that stay are counted again.
        let mut new_row = vec![0u16; rows as usize];
        let mut n = 0u16;
        for r in 0..rows as usize {
            if target[r] == r as u16 {
                new_row[r] = n;
                n += 1;
            }
        }
        for (i, b) in blocks[first..end].iter_mut().enumerate() {
            if drop[first + i] {
                continue;
            }
            if let BlockKind::TableCell { row, .. } = &mut b.kind {
                // A joined row has no cell left: its stub and entry were
                // dropped and a doubled row is never joined.
                debug_assert_eq!(target[*row as usize], *row);
                *row = new_row[*row as usize];
            }
        }
    }
    if !changed {
        return;
    }
    let mut out = Vec::with_capacity(blocks.len());
    for (i, block) in blocks.drain(..).enumerate() {
        if drop[i] {
            continue;
        }
        let mut block = block;
        block.index = out.len();
        out.push(block);
    }
    *blocks = out;
}

/// Whether a cell's text is prose: at least one letter in it. A page
/// number, a figure, a date in figures and a dash are not.
fn is_prose(text: &str) -> bool {
    text.chars().any(|c| c.is_alphabetic())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn cell(row: u16, column: u16, text: &str) -> Block {
        Block {
            kind: BlockKind::TableCell {
                row,
                column,
                row_span: 1,
                column_span: 1,
                header: false,
            },
            page: 0,
            bbox: [
                100.0 * column as f64,
                700.0 - 12.0 * row as f64,
                100.0 * column as f64 + 50.0,
                710.0 - 12.0 * row as f64,
            ],
            text: text.to_string(),
            spans: Vec::new(),
            index: 0,
            continues_previous: false,
            confidence: None,
            grid: None,
            table_origin: Default::default(),
            trace_learned: None,
        }
    }

    fn grid(blocks: &[Block]) -> Vec<(u16, u16, String)> {
        blocks
            .iter()
            .filter_map(|b| match b.kind {
                BlockKind::TableCell { row, column, .. } => Some((row, column, b.text.clone())),
                _ => None,
            })
            .collect()
    }

    #[test]
    fn wrapped_lines_join_the_entry_above() {
        let mut blocks = vec![
            cell(0, 0, "Number"),
            cell(0, 1, "Description"),
            cell(1, 0, "10.53"),
            cell(1, 1, "Fourth Amendment, dated"),
            cell(2, 0, ""),
            cell(2, 1, "February 27, 2014 (incorporated"),
            cell(3, 1, "by reference)."),
            cell(4, 0, "10.54"),
            cell(4, 1, "Fifth Amendment."),
        ];
        join_continuation_rows(&mut blocks);
        assert_eq!(
            grid(&blocks),
            vec![
                (0, 0, "Number".to_string()),
                (0, 1, "Description".to_string()),
                (1, 0, "10.53".to_string()),
                (
                    1,
                    1,
                    "Fourth Amendment, dated February 27, 2014 (incorporated by reference)."
                        .to_string()
                ),
                (2, 0, "10.54".to_string()),
                (2, 1, "Fifth Amendment.".to_string()),
            ]
        );
        assert!(blocks.iter().enumerate().all(|(i, b)| b.index == i));
        assert_eq!(blocks[3].bbox[1], 700.0 - 36.0);
    }

    #[test]
    fn a_glossary_set_apart_or_indented_keeps_its_rows() {
        // Terms set a paragraph apart: the white says they are rows.
        let mut blocks = vec![
            cell(0, 0, "Term"),
            cell(0, 1, "What the first term means"),
            cell(1, 0, ""),
            cell(1, 1, "a second meaning, set apart"),
        ];
        blocks[2].bbox[3] -= 30.0;
        blocks[2].bbox[1] -= 30.0;
        blocks[3].bbox[3] -= 30.0;
        blocks[3].bbox[1] -= 30.0;
        join_continuation_rows(&mut blocks);
        assert_eq!(blocks.len(), 4);
        // A value indented past the entry's edge is a row of its own.
        let mut blocks = vec![
            cell(0, 0, "Key"),
            cell(0, 1, "value one"),
            cell(1, 0, ""),
            cell(1, 1, "value two, indented"),
        ];
        blocks[3].bbox[0] += 20.0;
        join_continuation_rows(&mut blocks);
        assert_eq!(blocks.len(), 4);
        // Two cells at one `(row, column)` never share a range: `tables_in`
        // reads the second as a new table, so the `doubled` guard above is
        // belt and braces, and a test of it would test `tables_in`.
    }

    #[test]
    fn a_figure_under_an_empty_stub_is_a_row() {
        let mut blocks = vec![
            cell(0, 0, "Revenue"),
            cell(0, 1, "12"),
            cell(1, 0, ""),
            cell(1, 1, "13"),
            cell(2, 0, ""),
            cell(2, 1, "Total"),
        ];
        join_continuation_rows(&mut blocks);
        assert_eq!(blocks.len(), 6);
    }

    #[test]
    fn a_table_of_three_columns_is_left_alone() {
        let mut blocks = vec![
            cell(0, 0, "a"),
            cell(0, 1, "b"),
            cell(0, 2, "c"),
            cell(1, 0, ""),
            cell(1, 1, "wrapped"),
            cell(1, 2, ""),
        ];
        join_continuation_rows(&mut blocks);
        assert_eq!(blocks.len(), 6);
    }
}