use super::cv::{Interp, resize};
use super::rtl::{flip, has_rtl};
use image::GrayImage;
use regex::Regex;
use std::sync::LazyLock;
use unicode_normalization::UnicodeNormalization;
const PAD_Y: f64 = 0.2;
pub(super) const PAD_X: f64 = 0.3;
const WIDTH_MULTIPLE: usize = 8;
const MAX_WIDTH: usize = 4096;
pub(super) struct Crop {
pub width: usize,
pub data: Vec<f32>,
}
fn percentiles(pixels: impl Iterator<Item = u8>) -> [f64; 3] {
let mut hist = [0u64; 256];
let mut n = 0u64;
for v in pixels {
hist[v as usize] += 1;
n += 1;
}
let mut cum = hist;
for i in 1..256 {
cum[i] += cum[i - 1];
}
let value = |i: u64| cum.partition_point(|&c| c <= i) as f64;
[0.02, 0.5, 0.98].map(|q| {
let x = q * (n - 1) as f64;
let i = x.floor();
let g = x - i;
let lo = value(i as u64);
let hi = value((i as u64 + 1).min(n - 1));
let d = hi - lo;
if g >= 0.5 {
hi - d * (1.0 - g)
} else {
lo + d * g
}
})
}
pub(super) fn is_dark_line(gray: &GrayImage, b: [f32; 4]) -> bool {
let (pw, ph) = (gray.width() as i64, gray.height() as i64);
let [x0, y0, x1, y1] = b.map(|v| v as i64);
let (sx0, sy0, sx1, sy1) = (x0.max(0), y0.max(0), (x1 + 1).min(pw), (y1 + 1).min(ph));
if sx1 <= sx0 || sy1 <= sy0 {
return false;
}
let src = gray.as_raw();
let pixels = (sy0..sy1).flat_map(|y| {
let row = (y * pw) as usize;
src[row + sx0 as usize..row + sx1 as usize].iter().copied()
});
let [lo, med, hi] = percentiles(pixels);
hi - med > 2.0 * (med - lo) + 8.0
}
pub(super) fn crop_line(
gray: &GrayImage,
b: [f32; 4],
height: usize,
window: usize,
dark: bool,
) -> Crop {
let pad_y = (window as f64 * (1.0 + 2.0 * PAD_Y) - 1.0) / 2.0;
let height = height * window;
let [x0, y0, x1, y1] = b.map(f64::from);
let h = (y1 - y0).max(1.0);
let (ix0, iy0) = (
(x0 - PAD_X * h).floor() as i64,
(y0 - pad_y * h).floor() as i64,
);
let (ix1, iy1) = (
(x1 + PAD_X * h).ceil() as i64,
(y1 + pad_y * h).ceil() as i64,
);
let cw = (ix1 - ix0).max(1);
let ch = (iy1 - iy0).max(1);
let (pw, ph) = (gray.width() as i64, gray.height() as i64);
let ground = if dark { 0 } else { 255 };
let mut crop = GrayImage::from_pixel(cw as u32, ch as u32, image::Luma([ground]));
let (sx0, sy0, sx1, sy1) = (ix0.max(0), iy0.max(0), ix1.min(pw), iy1.min(ph));
if sx1 > sx0 && sy1 > sy0 {
let src = gray.as_raw();
let dst: &mut [u8] = &mut crop;
for y in sy0..sy1 {
let s = (y * pw + sx0) as usize;
let d = ((y - iy0) * cw + (sx0 - ix0)) as usize;
let n = (sx1 - sx0) as usize;
dst[d..d + n].copy_from_slice(&src[s..s + n]);
}
}
let out_w = ((cw as f64 * height as f64 / ch as f64).round_ties_even() as usize)
.clamp(WIDTH_MULTIPLE, MAX_WIDTH);
let interp = if ch as usize > height {
Interp::Area
} else {
Interp::Linear
};
let resized = resize(&crop, out_w as u32, height as u32, interp);
Crop {
width: out_w,
data: resized
.as_raw()
.iter()
.map(|&v| {
let ink = v as f32 / 255.0;
if dark { ink } else { 1.0 - ink }
})
.collect(),
}
}
pub(super) fn pad_batch(crops: &[&Crop], height: usize) -> (Vec<f32>, usize, Vec<i64>) {
let max = crops.iter().map(|c| c.width).max().unwrap_or(0);
let wm = max.div_ceil(WIDTH_MULTIPLE) * WIDTH_MULTIPLE;
let mut out = vec![0f32; crops.len() * height * wm];
for (i, c) in crops.iter().enumerate() {
for y in 0..height {
let d = (i * height + y) * wm;
out[d..d + c.width].copy_from_slice(&c.data[y * c.width..(y + 1) * c.width]);
}
}
(out, wm, crops.iter().map(|c| c.width as i64).collect())
}
pub(super) fn width_batches(
widths: &[usize],
max_batch: usize,
max_ratio: f64,
max_area: usize,
) -> Vec<Vec<usize>> {
const MIN_WIDTH: usize = 64;
let mut order: Vec<usize> = (0..widths.len()).collect();
order.sort_by_key(|&i| widths[i]);
let mut batches: Vec<Vec<usize>> = Vec::new();
for i in order {
let w = widths[i].max(MIN_WIDTH);
match batches.last_mut() {
Some(b)
if b.len() < max_batch
&& w as f64 <= max_ratio * widths[b[0]].max(MIN_WIDTH) as f64
&& (b.len() + 1) * w <= max_area =>
{
b.push(i)
}
_ => batches.push(vec![i]),
}
}
batches
}
pub(super) fn line_batches(crops: &[Crop]) -> Vec<Vec<usize>> {
let widths: Vec<usize> = crops.iter().map(|c| c.width).collect();
width_batches(&widths, 64, 1.3, 64 * 640)
}
#[derive(Clone, Debug)]
pub(super) struct Token {
pub text: String,
pub x: f64,
}
#[derive(Clone, Debug, Default)]
pub(super) struct LineText {
pub text: String,
pub conf: f64,
pub tokens: Vec<Token>,
pub char_tokens: bool,
}
pub(super) fn join_tokens<S: AsRef<str>>(tokens: &[S]) -> String {
let mut out = String::new();
for (k, t) in tokens.iter().enumerate() {
let t = t.as_ref();
out.push_str(t);
let next = tokens.get(k + 1).map(|n| n.as_ref()).unwrap_or("");
let command =
t.chars().count() > 2 && t.starts_with('\\') && t[1..].chars().all(char::is_alphabetic);
if command && next.chars().next().is_some_and(char::is_alphabetic) {
out.push(' ');
}
}
out
}
pub(super) fn decode(tokens: &[Token], char_tokens: bool, page_rtl: bool) -> String {
if char_tokens {
let joined: String = tokens.iter().map(|t| t.text.as_str()).collect();
let text: String = joined
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.nfc()
.collect();
if has_rtl(&text) {
flip(&text, page_rtl.then_some(true))
} else {
text
}
} else {
join_tokens(&tokens.iter().map(|t| t.text.as_str()).collect::<Vec<_>>())
}
}
fn is_space(t: &Token) -> bool {
t.text == " "
}
pub(super) fn split_line(
b: [f32; 4],
line: &LineText,
gap: f64,
at: &[f64],
page_rtl: bool,
) -> Vec<([f32; 4], String, Vec<Token>)> {
let h = ((b[3] - b[1]) as f64).max(1.0);
let mut segs: Vec<Vec<Token>> = vec![Vec::new()];
let mut prev: Option<f64> = None;
for tk in &line.tokens {
if !is_space(tk) {
let x = tk.x;
if let Some(p) = prev
&& (x - p > gap * h || at.iter().any(|&c| p < c && c < x))
{
segs.push(Vec::new());
}
prev = Some(x);
}
segs.last_mut().expect("segs is non-empty").push(tk.clone());
}
if segs.len() == 1 {
return vec![(
b,
decode(&line.tokens, line.char_tokens, page_rtl),
line.tokens.clone(),
)];
}
let mut out = Vec::new();
for sg in segs {
let text = decode(&sg, line.char_tokens, page_rtl).trim().to_string();
let xs: Vec<f64> = sg.iter().filter(|t| !is_space(t)).map(|t| t.x).collect();
if !text.is_empty() && !xs.is_empty() {
let lo = xs.iter().copied().fold(f64::INFINITY, f64::min);
let hi = xs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let bx = [
(b[0] as f64).max(lo - 0.35 * h) as f32,
b[1],
(b[2] as f64).min(hi + 0.35 * h) as f32,
b[3],
];
out.push((bx, text, sg));
}
}
out
}
const GUTTER_GAP: f64 = 1.6;
const GUTTER_EDGES: usize = 3;
pub(super) fn gutter_cuts(boxes: &[[f32; 4]], lines: &[LineText]) -> Vec<Vec<f64>> {
boxes
.iter()
.zip(lines)
.enumerate()
.map(|(i, (b, line))| {
let h = ((b[3] - b[1]) as f64).max(1.0);
let xs: Vec<f64> = line
.tokens
.iter()
.filter(|t| !is_space(t))
.map(|t| t.x)
.collect();
let mut cuts = Vec::new();
for w in xs.windows(2) {
let (a, c) = (w[0], w[1]);
if c - a <= GUTTER_GAP * h {
continue;
}
let (gl, gr) = (a + 0.35 * h, c - 0.35 * h);
let near = boxes
.iter()
.enumerate()
.filter(|&(j, o)| {
j != i
&& (((o[2] as f64) - gl).abs() < 0.5 * h
|| ((o[0] as f64) - gr).abs() < 0.5 * h)
})
.count();
if near >= GUTTER_EDGES {
cuts.push((a + c) / 2.0);
}
}
cuts
})
.collect()
}
const LEADER_CHARS: &str = ".·…‥_-–—=";
const DASHES: &str = "-–—";
const BULLETS: &str = "•◦▪▫●○■□‣⁃∙*";
static ENUMERATOR: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(?:(\d{1,3}|[A-Za-z])[.)]|\((\d{1,3}|[A-Za-z]|[ivxlcIVXLC]{1,5})\))$")
.expect("valid regex")
});
const MARKER_GAP: f32 = 2.5;
pub(super) fn tidy_lines(
mut boxes: Vec<[f32; 4]>,
mut lines: Vec<LineText>,
rtl: bool,
space: bool,
) -> (Vec<[f32; 4]>, Vec<LineText>) {
let n = lines.len();
let mut keep = vec![true; n];
let h: Vec<f32> = boxes.iter().map(|b| b[3] - b[1]).collect();
let w: Vec<f32> = boxes.iter().map(|b| b[2] - b[0]).collect();
for (i, line) in lines.iter().enumerate() {
let core: Vec<char> = line.text.chars().filter(|&c| c != ' ').collect();
if !core.is_empty() && core.iter().all(|&c| LEADER_CHARS.contains(c)) {
let wide = w[i] >= 3.0 * h[i];
if core.len() >= 3 || wide || !core.iter().any(|&c| DASHES.contains(c)) {
keep[i] = false;
}
}
}
for i in 0..n {
let t = lines[i].text.trim().to_string();
let mut chars = t.chars();
let bullet = matches!((chars.next(), chars.next()), (Some(c), None) if BULLETS.contains(c));
if !keep[i] || !(bullet || ENUMERATOR.is_match(&t)) {
continue;
}
let mut best: Option<(usize, f32)> = None;
for j in 0..n {
if j == i || !keep[j] || lines[j].text.trim().is_empty() {
continue;
}
let (bi, bj) = (boxes[i], boxes[j]);
let ov = bi[3].min(bj[3]) - bi[1].max(bj[1]);
if ov < 0.5 * h[i].min(h[j]) {
continue;
}
let gap = if rtl { bi[0] - bj[2] } else { bj[0] - bi[2] };
if -0.3 * h[i] <= gap
&& gap <= MARKER_GAP * h[i].max(h[j])
&& best.is_none_or(|(_, g)| gap < g)
{
best = Some((j, gap));
}
}
let Some((j, _)) = best else {
continue;
};
let marker = std::mem::take(&mut lines[i].tokens);
let item = &lines[j];
let space = space || item.char_tokens;
if !rtl
&& !marker.is_empty()
&& !item.tokens.is_empty()
&& space
&& lines[i].char_tokens == item.char_tokens
{
let x = (boxes[i][2] + boxes[j][0]) as f64 / 2.0;
let mut tokens = marker;
tokens.push(Token {
text: " ".to_string(),
x,
});
tokens.append(&mut lines[j].tokens);
lines[j].tokens = tokens;
}
lines[j].text = format!("{t} {}", lines[j].text.trim());
let (bi, bj) = (boxes[i], boxes[j]);
boxes[j] = [
bi[0].min(bj[0]),
bi[1].min(bj[1]),
bi[2].max(bj[2]),
bi[3].max(bj[3]),
];
keep[i] = false;
}
boxes
.into_iter()
.zip(lines)
.zip(keep)
.filter_map(|(line, k)| k.then_some(line))
.unzip()
}