use crate::segments::Segment;
use super::anydoc_log::capture_anydoc;
use super::{ConvertError, is_meaningful, markdown_to_plain, normalize_text};
#[derive(Debug)]
pub struct PdfConversion {
pub text: String,
pub warnings: Vec<String>,
pub segments: Vec<Segment>,
pub recovered: bool,
}
const RECOVERED_TEXT_WARNING: &str = "pdf_partial_text: text recovered from a page reported as \
image-based; route to OCR to confirm completeness";
fn error_code(error: &anydoc::ConvertError) -> &'static str {
if matches!(error, anydoc::ConvertError::Unsupported(_)) {
return "pdf_no_text_layer";
}
match error.code() {
"unsupported" => "unsupported",
"malformed" => "malformed",
"encrypted" => "encrypted",
"resourceLimit" => "resourceLimit",
"missingPart" => "missingPart",
_ => "io",
}
}
fn promote_warning(message: &str) -> String {
if message.contains("pages need OCR") {
let head = message.split(" and ").next().unwrap_or(message);
return format!("pdf_partial_text: {head}");
}
format!("anydoc_recovery: {message}")
}
fn recover_text_layer(source: &[u8]) -> Option<String> {
let raw = pdf_inspector::extractor::extract_text_mem(source).ok()?;
let text = normalize_text(&raw);
if text.chars().any(is_meaningful) {
Some(text)
} else {
None
}
}
pub fn convert_pdf(source: &[u8]) -> Result<PdfConversion, ConvertError> {
let (converted, captured) =
capture_anydoc(|| anydoc::to_markdown_bytes(source, anydoc::Format::Pdf));
match converted {
Ok(markdown) => {
let text = normalize_text(&markdown_to_plain(&markdown));
if !source.is_empty() && text.is_empty() {
return Err(empty_output(source.len()));
}
let warnings = captured
.iter()
.map(|message| promote_warning(message))
.collect();
let segments = vec![Segment::span(0, text.len(), "document")];
Ok(PdfConversion {
text,
warnings,
segments,
recovered: false,
})
}
Err(error) if matches!(error, anydoc::ConvertError::Unsupported(_)) => {
match recover_text_layer(source) {
Some(text) => {
let mut warnings: Vec<String> = captured
.iter()
.map(|message| promote_warning(message))
.collect();
if !warnings
.iter()
.any(|warning| warning.starts_with("pdf_partial_text:"))
{
warnings.push(RECOVERED_TEXT_WARNING.to_string());
}
let segments = vec![Segment::span(0, text.len(), "document")];
Ok(PdfConversion {
text,
warnings,
segments,
recovered: true,
})
}
None => Err(ConvertError {
code: "pdf_no_text_layer",
message: error.to_string(),
}),
}
}
Err(error) => Err(ConvertError {
code: error_code(&error),
message: error.to_string(),
}),
}
}
fn empty_output(source_len: usize) -> ConvertError {
ConvertError {
code: "empty_output",
message: format!("source is {source_len} bytes but conversion produced no text"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn build_pdf(content: &str) -> Vec<u8> {
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>"
.to_string(),
format!(
"<< /Length {} >>\nstream\n{content}\nendstream",
content.len()
),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
/Encoding /WinAnsiEncoding >>"
.to_string(),
];
let mut pdf = String::from("%PDF-1.6\n");
let mut offsets = Vec::new();
for (index, body) in objects.iter().enumerate() {
offsets.push(pdf.len());
pdf.push_str(&format!("{} 0 obj\n{body}\nendobj\n", index + 1));
}
let xref_at = pdf.len();
pdf.push_str(&format!("xref\n0 {}\n", objects.len() + 1));
pdf.push_str("0000000000 65535 f \n");
for offset in &offsets {
pdf.push_str(&format!("{offset:010} 00000 n \n"));
}
pdf.push_str(&format!(
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_at}\n%%EOF\n",
objects.len() + 1
));
pdf.into_bytes()
}
fn vector_paths(count: usize) -> String {
let mut stream = String::new();
for step in 0..count {
let n = (step % 500) + 50;
stream.push_str(&format!("{n} {n} m {n} {n} l S\n"));
}
stream
}
fn text_block(lines: &[&str]) -> String {
let mut stream = String::from("BT /F1 12 Tf 72 700 Td\n");
for (index, line) in lines.iter().enumerate() {
if index > 0 {
stream.push_str("0 -14 Td\n");
}
stream.push_str(&format!("({line}) Tj\n"));
}
stream.push_str("ET\n");
stream
}
fn image_based_with_glyph_decoding_to(dst: &str) -> Vec<u8> {
let cmap = format!(
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n\
/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n\
/CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n\
1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n\
1 beginbfchar\n<0001> <{dst}>\nendbfchar\nendcmap\n\
CMapName currentdict /CMap defineresource pop\nend\nend"
);
let content = format!("{}BT /F1 12 Tf 72 700 Td <0001> Tj ET\n", vector_paths(600));
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>"
.to_string(),
format!(
"<< /Length {} >>\nstream\n{content}\nendstream",
content.len()
),
"<< /Type /Font /Subtype /Type0 /BaseFont /ABCDEE+Test \
/Encoding /Identity-H /DescendantFonts [7 0 R] /ToUnicode 6 0 R >>"
.to_string(),
format!("<< /Length {} >>\nstream\n{cmap}\nendstream", cmap.len()),
"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /ABCDEE+Test \
/CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> \
/FontDescriptor 8 0 R /CIDToGIDMap /Identity /DW 1000 >>"
.to_string(),
"<< /Type /FontDescriptor /FontName /ABCDEE+Test /Flags 4 \
/FontBBox [0 0 1000 1000] /ItalicAngle 0 /Ascent 1000 /Descent 0 \
/CapHeight 1000 /StemV 80 >>"
.to_string(),
];
let mut pdf = String::from("%PDF-1.6\n");
let mut offsets = Vec::new();
for (index, body) in objects.iter().enumerate() {
offsets.push(pdf.len());
pdf.push_str(&format!("{} 0 obj\n{body}\nendobj\n", index + 1));
}
let xref_at = pdf.len();
pdf.push_str(&format!("xref\n0 {}\n", objects.len() + 1));
pdf.push_str("0000000000 65535 f \n");
for offset in &offsets {
pdf.push_str(&format!("{offset:010} 00000 n \n"));
}
pdf.push_str(&format!(
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_at}\n%%EOF\n",
objects.len() + 1
));
pdf.into_bytes()
}
fn image_based_with_text_layer() -> Vec<u8> {
build_pdf(&format!(
"{}{}",
vector_paths(600),
text_block(&["Small text layer only"])
))
}
fn text_rich_with_vector_paths() -> Vec<u8> {
let lines = [
"Quarterly report of the working group on data handling",
"The committee reviewed the summary and approved the plan",
"Revenue increased while costs held steady this period",
"Next review is scheduled for the following fiscal quarter",
"Signatures and approvals are recorded on the final page",
];
build_pdf(&format!("{}{}", vector_paths(80), text_block(&lines)))
}
fn vector_only_no_text() -> Vec<u8> {
build_pdf(&vector_paths(300))
}
#[test]
fn partial_pdf_warnings_keep_their_prefix() {
assert_eq!(
promote_warning("1 of 2 pages need OCR and were not extracted"),
"pdf_partial_text: 1 of 2 pages need OCR"
);
assert_eq!(
promote_warning("recovered a broken xref"),
"anydoc_recovery: recovered a broken xref"
);
}
#[test]
fn image_based_page_with_text_layer_recovers_with_the_ocr_warning() {
let conversion = convert_pdf(&image_based_with_text_layer())
.expect("a real text layer behind vector artwork is recovered, not failed closed");
assert!(conversion.recovered, "the recovery path produced the text");
assert!(
conversion.text.contains("Small text layer only"),
"recovered text: {:?}",
conversion.text
);
assert!(
conversion
.warnings
.iter()
.any(|warning| warning.starts_with("pdf_partial_text:")),
"a disputed page is always routed to OCR: {:?}",
conversion.warnings
);
}
#[test]
fn text_rich_page_converts_without_warning_spam() {
let conversion = convert_pdf(&text_rich_with_vector_paths())
.expect("a text-based page converts through the markdown path");
assert!(
!conversion.recovered,
"a text-based page does not use the recovery path"
);
assert!(
conversion.text.contains("Quarterly report"),
"text: {:?}",
conversion.text
);
assert!(
conversion.warnings.is_empty(),
"a text-rich page carries no OCR-routing warning: {:?}",
conversion.warnings
);
}
#[test]
fn vector_only_page_without_text_fails_closed() {
let error = convert_pdf(&vector_only_no_text())
.expect_err("a page with no text layer has nothing to recover");
assert_eq!(
error.code, "pdf_no_text_layer",
"the genuine no-text class still fails closed: {error:?}"
);
}
#[test]
fn a_page_whose_glyphs_decode_to_control_only_fails_closed() {
let error = convert_pdf(&image_based_with_glyph_decoding_to("0007"))
.expect_err("a page of control characters has nothing meaningful to recover");
assert_eq!(error.code, "pdf_no_text_layer", "error: {error:?}");
}
#[test]
fn a_page_of_zero_width_spaces_only_fails_closed() {
let error = convert_pdf(&image_based_with_glyph_decoding_to("200B"))
.expect_err("a page of zero-width spaces has nothing meaningful to recover");
assert_eq!(error.code, "pdf_no_text_layer", "error: {error:?}");
}
#[test]
fn outcomes_are_byte_reproducible() {
assert_eq!(
image_based_with_text_layer(),
image_based_with_text_layer(),
"the fixture generator is deterministic"
);
let first = convert_pdf(&image_based_with_text_layer()).unwrap();
let second = convert_pdf(&image_based_with_text_layer()).unwrap();
assert_eq!(first.text, second.text);
assert_eq!(first.warnings, second.warnings);
assert_eq!(first.recovered, second.recovered);
assert_eq!(first.segments.len(), second.segments.len());
}
}