use oxideav_pdf::reader::DocumentReader;
fn build_pdf_with_differences(
content: &[u8],
base_encoding: &str,
differences_body: &str,
) -> Vec<u8> {
let mut buf: Vec<u8> = Vec::new();
buf.extend_from_slice(b"%PDF-1.4\n%\xE2\xE3\xCF\xD3\n");
let off_1 = buf.len();
buf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
let off_2 = buf.len();
buf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n");
let off_3 = buf.len();
let page_dict = b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
/Resources << /Font << /F0 4 0 R >> >> /Contents 5 0 R >>\nendobj\n";
buf.extend_from_slice(page_dict);
let off_4 = buf.len();
let font_obj = format!(
"4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
/Encoding << /Type /Encoding /BaseEncoding /{base_encoding} \
/Differences [{differences_body}] >> >>\nendobj\n"
);
buf.extend_from_slice(font_obj.as_bytes());
let off_5 = buf.len();
let content_obj_header = format!("5 0 obj\n<< /Length {} >>\nstream\n", content.len());
buf.extend_from_slice(content_obj_header.as_bytes());
buf.extend_from_slice(content);
buf.extend_from_slice(b"\nendstream\nendobj\n");
let xref_off = buf.len();
buf.extend_from_slice(b"xref\n0 6\n");
buf.extend_from_slice(b"0000000000 65535 f \n");
for o in [off_1, off_2, off_3, off_4, off_5] {
buf.extend_from_slice(format!("{:010} 00000 n \n", o).as_bytes());
}
buf.extend_from_slice(b"trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n");
buf.extend_from_slice(format!("{xref_off}\n%%EOF\n").as_bytes());
buf
}
fn cross_check_pdftotext(pdf_bytes: &[u8], expected_substring: &str) {
use std::io::Write;
use std::process::{Command, Stdio};
let mut child = match Command::new("pdftotext")
.args(["-raw", "-", "-"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
{
Ok(c) => c,
Err(_) => {
eprintln!("pdftotext not on PATH; skipping cross-check");
return;
}
};
child
.stdin
.as_mut()
.unwrap()
.write_all(pdf_bytes)
.expect("write pdf bytes to pdftotext");
let output = child.wait_with_output().expect("wait for pdftotext");
if !output.status.success() {
eprintln!(
"pdftotext exited non-zero ({:?}); skipping cross-check",
output.status
);
return;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let stripped: String = stdout
.lines()
.map(|line| line.trim_end())
.collect::<Vec<_>>()
.join("\n");
assert!(
stripped.contains(expected_substring),
"pdftotext output did not contain expected substring:\n\
expected: {expected_substring:?}\nactual: {stripped:?}"
);
}
#[test]
fn differences_smart_quotes_resolve_via_agl() {
let differences = "145 /quoteleft /quoteright /quotedblleft /quotedblright";
let content = b"BT /F0 12 Tf 100 700 Td <91929394> Tj ET";
let pdf = build_pdf_with_differences(content, "WinAnsiEncoding", differences);
let mut reader = DocumentReader::open(&pdf).expect("open");
let runs = reader.text_extraction().expect("extract").runs;
assert_eq!(runs.len(), 1);
assert_eq!(
runs[0].text, "\u{2018}\u{2019}\u{201C}\u{201D}",
"smart quotes from /Differences should resolve via AGL"
);
cross_check_pdftotext(&pdf, "\u{2018}\u{2019}\u{201C}\u{201D}");
}
#[test]
fn differences_swap_alphabet_with_greek_glyphs() {
let differences = "65 /Omega /alpha /beta";
let content = b"BT /F0 12 Tf 100 700 Td <414243> Tj ET";
let pdf = build_pdf_with_differences(content, "WinAnsiEncoding", differences);
let mut reader = DocumentReader::open(&pdf).expect("open");
let runs = reader.text_extraction().expect("extract").runs;
assert_eq!(runs.len(), 1);
assert_eq!(
runs[0].text, "\u{03A9}\u{03B1}\u{03B2}",
"alphabet swap to Greek glyphs"
);
}
#[test]
fn differences_ligature_expansion_fi_fl() {
let differences = "253 /fi /fl";
let content = b"BT /F0 12 Tf 100 700 Td (o\xFD\xFEce) Tj ET";
let pdf = build_pdf_with_differences(content, "WinAnsiEncoding", differences);
let mut reader = DocumentReader::open(&pdf).expect("open");
let runs = reader.text_extraction().expect("extract").runs;
assert_eq!(runs.len(), 1);
assert_eq!(
runs[0].text, "ofiflce",
"ligature glyph names should expand to multi-char strings"
);
}
#[test]
fn differences_multiple_runs_with_resets() {
let differences = "24 /breve /caron /circumflex /tilde 32 /breve";
let content = b"BT /F0 12 Tf 100 700 Td <18191A1B20> Tj ET";
let pdf = build_pdf_with_differences(content, "WinAnsiEncoding", differences);
let mut reader = DocumentReader::open(&pdf).expect("open");
let runs = reader.text_extraction().expect("extract").runs;
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].text, "\u{02D8}\u{02C7}\u{02C6}\u{02DC}\u{02D8}");
}
#[test]
fn differences_unknown_glyph_becomes_replacement_char() {
let differences = "65 /not-a-real-glyph-name";
let content = b"BT /F0 12 Tf 100 700 Td <4142> Tj ET";
let pdf = build_pdf_with_differences(content, "WinAnsiEncoding", differences);
let mut reader = DocumentReader::open(&pdf).expect("open");
let runs = reader.text_extraction().expect("extract").runs;
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].text, "\u{FFFD}B");
}
#[test]
fn no_differences_with_winansi_base_unchanged() {
let differences = "";
let content = b"BT /F0 12 Tf 100 700 Td (Hello) Tj ET";
let pdf = build_pdf_with_differences(content, "WinAnsiEncoding", differences);
let mut reader = DocumentReader::open(&pdf).expect("open");
let runs = reader.text_extraction().expect("extract").runs;
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].text, "Hello");
cross_check_pdftotext(&pdf, "Hello");
}
#[test]
fn differences_with_macroman_base_encoding() {
let differences = "65 /Omega";
let content = b"BT /F0 12 Tf 100 700 Td <414243> Tj ET";
let pdf = build_pdf_with_differences(content, "MacRomanEncoding", differences);
let mut reader = DocumentReader::open(&pdf).expect("open");
let runs = reader.text_extraction().expect("extract").runs;
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].text, "\u{03A9}BC");
}