use docling_core::{Node, PictureClass, PictureImage, Table};
#[cfg(feature = "ml")]
use image::RgbImage;
use crate::layout::Region;
use crate::pdfium_backend::{PdfPage, TextCell};
fn area(l: f32, t: f32, r: f32, b: f32) -> f32 {
((r - l).max(0.0)) * ((b - t).max(0.0))
}
fn inter(a: &Region, l: f32, t: f32, r: f32, b: f32) -> f32 {
let il = a.l.max(l);
let it = a.t.max(t);
let ir = a.r.min(r);
let ib = a.b.min(b);
area(il, it, ir, ib)
}
fn is_wrapper(label: &str) -> bool {
matches!(
label,
"table" | "document_index" | "form" | "key_value_region"
)
}
pub fn is_table_like(label: &str) -> bool {
matches!(label, "table" | "document_index")
}
fn greedy(mut regions: Vec<Region>) -> Vec<Region> {
regions.sort_by(|a, b| b.score.total_cmp(&a.score));
let mut kept: Vec<Region> = Vec::new();
for r in regions {
let ra = area(r.l, r.t, r.r, r.b).max(1.0);
let covered = kept.iter().any(|k| {
let i = inter(&r, k.l, k.t, k.r, k.b);
let ka = area(k.l, k.t, k.r, k.b).max(1.0);
i / ra > 0.7 || i / (ra + ka - i) > 0.5
});
if !covered {
kept.push(r);
}
}
kept
}
pub fn resolve(regions: Vec<Region>) -> Vec<Region> {
let tables: Vec<(f32, f32, f32, f32)> = regions
.iter()
.filter(|r| r.label == "table")
.map(|r| (r.l, r.t, r.r, r.b))
.collect();
let mut regions = regions;
regions.retain(|r| {
if r.label != "picture" {
return true;
}
let ra = area(r.l, r.t, r.r, r.b).max(1.0);
!tables.iter().any(|&(l, t, rr, b)| {
let i = inter(r, l, t, rr, b);
let u = ra + area(l, t, rr, b) - i;
u > 0.0 && i / u > 0.8
})
});
let pictures = greedy(
regions
.iter()
.filter(|r| r.label == "picture")
.cloned()
.collect(),
);
let wrappers = greedy(
regions
.iter()
.filter(|r| is_wrapper(r.label))
.cloned()
.collect(),
);
let mut kept = greedy(
regions
.iter()
.filter(|r| r.label != "picture" && !is_wrapper(r.label))
.cloned()
.collect(),
);
dedup_nested_code(&mut kept);
kept.extend(pictures);
kept.extend(wrappers);
kept
}
pub fn drop_contained_regulars(regions: &mut Vec<Region>) {
let specials: Vec<(f32, f32, f32, f32)> = regions
.iter()
.filter(|r| r.label == "picture" || is_table_like(r.label))
.map(|r| (r.l, r.t, r.r, r.b))
.collect();
if specials.is_empty() {
return;
}
regions.retain(|r| {
if r.label == "picture" || is_wrapper(r.label) {
return true;
}
let ra = area(r.l, r.t, r.r, r.b).max(1.0);
!specials
.iter()
.any(|&(l, t, rr, b)| inter(r, l, t, rr, b) / ra > 0.8)
});
}
fn is_code_language(t: &str) -> bool {
let t = t.trim();
if t.is_empty() || t.chars().any(char::is_whitespace) || t.chars().count() > 12 {
return false;
}
const LANGS: &[&str] = &[
"xml",
"html",
"xhtml",
"json",
"jsonc",
"yaml",
"yml",
"toml",
"ini",
"c#",
"csharp",
"f#",
"fsharp",
"vb",
"c",
"c++",
"cpp",
"java",
"kotlin",
"scala",
"go",
"golang",
"rust",
"swift",
"javascript",
"js",
"typescript",
"ts",
"jsx",
"tsx",
"python",
"py",
"ruby",
"rb",
"php",
"perl",
"lua",
"r",
"dart",
"bash",
"sh",
"shell",
"powershell",
"zsh",
"batch",
"cmd",
"sql",
"tsql",
"plsql",
"graphql",
"dockerfile",
"makefile",
"css",
"scss",
"sass",
"less",
"markdown",
"md",
"tex",
"latex",
"diff",
"proto",
"razor",
"cshtml",
"xaml",
"aspx",
"http",
];
let lower = t.to_ascii_lowercase();
LANGS.contains(&lower.as_str())
}
fn code_language_labels(regions: &[Region], cells: &[TextCell]) -> Vec<bool> {
let mut drop = vec![false; regions.len()];
for (i, r) in regions.iter().enumerate() {
if matches!(r.label, "code" | "picture" | "table") {
continue;
}
if !is_code_language(®ion_text(r, cells)) {
continue;
}
let line_h = (r.b - r.t).abs().max(1.0);
let window = (line_h * 4.0).max(28.0);
let labels_code = regions.iter().enumerate().any(|(j, c)| {
if j == i || c.label != "code" {
return false;
}
let gap = c.t - r.b; let h_overlap = (r.r.min(c.r) - r.l.max(c.l)).max(0.0);
gap > -line_h * 3.0 && gap < window && h_overlap > 0.0
});
if labels_code {
drop[i] = true;
}
}
drop
}
fn dedup_nested_code(kept: &mut Vec<Region>) {
let mut drop = vec![false; kept.len()];
for i in 0..kept.len() {
if kept[i].label != "code" {
continue;
}
let ai = area(kept[i].l, kept[i].t, kept[i].r, kept[i].b).max(1.0);
for j in 0..kept.len() {
if i == j || drop[j] || kept[j].label != "code" {
continue;
}
let aj = area(kept[j].l, kept[j].t, kept[j].r, kept[j].b).max(1.0);
let overlap = inter(&kept[i], kept[j].l, kept[j].t, kept[j].r, kept[j].b);
if aj > ai && overlap / ai > 0.7 {
drop[i] = true;
break;
}
}
}
let mut keep = drop.iter();
kept.retain(|_| !*keep.next().unwrap());
}
pub fn add_orphan_regions(regions: &mut Vec<Region>, cells: &[TextCell]) {
let assigned = |c: &TextCell| {
let ca = area(c.l, c.t, c.r, c.b).max(1.0);
regions
.iter()
.filter(|r| r.label != "picture" && !is_wrapper(r.label))
.any(|r| inter(r, c.l, c.t, c.r, c.b) / ca > 0.2)
};
let mut orphans: Vec<&TextCell> = cells
.iter()
.filter(|c| !c.text.trim().is_empty() && !assigned(c))
.collect();
if orphans.is_empty() {
return;
}
orphans.sort_by(|a, b| a.t.total_cmp(&b.t).then(a.l.total_cmp(&b.l)));
let mut merged: Vec<Region> = Vec::new();
for c in orphans {
let h = (c.b - c.t).abs().max(1.0);
if let Some(last) = merged.last_mut() {
let same_line = (last.t - c.t).abs() < h * 0.5;
let touching = c.l <= last.r + h && c.l >= last.l - h;
if same_line && touching {
last.l = last.l.min(c.l);
last.r = last.r.max(c.r);
last.t = last.t.min(c.t);
last.b = last.b.max(c.b);
continue;
}
}
merged.push(Region {
label: "text",
score: 0.0,
l: c.l,
t: c.t,
r: c.r,
b: c.b,
});
}
regions.extend(merged);
}
pub fn recover_text_panels(regions: &mut Vec<Region>, cells: &[TextCell]) {
let captioned: Vec<bool> = regions
.iter()
.map(|r| {
r.label == "picture"
&& regions.iter().any(|c| {
c.label == "caption" && c.r.min(r.r) - c.l.max(r.l) > 0.0 && {
let gap = if c.t >= r.b {
c.t - r.b
} else if r.t >= c.b {
r.t - c.b
} else {
f32::MAX };
gap <= 25.0
}
})
})
.collect();
let mut out: Vec<Region> = Vec::with_capacity(regions.len());
let mut demoted_paras: Vec<Region> = Vec::new();
let mut demoted_boxes: Vec<(f32, f32, f32, f32)> = Vec::new();
for (i, r) in regions.drain(..).enumerate() {
if r.label != "picture" || captioned[i] {
out.push(r);
continue;
}
let inside: Vec<&TextCell> = cells
.iter()
.filter(|c| {
!c.text.trim().is_empty() && {
let ca = area(c.l, c.t, c.r, c.b).max(1.0);
inter(&r, c.l, c.t, c.r, c.b) / ca > 0.5
}
})
.collect();
let mut lines: Vec<(f32, f32, f32, f32)> = Vec::new(); for c in &inside {
let (ct, cb) = (c.t.min(c.b), c.t.max(c.b));
match lines.iter_mut().find(|(lt, lb, _, _)| {
let ov = cb.min(*lb) - ct.max(*lt);
ov > 0.5 * (cb - ct).min(*lb - *lt).max(1.0)
}) {
Some((lt, lb, ll, lr)) => {
*lt = lt.min(ct);
*lb = lb.max(cb);
*ll = ll.min(c.l);
*lr = lr.max(c.r);
}
None => lines.push((ct, cb, c.l, c.r)),
}
}
let panel_w = (r.r - r.l).max(1.0);
let coverage = inside.iter().map(|c| area(c.l, c.t, c.r, c.b)).sum::<f32>()
/ area(r.l, r.t, r.r, r.b).max(1.0);
let mut widths: Vec<f32> = lines.iter().map(|(_, _, l, rr)| rr - l).collect();
widths.sort_by(f32::total_cmp);
let text_panel =
lines.len() >= 3 && coverage >= 0.2 && widths[widths.len() / 2] >= 0.45 * panel_w;
if !text_panel {
out.push(r);
continue;
}
lines.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut heights: Vec<f32> = lines.iter().map(|(t, b, _, _)| b - t).collect();
heights.sort_by(f32::total_cmp);
let h = heights[heights.len() / 2].max(1.0);
let mut gaps: Vec<f32> = lines
.windows(2)
.map(|w| (w[1].0 - w[0].1).max(0.0))
.collect();
gaps.sort_by(f32::total_cmp);
let leading = if gaps.is_empty() {
0.0
} else {
gaps[gaps.len() / 2]
};
let brk = (1.8 * leading).max(0.75 * h);
let mut para: Option<(f32, f32, f32, f32)> = None; for (t, b, l, rr) in &lines {
match &mut para {
Some((pl, _, pr, pb)) if *t - *pb <= brk => {
*pl = pl.min(*l);
*pr = pr.max(*rr);
*pb = pb.max(*b);
}
_ => {
if let Some((pl, pt, pr, pb)) = para.take() {
demoted_paras.push(Region {
label: "text",
score: r.score,
l: pl,
t: pt,
r: pr,
b: pb,
});
}
para = Some((*l, *t, *rr, *b));
}
}
}
if let Some((pl, pt, pr, pb)) = para {
demoted_paras.push(Region {
label: "text",
score: r.score,
l: pl,
t: pt,
r: pr,
b: pb,
});
}
demoted_boxes.push((r.l, r.t, r.r, r.b));
}
if !demoted_boxes.is_empty() {
out.retain(|r| {
r.label == "picture" || is_wrapper(r.label) || {
let ra = area(r.l, r.t, r.r, r.b).max(1.0);
!demoted_boxes
.iter()
.any(|&(l, t, rr, b)| inter(r, l, t, rr, b) / ra > 0.5)
}
});
}
out.extend(demoted_paras);
*regions = out;
}
pub fn drop_false_pictures(
regions: &mut Vec<Region>,
cells: &[TextCell],
page_w: f32,
page_h: f32,
) {
if cells.iter().all(|c| c.text.trim().is_empty()) {
return; }
let content_regions = regions
.iter()
.filter(|r| r.label != "picture" && !region_text(r, cells).trim().is_empty())
.count();
if content_regions < 2 {
return;
}
let page_area = (page_w * page_h).max(1.0);
regions.retain(|r| {
if r.label != "picture" || r.score >= 0.5 {
return true;
}
if area(r.l, r.t, r.r, r.b) / page_area >= 0.25 {
return true; }
cells.iter().any(|c| {
let ca = area(c.l, c.t, c.r, c.b).max(1.0);
!c.text.trim().is_empty() && inter(r, c.l, c.t, c.r, c.b) / ca > 0.5
})
});
}
fn is_page_number(region: &Region, cells: &[TextCell], page_h: f32) -> bool {
let t = region_text(region, cells);
let t = t.trim();
!t.is_empty()
&& t.chars().all(|c| c.is_ascii_digit())
&& (region.b - region.t).abs() < 30.0
&& (region.t < page_h * 0.12 || region.b > page_h * 0.88)
}
fn is_skipped(label: &str) -> bool {
matches!(
label,
"page_header"
| "page_footer"
| "checkbox_selected"
| "checkbox_unselected"
| "form"
| "key_value_region"
)
}
fn order_regions<T: Clone>(
items: &mut Vec<T>,
page_w: f32,
page_h: f32,
reg: impl Fn(&T) -> &Region,
) {
let boxes: Vec<(f32, f32, f32, f32)> = items
.iter()
.map(|it| {
let r = reg(it);
(r.l, r.t, r.r, r.b)
})
.collect();
let is_header: Vec<bool> = items
.iter()
.map(|it| reg(it).label == "page_header")
.collect();
let is_footer: Vec<bool> = items
.iter()
.map(|it| reg(it).label == "page_footer")
.collect();
let order = crate::reading_order::order_page(&boxes, &is_header, &is_footer, page_w, page_h);
*items = order.iter().map(|&i| items[i].clone()).collect();
}
fn parse_ordered_marker(s: &str) -> Option<(u64, String)> {
let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
if digits.is_empty() {
return None;
}
let rest = s[digits.len()..].strip_prefix('.')?;
let number = digits.parse().ok()?;
Some((number, rest.trim_start().to_string()))
}
fn md_escape(text: &str) -> String {
text.replace('_', "\\_")
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
fn clean_text(text: &str) -> String {
let replaced = text
.replace("\u{2} ", "")
.replace("\u{ad} ", "")
.replace(['\u{2}', '\u{ad}'], "") .replace(
[
'\u{2018}', '\u{2019}', '\u{201b}', '\u{201c}', '\u{201d}', '\u{201e}', '\u{201f}',
],
"'",
) .replace('\u{201a}', ",") .replace(
[
'\u{2010}', '\u{2011}', '\u{2012}', '\u{2013}', '\u{2014}', '\u{2015}', '\u{2212}',
],
"-",
) .replace('\u{2044}', "/") .replace('\u{2022}', "\u{b7}") .replace('\u{2026}', "..."); let out = if crate::pdfium_backend::use_dp_lines() {
replaced.replace(['\n', '\r', '\t'], " ").trim().to_string()
} else {
replaced.split_whitespace().collect::<Vec<_>>().join(" ")
};
fix_arabic_lam_alef(&out)
}
fn fix_arabic_lam_alef(s: &str) -> String {
let is_arabic_letter = |c: char| ('\u{0620}'..='\u{064A}').contains(&c);
let chars: Vec<char> = s.chars().collect();
if !chars.iter().any(|&c| is_arabic_letter(c)) {
return s.to_string(); }
let mut a: Vec<char> = Vec::with_capacity(chars.len());
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if matches!(c, '\u{0622}' | '\u{0623}' | '\u{0625}')
&& chars.get(i + 1) == Some(&'\u{0644}')
&& i > 0
&& is_arabic_letter(chars[i - 1])
&& chars[i - 1] != '\u{0644}'
{
a.push('\u{0644}');
a.push(c);
i += 2;
continue;
}
a.push(c);
i += 1;
}
let mut out: Vec<char> = Vec::with_capacity(a.len());
for (j, &c) in a.iter().enumerate() {
if j > 0 {
let p = a[j - 1];
if (is_arabic_letter(p) && c.is_ascii_alphabetic())
|| (p.is_ascii_alphabetic() && is_arabic_letter(c))
{
out.push(' ');
}
}
out.push(c);
}
out.into_iter().collect()
}
pub(crate) fn resolve_link_anchors(page: &PdfPage) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = Vec::new();
let words = if page.word_cells.is_empty() {
&page.cells
} else {
&page.word_cells
};
for link in &page.links {
let mut inside: Vec<(&TextCell, String)> = words
.iter()
.filter(|c| {
let cy = (c.t + c.b) / 2.0;
cy >= link.t && cy <= link.b && c.r.min(link.r) > c.l.max(link.l)
})
.filter_map(|c| {
let text = cell_text_in_rect(c, link.l, link.r);
(!text.is_empty()).then_some((c, text))
})
.collect();
let band = inside
.iter()
.map(|(c, _)| (c.b - c.t).abs())
.fold(0.0f32, f32::max)
.max(1.0);
inside.sort_by_key(|(c, _)| ((c.t / band).round() as i64, (c.l * 10.0) as i64));
let anchor = clean_text(
&inside
.iter()
.map(|(_, t)| t.trim())
.filter(|t| !t.is_empty())
.collect::<Vec<_>>()
.join(" "),
);
if anchor.is_empty() {
continue;
}
if out
.last()
.is_some_and(|(a, u)| a == &anchor && u == &link.uri)
{
continue;
}
out.push((anchor, link.uri.clone()));
}
out
}
fn cell_text_in_rect(c: &TextCell, l: f32, r: f32) -> String {
let cx = (c.l + c.r) / 2.0;
if cx >= l && cx <= r && c.l >= l - (c.r - c.l) * 0.25 && c.r <= r + (c.r - c.l) * 0.25 {
return c.text.trim().to_string();
}
let chars: Vec<char> = c.text.chars().collect();
let n = chars.len();
if n == 0 || c.r <= c.l {
return String::new();
}
let per = (c.r - c.l) / n as f32;
let mut out: Vec<String> = Vec::new();
let mut token = String::new();
let mut start = 0usize;
for (i, &ch) in chars.iter().enumerate().chain(std::iter::once((n, &' '))) {
if ch.is_whitespace() {
if !token.is_empty() {
let mid = c.l + (start as f32 + (i - start) as f32 / 2.0) * per;
if mid >= l && mid <= r {
out.push(std::mem::take(&mut token));
} else {
token.clear();
}
}
} else {
if token.is_empty() {
start = i;
}
token.push(ch);
}
}
out.join(" ")
}
fn region_text(region: &Region, cells: &[TextCell]) -> String {
let mut inside: Vec<&TextCell> = cells
.iter()
.filter(|c| {
let ca = area(c.l, c.t, c.r, c.b).max(1.0);
inter(region, c.l, c.t, c.r, c.b) / ca > 0.5
})
.collect();
let band = inside
.iter()
.map(|c| (c.b - c.t).abs())
.fold(0.0f32, f32::max)
.max(1.0);
let arabic = inside
.iter()
.flat_map(|c| c.text.chars())
.filter(|&c| ('\u{0600}'..='\u{06FF}').contains(&c))
.count();
let latin = inside
.iter()
.flat_map(|c| c.text.chars())
.filter(|c| c.is_ascii_alphabetic())
.count();
let rtl = arabic > latin;
let dp = crate::pdfium_backend::use_dp_lines();
if dp {
let mut lines: Vec<(f32, f32, Vec<&TextCell>)> = Vec::new();
for c in inside.drain(..) {
let (ct, cb) = (c.t.min(c.b), c.t.max(c.b));
let line = lines.iter_mut().find(|(lt, lb, _)| {
let ov = cb.min(*lb) - ct.max(*lt);
ov > 0.5 * (cb - ct).min(*lb - *lt).max(1.0)
});
match line {
Some((lt, lb, cs)) => {
*lt = lt.min(ct);
*lb = lb.max(cb);
cs.push(c);
}
None => lines.push((ct, cb, vec![c])),
}
}
lines.sort_by(|a, b| a.0.total_cmp(&b.0));
for (_, _, mut cs) in lines {
cs.sort_by(|a, b| {
if rtl {
b.l.total_cmp(&a.l)
} else {
a.l.total_cmp(&b.l)
}
});
inside.extend(cs);
}
} else {
inside.sort_by_key(|c| {
let x = (c.l * 10.0) as i64;
((c.t / band).round() as i64, if rtl { -x } else { x })
});
}
let mut joined = String::new();
let mut prev: Option<&&TextCell> = None;
for c in &inside {
let t = c.text.trim();
if t.is_empty() {
continue;
}
if let Some(p) = prev {
let same_band = ((p.t / band).round() as i64) == ((c.t / band).round() as i64);
let h = (c.b - c.t).abs().max((p.b - p.t).abs()).max(1.0);
let gap = if rtl { p.l - c.r } else { c.l - p.r };
let ends_dash = matches!(
joined.chars().last(),
Some('-' | '\u{2010}' | '\u{2013}' | '\u{2014}')
);
let before = joined.chars().nth_back(1); let next = t.chars().next();
let alpha_dehyph = before.is_some_and(|c| c.is_alphabetic())
&& next.is_some_and(|n| {
n.is_lowercase()
|| (n.is_uppercase() && before.is_some_and(|b| b.is_lowercase()))
});
let num_dehyph = before.is_some_and(|c| c.is_ascii_digit())
&& next.is_some_and(|n| n.is_ascii_digit());
let dehyph = dp && ends_dash && (alpha_dehyph || num_dehyph);
if dehyph {
joined.pop();
} else if dp || !same_band || gap > h * 0.25 {
joined.push(' ');
}
}
joined.push_str(t);
prev = Some(c);
}
clean_text(&joined)
}
fn tighten_code_punct(s: &str) -> String {
s.replace(" .", ".")
.replace(" ,", ",")
.replace(" ;", ";")
.replace(" )", ")")
.replace(" (", "(")
}
fn code_region_text(region: &Region, cells: &[TextCell]) -> String {
let mut inside: Vec<&TextCell> = cells
.iter()
.filter(|c| {
let ca = area(c.l, c.t, c.r, c.b).max(1.0);
inter(region, c.l, c.t, c.r, c.b) / ca > 0.5
})
.filter(|c| !c.text.trim().is_empty())
.collect();
if inside.is_empty() {
return String::new();
}
let band = inside
.iter()
.map(|c| (c.b - c.t).abs())
.fold(0.0f32, f32::max)
.max(1.0);
let line_of = |c: &TextCell| (c.t / band).round() as i64;
inside.sort_by_key(|c| (line_of(c), (c.l * 10.0) as i64));
let (mut total_w, mut total_chars) = (0.0f32, 0usize);
for c in &inside {
let n = c.text.trim().chars().count();
if n > 0 {
total_w += (c.r - c.l).max(0.0);
total_chars += n;
}
}
let char_w = if total_chars > 0 {
(total_w / total_chars as f32).max(1.0)
} else {
1.0
};
let base_l = inside.iter().map(|c| c.l).fold(f32::INFINITY, f32::min);
let mut lines: Vec<String> = Vec::new();
let mut cur: Option<i64> = None;
for c in &inside {
let text = tighten_code_punct(&clean_text(c.text.trim()));
if Some(line_of(c)) == cur {
if let Some(last) = lines.last_mut() {
last.push(' ');
last.push_str(&text);
}
continue;
}
let indent = ((c.l - base_l) / char_w).round().max(0.0) as usize;
lines.push(format!("{}{}", " ".repeat(indent), text));
cur = Some(line_of(c));
}
lines.join("\n")
}
pub fn reconstruct_table(region: &Region, cells: &[TextCell]) -> Vec<Vec<String>> {
let mut inside: Vec<&TextCell> = cells
.iter()
.filter(|c| {
let ca = area(c.l, c.t, c.r, c.b).max(1.0);
inter(region, c.l, c.t, c.r, c.b) / ca > 0.5
})
.collect();
if inside.is_empty() {
return Vec::new();
}
inside.sort_by(|a, b| a.t.total_cmp(&b.t));
let mut rows: Vec<(f32, Vec<&TextCell>)> = Vec::new();
for c in &inside {
let cyc = (c.t + c.b) / 2.0;
let lh = (c.b - c.t).abs().max(1.0);
if let Some((ryc, row)) = rows.last_mut() {
if (cyc - *ryc).abs() < lh * 0.7 {
row.push(c);
continue;
}
}
rows.push((cyc, vec![c]));
}
let tol = {
let mut hs: Vec<f32> = inside.iter().map(|c| (c.b - c.t).abs()).collect();
hs.sort_by(f32::total_cmp);
hs[hs.len() / 2].max(4.0) * 1.5
};
let mut lefts: Vec<f32> = inside.iter().map(|c| c.l).collect();
lefts.sort_by(f32::total_cmp);
let mut col_starts: Vec<f32> = Vec::new();
for l in lefts {
if col_starts.last().is_none_or(|&last| l - last > tol) {
col_starts.push(l);
}
}
let ncols = col_starts.len().max(1);
let col_of = |l: f32| -> usize {
col_starts
.iter()
.rposition(|&s| l + tol * 0.5 >= s)
.unwrap_or(0)
.min(ncols - 1)
};
let mut grid = Vec::with_capacity(rows.len());
for (_, mut row) in rows {
row.sort_by(|a, b| a.l.total_cmp(&b.l));
let mut cols = vec![String::new(); ncols];
for c in row {
let ci = col_of(c.l);
let t = c.text.trim().replace(['\u{2}', '\u{ad}'], "");
if cols[ci].is_empty() {
cols[ci] = t;
} else {
cols[ci].push(' ');
cols[ci].push_str(&t);
}
}
grid.push(cols);
}
grid
}
pub fn geometric_table_is_reliable(rows: &[Vec<String>]) -> bool {
let ncols = rows.iter().map(Vec::len).max().unwrap_or(0);
if rows.len() < 2 || ncols < 2 {
return false;
}
let filled = |c: &String| !c.trim().is_empty();
let total = rows.len() * ncols;
let full = rows.iter().flatten().filter(|c| filled(c)).count();
if (full as f32) < MIN_TABLE_FILL * total as f32 {
return false;
}
if rows.len() >= 3 {
for ci in 0..ncols {
let used = rows
.iter()
.filter(|r| r.get(ci).is_some_and(filled))
.count();
if used <= 1 {
return false;
}
}
}
true
}
const MIN_TABLE_FILL: f32 = 0.6;
pub fn region_cell_bbox(region: &Region, cells: &[TextCell]) -> Option<[f32; 4]> {
let mut bbox: Option<[f32; 4]> = None;
for c in cells {
let ca = area(c.l, c.t, c.r, c.b).max(1.0);
if inter(region, c.l, c.t, c.r, c.b) / ca <= 0.5 {
continue;
}
bbox = Some(match bbox {
None => [c.l, c.t, c.r, c.b],
Some([l, t, r, b]) => [l.min(c.l), t.min(c.t), r.max(c.r), b.max(c.b)],
});
}
bbox
}
#[derive(Debug, Clone)]
pub enum Enrichment {
PictureClasses(Vec<PictureClass>),
Code {
language: Option<String>,
text: String,
},
Formula { latex: String },
}
#[cfg(feature = "ml")]
pub fn crop_region_scaled(page: &PdfPage, bbox: [f32; 4], target_scale: f32) -> Option<RgbImage> {
let s = page.scale;
let [l, t, r, b] = bbox;
let (iw, ih) = (page.image.width(), page.image.height());
let x = (l * s).max(0.0) as u32;
let y = (t * s).max(0.0) as u32;
if x >= iw || y >= ih {
return None;
}
let w = (((r - l.max(0.0)) * s) as u32).min(iw - x);
let h = (((b - t.max(0.0)) * s) as u32).min(ih - y);
if w == 0 || h == 0 {
return None;
}
let crop = image::imageops::crop_imm(&page.image, x, y, w, h).to_image();
let tw = ((w as f32 / s) * target_scale).round().max(1.0) as u32;
let th = ((h as f32 / s) * target_scale).round().max(1.0) as u32;
if (tw, th) == (w, h) {
return Some(crop);
}
Some(image::imageops::resize(
&crop,
tw,
th,
image::imageops::FilterType::CatmullRom,
))
}
#[cfg(feature = "ocr-prep")]
fn crop_region(page: &PdfPage, region: &Region) -> Option<PictureImage> {
let s = page.scale;
let (iw, ih) = (page.image.width(), page.image.height());
let x = (region.l * s).max(0.0) as u32;
let y = (region.t * s).max(0.0) as u32;
if x >= iw || y >= ih {
return None;
}
let w = (((region.r - region.l) * s) as u32).min(iw - x);
let h = (((region.b - region.t) * s) as u32).min(ih - y);
if w == 0 || h == 0 {
return None;
}
let sub = image::imageops::crop_imm(&page.image, x, y, w, h).to_image();
let mut buf = std::io::Cursor::new(Vec::new());
sub.write_to(&mut buf, image::ImageFormat::Png).ok()?;
Some(PictureImage {
mimetype: "image/png".into(),
width: w,
height: h,
data: buf.into_inner(),
})
}
fn pair_captions(regions: &[Region]) -> Vec<Option<usize>> {
let mut pairs = vec![None; regions.len()];
let mut taken = vec![false; regions.len()];
for (pi, p) in regions.iter().enumerate() {
if p.label != "picture" {
continue;
}
let mut best: Option<(usize, f32)> = None;
for (ci, c) in regions.iter().enumerate() {
if c.label != "caption" || taken[ci] {
continue;
}
let line_h = (c.b - c.t).abs().max(1.0);
let gap = c.t - p.b; let h_overlap = (p.r.min(c.r) - p.l.max(c.l)).max(0.0);
if gap > -line_h && gap < line_h * 3.0 && h_overlap > 0.0 {
let dist = gap.abs();
if best.is_none_or(|(_, bd)| dist < bd) {
best = Some((ci, dist));
}
}
}
if let Some((ci, _)) = best {
pairs[pi] = Some(ci);
taken[ci] = true;
}
}
pairs
}
fn pair_code_captions(regions: &[Region]) -> Vec<Option<usize>> {
let mut pairs = vec![None; regions.len()];
let mut taken = vec![false; regions.len()];
for (pi, p) in regions.iter().enumerate() {
if p.label != "code" {
continue;
}
let mut best: Option<(usize, f32)> = None;
for (ci, c) in regions.iter().enumerate() {
if c.label != "caption" || taken[ci] {
continue;
}
let line_h = (c.b - c.t).abs().max(1.0);
let gap = p.t - c.b; let h_overlap = (p.r.min(c.r) - p.l.max(c.l)).max(0.0);
if gap > -line_h && gap < line_h * 3.0 && h_overlap > 0.0 {
let dist = gap.abs();
if best.is_none_or(|(_, bd)| dist < bd) {
best = Some((ci, dist));
}
}
}
if let Some((ci, _)) = best {
pairs[pi] = Some(ci);
taken[ci] = true;
}
}
pairs
}
fn norm_loc(region: &Region, page_w: f32, page_h: f32) -> [u16; 4] {
let q = |v: f32, dim: f32| -> u16 {
if dim <= 0.0 {
return 0;
}
let g = (512.0 * (v as f64) / (dim as f64)).round() as i64;
g.clamp(0, 511) as u16
};
[
q(region.l, page_w),
q(region.t, page_h),
q(region.r, page_w),
q(region.b, page_h),
]
}
fn located(loc: [u16; 4], inner: Node) -> Node {
Node::Located {
location: loc,
inner: Box::new(inner),
}
}
pub fn assemble_page(
page: &PdfPage,
regions: Vec<Region>,
table_rows: &[Option<Vec<Vec<String>>>],
enrichments: &[Option<Enrichment>],
) -> (Vec<Node>, Vec<(String, String)>) {
let mut nodes: Vec<Node> = Vec::new();
let links = resolve_link_anchors(page);
type RegionItem = (Region, Option<Vec<Vec<String>>>, Option<Enrichment>);
let mut items: Vec<RegionItem> = regions
.into_iter()
.enumerate()
.map(|(i, r)| {
(
r,
table_rows.get(i).cloned().flatten(),
enrichments.get(i).cloned().flatten(),
)
})
.collect();
order_regions(&mut items, page.width, page.height, |it| &it.0);
let page_h = page.height;
items.sort_by_key(|(r, _, _)| !is_page_number(r, &page.cells, page_h));
let table_rows: Vec<Option<Vec<Vec<String>>>> =
items.iter().map(|(_, t, _)| t.clone()).collect();
let enrichments: Vec<Option<Enrichment>> = items.iter().map(|(_, _, e)| e.clone()).collect();
let regions: Vec<Region> = items.into_iter().map(|(r, _, _)| r).collect();
let caption_for = pair_captions(®ions);
let code_caption_for = pair_code_captions(®ions);
let mut consumed = vec![false; regions.len()];
for ci in caption_for.iter().flatten() {
consumed[*ci] = true;
}
for ci in code_caption_for.iter().flatten() {
consumed[*ci] = true;
}
for (i, is_label) in code_language_labels(®ions, &page.cells)
.into_iter()
.enumerate()
{
if is_label {
consumed[i] = true;
}
}
let region_texts: Vec<String> = regions
.iter()
.map(|r| region_text(r, &page.cells))
.collect();
let is_text: Vec<bool> = regions
.iter()
.enumerate()
.map(|(i, r)| r.label == "text" && !consumed[i])
.collect();
let is_skip: Vec<bool> = regions
.iter()
.enumerate()
.map(|(i, r)| {
consumed[i]
|| matches!(
r.label,
"page_header" | "page_footer" | "table" | "picture" | "caption" | "footnote"
)
})
.collect();
let boxes: Vec<(f32, f32, f32, f32)> = regions.iter().map(|r| (r.l, r.t, r.r, r.b)).collect();
let mut merge_suffix: Vec<String> = vec![String::new(); regions.len()];
for (head, children) in
crate::reading_order::predict_merges(&boxes, ®ion_texts, &is_text, &is_skip)
.into_iter()
.enumerate()
{
for c in children {
let t = region_texts[c].trim();
if !t.is_empty() {
merge_suffix[head].push(' ');
merge_suffix[head].push_str(t);
}
consumed[c] = true;
}
}
for (i, region) in regions.iter().enumerate() {
if consumed[i] {
continue;
}
if matches!(region.label, "page_header" | "page_footer") {
let text = region_text(region, &page.cells);
if !text.is_empty() {
nodes.push(Node::PageFurniture {
footer: region.label == "page_footer",
location: norm_loc(region, page.width, page_h),
text: md_escape(&text),
});
}
continue;
}
if is_skipped(region.label) {
continue;
}
let loc = norm_loc(region, page.width, page_h);
if region.label == "picture" {
let caption = caption_for[i]
.map(|ci| region_text(®ions[ci], &page.cells))
.filter(|t| !t.is_empty());
let classification = match &enrichments[i] {
Some(Enrichment::PictureClasses(classes)) => Some(classes.clone()),
_ => None,
};
#[cfg(feature = "ocr-prep")]
let image = crate::timing::timed("crop_region", || crop_region(page, region));
#[cfg(not(feature = "ocr-prep"))]
let image: Option<PictureImage> = None;
nodes.push(located(
loc,
Node::Picture {
caption,
image,
classification,
},
));
continue;
}
let mut text = region_text(region, &page.cells);
text.push_str(&merge_suffix[i]);
if text.is_empty() {
continue;
}
match region.label {
"title" | "section_header" => nodes.push(located(
loc,
Node::Heading {
level: 2,
text: md_escape(&text),
},
)),
"list_item" => {
let stripped = text
.trim_start_matches(['•', '◦', '▪', '·', '*', '-'])
.trim_start()
.to_string();
if let Some((number, rest)) = parse_ordered_marker(&stripped) {
nodes.push(Node::ListItem {
ordered: true,
number,
first_in_list: false,
text: md_escape(&rest),
level: 0,
marker: None,
location: Some(loc),
dclx: None,
href: None,
layer: None,
});
} else {
nodes.push(Node::ListItem {
ordered: false,
number: 0,
first_in_list: false,
text: md_escape(&stripped),
level: 0,
marker: Some("·".into()),
location: Some(loc),
dclx: None,
href: None,
layer: None,
});
}
}
"table" | "document_index" => {
let rows = table_rows[i].clone().unwrap_or_else(|| {
let rows = reconstruct_table(region, &page.cells);
if rows.iter().any(|r| r.len() > 1) {
rows
} else {
vec![vec![text.clone()]]
}
});
nodes.push(located(
loc,
Node::Table(Table {
rows,
location: None,
structure: None,
cell_blocks: None,
}),
));
}
"formula" => match &enrichments[i] {
Some(Enrichment::Formula { latex }) => nodes.push(Node::Formula {
latex: latex.clone(),
orig: text.clone(),
location: Some(loc),
}),
_ => nodes.push(Node::Paragraph {
text: "<!-- formula-not-decoded -->".into(),
}),
},
"code" => {
let code = code_region_text(region, &page.code_cells);
let code = if code.is_empty() {
tighten_code_punct(&text)
} else {
code
};
let node = match &enrichments[i] {
Some(Enrichment::Code {
language,
text: enriched,
}) => {
let flat = code
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join(" ");
Node::Code {
language: language.clone(),
text: enriched.clone(),
orig: Some(flat),
}
}
_ => Node::Code {
language: None,
text: code,
orig: None,
},
};
nodes.push(located(loc, node));
if let Some(ci) = code_caption_for[i] {
let cap = region_text(®ions[ci], &page.cells);
if !cap.is_empty() {
nodes.push(Node::Paragraph { text: cap });
}
}
}
_ => nodes.push(located(
loc,
Node::Paragraph {
text: md_escape(&text),
},
)),
}
}
(nodes, links)
}
fn looks_like_caption(text: &str) -> bool {
let head: String = text.trim_start().chars().take(14).collect();
(head.starts_with("Fig") || head.starts_with("Table"))
&& head.contains(|c: char| c.is_ascii_digit())
}
fn paragraph_is_open(text: &str) -> bool {
text.trim_end().chars().next_back().is_some_and(|c| {
c.is_alphabetic() || matches!(c, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}')
})
}
fn as_paragraph(n: &Node) -> Option<&str> {
match n {
Node::Paragraph { text } => Some(text),
Node::Located { inner, .. } => match inner.as_ref() {
Node::Paragraph { text } => Some(text),
_ => None,
},
_ => None,
}
}
fn is_picture_node(n: &Node) -> bool {
match n {
Node::Picture { .. } => true,
Node::Located { inner, .. } => matches!(inner.as_ref(), Node::Picture { .. }),
_ => false,
}
}
fn is_merge_trailer(n: &Node) -> bool {
is_picture_node(n)
|| matches!(n, Node::PageFurniture { .. })
|| as_paragraph(n).is_some_and(looks_like_caption)
}
fn reparagraph(node: &Node, text: String) -> Node {
match node {
Node::Located { location, .. } => located(*location, Node::Paragraph { text }),
_ => Node::Paragraph { text },
}
}
pub(crate) fn merge_continuations(nodes: &mut Vec<Node>) {
let mut i = 0;
while i + 1 < nodes.len() {
let Some(a) = as_paragraph(&nodes[i]) else {
i += 1;
continue;
};
if looks_like_caption(a) {
i += 1;
continue;
}
if !paragraph_is_open(a) {
i += 1;
continue;
}
let mut j = i + 1;
while nodes.get(j).is_some_and(is_merge_trailer) {
j += 1;
}
let cont = nodes.get(j).and_then(as_paragraph).is_some_and(|b| {
b.trim_start()
.chars()
.next()
.is_some_and(char::is_lowercase)
});
if cont {
let a = as_paragraph(&nodes[i]).unwrap().trim_end().to_string();
let b = as_paragraph(&nodes[j]).unwrap().trim_start().to_string();
nodes[i] = reparagraph(&nodes[i], format!("{a} {b}"));
nodes.remove(j);
} else {
i += 1;
}
}
}
fn hold_start(nodes: &[Node]) -> usize {
for k in (0..nodes.len()).rev() {
if is_merge_trailer(&nodes[k]) {
continue;
}
match as_paragraph(&nodes[k]) {
Some(text) if paragraph_is_open(text) => return k,
_ => return nodes.len(),
}
}
nodes.len()
}
pub(crate) struct StreamAssembler {
pending: Vec<Node>,
}
impl StreamAssembler {
pub(crate) fn new() -> Self {
Self {
pending: Vec::new(),
}
}
pub(crate) fn push(&mut self, mut nodes: Vec<Node>) -> Vec<Node> {
self.pending.append(&mut nodes);
merge_continuations(&mut self.pending);
let cut = hold_start(&self.pending);
let tail = self.pending.split_off(cut);
std::mem::replace(&mut self.pending, tail)
}
pub(crate) fn finish(self) -> Vec<Node> {
self.pending
}
}
#[cfg(test)]
mod tests {
use super::clean_text;
use super::{code_region_text, merge_continuations, resolve_link_anchors, StreamAssembler};
use crate::layout::Region;
use crate::pdfium_backend::{LinkAnnot, PdfPage, TextCell};
use docling_core::Node;
#[test]
fn border_straddling_lines_survive_picture_interior_is_still_dropped() {
let pic = Region {
label: "picture",
score: 0.9,
l: 0.0,
t: 0.0,
r: 100.0,
b: 100.0,
};
let straddler = TextCell {
text: "axis label".into(),
l: 90.0,
t: 40.0,
r: 120.0,
b: 48.0,
};
let interior = TextCell {
text: "in-figure callout".into(),
l: 10.0,
t: 10.0,
r: 60.0,
b: 18.0,
};
let mut regions = vec![pic];
super::add_orphan_regions(&mut regions, &[straddler, interior]);
assert_eq!(
regions.iter().filter(|r| r.label == "text").count(),
2,
"both unclaimed lines become orphans"
);
super::drop_contained_regulars(&mut regions);
let texts: Vec<(f32, f32)> = regions
.iter()
.filter(|r| r.label == "text")
.map(|r| (r.l, r.r))
.collect();
assert_eq!(
texts,
[(90.0, 120.0)],
"the straddler is emitted, the fully-contained callout is not"
);
}
#[test]
fn text_panels_demote_to_paragraphs_but_charts_keep_their_crop() {
let cell = |text: &str, l: f32, t: f32, r: f32, b: f32| TextCell {
text: text.to_string(),
l,
t,
r,
b,
};
let panel = Region {
label: "picture",
score: 0.9,
l: 0.0,
t: 0.0,
r: 100.0,
b: 100.0,
};
let cells = vec![
cell(
"C.7. Wenn Sie diesen Vertrag widerrufen,",
5.0,
10.0,
95.0,
18.0,
),
cell(
"haben wir Ihnen alle Zahlungen, die wir",
5.0,
20.0,
95.0,
28.0,
),
cell(
"von Ihnen erhalten haben, zurückzuzahlen.",
5.0,
30.0,
90.0,
38.0,
),
cell(
"C.8. Wir können die Rückzahlung verweigern,",
5.0,
52.0,
95.0,
60.0,
),
cell(
"bis wir die Waren wieder zurückerhalten haben.",
5.0,
62.0,
92.0,
70.0,
),
];
let mut regions = vec![panel.clone()];
super::recover_text_panels(&mut regions, &cells);
assert_eq!(
regions.iter().map(|r| r.label).collect::<Vec<_>>(),
["text", "text"],
"dense panel must demote into one text region per paragraph"
);
assert!(regions[0].b < regions[1].t, "paragraphs split at the gap");
let labels = vec![
cell("0", 5.0, 90.0, 8.0, 95.0),
cell("50", 5.0, 50.0, 10.0, 55.0),
cell("100", 5.0, 10.0, 12.0, 15.0),
cell("t, s", 45.0, 96.0, 55.0, 100.0),
];
let mut regions = vec![panel];
super::recover_text_panels(&mut regions, &labels);
assert_eq!(
regions.iter().map(|r| r.label).collect::<Vec<_>>(),
["picture"]
);
}
#[test]
fn interleaved_font_runs_read_in_visual_order() {
let cell = |text: &str, l: f32, t: f32, r: f32, b: f32| TextCell {
text: text.to_string(),
l,
t,
r,
b,
};
let cells = vec![
cell("C.", 10.0, 100.0, 18.0, 110.0),
cell("Zur Wahrung der Widerrufsfrist", 30.0, 100.0, 150.0, 110.0),
cell("über die Ausübung", 10.0, 112.0, 90.0, 122.0),
cell("6.", 19.0, 100.0, 27.0, 110.0), ];
let region = Region {
label: "text",
score: 1.0,
l: 0.0,
t: 95.0,
r: 200.0,
b: 130.0,
};
assert_eq!(
super::region_text(®ion, &cells),
"C. 6. Zur Wahrung der Widerrufsfrist über die Ausübung"
);
let raised = vec![
cell("(", 10.0, 100.0, 14.0, 110.0),
cell(">", 15.0, 97.0, 20.0, 104.0),
cell("10 pages)", 21.0, 100.0, 60.0, 110.0),
];
assert_eq!(super::region_text(®ion, &raised), "( > 10 pages)");
}
#[test]
fn geometric_reliability_rejects_split_column_grids() {
let g = |rows: &[&[&str]]| -> Vec<Vec<String>> {
rows.iter()
.map(|r| r.iter().map(|c| c.to_string()).collect())
.collect()
};
assert!(super::geometric_table_is_reliable(&g(&[
&["Datum", "Leistung", "Anzahl", "Kosten"],
&["04.07", "Internet", "1", "40.30"],
&["04.07", "Telefon", "2", "8.06"],
])));
assert!(!super::geometric_table_is_reliable(&g(&[
&["www.magenta.at/faq", "", "", ""],
&["Serviceteam", "", "", ""],
&["Telefon", "0676/2000", "", ""],
&["Kundennummer", "", "", "1.21699482"],
&["Rechnungsnummer", "", "922769430725", ""],
&["Rechnungsdatum", "", "", "04.07.2025"],
])));
assert!(!super::geometric_table_is_reliable(&g(&[
&["a", "b", ""],
&["c", "d", ""],
&["e", "f", "g"],
])));
assert!(!super::geometric_table_is_reliable(&g(&[&[
"only one column"
]])));
assert!(!super::geometric_table_is_reliable(&[]));
}
#[cfg(feature = "ocr-prep")]
#[test]
fn picture_regions_are_cropped_from_a_host_supplied_page_image() {
let mut img = image::RgbImage::new(200, 200);
for y in 100..160 {
for x in 20..120 {
img.put_pixel(x, y, image::Rgb([255, 0, 0]));
}
}
let page = PdfPage::from_cells_with_image(100.0, 100.0, 2.0, Vec::new(), img);
let region = Region {
label: "picture",
score: 0.9,
l: 10.0,
t: 50.0,
r: 60.0,
b: 80.0,
};
let (nodes, _) = super::assemble_page(&page, vec![region], &[None], &[None]);
let image = nodes
.iter()
.find_map(|n| match n {
Node::Located { inner, .. } => match &**inner {
Node::Picture { image, .. } => image.as_ref(),
_ => None,
},
Node::Picture { image, .. } => image.as_ref(),
_ => None,
})
.expect("a picture node with cropped pixels");
assert_eq!(image.mimetype, "image/png");
assert_eq!((image.width, image.height), (100, 60), "region × scale");
assert!(!image.data.is_empty(), "PNG bytes were encoded");
}
#[test]
fn link_anchors_split_a_shared_word_cell_between_adjacent_links() {
let annot = |l: f32, r: f32, uri: &str| LinkAnnot {
l,
t: 100.0,
r,
b: 114.0,
uri: uri.into(),
};
let page = PdfPage {
width: 600.0,
height: 800.0,
scale: 2.0,
cells: Vec::new(),
code_cells: Vec::new(),
word_cells: vec![cell(
"LinkedIn | GitHub | Credly",
100.0,
100.0,
360.0,
114.0,
)],
image: image::RgbImage::new(1, 1),
links: vec![
annot(100.0, 180.0, "https://l"),
annot(200.0, 260.0, "https://g"),
annot(290.0, 360.0, "https://c"),
],
};
assert_eq!(
resolve_link_anchors(&page),
vec![
("LinkedIn".to_string(), "https://l".to_string()),
("GitHub".to_string(), "https://g".to_string()),
("Credly".to_string(), "https://c".to_string()),
]
);
}
fn cell(text: &str, l: f32, t: f32, r: f32, b: f32) -> TextCell {
TextCell {
text: text.into(),
l,
t,
r,
b,
}
}
fn region(label: &'static str, score: f32, l: f32, t: f32, r: f32, b: f32) -> Region {
Region {
label,
score,
l,
t,
r,
b,
}
}
#[test]
fn resolve_collapses_nested_code_keeping_the_larger_box() {
let tight = region("code", 0.95, 78.0, 292.0, 300.0, 330.0);
let wide = region("code", 0.66, 63.0, 260.0, 320.0, 346.0);
let kept = super::resolve(vec![tight, wide]);
assert_eq!(kept.len(), 1, "nested code boxes must collapse to one");
assert!(
kept[0].l == 63.0 && kept[0].b == 346.0,
"the larger containing box is kept"
);
}
#[test]
fn resolve_keeps_distinct_and_differently_typed_regions() {
let text = region("text", 0.95, 90.0, 210.0, 200.0, 230.0);
let table = region("table", 0.60, 80.0, 200.0, 400.0, 500.0);
assert_eq!(super::resolve(vec![text, table]).len(), 2);
let code_a = region("code", 0.9, 78.0, 100.0, 300.0, 140.0);
let code_b = region("code", 0.9, 78.0, 300.0, 300.0, 360.0); assert_eq!(super::resolve(vec![code_a, code_b]).len(), 2);
}
#[test]
fn code_language_label_above_code_is_detected() {
let label = region("section_header", 0.9, 76.0, 540.0, 96.0, 549.0);
let code = region("code", 0.7, 77.0, 552.0, 290.0, 640.0);
let heading = region("section_header", 0.9, 76.0, 500.0, 260.0, 512.0);
let cells = vec![
cell("XML", 78.0, 541.0, 94.0, 548.0), cell("Overview", 78.0, 501.0, 250.0, 511.0), ];
let drop = super::code_language_labels(&[label, code, heading], &cells);
assert_eq!(drop, vec![true, false, false], "only the label is consumed");
let label2 = region("section_header", 0.9, 76.0, 540.0, 96.0, 549.0);
let only = vec![cell("XML", 78.0, 541.0, 94.0, 548.0)];
assert_eq!(super::code_language_labels(&[label2], &only), vec![false]);
let inside_lbl = region("text", 0.9, 76.0, 540.0, 96.0, 549.0);
let wide_code = region("code", 0.7, 63.0, 531.0, 320.0, 654.0);
let cells2 = vec![cell("XML", 78.0, 541.0, 94.0, 548.0)];
assert_eq!(
super::code_language_labels(&[inside_lbl, wide_code], &cells2),
vec![true, false]
);
assert!(super::is_code_language("XML") && super::is_code_language("c#"));
assert!(!super::is_code_language("Configure") && !super::is_code_language("XML schema"));
}
#[test]
fn code_region_text_keeps_lines_and_indentation() {
let region = Region {
label: "code",
score: 1.0,
l: 0.0,
t: -5.0,
r: 100.0,
b: 40.0,
};
let cells = vec![
cell("struct P {", 10.0, 0.0, 70.0, 10.0),
cell("int X;", 22.0, 12.0, 58.0, 22.0),
cell("}", 10.0, 24.0, 16.0, 34.0),
];
assert_eq!(code_region_text(®ion, &cells), "struct P {\n int X;\n}");
}
#[test]
fn code_region_text_tightens_punctuation_without_eating_indentation() {
let region = Region {
label: "code",
score: 1.0,
l: 0.0,
t: -5.0,
r: 100.0,
b: 40.0,
};
let cells = vec![
cell("builder", 10.0, 0.0, 52.0, 10.0),
cell(".Foo (x)", 22.0, 12.0, 70.0, 22.0),
];
assert_eq!(code_region_text(®ion, &cells), "builder\n .Foo(x)");
}
#[test]
fn code_region_text_orders_out_of_order_cells_and_ignores_blank_lines() {
let region = Region {
label: "code",
score: 1.0,
l: 0.0,
t: -5.0,
r: 100.0,
b: 60.0,
};
let cells = vec![
cell("b();", 10.0, 24.0, 34.0, 34.0),
cell(" ", 10.0, 12.0, 20.0, 22.0),
cell("a();", 10.0, 0.0, 34.0, 10.0),
];
assert_eq!(code_region_text(®ion, &cells), "a();\nb();");
assert_eq!(code_region_text(®ion, &[]), "");
}
fn para(text: &str) -> Node {
Node::Paragraph { text: text.into() }
}
fn assert_stream_eq(nodes: &[Node], splits: &[usize]) {
let mut want = nodes.to_vec();
merge_continuations(&mut want);
let mut asm = StreamAssembler::new();
let mut got = Vec::new();
let mut start = 0;
for &end in splits {
got.extend(asm.push(nodes[start..end].to_vec()));
start = end;
}
got.extend(asm.push(nodes[start..].to_vec()));
got.extend(asm.finish());
assert_eq!(got, want, "stream assembly diverged (splits={splits:?})");
}
#[test]
fn stream_assembler_matches_merge_continuations() {
let cross = [para("the definition of"), para("lists in scope")];
assert_stream_eq(&cross, &[1]);
assert_stream_eq(&cross, &[]);
let wrap = [
para("the wing type that is"),
Node::Picture {
caption: None,
image: None,
classification: None,
},
para("Fig. 1. a diagram"),
para("the most common kind"),
];
for splits in [&[][..], &[1][..], &[2][..], &[3][..], &[1, 3][..]] {
assert_stream_eq(&wrap, splits);
}
let blocked = [
para("ends mid word and"),
Node::Heading {
level: 2,
text: "New Section".into(),
},
para("more body here"),
];
for splits in [&[][..], &[1][..], &[2][..]] {
assert_stream_eq(&blocked, splits);
}
let chain = [
para("alpha beta"),
para("gamma delta"),
para("epsilon zeta"),
];
assert_stream_eq(&chain, &[1, 2]);
}
#[test]
fn clean_text_dehyphenates_and_normalizes_typography() {
assert_eq!(clean_text("com\u{2} pact"), "compact");
assert_eq!(clean_text("end-to\u{2} end deep"), "end-toend deep");
assert_eq!(clean_text("word\u{2}"), "word");
assert_eq!(
clean_text("Graph\u{2019}s \u{201c}x\u{201d} \"y\""),
"Graph's 'x' \"y\""
);
assert_eq!(clean_text("a\u{2026}"), "a...");
assert_eq!(clean_text("a b\nc"), "a b c");
}
#[test]
fn lam_alef_only_swaps_a_genuinely_reversed_ligature() {
assert_eq!(
clean_text("\u{0628}\u{0623}\u{0644}"),
"\u{0628}\u{0644}\u{0623}"
);
assert_eq!(
clean_text("\u{0627}\u{0644}\u{0622}\u{0644}\u{064a}"),
"\u{0627}\u{0644}\u{0622}\u{0644}\u{064a}"
);
}
}