pdf-engine 1.2.0

Unified PDF rendering engine — page rendering, text extraction, thumbnails.
Documentation
//! A stub that wraps onto the line of its figures is one stub (#714).
//!
//! A statement sets a long label over two lines and its figures beside
//! the second: `Fair value of outstanding stock options, direct
//! acquisition costs and` on one line, `the effect of FleetBoston shares
//! already owned by the Corporation | 1,360` on the next. The grid read
//! the first line as a row of its own with nothing beside it. Every
//! table corpus this engine is measured on writes the label as one cell
//! on the figures' row, and TEDS charged the extra row and the cut label.
//!
//! THE RULE, ON THE FINAL CELLS. A row with text in its stub alone,
//! followed by a row whose stub opens with a lowercase letter and that
//! has a figure beside it, is the first line of that row's stub: the
//! texts join with a space, the boxes as one, the spans in order, and the
//! rows under it move up. A section label (`Operating results`) opens
//! with a capital and stays a row; so does a stub-only row over a stub
//! that opens with a capital.

// 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;

/// Join every wrapped stub of every table in place.
pub(crate) fn join_wrapped_stubs(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 rows = table
            .iter()
            .filter_map(|b| match b.kind {
                BlockKind::TableCell { row, row_span, .. } => Some(row + row_span),
                _ => None,
            })
            .max()
            .unwrap_or(0) as usize;
        if rows < 2 {
            continue;
        }
        // Per row: the stub's block, whether the row has text past the
        // stub, and whether any cell spans.
        let mut stub: Vec<Option<usize>> = vec![None; rows];
        let mut beyond: Vec<bool> = vec![false; rows];
        let mut spans: Vec<bool> = vec![false; rows];
        for (i, b) in table.iter().enumerate() {
            if let BlockKind::TableCell {
                row,
                column,
                row_span,
                column_span,
                ..
            } = b.kind
            {
                let r = row as usize;
                if row_span > 1 || column_span > 1 {
                    spans[r] = true;
                }
                if column == 0 {
                    stub[r].get_or_insert(first + i);
                } else if !b.text.trim().is_empty() {
                    beyond[r] = true;
                }
            }
        }
        let mut gone = vec![false; rows];
        for r in 0..rows - 1 {
            if spans[r] || spans[r + 1] || beyond[r] || !beyond[r + 1] {
                continue;
            }
            let (Some(above), Some(below)) = (stub[r], stub[r + 1]) else {
                continue;
            };
            if blocks[above].text.trim().is_empty()
                || !blocks[below]
                    .text
                    .trim_start()
                    .chars()
                    .next()
                    .is_some_and(char::is_lowercase)
            {
                continue;
            }
            let (text, bbox, spans_above) = {
                let a = &blocks[above];
                (a.text.trim().to_string(), a.bbox, a.spans.clone())
            };
            let b = &mut blocks[below];
            b.text = format!("{text} {}", b.text.trim_start());
            b.bbox = [
                b.bbox[0].min(bbox[0]),
                b.bbox[1].min(bbox[1]),
                b.bbox[2].max(bbox[2]),
                b.bbox[3].max(bbox[3]),
            ];
            let mut joined = spans_above;
            joined.append(&mut b.spans);
            b.spans = joined;
            drop[above] = true;
            gone[r] = true;
            changed = true;
        }
        if !gone.iter().any(|&g| g) {
            continue;
        }
        // The rows that stay are counted again; a dropped row's other
        // cells (empty ones) go with it.
        let mut new_row = vec![0u16; rows];
        let mut n = 0u16;
        for r in 0..rows {
            new_row[r] = n;
            if !gone[r] {
                n += 1;
            }
        }
        for (i, b) in blocks[first..end].iter_mut().enumerate() {
            if let BlockKind::TableCell { row, .. } = &mut b.kind {
                let r = *row as usize;
                if gone[r] {
                    drop[first + i] = true;
                } else {
                    *row = new_row[r];
                }
            }
        }
    }
    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;
}

#[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 a_stub_wrapped_over_its_figures_is_one_stub() {
        let mut blocks = vec![
            cell(0, 0, "Total purchase"),
            cell(0, 1, "$47,253"),
            cell(1, 0, "Fair value of options and"),
            cell(1, 1, ""),
            cell(2, 0, "the effect of shares owned"),
            cell(2, 1, "1,360"),
            cell(3, 0, "Total"),
            cell(3, 1, "9"),
        ];
        join_wrapped_stubs(&mut blocks);
        assert_eq!(
            grid(&blocks),
            vec![
                (0, 0, "Total purchase".to_string()),
                (0, 1, "$47,253".to_string()),
                (
                    1,
                    0,
                    "Fair value of options and the effect of shares owned".to_string()
                ),
                (1, 1, "1,360".to_string()),
                (2, 0, "Total".to_string()),
                (2, 1, "9".to_string()),
            ]
        );
        assert!(blocks.iter().enumerate().all(|(i, b)| b.index == i));
    }

    #[test]
    fn a_section_label_over_a_capitalised_stub_stays_a_row() {
        let mut blocks = vec![
            cell(0, 0, "Operating results"),
            cell(0, 1, ""),
            cell(1, 0, "Net sales"),
            cell(1, 1, "8,023"),
        ];
        join_wrapped_stubs(&mut blocks);
        assert_eq!(blocks.len(), 4);
    }

    #[test]
    fn a_lowercase_stub_under_a_full_row_stays_a_row() {
        let mut blocks = vec![
            cell(0, 0, "Net sales"),
            cell(0, 1, "8,023"),
            cell(1, 0, "of which exports"),
            cell(1, 1, "1,000"),
        ];
        join_wrapped_stubs(&mut blocks);
        assert_eq!(blocks.len(), 4);
    }
}