use image::{DynamicImage, GenericImageView};
use crate::native_engine::{LayoutSpan, RecognizedDocument};
pub const TALL_ASPECT_TRIGGER: f64 = 3.0;
const STRIP_HEIGHT_WIDTHS: f64 = 1.0;
const CUT_SEARCH_WINDOW_FRAC: f64 = 0.125;
const MIN_STRIP_HEIGHT: u32 = 128;
pub const LOW_YIELD_CHARS_PER_MEGAPIXEL: f64 = 50.0;
pub const LOW_YIELD_MIN_MEGAPIXELS: f64 = 0.5;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Strip {
pub top: u32,
pub bottom: u32,
}
#[must_use]
pub fn is_tall(width: u32, height: u32) -> bool {
width > 0 && f64::from(height) / f64::from(width) >= TALL_ASPECT_TRIGGER
}
#[must_use]
pub fn ink_profile(img: &DynamicImage) -> Vec<u64> {
let gray = img.to_luma8();
let (w, h) = gray.dimensions();
let mut profile = vec![0u64; h as usize];
for y in 0..h {
let mut ink = 0u64;
for x in 0..w {
ink += u64::from(255 - gray.get_pixel(x, y).0[0]);
}
profile[y as usize] = ink;
}
profile
}
fn blankest_row(profile: &[u64], lo: u32, hi: u32, nominal: u32) -> u32 {
let mut best = nominal;
let mut best_ink = u64::MAX;
for row in lo..=hi {
let ink = profile[row as usize];
let better =
ink < best_ink || (ink == best_ink && row.abs_diff(nominal) < best.abs_diff(nominal));
if better {
best = row;
best_ink = ink;
}
}
best
}
#[must_use]
pub fn plan_strips(width: u32, height: u32, profile: &[u64]) -> Vec<Strip> {
debug_assert_eq!(profile.len(), height as usize);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let nominal = ((f64::from(width) * STRIP_HEIGHT_WIDTHS) as u32).max(MIN_STRIP_HEIGHT);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let window = ((f64::from(nominal) * CUT_SEARCH_WINDOW_FRAC) as u32).max(1);
let mut strips = Vec::new();
let mut top = 0u32;
while height - top > nominal + nominal / 2 {
let target = top + nominal;
let lo = target.saturating_sub(window).max(top + 1);
let hi = (target + window).min(height - 1);
let cut = blankest_row(profile, lo, hi, target);
strips.push(Strip { top, bottom: cut });
top = cut;
}
strips.push(Strip {
top,
bottom: height,
});
strips
}
#[must_use]
pub fn cut_strips(img: &DynamicImage, plan: &[Strip]) -> Vec<DynamicImage> {
let (w, _) = img.dimensions();
plan.iter()
.map(|s| img.crop_imm(0, s.top, w, s.bottom - s.top))
.collect()
}
#[must_use]
pub fn merge_documents(parts: Vec<(RecognizedDocument, u32)>) -> RecognizedDocument {
let mut markdown_parts: Vec<String> = Vec::new();
let mut layout: Vec<LayoutSpan> = Vec::new();
for (doc, top) in parts {
let body = doc.markdown.trim();
if !body.is_empty() {
markdown_parts.push(body.to_string());
}
let dy = i64::from(top);
for mut span in doc.layout {
for b in &mut span.boxes {
b[1] += dy;
b[3] += dy;
}
layout.push(span);
}
}
RecognizedDocument {
markdown: markdown_parts.join("\n\n"),
layout,
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct LowYield {
pub yield_chars: usize,
pub input_megapixels: f64,
}
#[must_use]
pub fn text_chars(markdown: &str) -> usize {
let mut rest = markdown;
let mut count = 0usize;
while let Some(start) = rest.find("![") {
count += rest[..start].chars().filter(|c| !c.is_whitespace()).count();
let after = &rest[start..];
if let Some(close) = after.find(')') {
rest = &after[close + 1..];
} else {
count += after.chars().filter(|c| !c.is_whitespace()).count();
rest = "";
}
}
count + rest.chars().filter(|c| !c.is_whitespace()).count()
}
#[must_use]
pub fn low_yield_assessment(markdown: &str, width: u32, height: u32) -> Option<LowYield> {
let megapixels = f64::from(width) * f64::from(height) / 1_000_000.0;
if megapixels < LOW_YIELD_MIN_MEGAPIXELS {
return None;
}
let chars = text_chars(markdown);
#[allow(clippy::cast_precision_loss)]
let density = chars as f64 / megapixels;
(density < LOW_YIELD_CHARS_PER_MEGAPIXEL).then_some(LowYield {
yield_chars: chars,
input_megapixels: megapixels,
})
}
#[cfg(test)]
mod tests {
use super::*;
use image::{ImageBuffer, Luma, Rgb};
fn white_image_with_dark_bands(width: u32, height: u32, bands: &[(u32, u32)]) -> DynamicImage {
let img = ImageBuffer::from_fn(width, height, |_, y| {
if bands.iter().any(|&(top, bottom)| y >= top && y < bottom) {
Rgb([0u8, 0, 0])
} else {
Rgb([255u8, 255, 255])
}
});
DynamicImage::ImageRgb8(img)
}
#[test]
fn is_tall_trigger_boundary() {
assert!(!is_tall(494, 494));
assert!(!is_tall(500, 1499)); assert!(is_tall(500, 1500)); assert!(is_tall(494, 2000)); assert!(!is_tall(2000, 494)); assert!(!is_tall(0, 100)); }
#[test]
fn ink_profile_separates_text_rows_from_blank_rows() {
let img = white_image_with_dark_bands(64, 32, &[(10, 12)]);
let profile = ink_profile(&img);
assert_eq!(profile.len(), 32);
assert_eq!(profile[0], 0);
assert_eq!(profile[10], 64 * 255);
assert_eq!(profile[11], 64 * 255);
assert_eq!(profile[31], 0);
}
#[test]
fn plan_tiles_full_height_without_gaps_or_overlap() {
for (w, h) in [(494u32, 2000u32), (200, 4000), (1000, 3200), (128, 12000)] {
let profile = vec![0u64; h as usize];
let plan = plan_strips(w, h, &profile);
assert!(!plan.is_empty(), "{w}x{h}");
assert_eq!(plan[0].top, 0, "{w}x{h}");
assert_eq!(plan.last().unwrap().bottom, h, "{w}x{h}");
for pair in plan.windows(2) {
assert_eq!(pair[0].bottom, pair[1].top, "{w}x{h}: gap/overlap");
}
for s in &plan {
assert!(s.bottom > s.top, "{w}x{h}: degenerate strip {s:?}");
}
}
}
#[test]
fn plan_cuts_snap_to_blank_rows() {
let (w, h) = (200u32, 700u32);
let dark = 200u64 * 255;
let mut profile = vec![dark; h as usize];
profile[190..193].fill(0);
let plan = plan_strips(w, h, &profile);
assert!(
(190..193).contains(&(plan[0].bottom as usize)),
"cut should land in the blank band, got {}",
plan[0].bottom
);
}
#[test]
fn plan_absorbs_short_tail_into_last_strip() {
let profile = vec![0u64; 620];
let plan = plan_strips(500, 620, &profile);
assert_eq!(plan.len(), 1);
assert_eq!(
plan[0],
Strip {
top: 0,
bottom: 620
}
);
}
#[test]
fn cut_strips_match_plan_geometry() {
let img = white_image_with_dark_bands(100, 900, &[]);
let profile = ink_profile(&img);
let plan = plan_strips(100, 900, &profile);
let strips = cut_strips(&img, &plan);
assert_eq!(strips.len(), plan.len());
for (strip, bounds) in strips.iter().zip(&plan) {
assert_eq!(strip.width(), 100);
assert_eq!(strip.height(), bounds.bottom - bounds.top);
}
}
#[test]
fn merge_offsets_layout_and_joins_markdown() {
let a = RecognizedDocument {
markdown: "first strip\n".to_string(),
layout: vec![LayoutSpan {
label: "text".to_string(),
boxes: vec![[1, 2, 3, 4]],
}],
};
let b = RecognizedDocument {
markdown: " ".to_string(), layout: vec![],
};
let c = RecognizedDocument {
markdown: "third strip".to_string(),
layout: vec![LayoutSpan {
label: "text".to_string(),
boxes: vec![[5, 6, 7, 8]],
}],
};
let merged = merge_documents(vec![(a, 0), (b, 500), (c, 1000)]);
assert_eq!(merged.markdown, "first strip\n\nthird strip");
assert_eq!(merged.layout.len(), 2);
assert_eq!(merged.layout[0].boxes[0], [1, 2, 3, 4]);
assert_eq!(merged.layout[1].boxes[0], [5, 1006, 7, 1008]);
}
#[test]
fn text_chars_ignores_image_placeholders_and_whitespace() {
assert_eq!(text_chars(""), 0);
assert_eq!(text_chars(" \n\t "), 0);
assert_eq!(text_chars("abc def"), 6);
assert_eq!(
text_chars("Google Messages\n\n"),
"GoogleMessages".len()
);
assert_eq!(text_chars("before  after"), 11);
assert!(text_chars("a  > 1);
}
#[test]
fn low_yield_flags_the_incident_shape_and_spares_dense_pages() {
let flagged = low_yield_assessment("Google Messages\n\n", 494, 2000)
.expect("incident shape must be flagged");
assert_eq!(flagged.yield_chars, 14);
assert!((flagged.input_megapixels - 0.988).abs() < 0.001);
assert!(low_yield_assessment("Google Messages", 494, 700).is_none());
let dense = "the quick brown fox jumps over the lazy dog ".repeat(60);
assert!(low_yield_assessment(&dense, 1000, 1000).is_none());
}
#[test]
fn ink_profile_len_matches_luma_height() {
let img = DynamicImage::ImageLuma8(ImageBuffer::from_pixel(10, 20, Luma([128u8])));
assert_eq!(ink_profile(&img).len(), 20);
}
}