use crate::table_core::{HocrWord, detect_rows, find_row_index, is_lone_cell_symbol, is_value, median_of};
const THIN_MARK_HEIGHT_RATIO: f64 = 0.25;
const TALL_MARK_HEIGHT_RATIO: f64 = 1.8;
const MARK_MAX_CONFIDENCE: f64 = 35.0;
pub(crate) fn shading_mark_keep_mask(words: &[HocrWord], row_threshold_ratio: f64) -> Vec<bool> {
let row_positions = detect_rows(words, row_threshold_ratio);
let rows: Vec<Option<usize>> = words.iter().map(|word| find_row_index(&row_positions, word)).collect();
let mut row_heights: Vec<Vec<u32>> = vec![Vec::new(); row_positions.len()];
for (word, row) in words.iter().zip(&rows) {
if let Some(row) = *row {
row_heights[row].push(word.height);
}
}
let row_medians: Vec<u32> = row_heights.into_iter().map(median_of).collect();
words
.iter()
.zip(rows)
.map(|(word, row)| !row.is_some_and(|row| is_shading_mark(word, row_medians[row])))
.collect()
}
fn is_shading_mark(word: &HocrWord, row_median_height: u32) -> bool {
if row_median_height == 0 || word.confidence >= MARK_MAX_CONFIDENCE {
return false;
}
let ratio = word.height as f64 / row_median_height as f64;
!(THIN_MARK_HEIGHT_RATIO..=TALL_MARK_HEIGHT_RATIO).contains(&ratio) && is_mark_text(&word.text)
}
fn is_mark_text(text: &str) -> bool {
let chars: Vec<char> = text.chars().collect();
!chars.iter().any(|ch| ch.is_alphabetic()) && !is_value(&chars) && !is_lone_cell_symbol(text)
}
#[cfg(test)]
mod tests {
use super::*;
fn drop_shading_marks(words: Vec<HocrWord>, row_threshold_ratio: f64) -> Vec<HocrWord> {
let keep = shading_mark_keep_mask(&words, row_threshold_ratio);
words
.into_iter()
.zip(keep)
.filter(|(_, keep)| *keep)
.map(|(word, _)| word)
.collect()
}
fn word(text: &str, left: u32, top: u32, height: u32, confidence: f64) -> HocrWord {
HocrWord {
text: text.to_string(),
left,
top,
width: 60,
height,
confidence,
}
}
fn row_with(mark: HocrWord) -> Vec<HocrWord> {
vec![
word("7,812", 100, 200, 30, 90.0),
mark,
word("7,968", 200, 200, 30, 90.0),
word("8,127", 300, 200, 30, 90.0),
]
}
fn texts(words: &[HocrWord]) -> Vec<&str> {
words.iter().map(|word| word.text.as_str()).collect()
}
const ROW_THRESHOLD_RATIO: f64 = 0.5;
#[test]
fn drops_a_tall_low_confidence_mark() {
let kept = drop_shading_marks(row_with(word("=", 165, 184, 62, 13.0)), ROW_THRESHOLD_RATIO);
assert_eq!(texts(&kept), ["7,812", "7,968", "8,127"]);
}
#[test]
fn drops_a_thin_low_confidence_mark() {
let kept = drop_shading_marks(row_with(word("\u{2014},", 165, 213, 5, 0.0)), ROW_THRESHOLD_RATIO);
assert_eq!(texts(&kept), ["7,812", "7,968", "8,127"]);
}
#[test]
fn drops_a_thin_mark_read_at_confidence_32() {
let kept = drop_shading_marks(row_with(word(":", 165, 214, 1, 32.0)), ROW_THRESHOLD_RATIO);
assert_eq!(texts(&kept), ["7,812", "7,968", "8,127"]);
}
#[test]
fn keeps_a_thin_dash_read_at_a_moderate_confidence() {
let row = vec![
word("Jan 1", 100, 200, 60, 95.0),
word("-", 165, 225, 10, 46.0),
word("Jan 31,", 200, 200, 60, 95.0),
word("2026", 300, 200, 60, 95.0),
];
let kept = drop_shading_marks(row, ROW_THRESHOLD_RATIO);
assert_eq!(texts(&kept), ["Jan 1", "-", "Jan 31,", "2026"]);
}
#[test]
fn keeps_a_tall_mark_read_at_a_high_confidence() {
let kept = drop_shading_marks(row_with(word("=", 165, 184, 62, 90.0)), ROW_THRESHOLD_RATIO);
assert_eq!(texts(&kept), ["7,812", "=", "7,968", "8,127"]);
}
#[test]
fn keeps_a_value_at_confidence_zero_and_normal_height() {
let kept = drop_shading_marks(row_with(word("(2,100)", 165, 200, 30, 0.0)), ROW_THRESHOLD_RATIO);
assert_eq!(texts(&kept), ["7,812", "(2,100)", "7,968", "8,127"]);
}
#[test]
fn keeps_a_low_confidence_word_with_a_letter_at_mark_height() {
let row = vec![
word("19.82", 100, 200, 11, 90.0),
word("10.47", 200, 200, 11, 90.0),
word("ATI", 300, 200, 11, 90.0),
word("Tech", 400, 191, 29, 27.6),
word("17.89", 500, 200, 11, 90.0),
];
let kept = drop_shading_marks(row, ROW_THRESHOLD_RATIO);
assert_eq!(texts(&kept), ["19.82", "10.47", "ATI", "Tech", "17.89"]);
}
#[test]
fn keeps_a_low_confidence_value_at_mark_height() {
for (top, height) in [(184, 62), (211, 7)] {
let kept = drop_shading_marks(row_with(word("22,636", 165, top, height, 20.0)), ROW_THRESHOLD_RATIO);
assert_eq!(texts(&kept), ["7,812", "22,636", "7,968", "8,127"], "height {height}");
}
}
#[test]
fn keeps_a_lone_cell_symbol_at_mark_height() {
for symbol in ["-", "\u{2014}", "(", "{", "$", "%", "*"] {
for (top, height) in [(213, 4), (180, 70)] {
let kept = drop_shading_marks(row_with(word(symbol, 165, top, height, 20.0)), ROW_THRESHOLD_RATIO);
assert_eq!(
texts(&kept),
["7,812", symbol, "7,968", "8,127"],
"{symbol} at height {height}"
);
}
}
}
#[test]
fn measures_a_mark_against_the_median_of_its_own_row() {
let region = vec![
word("1,101", 100, 100, 10, 90.0),
word("=", 165, 95, 20, 10.0),
word("1,102", 200, 100, 10, 90.0),
word("1,103", 300, 100, 10, 90.0),
word("1,104", 400, 100, 10, 90.0),
word("2,201", 100, 300, 60, 90.0),
word("=", 165, 300, 60, 10.0),
word("2,202", 200, 300, 60, 90.0),
word("2,203", 300, 300, 60, 90.0),
];
let kept = drop_shading_marks(region, ROW_THRESHOLD_RATIO);
assert_eq!(
texts(&kept),
["1,101", "1,102", "1,103", "1,104", "2,201", "=", "2,202", "2,203"]
);
}
#[test]
fn keeps_every_word_of_a_row_whose_median_height_is_zero() {
let row = vec![
word("-", 100, 200, 0, 0.0),
word("=", 200, 200, 0, 0.0),
word("7,812", 300, 200, 30, 0.0),
];
let kept = drop_shading_marks(row, ROW_THRESHOLD_RATIO);
assert_eq!(texts(&kept), ["-", "=", "7,812"]);
}
#[test]
fn keeps_every_word_of_an_empty_or_one_word_region() {
assert!(drop_shading_marks(Vec::new(), ROW_THRESHOLD_RATIO).is_empty());
let kept = drop_shading_marks(vec![word("=", 100, 200, 5, 0.0)], ROW_THRESHOLD_RATIO);
assert_eq!(texts(&kept), ["="]);
}
}