pdf-engine 1.2.0

Unified PDF rendering engine — page rendering, text extraction, thumbnails.
Documentation
//! The text of a table cell, read the way its reader reads it (#714).
//!
//! Two things a page draws inside a cell are not the cell's text to anyone
//! who quotes the table, and every table corpus this engine is measured on
//! writes the cell without them:
//!
//! * A DOT LEADER. `2011 . . . . . . . . $65.1` runs the stub out to its
//!   figure with full stops, the way a contents page runs a title out to its
//!   page number. The dots are a rule drawn in type; the cell says `2011`.
//!   FinTabNet writes `2011`, and a column that keeps the dots scores the cell
//!   as a different string on every row of the table.
//! * A CURRENCY SIGN SET IN ITS OWN COLUMN. A financial statement draws the
//!   `$` of a column once per figure, flush left of the figures, and the
//!   figure flush right: `$` and `336.5` are two runs with a gap between them
//!   wider than a space. Inside the cell they are one token, `$336.5` --
//!   which is how the annotation writes it, how a reader reads it, and how a
//!   number parser needs it.
//!
//! WHY HERE, AFTER THE GRID. The cell was found from the runs' positions,
//! and the gap between `$` and the figure is part of what placed them in
//! one cell (or, when the gap is wide enough to be a column, in two; see
//! `learned::tables`). The text is tidied once the grid is final, so no
//! decision that reads the geometry is changed by it. Only the cell's text
//! changes; its spans keep the page's own runs, for a caller that wants the
//! glyph boxes.
//!
//! WHAT IS LEFT ALONE. A cell of prose with an ellipsis in it (`…` or
//! `...`): three stops are an ellipsis and four are a leader, the same floor
//! `crate::lists` reads a contents entry with. A sign standing alone in its
//! cell (`$` as the whole cell) is left where it is: the grid put it there,
//! and the text pass has no second cell to hand it to.

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

/// Full stops in a row before they read as a leader and not as an ellipsis:
/// the same floor as `crate::lists::DOT_LEADER`.
const DOT_LEADER: usize = 4;

/// The signs a statement sets apart from its figures.
const CURRENCY: [char; 4] = ['$', '\u{20ac}', '\u{a3}', '\u{a5}'];

/// Tidy every table cell's text in place.
pub(crate) fn tidy_cells(blocks: &mut [Block]) {
    for block in blocks {
        if !matches!(block.kind, BlockKind::TableCell { .. }) {
            continue;
        }
        let tidy = tidy(&block.text);
        if tidy != block.text {
            block.text = tidy;
        }
    }
}

/// One cell's text with its leaders struck and its currency signs joined.
pub(crate) fn tidy(text: &str) -> String {
    join_currency(&strip_leaders(text))
}

/// `2011 . . . . . 65.1` -> `2011 65.1`; `Total.......` -> `Total`.
///
/// A leader is a run of at least [`DOT_LEADER`] full stops, each of which may
/// be followed by white space (`. . . .`), or an ellipsis character, which
/// counts as three. The run is replaced by one space and the result is
/// collapsed, so a leader in the middle of a cell parts the words it stood
/// between and a leader at an edge disappears.
fn strip_leaders(text: &str) -> String {
    if !text.contains('.') && !text.contains('\u{2026}') {
        return text.to_string();
    }
    let chars: Vec<char> = text.chars().collect();
    let mut out = String::with_capacity(text.len());
    let mut i = 0;
    while i < chars.len() {
        if chars[i] == '.' || chars[i] == '\u{2026}' {
            // How far a run of stops (white space between them allowed) goes,
            // and how many stops it holds.
            let mut j = i;
            let mut stops = 0usize;
            let mut last_stop = i;
            while j < chars.len() {
                match chars[j] {
                    '.' => {
                        stops += 1;
                        last_stop = j;
                    }
                    '\u{2026}' => {
                        stops += 3;
                        last_stop = j;
                    }
                    c if c.is_whitespace() => {}
                    _ => break,
                }
                j += 1;
            }
            if stops >= DOT_LEADER {
                if !out.ends_with(' ') && !out.is_empty() {
                    out.push(' ');
                }
                i = last_stop + 1;
                continue;
            }
            // An ellipsis or a full stop: kept as written.
            out.extend(&chars[i..=last_stop]);
            i = last_stop + 1;
            continue;
        }
        out.push(chars[i]);
        i += 1;
    }
    let collapsed: Vec<&str> = out.split_whitespace().collect();
    collapsed.join(" ")
}

/// `$ 336.5` -> `$336.5`; `$ (1.3)` -> `$(1.3)`; `$` alone stays `$`.
///
/// A sign token joins the token after it when that token opens with a digit
/// or an opening parenthesis (a negative figure). A sign followed by a word
/// (`$ million`) is prose and keeps its space.
fn join_currency(text: &str) -> String {
    if !text.chars().any(|c| CURRENCY.contains(&c)) {
        return text.to_string();
    }
    let words: Vec<&str> = text.split(' ').collect();
    let mut out = String::with_capacity(text.len());
    let mut i = 0;
    while i < words.len() {
        let word = words[i];
        let is_sign = word.chars().count() == 1 && word.chars().all(|c| CURRENCY.contains(&c));
        if is_sign
            && i + 1 < words.len()
            && words[i + 1]
                .chars()
                .next()
                .is_some_and(|c| c.is_ascii_digit() || c == '(')
        {
            if !out.is_empty() {
                out.push(' ');
            }
            out.push_str(word);
            out.push_str(words[i + 1]);
            i += 2;
            continue;
        }
        if !out.is_empty() {
            out.push(' ');
        }
        out.push_str(word);
        i += 1;
    }
    out
}

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

    #[test]
    fn a_leader_run_is_struck_and_the_words_round_it_kept() {
        assert_eq!(strip_leaders("2011 . . . . . . . 65.1"), "2011 65.1");
        assert_eq!(strip_leaders("Total........"), "Total");
        assert_eq!(strip_leaders("Thereafter . . . . ."), "Thereafter");
        assert_eq!(
            strip_leaders("Business Overview ....... 21"),
            "Business Overview 21"
        );
        assert_eq!(strip_leaders("Vested \u{2026}\u{2026}"), "Vested");
    }

    #[test]
    fn an_ellipsis_and_a_full_stop_are_not_a_leader() {
        assert_eq!(strip_leaders("Net income..."), "Net income...");
        assert_eq!(strip_leaders("and so on\u{2026}"), "and so on\u{2026}");
        assert_eq!(strip_leaders("U.S. Treasury"), "U.S. Treasury");
        assert_eq!(strip_leaders("2.8 %"), "2.8 %");
    }

    #[test]
    fn a_currency_sign_joins_the_figure_after_it() {
        assert_eq!(join_currency("$ 336.5"), "$336.5");
        assert_eq!(join_currency("$ (1.3)"), "$(1.3)");
        assert_eq!(join_currency("\u{20ac} 1,000"), "\u{20ac}1,000");
        assert_eq!(join_currency("Total $ 278.6"), "Total $278.6");
    }

    #[test]
    fn a_sign_alone_or_before_a_word_keeps_its_place() {
        assert_eq!(join_currency("$"), "$");
        assert_eq!(join_currency("$ million"), "$ million");
        assert_eq!(join_currency("US$ 5"), "US$ 5");
        assert_eq!(join_currency("$336.5"), "$336.5");
    }

    #[test]
    fn tidy_does_both() {
        assert_eq!(tidy("2011 . . . . . $ 65.1"), "2011 $65.1");
    }
}