pdf-engine 1.2.0

Unified PDF rendering engine — page rendering, text extraction, thumbnails.
Documentation
//! A currency sign at the end of a cell belongs to the figure in the cell
//! to its right (#714).
//!
//! A statement sets the `$` of a column a little left of its figure, and
//! where the column before it ends close by, the grid put the sign in that
//! cell: `26% $ | 2,962`. Every table corpus this engine is measured on
//! writes `26%` and `$2,962`, as the Markdown serializer already writes a
//! sign that has a column of its own (`join_currency_columns`) and
//! `crate::cell_tidy` a sign that shares a cell with its figure.
//!
//! THE RULE, ON THE FINAL CELLS. A cell whose text ends in a sign after a
//! space, with a word before it, gives the sign to the cell on its right in
//! the same row when that cell opens with a figure: a digit, an opening
//! parenthesis or a dash. `US$` has no space before its sign and is a word;
//! a bare `$` is the serializer's column to join.

// 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 std::collections::BTreeMap;

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

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

/// Move every trailing sign of every table onto the figure beside it.
pub(crate) fn move_trailing_signs(blocks: &mut [Block]) {
    for (first, end) in tables_in(blocks) {
        // Ordered, so the moves run in row-major order whatever the hashing.
        let mut at: BTreeMap<(u16, u16), usize> = BTreeMap::new();
        for (i, block) in blocks.iter().enumerate().take(end).skip(first) {
            if let BlockKind::TableCell {
                row,
                column,
                row_span: 1,
                column_span: 1,
                ..
            } = block.kind
            {
                at.insert((row, column), i);
            }
        }
        for (&(row, column), &i) in &at {
            let Some(&right) = at.get(&(row, column + 1)) else {
                continue;
            };
            let text = blocks[i].text.trim_end();
            let Some(sign) = text.chars().last().filter(|c| CURRENCY.contains(c)) else {
                continue;
            };
            let rest = &text[..text.len() - sign.len_utf8()];
            if !rest.ends_with(char::is_whitespace) {
                continue;
            }
            let before = rest.trim_end();
            if before.is_empty() || !opens_with_figure(&blocks[right].text) {
                continue;
            }
            let before = before.to_string();
            let joined = format!("{sign}{}", blocks[right].text.trim_start());
            blocks[i].text = before;
            blocks[right].text = joined;
        }
    }
}

/// Whether a cell's text opens with a figure a sign can join: a digit, an
/// opening parenthesis or a dash.
fn opens_with_figure(text: &str) -> bool {
    text.trim_start().chars().next().is_some_and(|c| {
        c.is_ascii_digit() || c == '(' || c == '\u{2014}' || c == '-' || c == '\u{2013}'
    })
}

#[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 texts(blocks: &[Block]) -> Vec<&str> {
        blocks.iter().map(|b| b.text.as_str()).collect()
    }

    #[test]
    fn a_trailing_sign_moves_to_the_figure_on_its_right() {
        let mut blocks = vec![
            cell(0, 0, "Korea"),
            cell(0, 1, "26% $"),
            cell(0, 2, "2,962"),
            cell(0, 3, "US$"),
            cell(0, 4, "40"),
            cell(1, 0, "Taiwan"),
            cell(1, 1, "17% $"),
            cell(1, 2, "(743)"),
            cell(1, 3, "Cash $"),
            cell(1, 4, "\u{2014}"),
        ];
        move_trailing_signs(&mut blocks);
        assert_eq!(
            texts(&blocks),
            vec![
                "Korea",
                "26%",
                "$2,962",
                "US$",
                "40",
                "Taiwan",
                "17%",
                "$(743)",
                "Cash",
                "$\u{2014}"
            ]
        );
    }

    #[test]
    fn a_sign_before_a_word_or_at_the_table_edge_stays() {
        let mut blocks = vec![
            cell(0, 0, "in $"),
            cell(0, 1, "millions"),
            cell(0, 2, "paid $"),
        ];
        move_trailing_signs(&mut blocks);
        assert_eq!(texts(&blocks), vec!["in $", "millions", "paid $"]);
    }

    #[test]
    fn a_bare_sign_is_left_to_the_serializer() {
        let mut blocks = vec![cell(0, 0, "$"), cell(0, 1, "12")];
        move_trailing_signs(&mut blocks);
        assert_eq!(texts(&blocks), vec!["$", "12"]);
    }
}