use std::collections::HashMap;
use std::path::Path;
use anyhow::{Context, Result};
use liteparse::config::ImageMode as LpImageMode;
use liteparse::types::PdfInput;
use liteparse::{LiteParse, LiteParseConfig, OutputFormat};
use super::blob::{BlobStore, Loc};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageMode {
Embedded,
Placeholder,
Off,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OcrMode {
Auto,
On,
Off,
}
#[derive(Debug, Clone)]
pub struct ParsedPage {
pub doc_id: String,
pub path: String,
pub page: i32,
pub markdown: String,
pub tables_json: String,
pub page_image_ref: Option<String>,
pub image_refs: Vec<String>,
pub file_type: String,
}
#[derive(Debug, Clone)]
pub struct ParseOptions {
pub recursive: bool,
pub include_globs: Vec<String>,
pub image_mode: ImageMode,
pub image_store: Option<String>,
pub ocr: OcrMode,
pub render_page_images: bool,
pub ocr_server_url: Option<String>,
}
impl Default for ParseOptions {
fn default() -> Self {
Self {
recursive: true,
include_globs: default_globs(),
image_mode: ImageMode::Off,
image_store: None,
ocr: OcrMode::Auto,
render_page_images: false,
ocr_server_url: None,
}
}
}
fn default_globs() -> Vec<String> {
[
"*.pdf", "*.docx", "*.xlsx", "*.pptx", "*.doc", "*.xls", "*.ppt", "*.odt", "*.ods",
"*.odp", "*.png", "*.jpg", "*.jpeg", "*.tif", "*.tiff", "*.bmp", "*.gif",
]
.iter()
.map(|s| s.to_string())
.collect()
}
impl ParseOptions {
pub fn from_map(o: Option<&HashMap<String, String>>) -> Self {
let mut opts = ParseOptions::default();
let Some(map) = o else {
return opts;
};
if let Some(v) = map.get("recursive") {
opts.recursive = parse_bool(v, opts.recursive);
}
if let Some(v) = map.get("include_globs") {
let globs: Vec<String> = v
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if !globs.is_empty() {
opts.include_globs = globs;
}
}
if let Some(v) = map.get("image_mode") {
opts.image_mode = match v.trim().to_ascii_lowercase().as_str() {
"embedded" | "embed" => ImageMode::Embedded,
"off" | "none" => ImageMode::Off,
_ => ImageMode::Placeholder,
};
}
if let Some(v) = map.get("image_store") {
let v = v.trim();
if !v.is_empty() {
opts.image_store = Some(v.to_string());
}
}
if let Some(v) = map.get("ocr") {
opts.ocr = match v.trim().to_ascii_lowercase().as_str() {
"on" | "true" => OcrMode::On,
"off" | "false" => OcrMode::Off,
_ => OcrMode::Auto,
};
}
if let Some(v) = map.get("render_page_images") {
opts.render_page_images = parse_bool(v, opts.render_page_images);
}
if let Some(v) = map.get("ocr_server_url") {
let v = v.trim();
if !v.is_empty() {
opts.ocr_server_url = Some(v.to_string());
}
}
opts
}
}
fn ocr_engine_available(opts: &ParseOptions) -> bool {
opts.ocr_server_url.is_some()
}
fn parse_bool(v: &str, default: bool) -> bool {
match v.trim().to_ascii_lowercase().as_str() {
"true" | "1" | "yes" | "on" => true,
"false" | "0" | "no" | "off" => false,
_ => default,
}
}
fn lp_image_mode(mode: ImageMode) -> LpImageMode {
match mode {
ImageMode::Embedded => LpImageMode::Embed,
ImageMode::Placeholder => LpImageMode::Placeholder,
ImageMode::Off => LpImageMode::Off,
}
}
fn doc_id_for(rel_path: &str) -> String {
blake3::hash(rel_path.as_bytes()).to_hex().to_string()
}
fn file_type_for(path: &Path) -> String {
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
match ext.as_str() {
"png" | "jpg" | "jpeg" | "tif" | "tiff" | "bmp" | "gif" => "image".to_string(),
other => other.to_string(),
}
}
fn glob_options() -> glob::MatchOptions {
glob::MatchOptions {
case_sensitive: false,
require_literal_separator: false,
require_literal_leading_dot: false,
}
}
fn matches_globs(file_name: &str, globs: &[String]) -> bool {
if globs.is_empty() {
return true;
}
globs.iter().any(|g| glob_match(g, file_name))
}
fn glob_match(pattern: &str, name: &str) -> bool {
match glob::Pattern::new(pattern) {
Ok(p) => p.matches_with(name, glob_options()),
Err(e) => {
tracing::warn!("documents: invalid include_glob '{}': {}", pattern, e);
false
}
}
}
async fn list_docs(
store: &BlobStore,
prefix: &Loc,
opts: &ParseOptions,
image_store: Option<&Loc>,
) -> Result<Vec<(Loc, String)>> {
let entries = store.list(prefix, opts.recursive).await?;
Ok(entries
.into_iter()
.filter(|(loc, rel)| {
let name = rel.rsplit('/').next().unwrap_or(rel);
if !matches_globs(name, &opts.include_globs) {
return false;
}
if let Some(base) = image_store {
if loc_is_under(loc, base) {
return false;
}
}
true
})
.collect())
}
fn loc_is_under(entry: &Loc, base: &Loc) -> bool {
match (entry, base) {
(Loc::Local(e), Loc::Local(b)) => e.starts_with(b),
(
Loc::S3 {
bucket: eb,
key: ek,
},
Loc::S3 {
bucket: bb,
key: bk,
},
) => {
if eb != bb {
return false;
}
let trimmed = bk.trim_end_matches('/');
trimmed.is_empty() || ek == trimmed || ek.starts_with(&format!("{trimmed}/"))
}
_ => false,
}
}
fn build_config(opts: &ParseOptions) -> LiteParseConfig {
let mut cfg = LiteParseConfig {
output_format: OutputFormat::Markdown,
image_mode: lp_image_mode(opts.image_mode),
quiet: true,
..Default::default()
};
cfg.ocr_enabled = false;
cfg.ocr_server_url = opts.ocr_server_url.clone();
if matches!(opts.ocr, OcrMode::On) && ocr_engine_available(opts) {
cfg.ocr_enabled = true;
}
cfg
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct WriteTally {
attempted: usize,
failed: usize,
}
impl WriteTally {
fn record(&mut self, ok: bool) {
self.attempted += 1;
if !ok {
self.failed += 1;
}
}
fn all_failed(&self) -> bool {
self.attempted > 0 && self.failed == self.attempted
}
}
async fn parse_file(
bytes: &[u8],
rel_path: &str,
opts: &ParseOptions,
write_store: Option<&BlobStore>,
writes: &mut WriteTally,
) -> Result<Vec<ParsedPage>> {
let mut cfg = build_config(opts);
if matches!(opts.ocr, OcrMode::Auto) && ocr_engine_available(opts) {
let probe = LiteParse::new(cfg.clone());
match probe.is_complex(PdfInput::Bytes(bytes.to_vec())).await {
Ok(stats) => {
if stats.iter().any(|s| s.needs_ocr) {
cfg.ocr_enabled = true;
}
}
Err(e) => {
tracing::warn!("documents: complexity probe failed for {}: {}", rel_path, e);
}
}
}
let parser = LiteParse::new(cfg.clone());
let result = parser
.parse_input(PdfInput::Bytes(bytes.to_vec()))
.await
.with_context(|| format!("liteparse failed for {}", rel_path))?;
let file_type = file_type_for(Path::new(rel_path));
let doc_id = doc_id_for(rel_path);
let mut page_image_refs: HashMap<u32, String> = HashMap::new();
if opts.render_page_images {
match parser
.screenshot_input(PdfInput::Bytes(bytes.to_vec()), None)
.await
{
Ok(shots) => {
for shot in shots {
let uri = page_image_uri(opts.image_store.as_deref(), rel_path, shot.page_num);
if let Some(store) = write_store {
let res = write_crop(store, &uri, &shot.image_bytes).await;
writes.record(res.is_ok());
if let Err(e) = res {
tracing::warn!(
"documents: failed to write page image {}: {:#}",
uri,
e
);
continue;
}
}
page_image_refs.insert(shot.page_num, uri);
}
}
Err(e) => {
tracing::warn!(
"documents: page-image rendering failed for {}: {}",
rel_path,
e
);
}
}
}
let mut pages = Vec::with_capacity(result.pages.len());
for page in &result.pages {
let markdown = liteparse::output::markdown::format_markdown(
std::slice::from_ref(page),
&result.outline,
cfg.image_mode,
);
let tables_json = tables_from_markdown(&markdown);
let image_refs: Vec<String> = if opts.image_mode == ImageMode::Embedded {
let mut refs = Vec::new();
for img in result
.images
.iter()
.filter(|img| img.page as usize == page.page_number)
{
let uri =
image_ref_uri(opts.image_store.as_deref(), rel_path, &img.id, &img.format);
if let Some(store) = write_store {
let res = write_crop(store, &uri, &img.bytes).await;
writes.record(res.is_ok());
if let Err(e) = res {
tracing::warn!("documents: failed to write image crop {}: {:#}", uri, e);
continue;
}
}
refs.push(uri);
}
refs
} else {
Vec::new()
};
pages.push(ParsedPage {
doc_id: doc_id.clone(),
path: rel_path.to_string(),
page: page.page_number as i32,
markdown,
tables_json,
page_image_ref: page_image_refs.get(&(page.page_number as u32)).cloned(),
image_refs,
file_type: file_type.clone(),
});
}
Ok(pages)
}
fn image_ref_uri(store: Option<&str>, rel_path: &str, image_id: &str, format: &str) -> String {
let stem = rel_path.replace('/', "_");
match store {
Some(s) => format!(
"{}/{}_{}.{}",
s.trim_end_matches('/'),
stem,
image_id,
format
),
None => format!("{}_{}.{}", stem, image_id, format),
}
}
fn page_image_uri(store: Option<&str>, rel_path: &str, page: u32) -> String {
let stem = rel_path.replace('/', "_");
match store {
Some(s) => format!("{}/{}_page_{}.png", s.trim_end_matches('/'), stem, page),
None => format!("{}_page_{}.png", stem, page),
}
}
async fn write_crop(store: &BlobStore, uri: &str, bytes: &[u8]) -> Result<()> {
let loc = Loc::parse(uri).with_context(|| format!("parsing image_store uri {uri}"))?;
store.put(&loc, bytes).await
}
fn tables_from_markdown(markdown: &str) -> String {
let lines: Vec<&str> = markdown.lines().collect();
let mut tables: Vec<serde_json::Value> = Vec::new();
let mut i = 0;
while i + 1 < lines.len() {
let header = lines[i].trim();
let sep = lines[i + 1].trim();
if is_table_row(header) && is_separator_row(sep) {
let headers = split_table_row(header);
let mut rows: Vec<Vec<String>> = Vec::new();
let mut j = i + 2;
while j < lines.len() && is_table_row(lines[j].trim()) {
rows.push(split_table_row(lines[j].trim()));
j += 1;
}
tables.push(serde_json::json!({ "header": headers, "rows": rows }));
i = j;
} else {
i += 1;
}
}
serde_json::to_string(&tables).unwrap_or_else(|_| "[]".to_string())
}
fn is_table_row(line: &str) -> bool {
line.contains('|') && !line.is_empty()
}
fn is_separator_row(line: &str) -> bool {
if !line.contains('|') || !line.contains('-') {
return false;
}
split_table_row(line)
.iter()
.all(|cell| !cell.is_empty() && cell.chars().all(|c| matches!(c, '-' | ':' | ' ')))
}
fn split_table_row(line: &str) -> Vec<String> {
let trimmed = line.trim().trim_matches('|');
trimmed.split('|').map(|c| c.trim().to_string()).collect()
}
pub fn parse_source(root: &str, opts: &ParseOptions) -> Result<Vec<ParsedPage>> {
let root = root.to_string();
let opts = opts.clone();
std::thread::scope(|scope| {
scope
.spawn(move || parse_source_blocking(&root, &opts))
.join()
.map_err(|_| anyhow::anyhow!("documents parse thread panicked"))?
})
}
fn parse_source_blocking(root: &str, opts: &ParseOptions) -> Result<Vec<ParsedPage>> {
let (read_store, prefix_loc) = BlobStore::resolve(root)
.with_context(|| format!("documents: resolving source path {root}"))?;
let (write_store, image_base) = match opts.image_store.as_deref() {
Some(s) => {
let (ws, loc) = BlobStore::resolve(s)
.with_context(|| format!("documents: resolving image_store {s}"))?;
(Some(ws), Some(loc))
}
None => (None, None),
};
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("failed to build tokio runtime for documents parse")?;
let entries = runtime.block_on(list_docs(
&read_store,
&prefix_loc,
opts,
image_base.as_ref(),
))?;
let total = entries.len();
let mut rows = Vec::new();
let mut ok_files = 0usize;
let mut writes = WriteTally::default();
for (loc, rel_path) in entries {
let bytes = match runtime.block_on(read_store.get(&loc)) {
Ok(b) => b,
Err(e) => {
tracing::warn!("documents: fetch failed for {}: {:#}", rel_path, e);
continue;
}
};
match runtime.block_on(parse_file(
&bytes,
&rel_path,
opts,
write_store.as_ref(),
&mut writes,
)) {
Ok(mut page_rows) => {
ok_files += 1;
rows.append(&mut page_rows);
}
Err(e) => {
tracing::warn!("documents: skipping {}: {:#}", rel_path, e);
}
}
}
if total > 0 && ok_files == 0 {
anyhow::bail!(
"documents: all {total} matched object(s) failed to fetch/parse — treating as a \
hard error rather than an empty result (check credentials/permissions)"
);
}
if writes.all_failed() {
anyhow::bail!(
"documents: all {} image_store write(s) failed — refusing to return rows whose \
page_image_ref/image_refs would silently be empty (check write permissions on \
image_store: s3:PutObject for s3://, filesystem permissions for a local path)",
writes.attempted
);
}
if writes.failed > 0 {
tracing::warn!(
"documents: {}/{} image_store write(s) failed; the affected rows carry no \
page_image_ref/image_refs (individual failures logged above)",
writes.failed,
writes.attempted
);
}
Ok(rows)
}
pub fn preflight(opts: &ParseOptions) -> Result<()> {
match opts.ocr {
OcrMode::On if !ocr_engine_available(opts) => {
anyhow::bail!(
"documents: ocr=on requires an `ocr_server_url` option (this build links \
liteparse without the bundled Tesseract engine, so OCR is only available via \
an HTTP OCR server). Set `ocr_server_url`, or use ocr=auto/off."
);
}
OcrMode::Auto if !ocr_engine_available(opts) => {
tracing::info!(
"documents: ocr=auto but no `ocr_server_url` configured; \
proceeding without OCR (native text only)"
);
}
_ => {}
}
let needs_conversion = opts.include_globs.iter().any(|g| {
let g = g.to_ascii_lowercase();
!(g.ends_with(".pdf") || g == "*" || g == "*.*")
});
if needs_conversion && !tool_available("soffice") && !tool_available("libreoffice") {
tracing::warn!(
"documents: LibreOffice (soffice) not found; non-PDF inputs cannot be converted"
);
}
Ok(())
}
fn tool_available(name: &str) -> bool {
let path = match std::env::var_os("PATH") {
Some(p) => p,
None => return false,
};
std::env::split_paths(&path).any(|dir| {
let candidate = dir.join(name);
candidate.is_file()
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_two_page_pdf_into_rows() {
let opts = ParseOptions {
recursive: true,
include_globs: vec!["*.pdf".into()],
image_mode: ImageMode::Off,
image_store: None,
ocr: OcrMode::Off,
render_page_images: false,
ocr_server_url: None,
};
let rows = parse_source("tests/fixtures/documents", &opts).unwrap();
assert_eq!(rows.len(), 2);
assert!(rows.iter().all(|r| r.file_type == "pdf"));
assert!(rows.iter().all(|r| !r.markdown.is_empty()));
assert_eq!(rows[0].page, 1);
}
#[test]
fn missing_root_fails_scan_instead_of_returning_empty() {
let opts = ParseOptions {
recursive: true,
include_globs: vec!["*.pdf".into()],
image_mode: ImageMode::Off,
image_store: None,
ocr: OcrMode::Off,
render_page_images: false,
ocr_server_url: None,
};
let err = parse_source("tests/fixtures/documents/does-not-exist", &opts).unwrap_err();
assert!(
format!("{err:#}").contains("cannot read root directory"),
"unexpected error: {err:#}"
);
}
#[test]
fn all_files_failing_to_parse_is_a_hard_error() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.pdf"), b"not a real pdf at all").unwrap();
std::fs::write(dir.path().join("b.pdf"), b"also garbage bytes").unwrap();
let opts = ParseOptions {
include_globs: vec!["*.pdf".into()],
ocr: OcrMode::Off,
..ParseOptions::default()
};
let err = parse_source(dir.path().to_str().unwrap(), &opts).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("failed to fetch/parse"),
"expected wholesale-failure hard error, got: {msg}"
);
}
#[test]
fn glob_match_basics() {
assert!(glob_match("*.pdf", "a.pdf"));
assert!(glob_match("*.pdf", "DIR.PDF"));
assert!(!glob_match("*.pdf", "a.docx"));
assert!(glob_match("*", "anything.xyz"));
assert!(glob_match("foo*bar", "fooXYZbar"));
assert!(!glob_match("foo*bar", "fooXYZ"));
}
#[test]
fn glob_match_invalid_pattern_warns_and_returns_false() {
assert!(!glob_match("[", "anything"));
}
#[test]
fn matches_globs_empty_list_matches_everything() {
assert!(matches_globs("whatever.xyz", &[]));
}
#[test]
fn from_map_defaults_and_overrides() {
let d = ParseOptions::from_map(None);
assert!(d.recursive);
assert_eq!(d.ocr, OcrMode::Auto);
let mut m = HashMap::new();
m.insert("recursive".to_string(), "false".to_string());
m.insert("include_globs".to_string(), "*.pdf, *.png".to_string());
m.insert("image_mode".to_string(), "embedded".to_string());
m.insert("ocr".to_string(), "off".to_string());
let o = ParseOptions::from_map(Some(&m));
assert!(!o.recursive);
assert_eq!(
o.include_globs,
vec!["*.pdf".to_string(), "*.png".to_string()]
);
assert_eq!(o.image_mode, ImageMode::Embedded);
assert_eq!(o.ocr, OcrMode::Off);
}
#[test]
fn from_map_covers_remaining_option_keys() {
let mut m = HashMap::new();
m.insert("image_mode".to_string(), "placeholder".to_string());
m.insert("image_store".to_string(), "/tmp/store".to_string());
m.insert("render_page_images".to_string(), "true".to_string());
m.insert("ocr".to_string(), "on".to_string());
m.insert(
"ocr_server_url".to_string(),
"http://ocr.example/ocr".to_string(),
);
let o = ParseOptions::from_map(Some(&m));
assert_eq!(o.image_mode, ImageMode::Placeholder);
assert_eq!(o.image_store, Some("/tmp/store".to_string()));
assert!(o.render_page_images);
assert_eq!(o.ocr, OcrMode::On);
assert_eq!(o.ocr_server_url, Some("http://ocr.example/ocr".to_string()));
let mut m2 = HashMap::new();
m2.insert("image_mode".to_string(), "off".to_string());
m2.insert("image_store".to_string(), " ".to_string());
m2.insert("ocr_server_url".to_string(), "".to_string());
let o2 = ParseOptions::from_map(Some(&m2));
assert_eq!(o2.image_mode, ImageMode::Off);
assert_eq!(o2.image_store, None);
assert_eq!(o2.ocr_server_url, None);
}
#[test]
fn parse_bool_falls_back_to_default_on_unrecognized_value() {
assert!(parse_bool("not-a-bool", true));
assert!(!parse_bool("not-a-bool", false));
assert!(parse_bool("YES", false));
assert!(!parse_bool("No", true));
}
#[test]
fn lp_image_mode_maps_all_variants() {
assert_eq!(lp_image_mode(ImageMode::Embedded), LpImageMode::Embed);
assert_eq!(
lp_image_mode(ImageMode::Placeholder),
LpImageMode::Placeholder
);
assert_eq!(lp_image_mode(ImageMode::Off), LpImageMode::Off);
}
#[test]
fn image_ref_uri_and_page_image_uri_without_store() {
assert_eq!(
image_ref_uri(None, "batch-a/catalog.pdf", "img0", "png"),
"batch-a_catalog.pdf_img0.png"
);
assert_eq!(
page_image_uri(None, "batch-a/catalog.pdf", 3),
"batch-a_catalog.pdf_page_3.png"
);
assert_eq!(
image_ref_uri(Some("/tmp/out/"), "a.pdf", "img0", "png"),
"/tmp/out/a.pdf_img0.png"
);
}
#[test]
fn write_tally_only_escalates_on_wholesale_failure() {
let empty = WriteTally::default();
assert!(!empty.all_failed());
let mut partial = WriteTally::default();
partial.record(true);
partial.record(false);
assert_eq!((partial.attempted, partial.failed), (2, 1));
assert!(
!partial.all_failed(),
"a partial write failure must not fail the whole scan"
);
let mut total = WriteTally::default();
total.record(false);
total.record(false);
assert!(total.all_failed());
}
#[tokio::test]
async fn wholesale_image_store_write_failure_is_an_error() {
let src = tempfile::tempdir().unwrap();
std::fs::copy(
"tests/fixtures/documents/two_pages.pdf",
src.path().join("a.pdf"),
)
.unwrap();
let store_dir = tempfile::tempdir().unwrap();
let store_path = store_dir.path().join("readonly");
std::fs::create_dir(&store_path).unwrap();
let mut perms = std::fs::metadata(&store_path).unwrap().permissions();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
perms.set_mode(0o500);
}
std::fs::set_permissions(&store_path, perms).unwrap();
let opts = ParseOptions {
recursive: false,
include_globs: vec!["*.pdf".into()],
image_store: Some(store_path.to_string_lossy().into_owned()),
render_page_images: true,
ocr: OcrMode::Off,
..ParseOptions::default()
};
let err = parse_source(src.path().to_str().unwrap(), &opts)
.expect_err("every image_store write failing must be a hard error");
let msg = err.to_string();
assert!(
msg.contains("image_store write(s) failed"),
"unexpected error: {msg}"
);
let mut perms = std::fs::metadata(&store_path).unwrap().permissions();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
perms.set_mode(0o700);
}
std::fs::set_permissions(&store_path, perms).unwrap();
}
#[test]
fn s3_image_refs_round_trip_back_into_a_loc() {
let store = "s3://my-bucket/extracted";
let page_uri = page_image_uri(Some(store), "nested/sub/report.pdf", 2);
assert_eq!(
page_uri,
"s3://my-bucket/extracted/nested_sub_report.pdf_page_2.png"
);
assert_eq!(
Loc::parse(&page_uri).unwrap(),
Loc::S3 {
bucket: "my-bucket".into(),
key: "extracted/nested_sub_report.pdf_page_2.png".into(),
},
"page_image_ref must parse back to the object it was written to"
);
let crop_uri = image_ref_uri(Some(store), "nested/sub/report.pdf", "img0", "png");
assert_eq!(
Loc::parse(&crop_uri).unwrap(),
Loc::S3 {
bucket: "my-bucket".into(),
key: "extracted/nested_sub_report.pdf_img0.png".into(),
},
"image_refs entries must parse back to the object they were written to"
);
let slashed = page_image_uri(Some("s3://my-bucket/extracted/"), "a.pdf", 1);
assert_eq!(
Loc::parse(&slashed).unwrap(),
Loc::S3 {
bucket: "my-bucket".into(),
key: "extracted/a.pdf_page_1.png".into(),
}
);
}
#[tokio::test]
async fn list_docs_filters_by_glob_and_respects_recursive() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("top.pdf"), b"x").unwrap();
std::fs::write(dir.path().join("skip.txt"), b"x").unwrap();
let sub = dir.path().join("sub");
std::fs::create_dir(&sub).unwrap();
std::fs::write(sub.join("nested.pdf"), b"x").unwrap();
let store = BlobStore::Local;
let prefix = Loc::Local(dir.path().to_path_buf());
let opts = ParseOptions {
recursive: true,
include_globs: vec!["*.pdf".into()],
..ParseOptions::default()
};
let rels: Vec<String> = list_docs(&store, &prefix, &opts, None)
.await
.unwrap()
.into_iter()
.map(|(_, r)| r)
.collect();
assert_eq!(rels, vec!["sub/nested.pdf", "top.pdf"]);
let opts_flat = ParseOptions {
recursive: false,
..opts.clone()
};
let rels: Vec<String> = list_docs(&store, &prefix, &opts_flat, None)
.await
.unwrap()
.into_iter()
.map(|(_, r)| r)
.collect();
assert_eq!(rels, vec!["top.pdf"]);
}
#[tokio::test]
async fn list_docs_excludes_image_store_prefix() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.pdf"), b"x").unwrap();
let crops = dir.path().join("crops");
std::fs::create_dir(&crops).unwrap();
std::fs::write(crops.join("a.pdf_img0.png"), b"x").unwrap();
let store = BlobStore::Local;
let prefix = Loc::Local(dir.path().to_path_buf());
let image_store = Loc::Local(crops.clone());
let opts = ParseOptions {
recursive: true,
include_globs: vec!["*.pdf".into(), "*.png".into()],
..ParseOptions::default()
};
let with_excl: Vec<String> = list_docs(&store, &prefix, &opts, Some(&image_store))
.await
.unwrap()
.into_iter()
.map(|(_, r)| r)
.collect();
assert_eq!(with_excl, vec!["a.pdf"]);
let without_excl: Vec<String> = list_docs(&store, &prefix, &opts, None)
.await
.unwrap()
.into_iter()
.map(|(_, r)| r)
.collect();
assert_eq!(without_excl, vec!["a.pdf", "crops/a.pdf_img0.png"]);
}
#[test]
fn loc_is_under_matches_backends() {
assert!(loc_is_under(
&Loc::Local("/root/crops/x.png".into()),
&Loc::Local("/root/crops".into())
));
assert!(!loc_is_under(
&Loc::Local("/root/a.pdf".into()),
&Loc::Local("/root/crops".into())
));
let e = |k: &str| Loc::S3 {
bucket: "b".into(),
key: k.into(),
};
assert!(loc_is_under(&e("corpus/crops/x.png"), &e("corpus/crops")));
assert!(!loc_is_under(&e("corpus/a.pdf"), &e("corpus/crops")));
assert!(!loc_is_under(
&Loc::S3 {
bucket: "b1".into(),
key: "k".into()
},
&Loc::S3 {
bucket: "b2".into(),
key: "k".into()
}
));
assert!(!loc_is_under(&Loc::Local("/x".into()), &e("corpus")));
}
#[test]
fn doc_id_is_stable_and_path_relative() {
let a = doc_id_for("batch-a/catalog.pdf");
let b = doc_id_for("batch-a/catalog.pdf");
let c = doc_id_for("batch-b/catalog.pdf");
assert_eq!(a, b);
assert_ne!(a, c);
}
#[test]
fn tables_from_markdown_extracts_gfm_tables() {
let md = "Intro text.\n\n\
| Name | Qty |\n\
| --- | --- |\n\
| Widget | 10 |\n\
| Gadget | 20 |\n\n\
Trailing prose.";
let json = tables_from_markdown(md);
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v.as_array().unwrap().len(), 1);
assert_eq!(v[0]["header"], serde_json::json!(["Name", "Qty"]));
assert_eq!(v[0]["rows"][0], serde_json::json!(["Widget", "10"]));
assert_eq!(v[0]["rows"][1], serde_json::json!(["Gadget", "20"]));
}
#[test]
fn tables_from_markdown_empty_when_none() {
assert_eq!(
tables_from_markdown("just some prose\nno tables here"),
"[]"
);
}
#[tokio::test]
async fn write_crop_writes_local_file() {
let dir = tempfile::tempdir().unwrap();
let uri = format!("{}/sub/crop_p1_0.png", dir.path().display());
write_crop(&BlobStore::Local, &uri, b"\x89PNGfake")
.await
.unwrap();
assert_eq!(std::fs::read(&uri).unwrap(), b"\x89PNGfake");
}
#[test]
fn preflight_errors_when_ocr_on_without_engine() {
let opts = ParseOptions {
ocr: OcrMode::On,
ocr_server_url: None,
..ParseOptions::default()
};
let err = preflight(&opts).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("ocr=on"), "unexpected error: {msg}");
assert!(msg.contains("ocr_server_url"), "unexpected error: {msg}");
}
#[test]
fn preflight_ok_when_ocr_on_with_engine() {
let opts = ParseOptions {
ocr: OcrMode::On,
ocr_server_url: Some("http://ocr.internal:8080/ocr".into()),
include_globs: vec!["*.pdf".into()],
..ParseOptions::default()
};
assert!(preflight(&opts).is_ok());
}
#[test]
fn preflight_ok_when_ocr_auto_without_engine() {
let opts = ParseOptions {
ocr: OcrMode::Auto,
ocr_server_url: None,
include_globs: vec!["*.pdf".into()],
..ParseOptions::default()
};
assert!(preflight(&opts).is_ok());
}
#[test]
fn preflight_ok_when_ocr_off() {
let opts = ParseOptions {
ocr: OcrMode::Off,
include_globs: vec!["*.pdf".into()],
..ParseOptions::default()
};
assert!(preflight(&opts).is_ok());
}
#[test]
fn render_page_images_sets_page_image_ref_and_writes_files() {
let store = tempfile::tempdir().unwrap();
let opts = ParseOptions {
include_globs: vec!["*.pdf".into()],
ocr: OcrMode::Off,
render_page_images: true,
image_store: Some(store.path().to_str().unwrap().to_string()),
..ParseOptions::default()
};
let rows = parse_source("tests/fixtures/documents", &opts).unwrap();
assert_eq!(rows.len(), 2);
for r in &rows {
let uri = r
.page_image_ref
.as_ref()
.expect("page_image_ref should be set when render_page_images=true");
assert!(
std::path::Path::new(uri).exists(),
"rendered page image not written: {uri}"
);
let bytes = std::fs::read(uri).unwrap();
assert!(bytes.starts_with(b"\x89PNG"), "not a PNG: {uri}");
}
}
#[test]
fn render_page_images_without_store_still_sets_ref() {
let opts = ParseOptions {
include_globs: vec!["*.pdf".into()],
ocr: OcrMode::Off,
render_page_images: true,
image_store: None,
..ParseOptions::default()
};
let rows = parse_source("tests/fixtures/documents", &opts).unwrap();
assert!(rows.iter().all(|r| r.page_image_ref.is_some()));
}
#[test]
fn scanned_png_parses_or_skips() {
if !libreoffice_available() && !imagemagick_available() {
eprintln!("skipping scanned_png_parses_or_skips: no image->PDF converter found");
return;
}
let dir = tempfile::tempdir().unwrap();
std::fs::copy(
"tests/fixtures/documents/scanned.png",
dir.path().join("scanned.png"),
)
.unwrap();
let opts = ParseOptions {
include_globs: vec!["*.png".into()],
ocr: OcrMode::Off,
..ParseOptions::default()
};
let rows = parse_source(dir.path().to_str().unwrap(), &opts).unwrap();
assert!(rows.iter().all(|r| r.file_type == "image"));
}
fn libreoffice_available() -> bool {
tool_available("soffice") || tool_available("libreoffice")
}
fn imagemagick_available() -> bool {
tool_available("magick") || tool_available("convert")
}
#[test]
fn docx_table_yields_tables_json_or_skips() {
if !libreoffice_available() {
eprintln!("skipping docx_table_yields_tables_json_or_skips: LibreOffice not found");
return;
}
let dir = tempfile::tempdir().unwrap();
std::fs::copy(
"tests/fixtures/documents/table.docx",
dir.path().join("table.docx"),
)
.unwrap();
let opts = ParseOptions {
include_globs: vec!["*.docx".into()],
ocr: OcrMode::Off,
..ParseOptions::default()
};
let rows = parse_source(dir.path().to_str().unwrap(), &opts).unwrap();
assert!(!rows.is_empty(), "docx should produce at least one page");
assert!(rows.iter().all(|r| r.file_type == "docx"));
assert!(
rows.iter().any(|r| r.tables_json != "[]"),
"expected a non-empty tables_json from the docx table"
);
}
#[test]
fn xlsx_sheet_yields_tables_json_or_skips() {
if !libreoffice_available() {
eprintln!("skipping xlsx_sheet_yields_tables_json_or_skips: LibreOffice not found");
return;
}
let dir = tempfile::tempdir().unwrap();
std::fs::copy(
"tests/fixtures/documents/sheet.xlsx",
dir.path().join("sheet.xlsx"),
)
.unwrap();
let opts = ParseOptions {
include_globs: vec!["*.xlsx".into()],
ocr: OcrMode::Off,
..ParseOptions::default()
};
let rows = parse_source(dir.path().to_str().unwrap(), &opts).unwrap();
assert!(!rows.is_empty(), "xlsx should produce at least one page");
assert!(rows.iter().all(|r| r.file_type == "xlsx"));
assert!(
rows.iter().any(|r| r.tables_json != "[]"),
"expected a non-empty tables_json from the xlsx sheet"
);
}
#[test]
fn embedded_image_mode_writes_crops_or_skips() {
if !imagemagick_available() {
eprintln!("skipping embedded_image_mode_writes_crops_or_skips: ImageMagick not found");
return;
}
let src = tempfile::tempdir().unwrap();
std::fs::copy(
"tests/fixtures/documents/scanned.png",
src.path().join("scanned.png"),
)
.unwrap();
let store = tempfile::tempdir().unwrap();
let opts = ParseOptions {
include_globs: vec!["*.png".into()],
ocr: OcrMode::Off,
image_mode: ImageMode::Embedded,
image_store: Some(store.path().to_str().unwrap().to_string()),
..ParseOptions::default()
};
let rows = parse_source(src.path().to_str().unwrap(), &opts).unwrap();
let refs: Vec<&String> = rows.iter().flat_map(|r| r.image_refs.iter()).collect();
assert!(
!refs.is_empty(),
"embedded mode over an image PDF should yield image_refs"
);
for uri in refs {
assert!(
std::path::Path::new(uri).exists(),
"image crop not written: {uri}"
);
}
}
}