use std::path::{Path, PathBuf};
use image::DynamicImage;
use pdf_oxide::{api::Pdf, geometry::Rect};
use tracing::{info, warn};
use crate::config::{ConverterConfig, RoutingMode};
use crate::engine::OnnxEngine;
use crate::error::{BobineError, Result};
use crate::pdf_source::{PdfSource, SourceChar};
use crate::rapid_layout::LayoutRegion;
pub struct ProgressHooks<'a> {
pub should_continue: Box<dyn Fn() -> bool + 'a>,
pub on_page: Box<dyn Fn(usize, usize) + 'a>,
}
impl Default for ProgressHooks<'_> {
fn default() -> Self {
Self {
should_continue: Box::new(|| true),
on_page: Box::new(|_, _| {}),
}
}
}
const MATH_FONT_KEYWORDS: &[&str] = &[
"cmmi", "cmsy", "cmex", "msam", "msbm", "math", "symbol", "mathjax", "stix", "xits", "asana",
"euclid",
];
const MATH_UNICODE_RANGES: &[(u32, u32)] = &[
(0x0370, 0x03FF),
(0x2200, 0x22FF),
(0x2A00, 0x2AFF),
(0x27C0, 0x27EF),
(0x2980, 0x29FF),
(0x1D400, 0x1D7FF),
];
const MONO_FONT_KEYWORDS: &[&str] = &[
"mono",
"courier",
"consol",
"menlo",
"inconsolata",
"sourcecode",
"dejavu sans mono",
"fixed",
"terminal",
];
const CLAIM_MATH: u8 = 0;
const CLAIM_TABLE: u8 = 1;
const CLAIM_CAPTION: u8 = 2;
const CLAIM_TEXT: u8 = 3;
const CLAIM_NONE: u8 = 9;
fn region_claim_priority(label: &str) -> u8 {
let l = label.to_lowercase();
if l.contains("caption") || l.contains("footnote") {
CLAIM_CAPTION
} else if l.contains("table") {
CLAIM_TABLE
} else if MATH_LAYOUT_LABELS.iter().any(|k| l.contains(k)) {
CLAIM_MATH
} else if l.contains("figure") || l.contains("image") {
CLAIM_NONE
} else {
CLAIM_TEXT
}
}
const MATH_LAYOUT_LABELS: &[&str] = &[
"equation",
"display_formula",
"inline_formula",
"isolate_formula",
"formula",
];
fn claim_ranks(sorted: &[crate::rapid_layout::LayoutRegion]) -> (Vec<usize>, Vec<usize>) {
let mut claim_order: Vec<usize> = (0..sorted.len()).collect();
claim_order.sort_by(|&a, &b| {
region_claim_priority(&sorted[a].label)
.cmp(®ion_claim_priority(&sorted[b].label))
.then_with(|| {
sorted[a]
.y0
.partial_cmp(&sorted[b].y0)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| {
sorted[a]
.x0
.partial_cmp(&sorted[b].x0)
.unwrap_or(std::cmp::Ordering::Equal)
})
})
});
let mut rank = vec![0usize; sorted.len()];
for (pos, &i) in claim_order.iter().enumerate() {
rank[i] = pos;
}
(claim_order, rank)
}
fn assign_glyph_owners(
page_chars: &[SourceChar],
sorted: &[crate::rapid_layout::LayoutRegion],
rank: &[usize],
scale: f32,
) -> Vec<Option<usize>> {
let mut glyph_owner: Vec<Option<usize>> = vec![None; page_chars.len()];
for (ci, c) in page_chars.iter().enumerate() {
let mut best: Option<(f32, usize)> = None; for (ri, region) in sorted.iter().enumerate() {
if region_claim_priority(®ion.label) == CLAIM_NONE {
continue;
}
let iw = region.x1.min((c.bbox.x + c.bbox.width) * scale)
- region.x0.max(c.bbox.x * scale);
if iw <= 0.0 {
continue;
}
let ih = region.y1.min((c.bbox.y + c.bbox.height) * scale)
- region.y0.max(c.bbox.y * scale);
if ih <= 0.0 {
continue;
}
let area = iw * ih;
let take = match best {
None => true,
Some((ba, bri)) => {
area > ba || ((area - ba).abs() <= ba * 1e-6 && rank[ri] < rank[bri])
}
};
if take {
best = Some((area, ri));
}
}
if let Some((_, ri)) = best {
glyph_owner[ci] = Some(ri);
}
}
glyph_owner
}
struct RegionCtx<'a> {
config: &'a ConverterConfig,
page_chars: &'a [SourceChar],
sorted: &'a [LayoutRegion],
region_lines: &'a [Vec<Vec<usize>>],
scale: f32,
img: &'a DynamicImage,
dpi: u32,
work_dir: &'a Path,
page_index: usize,
assets: &'a [EmbeddedAsset],
median_char_h: f32,
line_height: f32,
}
fn caption_block(page_chars: &[SourceChar], lines: &[Vec<usize>], lab: &str) -> Option<String> {
if !(lab.contains("caption") || lab.contains("footnote")) {
return None;
}
let text = region_lines_to_text(page_chars, lines);
if text.trim().is_empty() {
None
} else {
Some(text)
}
}
fn figure_block(ctx: &RegionCtx<'_>, bbox: Rect, block_no: usize) -> Option<String> {
let cap_alt = nearby_caption_text(
ctx.sorted,
ctx.region_lines,
ctx.page_chars,
&bbox,
ctx.scale,
3.0 * ctx.median_char_h,
)
.and_then(|t| caption_alt(&t, 120));
let mk_img = |src: &str| match &cap_alt {
Some(a) => format!("", a, src),
None => format!("", src),
};
let matched = best_embedded_match(&bbox, ctx.assets, 0.6)
.and_then(|ai| ctx.assets.get(ai))
.filter(|a| {
a.bbox_pts.map_or(false, |b| {
(b.width as f64) * (b.height as f64) >= ctx.config.render.min_figure_area_pts
})
});
if let Some(a) = matched {
info!(
"page {}: figure → embedded asset {}",
ctx.page_index + 1,
a.rel_path
);
return Some(mk_img(&a.rel_path));
}
if let Some(crop) = crop_image(ctx.img, bbox, ctx.dpi, 0.0) {
let p = ctx
.work_dir
.join(&ctx.config.render.image_output_dir)
.join(format!("p{}", ctx.page_index))
.join(format!("crop{}.png", block_no));
std::fs::create_dir_all(p.parent().unwrap()).ok();
if crop.save(&p).is_ok() {
let rel = format!(
"{}/p{}/{}",
ctx.config.render.image_output_dir,
ctx.page_index,
p.file_name().unwrap().to_string_lossy()
);
return Some(mk_img(&rel));
}
}
None
}
fn text_block(
ctx: &RegionCtx<'_>,
engine: &mut OnnxEngine,
ri: usize,
ocr_claimed: &mut Vec<Rect>,
) -> Option<String> {
let lines = &ctx.region_lines[ri];
let region = &ctx.sorted[ri];
let lab = region.label.to_lowercase();
let text = region_lines_to_text(ctx.page_chars, lines);
if !text.trim().is_empty() {
let mut t = text;
if lab.contains("title") {
t = format!("## {}", t);
}
return Some(t);
}
let base = Rect::new(
region.x0,
region.y0,
region.x1 - region.x0,
region.y1 - region.y0,
);
let base_area = (base.width * base.height).max(1e-6);
let already = ocr_claimed.iter().any(|r| {
let iw = (r.x + r.width).min(base.x + base.width) - r.x.max(base.x);
let ih = (r.y + r.height).min(base.y + base.height) - r.y.max(base.y);
iw > 0.0 && ih > 0.0 && (iw * ih) / base_area > 0.25
});
if already {
return None;
}
if let Some(crop) = crop_image(ctx.img, base, ctx.dpi, 0.0) {
if let Ok(lines) = engine.ocr_lines(&crop) {
let t: String = lines
.into_iter()
.map(|l| l.text)
.filter(|t| !t.is_empty())
.collect::<Vec<_>>()
.join(" ");
if !t.trim().is_empty() {
ocr_claimed.push(base);
let mut t = t;
if lab.contains("title") {
t = format!("## {}", t);
}
return Some(t);
}
}
}
None
}
fn table_block(
ctx: &RegionCtx<'_>,
pdf: &mut dyn PdfSource,
engine: &mut OnnxEngine,
ri: usize,
bbox: Rect,
) -> String {
let lines = &ctx.region_lines[ri];
let region_label = ctx.sorted[ri].label.as_str();
if ctx.config.text.structured_tables {
match pdf.tables_in_rect(ctx.page_index, bbox) {
Ok(tables) => {
let md: Vec<String> = tables
.iter()
.filter(|t| crate::tables::is_plausible_table(t))
.filter_map(crate::tables::source_table_markdown)
.collect();
if !md.is_empty() {
info!(
"page {}: {} structured table(s) in region",
ctx.page_index + 1,
md.len()
);
return md.join("\n\n");
}
}
Err(e) => {
tracing::warn!("page {}: structured table probe failed: {e}", ctx.page_index + 1)
}
}
}
let text = region_lines_to_text(ctx.page_chars, lines);
if !text.trim().is_empty() {
return if ctx.config.text.convert_html_tables {
crate::tables::html_tables_to_gfm(&text)
} else {
text
};
}
if let Some(crop) = crop_image(ctx.img, bbox, ctx.dpi, 0.0) {
match engine
.ocr_lines(&crop)
.map(|lines| engine.recognize_table(&crop, &lines))
{
Ok(Ok(Some(html))) => {
info!(
"page {}: table recognized ({}px crop)",
ctx.page_index + 1,
crop.width()
);
return if ctx.config.text.convert_html_tables {
crate::tables::html_tables_to_gfm(&html)
} else {
html
};
}
Ok(Ok(None)) | Ok(Err(_)) | Err(_) => {}
}
}
format!("[table: {}]", region_label)
}
#[derive(Debug)]
struct MathCluster {
bounds: Rect,
members: Vec<Rect>,
}
fn cluster_math_boxes(
boxes: &[Rect],
chars: &[SourceChar],
line_height: f32,
) -> Vec<MathCluster> {
let sorted = boxes.to_vec();
let max_hgap = 3.0 * line_height;
let max_vgap = 1.0 * line_height;
let mut out: Vec<MathCluster> = Vec::new();
for b in sorted {
let mut absorbed = false;
if let Some(cur) = out.last_mut() {
let cb = cur.bounds;
let dcy = ((b.y + b.height / 2.0) - (cb.y + cb.height / 2.0)).abs();
let hgap = b.x - (cb.x + cb.width);
let vgap = b.y - (cb.y + cb.height);
let row_join = dcy <= 0.5 * line_height
&& hgap >= 0.0
&& hgap <= max_hgap
&& gap_empty(
chars,
cb.x + cb.width,
b.y.min(cb.y),
b.x,
(cb.y + cb.height).max(b.y + b.height),
);
let ox0 = cb.x.max(b.x);
let ox1 = (cb.x + cb.width).min(b.x + b.width);
let stack_join = ox1 > ox0
&& vgap >= 0.0
&& vgap <= max_vgap
&& gap_empty(chars, ox0, cb.y + cb.height, ox1, b.y);
if row_join || stack_join {
let nx = cb.x.min(b.x);
let ny = cb.y.min(b.y);
let nx1 = (cb.x + cb.width).max(b.x + b.width);
let ny1 = (cb.y + cb.height).max(b.y + b.height);
cur.bounds = Rect::new(nx, ny, nx1 - nx, ny1 - ny);
cur.members.push(b);
absorbed = true;
}
}
if !absorbed {
out.push(MathCluster {
bounds: b,
members: vec![b],
});
}
}
out
}
fn gap_empty(chars: &[SourceChar], x0: f32, y0: f32, x1: f32, y1: f32) -> bool {
!chars.iter().any(|c| {
let cx = c.bbox.x + c.bbox.width / 2.0;
let cy = c.bbox.y + c.bbox.height / 2.0;
cx > x0 && cx < x1 && cy >= y0 && cy <= y1
})
}
fn math_blocks(
ctx: &RegionCtx<'_>,
pdf: &mut dyn PdfSource,
engine: &mut OnnxEngine,
ri: usize,
bbox: Rect,
block_no: usize,
page_math_boxes: &mut Vec<Rect>,
) -> Result<Vec<String>> {
let lines = &ctx.region_lines[ri];
let region = &ctx.sorted[ri];
let line_height = ctx.line_height;
let page_index = ctx.page_index;
let mut out: Vec<String> = Vec::new();
let page_h_px = ctx.img.height() as f64;
let page_px = ctx.img.width() as f64 * page_h_px;
let w_px = (region.x1 - region.x0) as f64;
let h_px = (region.y1 - region.y0) as f64;
let plausible = h_px <= 0.25 * page_h_px && w_px * h_px <= 0.15 * page_px;
let margin = 6.0_f32;
let refined: Vec<Rect> = page_math_boxes
.iter()
.copied()
.filter(|b| {
let cx = b.x + b.width / 2.0;
let cy = b.y + b.height / 2.0;
cx >= bbox.x - margin
&& cx <= bbox.x + bbox.width + margin
&& cy >= bbox.y - margin
&& cy <= bbox.y + bbox.height + margin
})
.collect();
if !refined.is_empty() {
let taken = refined.clone();
page_math_boxes.retain(|b| !taken.iter().any(|t| t.x == b.x && t.y == b.y));
}
if std::env::var("BOB_DEBUG_HYBRID").is_ok() {
eprintln!(
"HYBRID p{} region pt=({:.0},{:.0},{:.0},{:.0}) mathboxes={} refined={}",
page_index + 1,
bbox.x,
bbox.y,
bbox.width,
bbox.height,
page_math_boxes.len(),
refined.len()
);
for b in page_math_boxes.iter().take(10) {
eprintln!(
" mb x={:.0} y={:.0} w={:.0} h={:.0}",
b.x, b.y, b.width, b.height
);
}
}
if !refined.is_empty() {
let text = region_lines_to_text(ctx.page_chars, lines);
let mut reps: Vec<(String, String)> = Vec::new();
let gated: Vec<Rect> = refined
.iter()
.copied()
.filter(|rb| {
if rb.width < 40.0 {
return false;
}
let display = rb.height > 1.6 * line_height
|| rb.width > ctx.config.text.formula_inline_max_width_pts as f32;
display && rb.height <= 3.0 * line_height
})
.collect();
for cluster in cluster_math_boxes(&gated, ctx.page_chars, line_height) {
let t_rec = std::time::Instant::now();
let crop_res = crop_image(ctx.img, cluster.bounds, ctx.dpi, 0.0);
if std::env::var("BOB_DEBUG_HYBRID").is_ok() {
eprintln!(
"HYBRID-crop p{} w={:.0} h={:.0} (lh={:.1}) members={}",
page_index + 1,
cluster.bounds.width,
cluster.bounds.height,
line_height,
cluster.members.len(),
);
}
if let Some(crop) = crop_res {
if crop.width() < 4 || crop.height() < 4 {
continue;
}
let p = ctx.work_dir.join(format!(
"_reg_hf_{}_{}.png",
page_index,
block_no + reps.len()
));
if crop.save(&p).is_ok() {
let budget = ((cluster.bounds.width * cluster.bounds.height) / 40.0)
.clamp(64.0, 512.0) as usize;
if let Some(latex) = engine.recognize_formula_capped(&p, budget)? {
if std::env::var("BOB_DEBUG_HYBRID").is_ok() {
eprintln!("HYBRID-png {}", p.display());
eprintln!(
"HYBRID-ocr p{} {} chars in {:?}",
page_index + 1,
latex.len(),
t_rec.elapsed()
);
}
if plausible_display_latex(&latex) {
if let Some((first, rest)) = cluster.members.split_first() {
let needle = region_text(pdf, page_index, *first);
reps.push((needle, format!("$$\n{}\n$$", latex)));
for m in rest {
reps.push((region_text(pdf, page_index, *m), String::new()));
}
}
} else if std::env::var("BOB_DEBUG_HYBRID").is_ok() {
eprintln!("HYBRID-reject p{} {:?}", page_index + 1, latex);
}
}
}
}
}
if !text.trim().is_empty() {
out.push(splice(&text, &reps));
return Ok(out);
}
if !reps.is_empty() {
for (_, wrapped) in reps {
out.push(wrapped);
}
return Ok(out);
}
}
if !plausible {
tracing::warn!(
"page {}: implausible {} region ({:.0}x{:.0}px, {:.0}% of page); treating as text",
page_index + 1,
region.label,
w_px,
h_px,
100.0 * (w_px * h_px) / page_px
);
let text = region_lines_to_text(ctx.page_chars, lines);
if !text.trim().is_empty() {
out.push(text);
}
return Ok(out);
}
let text = region_lines_to_text(ctx.page_chars, lines);
if text.trim().is_empty() {
if let Some(crop) = crop_image(ctx.img, bbox, ctx.dpi, ctx.config.text.formula_pad_pts) {
if crop.width() >= 4 && crop.height() >= 4 {
let p = ctx.work_dir.join(format!("_reg_f_{}.png", block_no));
if crop.save(&p).is_ok() {
let budget =
((bbox.width * bbox.height) / 40.0).clamp(64.0, 512.0) as usize;
if let Some(latex) = engine.recognize_formula_capped(&p, budget)? {
out.push(format!("$$\n{}\n$$", latex));
return Ok(out);
}
}
}
}
} else {
out.push(text);
}
Ok(out)
}
fn repair_seams(
page_chars: &[SourceChar],
sorted: &[crate::rapid_layout::LayoutRegion],
glyph_owner: &[Option<usize>],
region_lines: &mut [Vec<Vec<usize>>],
blocks: &mut Vec<String>,
scale: f32,
page_index: usize,
) {
let unowned: Vec<usize> = (0..page_chars.len())
.filter(|&i| glyph_owner[i].is_none())
.collect();
if unowned.is_empty() {
return;
}
let pad_px = 8.0 * scale; let mut homeless = 0usize;
for line in cluster_lines(page_chars, &unowned) {
let lx0 = line
.iter()
.map(|&i| page_chars[i].bbox.x)
.fold(f32::INFINITY, f32::min);
let ly0 = line
.iter()
.map(|&i| page_chars[i].bbox.y)
.fold(f32::INFINITY, f32::min);
let lx1 = line
.iter()
.map(|&i| page_chars[i].bbox.x + page_chars[i].bbox.width)
.fold(f32::NEG_INFINITY, f32::max);
let ly1 = line
.iter()
.map(|&i| page_chars[i].bbox.y + page_chars[i].bbox.height)
.fold(f32::NEG_INFINITY, f32::max);
let cx = (lx0 + lx1) * 0.5 * scale;
let cy = (ly0 + ly1) * 0.5 * scale;
let mut best: Option<(f32, usize)> = None;
for (ri, region) in sorted.iter().enumerate() {
if region_claim_priority(®ion.label) == CLAIM_NONE {
continue;
}
if cx >= region.x0 - pad_px
&& cx <= region.x1 + pad_px
&& cy >= region.y0 - pad_px
&& cy <= region.y1 + pad_px
{
let d = if cy < region.y0 {
region.y0 - cy
} else if cy > region.y1 {
cy - region.y1
} else {
0.0
};
if best.map_or(true, |(bd, _)| d < bd) {
best = Some((d, ri));
}
}
}
match best {
Some((_, ri)) => {
region_lines[ri].push(line);
region_lines[ri].sort_by(|a, b| {
let ay = a
.iter()
.map(|&i| page_chars[i].bbox.y)
.fold(f32::INFINITY, f32::min);
let by = b
.iter()
.map(|&i| page_chars[i].bbox.y)
.fold(f32::INFINITY, f32::min);
ay.partial_cmp(&by).unwrap_or(std::cmp::Ordering::Equal)
});
}
None => {
homeless += line.len();
let t = region_lines_to_text(page_chars, &[line]);
if !t.trim().is_empty() {
blocks.push(t);
}
}
}
}
if homeless > 0 {
info!(
"page {}: {} chars outside every layout region (appended)",
page_index + 1,
homeless
);
}
}
fn assign_region_lines(
page_chars: &[SourceChar],
glyph_owner: &[Option<usize>],
rank: &[usize],
num_regions: usize,
) -> Vec<Vec<Vec<usize>>> {
let owned: Vec<usize> = (0..page_chars.len())
.filter(|&i| glyph_owner[i].is_some())
.collect();
let mut region_lines: Vec<Vec<Vec<usize>>> = vec![Vec::new(); num_regions];
for line in cluster_lines(page_chars, &owned) {
let mut tally: std::collections::HashMap<usize, usize> = Default::default();
for &i in &line {
let Some(owner) = glyph_owner[i] else { continue };
*tally.entry(owner).or_default() += 1;
}
let winner = tally
.into_iter()
.max_by(|a, b| a.1.cmp(&b.1).then_with(|| rank[b.0].cmp(&rank[a.0])));
if let Some((ri, _)) = winner {
region_lines[ri].push(line);
}
}
for rl in region_lines.iter_mut() {
rl.sort_by(|a, b| {
let ay = a
.iter()
.map(|&i| page_chars[i].bbox.y)
.fold(f32::INFINITY, f32::min);
let by = b
.iter()
.map(|&i| page_chars[i].bbox.y)
.fold(f32::INFINITY, f32::min);
ay.partial_cmp(&by).unwrap_or(std::cmp::Ordering::Equal)
});
}
region_lines
}
pub struct HybridConverter {
#[allow(dead_code)]
pub config: ConverterConfig,
pub engine: OnnxEngine,
}
impl HybridConverter {
pub fn new(config: ConverterConfig, cache_dir: &Path) -> Self {
let engine = OnnxEngine::new(&config, cache_dir);
Self { config, engine }
}
pub fn convert(&mut self, input: &Path, work_dir: &Path) -> Result<String> {
let ext = input
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
match ext.as_str() {
"pdf" => self.convert_pdf(input, work_dir),
"docx" | "xlsx" | "pptx" | "doc" | "xls" | "ppt" => self.convert_office_staged(input, work_dir),
_ => std::fs::read_to_string(input).map_err(BobineError::Io),
}
}
pub fn convert_pdf(&mut self, path: &Path, work_dir: &Path) -> Result<String> {
self.convert_pdf_with(path, work_dir, &ProgressHooks::default())
}
pub fn convert_pdf_with(
&mut self,
path: &Path,
work_dir: &Path,
hooks: &ProgressHooks<'_>,
) -> Result<String> {
let mut pdf = Pdf::open(path).map_err(|e| BobineError::PdfOxide(format!("open: {e}")))?;
self.convert_pdf_source(&mut pdf, work_dir, hooks)
}
pub fn convert_pdf_source<S: PdfSource>(
&mut self,
pdf: &mut S,
work_dir: &Path,
hooks: &ProgressHooks<'_>,
) -> Result<String> {
let n_pages = pdf.page_count()?;
std::fs::create_dir_all(work_dir)?;
let mut blocks: Vec<String> = Vec::with_capacity(n_pages);
for i in 0..n_pages {
if !(hooks.should_continue)() {
info!("conversion cancelled at page {} of {}", i + 1, n_pages);
break;
}
(hooks.on_page)(i, n_pages);
let assets = if self.config.render.extract_images {
match self.extract_page_images(pdf, i, work_dir) {
Ok(paths) => {
let infos = pdf.images(i).unwrap_or_default();
paths
.into_iter()
.zip(infos)
.map(|(path, im)| EmbeddedAsset {
rel_path: format!(
"{}/p{}/{}",
self.config.render.image_output_dir,
i,
path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
),
bbox_pts: im.bbox,
})
.collect()
}
Err(e) => {
warn!("image extraction failed on page {}: {e}", i + 1);
Vec::new()
}
}
} else {
Vec::new()
};
let page_md = self.route_page(pdf, i, work_dir, &assets)?;
let final_md = if self.config.render.extract_images && self.config.render.append_unreferenced_images {
maybe_append_gallery(page_md, &assets)
} else {
page_md
};
blocks.push(final_md);
}
let joined = blocks.join("\n\n---\n\n");
Ok(if self.config.text.promote_title {
promote_document_title(&joined)
} else {
joined
})
}
fn route_page(
&mut self,
pdf: &mut dyn PdfSource,
index: usize,
work_dir: &Path,
assets: &[EmbeddedAsset],
) -> Result<String> {
let md = self.route_page_inner(pdf, index, work_dir, assets)?;
let md = if self.config.text.promote_headings {
promote_headings(&md)
} else {
md
};
if self.config.text.detect_code_blocks {
let mut md = md;
for block in wrap_code_blocks(pdf, index) {
md.push_str("\n\n");
md.push_str(&block);
}
return Ok(md);
}
Ok(md)
}
fn route_page_inner(
&mut self,
pdf: &mut dyn PdfSource,
index: usize,
work_dir: &Path,
assets: &[EmbeddedAsset],
) -> Result<String> {
if !self.config.routing.use_onnx || self.config.routing.routing_mode == RoutingMode::Never {
return self.fast_markdown_placed(pdf, index, assets);
}
if self.config.routing.routing_mode == RoutingMode::Surgical {
if is_scanned(pdf, index, self.config.text.scanned_text_threshold) {
self.engine.ensure_models()?;
match self.full_structure_page_markdown(pdf, index, work_dir, assets) {
Ok(Some(md)) if !md.trim().is_empty() => {
info!("page {}: scanned → ONNX layout+OCR", index + 1);
return Ok(md);
}
Err(e) => warn!(
"page {}: ONNX full-structure failed ({}); falling back to fast path",
index + 1,
e
),
_ => {}
}
return self.fast_markdown_placed(pdf, index, assets);
}
let md = self.surgical_page_markdown(pdf, index, work_dir)?;
return Ok(interleave_images(
pdf,
index,
self.config.render.min_figure_area_pts,
&md,
assets,
));
}
if needs_onnx(pdf, index, &self.config) {
match self.full_structure_page_markdown(pdf, index, work_dir, assets) {
Ok(Some(md)) if !md.trim().is_empty() => {
info!("page {} → ONNX layout+OCR", index + 1);
return Ok(md);
}
Err(e) => tracing::warn!(
"page {}: ONNX full-structure failed ({}); falling back to fast path",
index + 1,
e
),
_ => {}
}
}
self.fast_markdown_placed(pdf, index, assets)
}
fn fast_markdown_placed(
&mut self,
pdf: &mut dyn PdfSource,
index: usize,
assets: &[EmbeddedAsset],
) -> Result<String> {
let md = fast_page_markdown(pdf, index)?;
Ok(interleave_images(
pdf,
index,
self.config.render.min_figure_area_pts,
&md,
assets,
))
}
fn surgical_page_markdown(
&mut self,
pdf: &mut dyn PdfSource,
index: usize,
work_dir: &Path,
) -> Result<String> {
let fast_md = fast_page_markdown(pdf, index)?;
let mut boxes =
math_boxes_from_chars(pdf, index, self.config.text.min_formula_math_chars, 1.5, 1.5);
if boxes.is_empty() && self.config.text.formula_layout_fallback {
let dpi = self.config.render.formula_dpi;
if let Ok(img) = render_page_image(pdf, index, dpi) {
let scale = dpi as f32 / 72.0;
if let Ok(regions) = self.engine.layout_regions(&img) {
boxes = regions
.into_iter()
.filter(|r| {
MATH_LAYOUT_LABELS
.iter()
.any(|k| r.label.to_lowercase().contains(k))
})
.map(|r| {
Rect::new(
r.x0 / scale,
r.y0 / scale,
(r.x1 - r.x0) / scale,
(r.y1 - r.y0) / scale,
)
})
.collect();
}
}
}
if boxes.is_empty() {
return Ok(fast_md);
}
let dpi = self.config.render.formula_dpi;
let img = render_page_image(pdf, index, dpi)?;
let media = page_media_box(pdf, index)?;
let page_h = media[3];
let line_height = estimate_line_height(pdf, index, page_h);
let chars = pdf.chars(index).unwrap_or_default();
let clusters = cluster_math_boxes(&boxes, &chars, line_height);
let mut crops: Vec<(usize, PathBuf)> = Vec::new();
for (j, cluster) in clusters.iter().enumerate() {
if let Some(crop) = crop_image(&img, cluster.bounds, dpi, self.config.text.formula_pad_pts) {
if crop.width() >= 4 && crop.height() >= 4 {
let p = work_dir.join(format!("_formula_p{}_{}.png", index, j));
if crop.save(&p).is_ok() {
crops.push((j, p));
}
}
}
}
if crops.is_empty() {
return Ok(fast_md);
}
info!(
"page {}: {} formula cluster(s) → OCR ({} boxes)",
index + 1,
crops.len(),
boxes.len(),
);
let mut replacements: Vec<(String, String)> = Vec::new();
for (j, crop_path) in &crops {
if let Some(latex) = self.engine.recognize_formula(crop_path)? {
let cluster = &clusters[*j];
let display = cluster.bounds.height > 1.6 * line_height
|| cluster.bounds.width > self.config.text.formula_inline_max_width_pts as f32;
let wrapped = if display {
format!("$$\n{}\n$$", latex)
} else {
format!("${}$", latex)
};
if let Some((first, rest)) = cluster.members.split_first() {
replacements.push((region_text(pdf, index, *first), wrapped));
for m in rest {
replacements.push((region_text(pdf, index, *m), String::new()));
}
}
}
}
Ok(splice(&fast_md, &replacements))
}
fn extract_page_images(
&self,
pdf: &mut dyn PdfSource,
index: usize,
dir: &Path,
) -> Result<Vec<std::path::PathBuf>> {
let out_dir = dir
.join(&self.config.render.image_output_dir)
.join(format!("p{}", index));
pdf.extract_image_files(index, &out_dir, "img")
}
pub fn convert_office(path: &Path) -> Result<String> {
use office_oxide::Document;
let doc =
Document::open(path).map_err(|e| BobineError::OfficeOxide(format!("open: {e}")))?;
Ok(doc.to_markdown())
}
fn convert_office_staged(&self, path: &Path, work_dir: &Path) -> Result<String> {
use office_oxide::Document;
let doc =
Document::open(path).map_err(|e| BobineError::OfficeOxide(format!("open: {e}")))?;
let md = doc.to_markdown();
if !self.config.render.extract_images {
return Ok(md);
}
let images = {
let ir_images = crate::office_images::collect_office_images(&doc.to_ir());
if ir_images.is_empty() {
crate::office_images::collect_package_images(path)
} else {
ir_images
}
};
if images.is_empty() {
return Ok(md);
}
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("document");
let dest = work_dir.join(&self.config.render.image_output_dir).join("office");
crate::office_images::splice_office_images(&md, &images, &dest, stem)
}
}
fn maybe_append_gallery(md: String, assets: &[EmbeddedAsset]) -> String {
let mut gallery = String::new();
for a in assets {
if md.contains(&format!("({})", a.rel_path)) {
continue; }
if let Some(b) = a.bbox_pts {
if f64::from(b.width) * f64::from(b.height) < 100.0 {
continue; }
}
gallery.push_str(&format!("\n", a.rel_path));
}
if gallery.is_empty() {
md
} else {
format!("{}\n\n{}", md, gallery)
}
}
fn normalize_with_offsets(s: &str) -> (String, Vec<usize>) {
let mut norm = String::with_capacity(s.len());
let mut offsets = Vec::with_capacity(s.len());
let mut last_ws = true; for (i, ch) in s.char_indices() {
if ch.is_whitespace() {
if !last_ws {
norm.push(' ');
offsets.push(i);
}
last_ws = true;
} else {
norm.push(ch);
offsets.push(i);
last_ws = false;
}
}
while norm.ends_with(' ') {
norm.pop();
offsets.pop();
}
(norm, offsets)
}
fn caption_alt(text: &str, max_chars: usize) -> Option<String> {
let collapsed: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
let mut words = collapsed.split(' ');
let first = words.next()?.trim_end_matches(['.', ':']).to_lowercase();
if !matches!(first.as_str(), "figure" | "fig" | "table" | "abb" | "plate") {
return None;
}
let rest = words.next()?;
if rest.is_empty() {
return None;
}
if !rest.chars().next().map_or(false, |c| c.is_ascii_digit())
&& !rest.chars().any(|c| c.is_ascii_alphanumeric())
{
return None;
}
let mut out = String::new();
for w in collapsed.split(' ') {
if out.len() + w.len() + 1 > max_chars {
out.push('…');
break;
}
if !out.is_empty() {
out.push(' ');
}
out.push_str(w);
}
Some(out.replace(['[', ']'], ""))
}
fn promote_headings(md: &str) -> String {
let mut out: Vec<String> = Vec::new();
let mut in_fence = false;
for line in md.split('\n') {
let t = line.trim();
if t.starts_with("```") {
in_fence = !in_fence;
out.push(line.to_string());
continue;
}
if !in_fence {
if let Some(h) = promote_heading_line(t) {
out.push(h);
continue;
}
}
out.push(line.to_string());
}
out.join("\n")
}
fn promote_heading_line(t: &str) -> Option<String> {
if t.starts_with(['#', '|', '>', '-', '!'])
|| t.starts_with("* ")
|| t == "*"
{
return None;
}
let bare = t.replace("**", "");
let bare = bare.trim();
let (level, rest, list_risk) = split_section_prefix(bare)?;
let ok = plausible_heading_text(rest)
|| bold_dominant(t)
|| (!list_risk && short_title_like(rest));
if !ok {
return None;
}
Some(format!("{} {}", "#".repeat(level), rest))
}
fn split_section_prefix(s: &str) -> Option<(usize, &str, bool)> {
let b = s.as_bytes();
if b.is_empty() {
return None;
}
if b[0].is_ascii_digit() {
let mut i = 0;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
let mut parts = 1;
loop {
if i + 1 < b.len() && b[i] == b'.' && b[i + 1].is_ascii_digit() {
parts += 1;
i += 1;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
} else {
break;
}
}
if i < b.len() && b[i] == b'.' {
i += 1;
}
if i < b.len() && (b[i] == b' ' || b[i] == b'\t') {
let rest = s[i..].trim();
let leading_ok = s[..i].trim_end_matches('.').parse::<u32>().map_or(true, |v| v > 0);
if !rest.is_empty() && leading_ok {
let level = match parts {
1 => 2,
2 => 3,
_ => 4,
};
return Some((level, rest, parts == 1));
}
}
return None;
}
const ROMANS: [&str; 12] = [
"XII", "XI", "IX", "VIII", "VII", "VI", "IV", "X", "V", "III", "II", "I",
];
for r in ROMANS {
if let Some(after_dot) = s.strip_prefix(r).and_then(|x| x.strip_prefix(". ")) {
let rest = after_dot.trim();
if !rest.is_empty() {
return Some((2, rest, true));
}
}
}
let c = b[0] as char;
if c.is_ascii_uppercase() {
if let Some(after_dot) = s[1..].strip_prefix(". ") {
let rest = after_dot.trim();
if !rest.is_empty() {
return Some((3, rest, false));
}
}
}
None
}
fn short_title_like(rest: &str) -> bool {
if rest.ends_with(['.', ',', ';', ':']) || rest.contains(',') || rest.contains('=') {
return false;
}
if rest.split_whitespace().any(|w| w.parse::<f64>().is_ok()) {
return false;
}
let starts_capital = match rest.split_whitespace().next() {
Some(w) => w.chars().next().map_or(false, |c| c.is_uppercase()),
None => false,
};
starts_capital && rest.split_whitespace().count() <= 8
}
fn plausible_heading_text(rest: &str) -> bool {
if rest.is_empty() || rest.len() > 100 {
return false;
}
if rest.ends_with(['.', ',', ';', '!']) {
return false;
}
if rest.split_whitespace().count() > 14 {
return false;
}
if rest.contains('=') {
return false;
}
if rest
.split_whitespace()
.any(|w| w.parse::<f64>().is_ok())
{
return false;
}
let letters = rest.chars().filter(|c| c.is_ascii_alphabetic()).count();
if letters < 3 {
return false;
}
let upper = rest.chars().filter(|c| c.is_ascii_uppercase()).count();
upper * 10 >= letters * 7
}
fn bold_dominant(line: &str) -> bool {
let segs: Vec<&str> = line.split("**").collect();
if segs.len() < 3 {
return false;
}
let nsw = |x: &str| x.chars().filter(|c| !c.is_whitespace()).count();
let total: usize = segs.iter().map(|x| nsw(x)).sum();
let bold: usize = segs
.iter()
.enumerate()
.filter(|(i, _)| i % 2 == 1)
.map(|(_, x)| nsw(x))
.sum();
total > 0 && bold * 10 >= total * 6
}
fn promote_document_title(md: &str) -> String {
let lines: Vec<&str> = md.split('\n').collect();
let is_content = |l: &str| {
let t = l.trim();
!t.is_empty() && t != "---"
};
if let Some(i) = lines.iter().position(|l| is_content(l)) {
let t = lines[i].trim();
if !t.starts_with('#') && title_like(t) && !t.ends_with(['.', ',', ';']) {
let mut out: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
out[i] = format!("# {t}");
return out.join("\n");
}
}
let mut out: Option<Vec<String>> = None;
for (i, l) in lines.iter().enumerate() {
if l.trim() == "---" {
break;
}
if !is_content(l) {
continue;
}
let t = l.trim_start();
let hashes = t.chars().take_while(|&c| c == '#').count();
if hashes >= 2 && title_like(t[hashes..].trim()) {
let mut o: Vec<String> = lines.iter().map(|x| x.to_string()).collect();
o[i] = format!("# {}", t[hashes..].trim());
out = Some(o);
break;
}
}
if let Some(o) = out {
return o.join("\n");
}
md.to_string()
}
fn title_like(t: &str) -> bool {
if t.starts_with(['*', '•', '\u{2217}', '\u{2212}', '-']) {
return false;
}
if t.contains('=') || t.contains('+') || t.contains(")(") || t.contains('@') {
return false;
}
if t.matches(',').count() >= 2 {
return false;
}
if t.split_whitespace().any(|w| w.trim_end_matches(',').parse::<f64>().is_ok()) {
return false;
}
t.len() <= 120 && t.split_whitespace().count() <= 25
}
fn nearby_caption_text(
sorted: &[crate::rapid_layout::LayoutRegion],
region_lines: &[Vec<Vec<usize>>],
chars: &[SourceChar],
fig: &Rect,
scale: f32,
max_gap_pts: f32,
) -> Option<String> {
let mut best: Option<(f32, usize)> = None;
for (j, r) in sorted.iter().enumerate() {
if !r.label.to_lowercase().contains("caption") {
continue;
}
let cr = Rect::new(
r.x0 / scale,
r.y0 / scale,
(r.x1 - r.x0) / scale,
(r.y1 - r.y0) / scale,
);
let ix0 = fig.x.max(cr.x);
let ix1 = (fig.x + fig.width).min(cr.x + cr.width);
let min_w = fig.width.min(cr.width);
if ix1 - ix0 < 0.3 * min_w {
continue;
}
let gap = if cr.y >= fig.y + fig.height {
cr.y - (fig.y + fig.height) } else if fig.y >= cr.y + cr.height {
fig.y - (cr.y + cr.height) } else {
let hgap = if cr.x >= fig.x + fig.width {
cr.x - (fig.x + fig.width)
} else if fig.x >= cr.x + cr.width {
fig.x - (cr.x + cr.width)
} else {
0.0 };
let vov0 = fig.y.max(cr.y);
let vov1 = (fig.y + fig.height).min(cr.y + cr.height);
let voverlap = (vov1 - vov0).max(0.0);
if hgap <= max_gap_pts * 2.0 && voverlap >= 0.5 * fig.height.min(cr.height) {
hgap
} else {
continue;
}
};
if gap <= max_gap_pts && best.map_or(true, |(bg, _)| gap < bg) {
best = Some((gap, j));
}
}
best.and_then(|(_, j)| {
let t = region_lines_to_text(chars, ®ion_lines[j]);
if t.trim().is_empty() { None } else { Some(t) }
})
}
fn interleave_images(
pdf: &mut dyn PdfSource,
index: usize,
min_area_pts: f64,
md: &str,
assets: &[EmbeddedAsset],
) -> String {
let mut candidates: Vec<&EmbeddedAsset> = assets
.iter()
.filter(|a| {
a.bbox_pts.is_some()
&& a.area_pts() >= min_area_pts
&& !md.contains(&format!("({})", a.rel_path))
})
.collect();
if candidates.is_empty() {
return md.to_string();
}
candidates.sort_by(|a, b| {
let ay = a.bbox_pts.map_or(0.0, |b2| b2.y);
let by = b.bbox_pts.map_or(0.0, |b2| b2.y);
ay.partial_cmp(&by).unwrap_or(std::cmp::Ordering::Equal)
});
let Ok(chars) = pdf.chars(index) else { return md.to_string() };
if chars.is_empty() {
return md.to_string();
}
let all: Vec<usize> = (0..chars.len()).collect();
let mut lines: Vec<(f32, f32, String)> = cluster_lines(&chars, &all)
.into_iter()
.map(|l| {
let y0 = l.iter().map(|&i| chars[i].bbox.y).fold(f32::INFINITY, f32::min);
let y1 = l
.iter()
.map(|&i| chars[i].bbox.y + chars[i].bbox.height)
.fold(f32::NEG_INFINITY, f32::max);
(y0, y1, line_string(&chars, &l))
})
.collect();
lines.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
let (norm, offsets) = normalize_with_offsets(md);
let mut insertions: Vec<(usize, String)> = Vec::new(); let mut cursor = 0usize; let mut line_i = 0usize;
for asset in &candidates {
let Some(b) = asset.bbox_pts else { continue };
let bottom = b.y + b.height;
let anchor = (line_i..lines.len()).find(|&li| lines[li].0 >= bottom - 0.5);
let Some(li) = anchor else {
continue; };
line_i = li + 1;
let lo = li.saturating_sub(4);
let hi = (li + 2).min(lines.len());
let mut alt: Option<String> = None;
let mut best_dist = f32::INFINITY;
for j in lo..hi {
let Some(c) = caption_alt(&lines[j].2, 120) else { continue };
let dist = if lines[j].0 >= bottom {
lines[j].0 - bottom
} else {
bottom - lines[j].1
};
if dist < best_dist {
best_dist = dist;
alt = Some(c);
}
}
let link = match alt {
Some(a) => format!("\n\n\n\n", a, asset.rel_path),
None => format!("\n\n\n\n", asset.rel_path),
};
let needle_full = normalize_with_offsets(&lines[li].2).0;
let words: Vec<&str> = needle_full.split(' ').collect();
let try8 = words.iter().take(8).copied().collect::<Vec<&str>>().join(" ");
let try4 = words.iter().take(4).copied().collect::<Vec<&str>>().join(" ");
let tries = [needle_full.as_str(), try8.as_str(), try4.as_str()];
let mut hit: Option<(usize, usize)> = None; for needle in tries.iter() {
if needle.is_empty() {
continue;
}
if let Some(pos) = norm[cursor..].find(needle) {
hit = Some((cursor + pos, needle.len()));
break;
}
}
match hit {
Some((pos, matched_len)) => {
let orig = offsets.get(pos).copied().unwrap_or(0);
let line_start = md[..orig].rfind('\n').map_or(0, |p| p + 1);
insertions.push((line_start, link));
cursor = pos + matched_len;
}
None => {} }
}
if insertions.is_empty() {
return md.to_string();
}
let mut out = String::with_capacity(md.len() + 128 * insertions.len());
let mut last = 0usize;
for (pos, link) in insertions {
out.push_str(&md[last..pos]);
out.push_str(&link);
last = pos;
}
out.push_str(&md[last..]);
out
}
fn fast_page_markdown(pdf: &mut dyn PdfSource, index: usize) -> Result<String> {
match pdf.page_markdown(index) {
Ok(md) => Ok(md),
Err(_) => {
let r = Rect::new(0.0, 0.0, f32::MAX, f32::MAX);
Ok(pdf.text_in_rect(index, r).unwrap_or_default())
}
}
}
fn render_page_image(pdf: &mut dyn PdfSource, index: usize, dpi: u32) -> Result<DynamicImage> {
let png = pdf.render_png(index, dpi)?;
image::load_from_memory(&png).map_err(|e| BobineError::Ort(format!("decode render: {e}")))
}
fn page_media_box(pdf: &mut dyn PdfSource, index: usize) -> Result<[f32; 4]> {
pdf.media_box(index)
}
fn estimate_line_height(pdf: &mut dyn PdfSource, index: usize, page_h: f32) -> f32 {
if let Ok(chars) = pdf.chars(index) {
let mut heights: Vec<f32> = chars
.iter()
.map(|c| c.bbox.height)
.filter(|&h| h > 0.0)
.collect();
heights.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
if !heights.is_empty() {
return heights[heights.len() / 2];
}
}
page_h / 50.0
}
fn is_math_unicode(ch: char) -> bool {
let cp = ch as u32;
MATH_UNICODE_RANGES
.iter()
.any(|&(lo, hi)| cp >= lo && cp <= hi)
}
fn plausible_display_latex(latex: &str) -> bool {
let l = latex.to_lowercase();
if l.contains("\\mbox{") || l.contains("\\text{") {
return false;
}
if l.contains(" the ") || l.contains(" with ") || l.contains(" and ") {
return false;
}
let words: std::collections::HashMap<&str, usize> =
l.split_whitespace().fold(Default::default(), |mut m, w| {
*m.entry(w).or_default() += 1;
m
});
if words.values().any(|&c| c > 4) {
return false;
}
["=", "+", "^", "_", "\\frac", "\\sum", "\\int", "\\sqrt"]
.iter()
.any(|m| l.contains(m))
}
fn is_math_font(font_name: &str) -> bool {
let lower = font_name.to_lowercase();
MATH_FONT_KEYWORDS.iter().any(|k| lower.contains(k))
}
fn page_math_signal(pdf: &mut dyn PdfSource, index: usize) -> (usize, usize) {
let chars = match pdf.chars(index) {
Ok(c) => c,
Err(_) => return (0, 0),
};
let total = chars.len();
let math = chars
.iter()
.filter(|c| is_math_font(&c.font_name) || is_math_unicode(c.char))
.count();
(math, total)
}
fn is_scanned(pdf: &mut dyn PdfSource, index: usize, threshold: usize) -> bool {
let chars = match pdf.chars(index) {
Ok(c) => c,
Err(_) => return false,
};
let has_images = pdf.image_count(index).unwrap_or(0) > 0;
chars.len() < threshold && has_images
}
fn needs_onnx(pdf: &mut dyn PdfSource, index: usize, config: &ConverterConfig) -> bool {
if !config.routing.use_onnx || config.routing.routing_mode == RoutingMode::Never {
return false;
}
if config.routing.routing_mode == RoutingMode::Always {
return true;
}
let (math, total) = page_math_signal(pdf, index);
if math > config.text.math_char_threshold && (total == 0 || math as f64 / total as f64 > 0.02) {
return true;
}
is_scanned(pdf, index, config.text.scanned_text_threshold)
}
#[allow(clippy::too_many_arguments)]
fn math_boxes_from_chars(
pdf: &mut dyn PdfSource,
index: usize,
min_chars: usize,
hgap_mult: f32,
vgap_mult: f32,
) -> Vec<Rect> {
let chars = match pdf.chars(index) {
Ok(c) => c,
Err(_) => return vec![],
};
let items: Vec<&SourceChar> = chars
.iter()
.filter(|c| is_math_font(&c.font_name) || is_math_unicode(c.char))
.collect();
if items.is_empty() {
return vec![];
}
let mut heights: Vec<f32> = items.iter().map(|c| c.bbox.height).collect();
heights.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let med_h = heights[heights.len() / 2].max(10.0);
let line_tol = 0.8 * med_h;
let hgap = hgap_mult * med_h;
let vgap_max = vgap_mult * med_h;
let mut sorted: Vec<&&SourceChar> = items.iter().collect();
sorted.sort_by(|a, b| {
(a.bbox.y + a.bbox.height / 2.0)
.partial_cmp(&(b.bbox.y + b.bbox.height / 2.0))
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut lines: Vec<Vec<Rect>> = vec![];
for c in sorted {
let r = c.bbox;
let cy = r.y + r.height / 2.0;
if let Some(last) = lines.last() {
let last_cy = last[0].y + last[0].height / 2.0;
if (cy - last_cy).abs() <= line_tol {
lines.last_mut().unwrap().push(r);
continue;
}
}
lines.push(vec![r]);
}
let mut boxes: Vec<(Rect, usize)> = vec![];
for ln in &lines {
let mut sorted_ln = ln.clone();
sorted_ln.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(std::cmp::Ordering::Equal));
let mut runs: Vec<(Rect, usize)> = vec![];
for r in &sorted_ln {
let mut merged = false;
for (run_r, count) in &mut runs {
if r.x <= run_r.x + run_r.width + hgap && r.x + r.width >= run_r.x - hgap {
let new_x = run_r.x.min(r.x);
let new_y = run_r.y.min(r.y);
let new_x1 = (run_r.x + run_r.width).max(r.x + r.width);
let new_y1 = (run_r.y + run_r.height).max(r.y + r.height);
*run_r = Rect::new(new_x, new_y, new_x1 - new_x, new_y1 - new_y);
*count += 1;
merged = true;
break;
}
}
if !merged {
runs.push((*r, 1));
}
}
boxes.extend(runs);
}
boxes.sort_by(|(a, _), (b, _)| {
a.y.partial_cmp(&b.y)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.x.partial_cmp(&b.x).unwrap_or(std::cmp::Ordering::Equal))
});
let mut changed = true;
while changed {
changed = false;
let mut out: Vec<(Rect, usize)> = vec![];
for (b, n) in &boxes {
let mut merged = false;
for (u, count) in &mut out {
let hx = !(b.x > u.x + u.width + hgap || b.x + b.width < u.x - hgap);
if !hx {
continue;
}
let gap = (b.y - (u.y + u.height))
.max(u.y - (b.y + b.height))
.max(0.0);
if gap > vgap_max {
continue;
}
let new_x = u.x.min(b.x);
let new_y = u.y.min(b.y);
let new_x1 = (u.x + u.width).max(b.x + b.width);
let new_y1 = (u.y + u.height).max(b.y + b.height);
*u = Rect::new(new_x, new_y, new_x1 - new_x, new_y1 - new_y);
*count += n;
merged = true;
changed = true;
break;
}
if !merged {
out.push((*b, *n));
}
}
boxes = out;
}
boxes
.into_iter()
.filter(|(_, n)| *n >= min_chars)
.map(|(r, _)| r)
.collect()
}
#[derive(Debug, Clone)]
pub(crate) struct EmbeddedAsset {
pub rel_path: String,
pub bbox_pts: Option<Rect>,
}
impl EmbeddedAsset {
fn area_pts(&self) -> f64 {
self.bbox_pts
.map_or(0.0, |b| f64::from(b.width) * f64::from(b.height))
}
}
fn rect_iou(a: &Rect, b: &Rect) -> f32 {
let ix0 = a.x.max(b.x);
let iy0 = a.y.max(b.y);
let ix1 = (a.x + a.width).min(b.x + b.width);
let iy1 = (a.y + a.height).min(b.y + b.height);
let iw = (ix1 - ix0).max(0.0);
let ih = (iy1 - iy0).max(0.0);
let inter = iw * ih;
if inter <= 0.0 {
return 0.0;
}
let union = a.width * a.height + b.width * b.height - inter;
if union <= 0.0 {
0.0
} else {
inter / union
}
}
fn best_embedded_match(
region: &Rect,
assets: &[EmbeddedAsset],
min_coverage: f32,
) -> Option<usize> {
let mut best: Option<(f32, usize)> = None;
for (i, a) in assets.iter().enumerate() {
let Some(b) = a.bbox_pts else { continue };
let ix0 = region.x.max(b.x);
let iy0 = region.y.max(b.y);
let ix1 = (region.x + region.width).min(b.x + b.width);
let iy1 = (region.y + region.height).min(b.y + b.height);
let inter = (ix1 - ix0).max(0.0) * (iy1 - iy0).max(0.0);
let area = b.width * b.height;
if area <= 0.0 {
continue;
}
let cov = inter / area;
if cov >= min_coverage && best.map_or(true, |(bv, _)| cov > bv) {
best = Some((cov, i));
}
}
best.map(|(_, i)| i)
}
fn is_figure_label(label: &str) -> bool {
let l = label.to_lowercase();
l.contains("figure") || l.contains("image")
}
fn dedup_figure_regions(regions: Vec<crate::rapid_layout::LayoutRegion>) -> Vec<crate::rapid_layout::LayoutRegion> {
let mut kept: Vec<crate::rapid_layout::LayoutRegion> = Vec::with_capacity(regions.len());
for r in regions {
let dup = is_figure_label(&r.label)
&& kept.iter().any(|k| {
is_figure_label(&k.label)
&& rect_iou(
&Rect::new(k.x0, k.y0, k.x1 - k.x0, k.y1 - k.y0),
&Rect::new(r.x0, r.y0, r.x1 - r.x0, r.y1 - r.y0),
) >= 0.5
});
if !dup {
kept.push(r);
}
}
kept
}
fn crop_image(img: &DynamicImage, bbox: Rect, dpi: u32, pad_pts: f64) -> Option<DynamicImage> {
let s = dpi as f32 / 72.0;
let pad = pad_pts as f32;
let left = ((bbox.x - pad) * s).max(0.0) as u32;
let right = ((bbox.x + bbox.width + pad) * s) as u32;
let top = ((bbox.y - pad) * s).max(0.0) as u32;
let bottom = ((bbox.y + bbox.height + pad) * s) as u32;
if right <= left || bottom <= top {
return None;
}
Some(img.crop_imm(left, top, right - left, bottom - top))
}
fn region_text(pdf: &mut dyn PdfSource, index: usize, bbox: Rect) -> String {
pdf.text_in_rect(index, bbox).unwrap_or_default()
}
fn line_string(chars: &[SourceChar], idxs: &[usize]) -> String {
let mut idxs = idxs.to_vec();
idxs.sort_by(|&a, &b| {
chars[a].bbox.x.partial_cmp(&chars[b].bbox.x).unwrap_or(std::cmp::Ordering::Equal)
});
let mut text = String::new();
let mut prev_end = f32::NAN;
for &i in &idxs {
let g = &chars[i].bbox;
if !prev_end.is_nan() && g.x - prev_end > g.width * 0.25 {
text.push(' ');
}
text.push(chars[i].char);
prev_end = g.x + g.width;
}
text.trim().to_string()
}
fn cluster_lines(chars: &[SourceChar], glyph_ids: &[usize]) -> Vec<Vec<usize>> {
if glyph_ids.is_empty() {
return vec![];
}
let gyc = |i: usize| chars[i].bbox.y + chars[i].bbox.height / 2.0;
let mut ws: Vec<f32> = glyph_ids.iter().map(|&i| chars[i].bbox.width).collect();
ws.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let tol = ws[ws.len() / 2].max(0.5);
let mut order = glyph_ids.to_vec();
order.sort_by(|&a, &b| {
gyc(a).partial_cmp(&gyc(b)).unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| {
chars[a].bbox.x.partial_cmp(&chars[b].bbox.x).unwrap_or(std::cmp::Ordering::Equal)
})
});
let mut lines: Vec<Vec<usize>> = vec![vec![]];
let mut line_y = gyc(order[0]);
for &i in &order {
if (gyc(i) - line_y).abs() > tol {
lines.push(vec![]);
line_y = gyc(i);
}
if let Some(&last) = lines.last().unwrap().last() {
let gap = chars[i].bbox.x - (chars[last].bbox.x + chars[last].bbox.width);
if gap > (chars[i].bbox.width * 2.0).max(12.0) {
lines.push(vec![]);
}
}
lines.last_mut().unwrap().push(i);
}
lines
}
fn region_lines_to_text(chars: &[SourceChar], lines: &[Vec<usize>]) -> String {
let mut out = String::new();
for line in lines {
let t = line_string(chars, line);
if !t.is_empty() {
if !out.is_empty() {
out.push('\n');
}
out.push_str(&t);
}
}
out
}
#[cfg(test)]
fn rect_subtract(base: Rect, subs: &[Rect]) -> Vec<Rect> {
const EPS: f32 = 1e-3;
let mut pieces: Vec<Rect> = vec![base];
for s in subs {
if s.width <= 0.0 || s.height <= 0.0 {
continue;
}
let mut next: Vec<Rect> = Vec::with_capacity(pieces.len() + 4);
for p in pieces.drain(..) {
if s.x >= p.x + p.width - EPS
|| s.x + s.width <= p.x + EPS
|| s.y >= p.y + p.height - EPS
|| s.y + s.height <= p.y + EPS
{
next.push(p);
continue;
}
if s.y > p.y + EPS {
next.push(Rect::new(p.x, p.y, p.width, s.y - p.y));
}
if s.y + s.height < p.y + p.height - EPS {
next.push(Rect::new(
p.x,
s.y + s.height,
p.width,
p.y + p.height - s.y - s.height,
));
}
let sy0 = s.y.max(p.y);
let sy1 = (s.y + s.height).min(p.y + p.height);
if s.x > p.x + EPS {
next.push(Rect::new(p.x, sy0, s.x - p.x, sy1 - sy0));
}
if s.x + s.width < p.x + p.width - EPS {
let x0 = (s.x + s.width).max(p.x);
next.push(Rect::new(x0, sy0, p.x + p.width - x0, sy1 - sy0));
}
}
pieces = next
.into_iter()
.filter(|r| r.width > 0.5 && r.height > 0.5)
.collect();
if pieces.len() > 64 {
return vec![base];
}
}
pieces
}
fn ws_replace(md: &str, needle: &str, block: &str) -> Option<String> {
let tokens: Vec<&str> = needle
.split_whitespace()
.filter(|t| !t.is_empty())
.collect();
if tokens.is_empty() {
return None;
}
let escaped: Vec<String> = tokens.iter().map(|t| regex::escape(t)).collect();
let pattern = escaped.join(r"\s+");
let re = regex::Regex::new(&pattern).ok()?;
re.find(md).map(|m| {
let mut s = String::with_capacity(md.len());
s.push_str(&md[..m.start()]);
s.push_str(block);
s.push_str(&md[m.end()..]);
s
})
}
fn splice(md: &str, replacements: &[(String, String)]) -> String {
let mut result = md.to_string();
let mut leftovers: Vec<&str> = vec![];
for (needle, block) in replacements {
let needle = needle.trim();
if !needle.is_empty() && result.contains(needle) {
result = result.replacen(needle, block, 1);
continue;
}
if !needle.is_empty() {
if let Some(new) = ws_replace(&result, needle, block) {
result = new;
continue;
}
}
leftovers.push(block);
}
if !leftovers.is_empty() {
result.push_str("\n\n");
result.push_str(&leftovers.join("\n\n"));
}
result
}
impl HybridConverter {
fn full_structure_page_markdown(
&mut self,
pdf: &mut dyn PdfSource,
index: usize,
work_dir: &Path,
assets: &[EmbeddedAsset],
) -> Result<Option<String>> {
let dpi = self.config.render.render_dpi;
let img = match render_page_image(pdf, index, dpi) {
Ok(i) => i,
Err(_) => return Ok(None),
};
let media = page_media_box(pdf, index)?;
let page_h = media[3];
let scale = dpi as f32 / 72.0;
let mut page_math_boxes =
math_boxes_from_chars(pdf, index, self.config.text.min_formula_math_chars, 2.5, 0.9);
let line_height = estimate_line_height(pdf, index, page_h);
let regions = self.engine.layout_regions(&img)?;
if regions.is_empty() {
return Ok(None);
}
let mut sorted = regions.clone();
sorted.sort_by(|a, b| {
a.y0.partial_cmp(&b.y0)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.x0.partial_cmp(&b.x0).unwrap_or(std::cmp::Ordering::Equal))
});
sorted = dedup_figure_regions(sorted);
let (_claim_order, rank) = claim_ranks(&sorted);
let page_chars = pdf.chars(index).unwrap_or_default();
let glyph_owner = assign_glyph_owners(&page_chars, &sorted, &rank, scale);
let mut region_lines =
assign_region_lines(&page_chars, &glyph_owner, &rank, sorted.len());
let mut char_heights: Vec<f32> =
page_chars.iter().map(|c| c.bbox.height).collect();
char_heights.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let median_char_h = char_heights.get(char_heights.len() / 2).copied().unwrap_or(11.0);
let ctx = RegionCtx {
config: &self.config,
page_chars: &page_chars,
sorted: &sorted,
region_lines: ®ion_lines,
scale,
img: &img,
dpi,
work_dir,
page_index: index,
assets,
median_char_h,
line_height,
};
let mut blocks: Vec<String> = Vec::new();
let dbg_blocks = std::env::var("BOB_DEBUG_BLOCKS").is_ok();
let mut ocr_claimed: Vec<Rect> = Vec::new();
for (ri, region) in sorted.iter().enumerate() {
if dbg_blocks {
eprintln!(
"DBG p{} region[{}] {:?} prio={} eff_pieces={}",
index + 1,
ri,
region.label,
region_claim_priority(®ion.label),
region_lines[ri].len()
);
}
let bbox = Rect::new(
region.x0 / scale,
region.y0 / scale,
(region.x1 - region.x0) / scale,
(region.y1 - region.y0) / scale,
);
let lab = region.label.to_lowercase();
if let Some(text) = caption_block(&page_chars, ®ion_lines[ri], &lab) {
blocks.push(text);
continue;
}
if lab.contains("table") {
blocks.push(table_block(&ctx, pdf, &mut self.engine, ri, bbox));
} else if MATH_LAYOUT_LABELS.iter().any(|k| lab.contains(k)) {
blocks.extend(math_blocks(
&ctx,
pdf,
&mut self.engine,
ri,
bbox,
blocks.len(),
&mut page_math_boxes,
)?);
} else if lab.contains("figure") || lab.contains("image") {
if let Some(link) = figure_block(&ctx, bbox, blocks.len()) {
blocks.push(link);
}
} else if let Some(t) = text_block(&ctx, &mut self.engine, ri, &mut ocr_claimed) {
blocks.push(t);
}
}
repair_seams(
&page_chars,
&sorted,
&glyph_owner,
&mut region_lines,
&mut blocks,
scale,
index,
);
let md = blocks.join("\n\n");
if dbg_blocks {
for (bi, b) in blocks.iter().enumerate() {
eprintln!("DBG p{} block[{}] {:?}...", index + 1, bi, b.chars().take(60).collect::<String>());
}
}
if md.trim().is_empty() {
Ok(None)
} else {
Ok(Some(md))
}
}
}
#[allow(dead_code)]
fn wrap_code_blocks(pdf: &mut dyn PdfSource, index: usize) -> Vec<String> {
let chars = match pdf.chars(index) {
Ok(c) => c,
Err(_) => return vec![],
};
let mut lines: std::collections::BTreeMap<i32, String> = std::collections::BTreeMap::new();
for c in &chars {
if is_mono_font(&c.font_name) {
let y_key = (c.bbox.y * 10.0) as i32;
lines.entry(y_key).or_default().push(c.char);
}
}
let mut blocks: Vec<String> = vec![];
for text in lines.values() {
let trimmed: String = text.chars().collect();
if trimmed.len() > 4 {
blocks.push(format!("```\n{}\n```", trimmed));
}
}
blocks
}
fn is_mono_font(font_name: &str) -> bool {
let lower = font_name.to_lowercase();
MONO_FONT_KEYWORDS.iter().any(|k| lower.contains(k))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::RoutingOpts;
use crate::pdf_source::fake::{FakePage, FakePdf};
fn temp_dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("bobine_conv_{}", name));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn never_mode_joins_pages_and_fires_hooks() {
let mut pdf = FakePdf::new(vec![
FakePage::from_text("Page one text", "Helvetica"),
FakePage::from_text("Page two text", "Helvetica"),
FakePage::from_text("Page three text", "Helvetica"),
]);
let mut conv = HybridConverter::new(
ConverterConfig {
routing: RoutingOpts {
routing_mode: RoutingMode::Never,
..Default::default()
},
..Default::default()
},
&temp_dir("never"),
);
let pages = std::cell::RefCell::new(0usize);
let hooks = ProgressHooks {
should_continue: Box::new(|| true),
on_page: Box::new(|_, _| *pages.borrow_mut() += 1),
};
let out = conv
.convert_pdf_source(&mut pdf, Path::new("/tmp"), &hooks)
.unwrap();
assert_eq!(*pages.borrow(), 3);
assert_eq!(out.matches("\n\n---\n\n").count(), 2);
assert!(out.contains("Page one text"));
assert!(out.contains("Page three text"));
}
#[test]
fn cancellation_stops_mid_document() {
let mut pdf = FakePdf::new(vec![
FakePage::from_text("first page words", "Helvetica"),
FakePage::from_text("second page words", "Helvetica"),
FakePage::from_text("third page words", "Helvetica"),
]);
let mut conv = HybridConverter::new(
ConverterConfig {
routing: RoutingOpts {
routing_mode: RoutingMode::Never,
..Default::default()
},
..Default::default()
},
&temp_dir("cancel"),
);
let n = std::cell::Cell::new(0usize);
let hooks = ProgressHooks {
should_continue: Box::new(|| n.get() < 2), on_page: Box::new(|_, _| n.set(n.get() + 1)),
};
let out = conv
.convert_pdf_source(&mut pdf, Path::new("/tmp"), &hooks)
.unwrap();
assert_eq!(n.get(), 2);
assert!(out.contains("first page"));
assert!(out.contains("second page"));
assert!(!out.contains("third page"));
}
#[test]
fn rect_iou_basics() {
let a = Rect::new(0.0, 0.0, 10.0, 10.0);
assert!((rect_iou(&a, &a) - 1.0).abs() < 1e-6);
let b = Rect::new(5.0, 5.0, 10.0, 10.0); assert!((rect_iou(&a, &b) - 25.0 / 175.0).abs() < 1e-6);
let c = Rect::new(100.0, 100.0, 5.0, 5.0);
assert_eq!(rect_iou(&a, &c), 0.0);
}
#[test]
fn embedded_match_prefers_best_coverage() {
let region = Rect::new(100.0, 100.0, 200.0, 100.0);
let assets = vec![
EmbeddedAsset {
rel_path: "weak.png".to_string(),
bbox_pts: Some(Rect::new(90.0, 90.0, 40.0, 30.0)), },
EmbeddedAsset {
rel_path: "strong.png".to_string(),
bbox_pts: Some(Rect::new(105.0, 105.0, 190.0, 92.0)), },
EmbeddedAsset {
rel_path: "unplaced.png".to_string(),
bbox_pts: None,
},
];
let hit = best_embedded_match(®ion, &assets, 0.6).unwrap();
assert_eq!(assets[hit].rel_path, "strong.png");
let far = Rect::new(500.0, 500.0, 50.0, 50.0);
assert!(best_embedded_match(&far, &assets, 0.6).is_none());
let big_region = Rect::new(0.0, 0.0, 400.0, 300.0);
let small = vec![EmbeddedAsset {
rel_path: "small.png".to_string(),
bbox_pts: Some(Rect::new(10.0, 10.0, 30.0, 20.0)), }];
assert!(best_embedded_match(&big_region, &small, 0.6).is_some());
}
#[test]
fn figure_region_dedup_drops_overlap_keeps_text() {
use crate::rapid_layout::LayoutRegion;
let mk = |x0: f32, y0: f32, x1: f32, y1: f32, label: &str| LayoutRegion {
x0,
y0,
x1,
y1,
label: label.into(),
confidence: 0.9,
};
let regions = vec![
mk(50.0, 50.0, 250.0, 250.0, "figure"),
mk(55.0, 55.0, 255.0, 255.0, "figure"), mk(300.0, 50.0, 500.0, 200.0, "plain text"), mk(310.0, 60.0, 510.0, 210.0, "plain text"), ];
let kept = dedup_figure_regions(regions);
assert_eq!(kept.len(), 3);
assert_eq!(kept.iter().filter(|r| r.label == "figure").count(), 1);
}
#[test]
fn gallery_appends_unreferenced_images() {
let img = image::DynamicImage::new_rgb8(4, 4);
let with_img = FakePage::from_text("caption-less page", "Helvetica").with_image(img);
let dir = temp_dir("gallery");
let mut conv = HybridConverter::new(ConverterConfig::default(), &dir);
let mut pdf = FakePdf::new(vec![with_img]);
let out = conv
.convert_pdf_source(&mut pdf, &dir.join("work"), &ProgressHooks::default())
.unwrap();
assert!(out.contains(""), "{}", out);
assert!(out.contains("assets"), "structured path expected: {}", out);
let md_page = FakePage::from_text("", "Helvetica")
.with_markdown("")
.with_image(image::DynamicImage::new_rgb8(4, 4));
let dir2 = temp_dir("gallery2");
let mut pdf2 = FakePdf::new(vec![md_page]);
let out2 = conv
.convert_pdf_source(&mut pdf2, &dir2.join("work"), &ProgressHooks::default())
.unwrap();
assert!(out2.contains(""), "{}", out2);
assert!(out2.contains(""), "{}", out2);
let md_page3 = FakePage::from_text("", "Helvetica")
.with_markdown("see  above")
.with_image(image::DynamicImage::new_rgb8(4, 4));
let dir3 = temp_dir("gallery3");
let mut pdf3 = FakePdf::new(vec![md_page3]);
let out3 = conv
.convert_pdf_source(&mut pdf3, &dir3.join("work"), &ProgressHooks::default())
.unwrap();
assert_eq!(out3.matches("img0.png").count(), 1, "{}", out3);
}
#[test]
fn promote_headings_patterns() {
let md = "**I.** **INTRODUCTION**\n\nsome prose paragraph.\n\n**3.1** **Encoder** **and** **Decoder** **Stacks**\n\n3.1.1 Sub-sub section title\n\nA. Setup details";
let out = promote_headings(md);
assert!(out.contains("## INTRODUCTION"), "{out}");
assert!(out.contains("### Encoder and Decoder Stacks"), "{out}");
assert!(out.contains("#### Sub-sub section title"), "{out}");
assert!(out.contains("### Setup details"), "{out}");
assert!(out.contains("some prose paragraph."));
}
#[test]
fn promote_headings_guards() {
let md = "1. Set up the environment before running.\n2. Install dependencies";
assert_eq!(promote_headings(md), md);
let md = "| a | b |\n### already\n```\nI. NOT A HEADING\n```";
assert_eq!(promote_headings(md), md);
let md = "4. We now turn to the analysis of the general case with several more words following it here";
assert_eq!(promote_headings(md), md);
}
#[test]
fn promote_document_title_cases() {
let md = "Vector Edge Solitons and Domain Walls\n\nDavid Snee\n\n## I. Intro";
let out = promote_document_title(md);
assert!(out.starts_with("# Vector Edge Solitons and Domain Walls"), "{out}");
let md = "Provided proper attribution is provided, Google grants permission.\n\n## Attention Is All You Need\n\ntext";
let out = promote_document_title(md);
assert!(out.contains("# Attention Is All You Need"), "{out}");
let md = "Long introductory prose sentence ends here.\n\nbody continues\n\nmore prose.\n\n---\n\nmore pages follow here.\n\n### Deep heading";
assert_eq!(promote_document_title(md), md);
let md = "## \u{2217} yiping.ma@northumbria.ac.uk Here we focus on high-frequ\n\n## Vector Edge Solitons and Domain Walls";
let out = promote_document_title(md);
assert!(out.starts_with("## \u{2217}"), "{out}");
}
#[test]
fn caption_alt_detection() {
assert_eq!(
caption_alt("Figure 1: Temporal L2 error at time T=1", 120).as_deref(),
Some("Figure 1: Temporal L2 error at time T=1")
);
assert_eq!(caption_alt("Fig. S1 setup", 120).as_deref(), Some("Fig. S1 setup"));
assert_eq!(caption_alt("Table 2: results", 120).as_deref(), Some("Table 2: results"));
assert_eq!(caption_alt("We show that the scheme converges.", 120), None);
assert_eq!(caption_alt("configuration of the model", 120), None);
let long = caption_alt("Figure 3: a very long caption that keeps going and going beyond the limit", 40).unwrap();
assert!(long.ends_with('…') && long.chars().count() <= 41, "{long}");
assert_eq!(caption_alt("Figure 4: [normalized] error", 120).as_deref(),
Some("Figure 4: normalized error"));
assert_eq!(caption_alt("Figure 5:\ttitle", 120).as_deref(), Some("Figure 5: title"));
}
#[test]
fn interleave_places_asset_between_text_bands() {
let mut page = FakePage::default();
page.media_box = [0.0, 0.0, 612.0, 792.0];
page.md = Some(
"First paragraph text.
Figure 1: Test caption here
Second paragraph text."
.into(),
);
for (i, ch) in "First paragraph text".chars().enumerate() {
page.chars.push(SourceChar::new(ch, 50.0 + i as f32 * 8.0, 100.0, 7.0, 11.0, "Helvetica"));
}
for (i, ch) in "Figure 1: Test caption here".chars().enumerate() {
page.chars.push(SourceChar::new(ch, 50.0 + i as f32 * 8.0, 255.0, 7.0, 11.0, "Helvetica"));
}
for (i, ch) in "Second paragraph text".chars().enumerate() {
page.chars.push(SourceChar::new(ch, 50.0 + i as f32 * 8.0, 300.0, 7.0, 11.0, "Helvetica"));
}
let img = image::DynamicImage::new_rgb8(10, 10);
page.images.push(img);
page.image_bboxes.push(Some(Rect::new(50.0, 150.0, 200.0, 100.0))); let dir = temp_dir("interleave");
let mut conv = HybridConverter::new(ConverterConfig::default(), &dir);
let mut pdf = FakePdf::new(vec![page]);
let out = conv
.convert_pdf_source(&mut pdf, &dir.join("work"), &ProgressHooks::default())
.unwrap();
let first = out.find("First paragraph").unwrap();
let link = out.find(""),
"alt text expected: {out}"
);
}
#[test]
fn surgical_without_math_returns_fast_markdown() {
let mut pdf = FakePdf::new(vec![FakePage::from_text(
"plain body prose only",
"Helvetica",
)]);
let mut conv = HybridConverter::new(
ConverterConfig {
routing: RoutingOpts {
routing_mode: RoutingMode::Surgical,
..Default::default()
},
..Default::default()
},
&temp_dir("surgical_plain"),
);
let out = conv
.convert_pdf_source(
&mut pdf,
Path::new("/tmp/nowhere"),
&ProgressHooks::default(),
)
.unwrap();
assert!(out.contains("plain body prose"), "{}", out);
}
#[test]
fn auto_mode_plain_ascii_page_stays_on_fast_path() {
let mut pdf = FakePdf::new(vec![FakePage::from_text(
"just regular sentences here",
"Helvetica",
)]);
let mut conv = HybridConverter::new(ConverterConfig::default(), &temp_dir("auto_plain"));
let out = conv
.convert_pdf_source(
&mut pdf,
Path::new("/tmp/nowhere"),
&ProgressHooks::default(),
)
.unwrap();
assert!(out.contains("regular sentences"), "{}", out);
}
#[test]
fn fast_path_falls_back_to_char_join_when_md_errors() {
let mut page = FakePage::from_text("fallback chars", "Helvetica");
page.md = None; let mut pdf = FakePdf::new(vec![page]);
let mut conv = HybridConverter::new(
ConverterConfig {
routing: RoutingOpts {
routing_mode: RoutingMode::Never,
..Default::default()
},
..Default::default()
},
&temp_dir("fallback"),
);
let out = conv
.convert_pdf_source(&mut pdf, Path::new("/tmp"), &ProgressHooks::default())
.unwrap();
assert!(out.contains("fallback chars"), "{}", out);
}
#[test]
fn math_boxes_group_lines_and_respect_min_chars() {
let mut chars: Vec<SourceChar> = (0..6)
.map(|i| SourceChar::new('x', 10.0 + i as f32 * 8.0, 100.0, 7.0, 11.0, "cmmi"))
.collect();
for i in 0..4 {
chars.push(SourceChar::new(
'y',
12.0 + i as f32 * 8.0,
112.0,
7.0,
11.0,
"msam",
));
}
for i in 0..20 {
chars.push(SourceChar::new(
'a',
10.0 + i as f32 * 8.0,
400.0,
7.0,
11.0,
"Helvetica",
));
}
let mut fake = FakePdf::default();
fake.pages[0].chars = chars;
let boxes = math_boxes_from_chars(&mut fake, 0, 5, 1.5, 1.5);
assert_eq!(boxes.len(), 1, "{boxes:?}");
let b = boxes[0];
assert!(b.y <= 100.0 && b.y + b.height >= 123.0, "{b:?}");
let none = math_boxes_from_chars(&mut fake, 0, 11, 1.5, 1.5);
assert!(none.is_empty());
}
#[test]
fn greek_is_math() {
assert!(is_math_unicode('α'));
assert!(is_math_unicode('β'));
assert!(is_math_unicode('Σ'));
}
#[test]
fn math_operator_is_math() {
assert!(is_math_unicode('∀')); assert!(is_math_unicode('∫')); assert!(is_math_unicode('∑')); }
#[test]
fn ascii_letter_not_math() {
assert!(!is_math_unicode('a'));
assert!(!is_math_unicode('Z'));
assert!(!is_math_unicode('5'));
}
#[test]
fn math_font_keyword_match() {
assert!(is_math_font("cmmi10"));
assert!(is_math_font("STIXMath"));
assert!(is_math_font("XITS Math"));
}
#[test]
fn body_font_not_math() {
assert!(!is_math_font("Times New Roman"));
assert!(!is_math_font("Arial"));
assert!(!is_math_font("cmr10")); }
#[test]
fn mono_font_keyword_match() {
assert!(is_mono_font("Courier New"));
assert!(is_mono_font("Consolas"));
assert!(is_mono_font("DejaVu Sans Mono"));
}
#[test]
fn proportional_font_not_mono() {
assert!(!is_mono_font("Times New Roman"));
assert!(!is_mono_font("Helvetica"));
}
#[test]
fn ws_replace_simple() {
let result = ws_replace("Hello world", "Hello world", "Goodbye");
assert_eq!(result, Some("Goodbye".into()));
}
#[test]
fn ws_replace_preserves_surrounding() {
let result = ws_replace("aa hello world bb", "hello world", "X");
assert_eq!(result, Some("aa X bb".into()));
}
#[test]
fn ws_replace_not_found() {
let result = ws_replace("hello world", "goodbye", "X");
assert!(result.is_none());
}
#[test]
fn ws_replace_empty_needle() {
assert!(ws_replace("text", " ", "X").is_none());
}
#[test]
fn splice_exact_match() {
let md = "The formula is x = y";
let repls = vec![("x = y".into(), "$x = y$".into())];
assert_eq!(splice(md, &repls), "The formula is $x = y$");
}
#[test]
fn splice_ws_tolerant() {
let md = "The formula is x = y";
let repls = vec![("x = y".into(), "$x = y$".into())];
assert_eq!(splice(md, &repls), "The formula is $x = y$");
}
#[test]
fn splice_leftover_appended() {
let md = "some text";
let repls = vec![("not found".into(), "replacement".into())];
let result = splice(md, &repls);
assert!(result.contains("some text"));
assert!(result.contains("replacement"));
}
#[test]
fn splice_empty_needle_appends() {
let md = "base";
let repls = vec![("".into(), "extra".into())];
let result = splice(md, &repls);
assert!(result.contains("base"));
assert!(result.contains("extra"));
}
#[test]
fn needs_onnx_flags_scanned_pages_in_auto_mode() {
let scanned_page = FakePage::from_text("tiny", "Helvetica") .with_image(image::DynamicImage::new_rgb8(2, 2));
let mut scanned = FakePdf::new(vec![scanned_page]);
let cfg = ConverterConfig::default();
assert!(needs_onnx(&mut scanned, 0, &cfg));
let textual_page = FakePage::from_text("word word word word word word", "Helvetica");
let mut textual = FakePdf::new(vec![textual_page]);
assert!(!needs_onnx(&mut textual, 0, &cfg));
let cfg_always = ConverterConfig {
routing: RoutingOpts {
routing_mode: RoutingMode::Always,
..Default::default()
},
..Default::default()
};
assert!(needs_onnx(&mut textual, 0, &cfg_always));
let cfg_never = ConverterConfig {
routing: RoutingOpts {
routing_mode: RoutingMode::Never,
..Default::default()
},
..Default::default()
};
assert!(!needs_onnx(&mut scanned, 0, &cfg_never));
}
#[test]
fn wrap_code_blocks_emits_fence_for_mono_lines() {
let mut page = FakePage::from_text("", "Helvetica");
page.chars.clear();
for (i, ch) in "let x = 42;".chars().enumerate() {
page.chars.push(SourceChar::new(
ch,
50.0 + i as f32 * 8.0,
300.0,
7.0,
11.0,
"Consolas",
));
}
let mut fake = FakePdf::new(vec![page]);
let blocks = wrap_code_blocks(&mut fake, 0);
assert_eq!(blocks.len(), 1);
assert!(blocks[0].starts_with("```"), "{}", blocks[0]);
assert!(blocks[0].contains("let x = 42;"));
let prop = FakePage::from_text("hello world", "Georgia");
let mut prop_doc = FakePdf::new(vec![prop]);
assert!(wrap_code_blocks(&mut prop_doc, 0).is_empty());
}
fn math_rect(x: f32, y: f32, w: f32, h: f32) -> Rect {
Rect::new(x, y, w, h)
}
#[test]
fn math_clusters_merge_same_line_empty_gap() {
let boxes = vec![
math_rect(10.0, 100.0, 50.0, 12.0),
math_rect(70.0, 100.0, 50.0, 12.0),
];
let out = cluster_math_boxes(&boxes, &[], 12.0);
assert_eq!(out.len(), 1, "{out:?}");
assert_eq!(out[0].members.len(), 2);
assert!((out[0].bounds.width - 110.0).abs() < 1e-3);
}
#[test]
fn math_clusters_split_on_gap_text() {
let boxes = vec![
math_rect(10.0, 100.0, 50.0, 12.0),
math_rect(70.0, 100.0, 50.0, 12.0),
];
let chars = vec![SourceChar::new('a', 62.0, 100.0, 6.0, 11.0, "Helvetica")];
let out = cluster_math_boxes(&boxes, &chars, 12.0);
assert_eq!(out.len(), 2, "{out:?}");
}
#[test]
fn math_clusters_split_on_band_and_distance() {
let boxes = vec![
math_rect(10.0, 100.0, 50.0, 12.0),
math_rect(70.0, 130.0, 50.0, 12.0),
];
assert_eq!(cluster_math_boxes(&boxes, &[], 12.0).len(), 2);
let boxes = vec![
math_rect(10.0, 100.0, 50.0, 12.0),
math_rect(110.0, 100.0, 50.0, 12.0),
];
assert_eq!(cluster_math_boxes(&boxes, &[], 12.0).len(), 2);
}
#[test]
fn math_clusters_merge_stacked_display_lines() {
let boxes = vec![
math_rect(10.0, 100.0, 120.0, 12.0),
math_rect(10.0, 118.0, 120.0, 12.0),
];
let out = cluster_math_boxes(&boxes, &[], 12.0);
assert_eq!(out.len(), 1, "stacked lines must merge");
assert_eq!(out[0].members.len(), 2);
assert!((out[0].bounds.height - 30.0).abs() < 1e-3);
}
#[test]
fn math_clusters_split_stack_on_gap_text_or_distance() {
let boxes = vec![
math_rect(10.0, 100.0, 120.0, 12.0),
math_rect(10.0, 118.0, 120.0, 12.0),
];
let chars = vec![SourceChar::new('w', 20.0, 112.0, 6.0, 8.0, "Helvetica")];
assert_eq!(cluster_math_boxes(&boxes, &chars, 12.0).len(), 2);
let boxes = vec![
math_rect(10.0, 100.0, 120.0, 12.0),
math_rect(10.0, 132.0, 120.0, 12.0),
];
assert_eq!(cluster_math_boxes(&boxes, &[], 12.0).len(), 2);
let boxes = vec![
math_rect(10.0, 100.0, 40.0, 12.0),
math_rect(100.0, 118.0, 40.0, 12.0),
];
assert_eq!(cluster_math_boxes(&boxes, &[], 12.0).len(), 2);
}
#[test]
fn rect_subtract_no_overlap_returns_base() {
let base = Rect::new(0.0, 0.0, 100.0, 100.0);
let subs = vec![Rect::new(200.0, 200.0, 10.0, 10.0)];
let out = rect_subtract(base, &subs);
assert_eq!(out.len(), 1);
assert!((out[0].x - 0.0).abs() < 1e-6);
assert!((out[0].width - 100.0).abs() < 1e-6);
assert!((out[0].height - 100.0).abs() < 1e-6);
}
#[test]
fn rect_subtract_full_cover_is_empty() {
let base = Rect::new(10.0, 10.0, 50.0, 50.0);
let subs = vec![Rect::new(0.0, 0.0, 100.0, 100.0)];
let out = rect_subtract(base, &subs);
assert!(out.is_empty());
}
#[test]
fn rect_subtract_central_hole_yields_four_pieces() {
let base = Rect::new(0.0, 0.0, 100.0, 100.0);
let subs = vec![Rect::new(40.0, 40.0, 20.0, 20.0)];
let out = rect_subtract(base, &subs);
assert_eq!(out.len(), 4);
let area: f32 = out.iter().map(|r| r.width * r.height).sum();
assert!((area - 9600.0).abs() < 1e-3, "area {area}");
for r in &out {
let overlaps = !(r.x >= 60.0 - 1e-3
|| r.x + r.width <= 40.0 + 1e-3
|| r.y >= 60.0 - 1e-3
|| r.y + r.height <= 40.0 + 1e-3);
assert!(!overlaps, "piece {:?} overlaps hole", r);
}
}
#[test]
fn rect_subtract_left_third_strips_to_one_piece() {
let base = Rect::new(0.0, 0.0, 90.0, 30.0);
let subs = vec![Rect::new(0.0, 0.0, 30.0, 30.0)];
let out = rect_subtract(base, &subs);
assert_eq!(out.len(), 1);
assert!((out[0].x - 30.0).abs() < 1e-6);
assert!((out[0].width - 60.0).abs() < 1e-6);
assert!((out[0].height - 30.0).abs() < 1e-6);
}
#[test]
fn rect_subtract_two_subs_preserves_area() {
let base = Rect::new(0.0, 0.0, 100.0, 100.0);
let subs = vec![
Rect::new(0.0, 0.0, 25.0, 100.0),
Rect::new(60.0, 40.0, 20.0, 20.0),
];
let out = rect_subtract(base, &subs);
let area: f32 = out.iter().map(|r| r.width * r.height).sum();
assert!((area - 7100.0).abs() < 1e-3, "area {area}");
for i in 0..out.len() {
for j in (i + 1)..out.len() {
let (a, b) = (&out[i], &out[j]);
let disjoint = a.x >= b.x + b.width - 1e-3
|| b.x >= a.x + a.width - 1e-3
|| a.y >= b.y + b.height - 1e-3
|| b.y >= a.y + a.height - 1e-3;
assert!(disjoint, "pieces {i} and {j} overlap");
}
}
}
#[test]
fn claim_priority_mirrors_routing() {
assert_eq!(region_claim_priority("isolate_formula"), CLAIM_MATH);
assert_eq!(region_claim_priority("formula"), CLAIM_MATH);
assert_eq!(region_claim_priority("equation"), CLAIM_MATH);
assert_eq!(region_claim_priority("formula_caption"), CLAIM_CAPTION);
assert_eq!(region_claim_priority("table_caption"), CLAIM_CAPTION);
assert_eq!(region_claim_priority("table_footnote"), CLAIM_CAPTION);
assert_eq!(region_claim_priority("table"), CLAIM_TABLE);
assert_eq!(region_claim_priority("plain text"), CLAIM_TEXT);
assert_eq!(region_claim_priority("title"), CLAIM_TEXT);
assert_eq!(region_claim_priority("figure"), CLAIM_NONE);
assert_eq!(region_claim_priority("image"), CLAIM_NONE);
}
}