use std::io::Read as _;
use anyhow::Result;
use flate2::read::{DeflateDecoder, ZlibDecoder};
use super::{ContentHandler, ConversionResult};
const MAX_SCAN_BYTES: usize = 2 * 1024 * 1024;
const MAX_DECOMPRESSED_BYTES: usize = 8 * 1024 * 1024;
const MAX_OUTPUT_CHARS: usize = 50_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PdfKind {
HasTextLayer,
ImageOnly,
Indeterminate,
}
pub struct PdfLightHandler;
impl ContentHandler for PdfLightHandler {
fn supported_types(&self) -> &[&str] {
&["application/pdf"]
}
fn to_markdown(&self, bytes: &[u8], content_type: &str) -> Result<ConversionResult> {
let start = std::time::Instant::now();
if !is_pdf(bytes) {
anyhow::bail!("Not a PDF: missing %PDF- header");
}
let page_count = count_pages(bytes);
let scan = &bytes[..bytes.len().min(MAX_SCAN_BYTES)];
let decompressed = collect_stream_contents(scan).unwrap_or_default();
let text = extract_pdf_text(&decompressed).or_else(|| extract_pdf_text(scan));
let kind = classify_pdf(scan, &decompressed);
let markdown = build_markdown(text, page_count, kind);
Ok(ConversionResult {
markdown,
page_count: Some(page_count),
content_type: content_type.to_string(),
elapsed_ms: start.elapsed().as_secs_f64() * 1000.0,
quality: None,
})
}
}
fn is_pdf(bytes: &[u8]) -> bool {
bytes.starts_with(b"%PDF-")
}
fn count_pages(bytes: &[u8]) -> usize {
let count = count_occurrences(bytes, b"/Type /Page");
let count2 = count_occurrences(bytes, b"/Type/Page");
count.max(count2).max(1)
}
fn count_occurrences(haystack: &[u8], needle: &[u8]) -> usize {
if needle.is_empty() {
return 0;
}
let mut count = 0;
let mut i = 0;
while i + needle.len() <= haystack.len() {
if haystack[i..i + needle.len()] == *needle {
count += 1;
i += needle.len();
} else {
i += 1;
}
}
count
}
fn extract_pdf_text(bytes: &[u8]) -> Option<String> {
let mut output = String::with_capacity(4096);
let mut in_bt_block = false;
let mut pending_strings: Vec<String> = Vec::new();
let mut i = 0;
while i < bytes.len() && output.len() < MAX_OUTPUT_CHARS {
if !in_bt_block {
if bytes[i..].starts_with(b"BT") && is_pdf_token_boundary(bytes, i, 2) {
in_bt_block = true;
i += 2;
continue;
}
i += 1;
continue;
}
if bytes[i..].starts_with(b"ET") && is_pdf_token_boundary(bytes, i, 2) {
flush_strings(&mut pending_strings, &mut output);
in_bt_block = false;
i += 2;
continue;
}
if bytes[i] == b'('
&& let Some((s, consumed)) = parse_literal_string(&bytes[i..])
{
pending_strings.push(s);
i += consumed;
continue;
}
if bytes[i] == b'<'
&& bytes.get(i + 1).is_some_and(|&b| b != b'<')
&& let Some((s, consumed)) = parse_hex_string(&bytes[i..])
{
pending_strings.push(s);
i += consumed;
continue;
}
if bytes[i] == b'['
&& let Some((strings, consumed)) = parse_array_strings(&bytes[i..])
{
pending_strings.extend(strings);
i += consumed;
continue;
}
if matches!(bytes[i], b'T' | b'\'' | b'"') {
let op_end = scan_operator_end(bytes, i);
let op = &bytes[i..op_end];
match op {
b"Tj" | b"TJ" | b"'" | b"\"" => {
flush_strings(&mut pending_strings, &mut output);
}
_ => {}
}
i = op_end;
continue;
}
if bytes[i] == b'T' && i + 1 < bytes.len() && matches!(bytes[i + 1], b'd' | b'D' | b'*') {
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
i += 2;
continue;
}
i += 1;
}
if output.trim().is_empty() {
None
} else {
Some(output)
}
}
fn flush_strings(pending: &mut Vec<String>, output: &mut String) {
if pending.is_empty() {
return;
}
let line: String = pending.drain(..).collect();
let trimmed = line.trim();
if !trimmed.is_empty() {
if !output.is_empty() && !output.ends_with('\n') {
output.push(' ');
}
output.push_str(trimmed);
}
}
fn is_pdf_token_boundary(bytes: &[u8], i: usize, len: usize) -> bool {
let before_ok = i == 0 || is_pdf_delimiter_or_ws(bytes[i - 1]);
let after_ok = i + len >= bytes.len() || is_pdf_delimiter_or_ws(bytes[i + len]);
before_ok && after_ok
}
fn is_pdf_delimiter_or_ws(b: u8) -> bool {
matches!(
b,
b' ' | b'\t' | b'\n' | b'\r' | b'(' | b')' | b'[' | b']' | b'{' | b'}' | b'/' | b'<' | b'>'
)
}
fn parse_literal_string(bytes: &[u8]) -> Option<(String, usize)> {
if bytes.first() != Some(&b'(') {
return None;
}
let mut result = String::new();
let mut i = 1;
let mut depth = 1usize;
while i < bytes.len() {
match bytes[i] {
b'\\' if i + 1 < bytes.len() => {
match bytes[i + 1] {
b'n' => {
result.push('\n');
i += 2;
}
b'r' => {
result.push('\r');
i += 2;
}
b't' => {
result.push('\t');
i += 2;
}
b'b' => {
result.push('\u{8}');
i += 2;
}
b'f' => {
result.push('\u{c}');
i += 2;
}
d @ b'0'..=b'7' => {
let mut val = (d - b'0') as u32;
let mut consumed = 2;
for k in 0..2 {
match bytes.get(i + 2 + k) {
Some(&c @ b'0'..=b'7') => {
val = val * 8 + (c - b'0') as u32;
consumed += 1;
}
_ => break,
}
}
let byte = (val & 0xFF) as u8;
if byte.is_ascii_graphic() || byte == b' ' {
result.push(char::from(byte));
}
i += consumed;
}
b'\n' => i += 2,
b'\r' => {
i += 2;
if bytes.get(i) == Some(&b'\n') {
i += 1;
}
}
c => {
result.push(char::from(c));
i += 2;
}
}
}
b'(' => {
depth += 1;
result.push('(');
i += 1;
}
b')' => {
depth -= 1;
if depth == 0 {
return Some((sanitize_pdf_string(&result), i + 1));
}
result.push(')');
i += 1;
}
b => {
if b.is_ascii_graphic() || b == b' ' {
result.push(char::from(b));
}
i += 1;
}
}
}
None }
fn parse_hex_string(bytes: &[u8]) -> Option<(String, usize)> {
if bytes.first() != Some(&b'<') {
return None;
}
let end = bytes[1..].iter().position(|&b| b == b'>')?;
let hex_slice = &bytes[1..=end];
let decoded = decode_hex_string(hex_slice);
Some((decoded, end + 2))
}
fn decode_hex_string(hex: &[u8]) -> String {
let digits: Vec<u8> = hex
.iter()
.filter(|&&b| !b.is_ascii_whitespace())
.copied()
.collect();
let mut result = String::new();
let mut j = 0;
while j < digits.len() {
let hi = hex_digit(digits[j]);
let lo = if j + 1 < digits.len() {
hex_digit(digits[j + 1])
} else {
Some(0)
};
match (hi, lo) {
(Some(h), Some(l)) => {
let byte: u8 = (h << 4) | l;
if byte.is_ascii_graphic() || byte == b' ' {
result.push(char::from(byte));
}
j += 2;
}
_ => {
j += 1;
}
}
}
result
}
fn hex_digit(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
fn parse_array_strings(bytes: &[u8]) -> Option<(Vec<String>, usize)> {
if bytes.first() != Some(&b'[') {
return None;
}
let mut strings = Vec::new();
let mut i = 1;
while i < bytes.len() {
match bytes[i] {
b']' => return Some((strings, i + 1)),
b'(' => {
if let Some((s, consumed)) = parse_literal_string(&bytes[i..]) {
strings.push(s);
i += consumed;
} else {
i += 1;
}
}
b'<' if bytes.get(i + 1).is_some_and(|&b| b != b'<') => {
if let Some((s, consumed)) = parse_hex_string(&bytes[i..]) {
strings.push(s);
i += consumed;
} else {
i += 1;
}
}
_ => {
i += 1;
}
}
}
None }
fn scan_operator_end(bytes: &[u8], i: usize) -> usize {
let mut j = i;
while j < bytes.len() && !is_pdf_delimiter_or_ws(bytes[j]) {
j += 1;
}
j
}
fn sanitize_pdf_string(s: &str) -> String {
s.chars()
.map(|c| if c.is_control() && c != '\n' { ' ' } else { c })
.collect()
}
fn collect_stream_contents(bytes: &[u8]) -> Option<Vec<u8>> {
const KW: &[u8] = b"stream";
let mut out: Vec<u8> = Vec::new();
let mut i = 0;
let mut found_any = false;
let mut prev_end = 0usize;
while i + KW.len() <= bytes.len() {
if &bytes[i..i + KW.len()] != KW {
i += 1;
continue;
}
if i > 0 && bytes[i - 1].is_ascii_alphabetic() {
i += KW.len();
continue;
}
found_any = true;
let dict_start = i.saturating_sub(512).max(prev_end);
let dict = &bytes[dict_start..i];
let mut data_start = i + KW.len();
if bytes.get(data_start) == Some(&b'\r') {
data_start += 1;
}
if bytes.get(data_start) == Some(&b'\n') {
data_start += 1;
}
let Some(rel_end) = find_subsequence(&bytes[data_start..], b"endstream") else {
break;
};
let data = &bytes[data_start..data_start + rel_end];
i = data_start + rel_end + b"endstream".len();
prev_end = i;
if dict_is_image(dict) {
continue;
}
if window_contains(dict, b"/FlateDecode") {
if let Some(inflated) = inflate_flate(data) {
append_capped(&mut out, &inflated);
}
} else if !window_contains(dict, b"Decode") {
append_capped(&mut out, data);
}
if out.len() >= MAX_DECOMPRESSED_BYTES {
break;
}
}
if found_any { Some(out) } else { None }
}
fn append_capped(dst: &mut Vec<u8>, src: &[u8]) {
let room = MAX_DECOMPRESSED_BYTES.saturating_sub(dst.len());
if room == 0 {
return;
}
dst.extend_from_slice(&src[..src.len().min(room)]);
}
fn dict_is_image(dict: &[u8]) -> bool {
window_contains(dict, b"/DCTDecode")
|| window_contains(dict, b"/CCITTFaxDecode")
|| window_contains(dict, b"/JPXDecode")
|| window_contains(dict, b"/JBIG2Decode")
|| window_contains(dict, b"/Image")
}
fn inflate_flate(data: &[u8]) -> Option<Vec<u8>> {
let mut buf = Vec::new();
let mut z = ZlibDecoder::new(data).take(MAX_DECOMPRESSED_BYTES as u64);
if z.read_to_end(&mut buf).is_ok() && !buf.is_empty() {
return Some(buf);
}
buf.clear();
let mut d = DeflateDecoder::new(data).take(MAX_DECOMPRESSED_BYTES as u64);
let _ = d.read_to_end(&mut buf);
if buf.is_empty() { None } else { Some(buf) }
}
fn classify_pdf(raw: &[u8], decompressed: &[u8]) -> PdfKind {
let has_font = window_contains(raw, b"/Font")
|| window_contains(raw, b"/FontDescriptor")
|| window_contains(decompressed, b"/Font")
|| window_contains(decompressed, b"/FontDescriptor");
if has_font {
return PdfKind::HasTextLayer;
}
let has_image = dict_is_image(raw) || dict_is_image(decompressed);
if has_image {
return PdfKind::ImageOnly;
}
PdfKind::Indeterminate
}
fn window_contains(haystack: &[u8], needle: &[u8]) -> bool {
find_subsequence(haystack, needle).is_some()
}
fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() || needle.len() > haystack.len() {
return None;
}
haystack.windows(needle.len()).position(|w| w == needle)
}
fn build_markdown(text: Option<String>, page_count: usize, kind: PdfKind) -> String {
let pages_label = if page_count == 1 {
"1 page".to_string()
} else {
format!("{page_count} pages")
};
match text {
Some(extracted) if !extracted.trim().is_empty() => {
format!(
"[PDF: {pages_label}, text extracted — for full fidelity rebuild with `--features pdf`]\n\n{}",
extracted.trim()
)
}
_ => match kind {
PdfKind::HasTextLayer => format!(
"[PDF: {pages_label}, text layer present but not decoded by the built-in extractor \
(likely custom font encoding). Rebuild with `--features pdf` for pdfium extraction.]"
),
PdfKind::ImageOnly => format!(
"[PDF: {pages_label}, scanned (image-only) — no text layer detected. Use OCR to extract text.]"
),
PdfKind::Indeterminate => format!(
"[PDF: {pages_label}, no extractable text. If this is a scan, use OCR; \
otherwise rebuild with `--features pdf` for pdfium extraction.]"
),
},
}
}
#[cfg(test)]
mod tests {
use std::io::Write as _;
use super::*;
#[test]
fn is_pdf_returns_true_for_valid_header() {
assert!(is_pdf(b"%PDF-1.4\n"));
}
#[test]
fn is_pdf_returns_false_for_non_pdf_bytes() {
assert!(!is_pdf(b"<!DOCTYPE html>"));
}
#[test]
fn is_pdf_returns_false_for_empty_bytes() {
assert!(!is_pdf(b""));
}
#[test]
fn count_pages_returns_1_for_single_page_pdf_fragment() {
let bytes = b"%PDF-1.4\n/Type /Page\n";
let count = count_pages(bytes);
assert_eq!(count, 1);
}
#[test]
fn count_pages_returns_3_for_three_page_entries() {
let bytes = b"%PDF-1.4\n/Type /Page\n/Type /Page\n/Type /Page\n";
let count = count_pages(bytes);
assert_eq!(count, 3);
}
#[test]
fn count_pages_handles_compact_nospace_variant() {
let bytes = b"%PDF-1.4\n/Type/Page\n/Type/Page\n";
let count = count_pages(bytes);
assert_eq!(count, 2);
}
#[test]
fn parse_literal_string_decodes_simple_string() {
let (s, consumed) = parse_literal_string(b"(Hello, World!)").unwrap();
assert_eq!(s, "Hello, World!");
assert_eq!(consumed, 15);
}
#[test]
fn parse_literal_string_handles_escaped_parens() {
let (s, consumed) = parse_literal_string(b"(foo\\(bar\\)baz)").unwrap();
assert_eq!(s, "foo(bar)baz");
assert_eq!(consumed, 15);
}
#[test]
fn parse_literal_string_decodes_octal_escapes() {
let (s, _) = parse_literal_string(b"(RL\\050x\\051)").unwrap();
assert_eq!(s, "RL(x)");
}
#[test]
fn parse_literal_string_octal_line_continuation_is_dropped() {
let (s, _) = parse_literal_string(b"(ab\\\ncd)").unwrap();
assert_eq!(s, "abcd");
}
#[test]
fn parse_literal_string_handles_nested_parens() {
let (s, _) = parse_literal_string(b"(outer (inner) end)").unwrap();
assert_eq!(s, "outer (inner) end");
}
#[test]
fn parse_literal_string_returns_none_for_unclosed() {
let result = parse_literal_string(b"(unclosed");
assert!(result.is_none());
}
#[test]
fn parse_hex_string_decodes_ascii_hex_pairs() {
let (s, consumed) = parse_hex_string(b"<4869>").unwrap();
assert_eq!(s, "Hi");
assert_eq!(consumed, 6);
}
#[test]
fn parse_hex_string_ignores_spaces_in_hex_content() {
let (s, _) = parse_hex_string(b"<48 65 6C 6C 6F>").unwrap();
assert_eq!(s, "Hello");
}
#[test]
fn parse_hex_string_returns_none_for_unclosed() {
let result = parse_hex_string(b"<4869");
assert!(result.is_none());
}
#[test]
fn extract_pdf_text_finds_text_in_bt_et_block() {
let pdf = b"%PDF-1.4\nBT\n(Hello PDF) Tj\nET\n";
let text = extract_pdf_text(pdf);
assert!(text.is_some());
let t = text.unwrap();
assert!(t.contains("Hello PDF"), "got: {t}");
}
#[test]
fn extract_pdf_text_returns_none_for_no_bt_blocks() {
let pdf = b"%PDF-1.4\nxref\n0 1\n0000000000 65535 f \n";
let text = extract_pdf_text(pdf);
assert!(text.is_none());
}
#[test]
fn extract_pdf_text_handles_multiple_bt_et_blocks() {
let pdf = b"%PDF-1.4\nBT\n(First line) Tj\nET\nBT\n(Second line) Tj\nET\n";
let text = extract_pdf_text(pdf);
let t = text.expect("expected text");
assert!(t.contains("First line"), "got: {t}");
assert!(t.contains("Second line"), "got: {t}");
}
#[test]
fn build_markdown_with_text_includes_extraction_note() {
let md = build_markdown(Some("Sample content".to_string()), 2, PdfKind::HasTextLayer);
assert!(md.contains("[PDF: 2 pages"), "got: {md}");
assert!(md.contains("Sample content"), "got: {md}");
}
#[test]
fn build_markdown_image_only_reports_scanned() {
let md = build_markdown(None, 5, PdfKind::ImageOnly);
assert!(md.contains("[PDF:"), "got: {md}");
assert!(md.contains("scanned"), "got: {md}");
assert!(md.contains("OCR"), "got: {md}");
assert!(md.contains("5 pages"), "got: {md}");
}
#[test]
fn build_markdown_born_digital_never_reports_scanned() {
let md = build_markdown(None, 28, PdfKind::HasTextLayer);
assert!(
!md.contains("scanned"),
"born-digital must not say scanned: {md}"
);
assert!(md.contains("text layer present"), "got: {md}");
assert!(md.contains("--features pdf"), "got: {md}");
}
#[test]
fn build_markdown_messages_are_distinct_per_kind() {
let image = build_markdown(None, 3, PdfKind::ImageOnly);
let born = build_markdown(None, 3, PdfKind::HasTextLayer);
assert_ne!(image, born, "scanned vs born-digital messages must differ");
}
#[test]
fn build_markdown_single_page_uses_singular_form() {
let md = build_markdown(None, 1, PdfKind::ImageOnly);
assert!(md.contains("1 page"), "got: {md}");
assert!(!md.contains("1 pages"), "got: {md}");
}
#[test]
fn pdf_light_handler_returns_error_for_non_pdf_bytes() {
let handler = PdfLightHandler;
let result = handler.to_markdown(b"<html>not a pdf</html>", "application/pdf");
assert!(result.is_err());
}
#[test]
fn pdf_light_handler_extracts_text_from_simple_pdf() {
let pdf = b"%PDF-1.4\n/Type /Page\nBT\n(Test document) Tj\nET\n%%EOF";
let handler = PdfLightHandler;
let result = handler.to_markdown(pdf, "application/pdf").unwrap();
assert!(
result.markdown.contains("Test document"),
"got: {}",
result.markdown
);
assert_eq!(result.page_count, Some(1));
}
#[test]
fn pdf_light_handler_reports_scanned_for_image_only_pdf() {
let pdf = b"%PDF-1.4\n/Type /Page\n<< /Type /XObject /Subtype /Image /Filter /DCTDecode >>\nstream\n\xff\xd8\xff\xe0junk\nendstream\nxref\n%%EOF";
let handler = PdfLightHandler;
let result = handler.to_markdown(pdf, "application/pdf").unwrap();
assert!(
result.markdown.contains("scanned"),
"got: {}",
result.markdown
);
}
#[test]
fn pdf_light_handler_extracts_text_from_flate_compressed_stream() {
let content = b"BT\n(Hello from a compressed stream) Tj\nET";
let mut enc = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
enc.write_all(content).unwrap();
let compressed = enc.finish().unwrap();
let mut pdf: Vec<u8> = Vec::new();
pdf.extend_from_slice(b"%PDF-1.5\n/Type /Page\n/Font /F1\n");
pdf.extend_from_slice(b"<< /Filter /FlateDecode /Length ");
pdf.extend_from_slice(compressed.len().to_string().as_bytes());
pdf.extend_from_slice(b" >>\nstream\n");
pdf.extend_from_slice(&compressed);
pdf.extend_from_slice(b"\nendstream\n%%EOF");
let handler = PdfLightHandler;
let result = handler.to_markdown(&pdf, "application/pdf").unwrap();
assert!(
result.markdown.contains("Hello from a compressed stream"),
"got: {}",
result.markdown
);
assert!(
!result.markdown.contains("scanned"),
"born-digital compressed PDF must not be scanned: {}",
result.markdown
);
}
#[test]
fn collect_stream_contents_inflates_flate_and_skips_images() {
let text = b"BT (visible) Tj ET";
let mut enc = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
enc.write_all(text).unwrap();
let compressed = enc.finish().unwrap();
let mut pdf: Vec<u8> = Vec::new();
pdf.extend_from_slice(
b"<< /Subtype /Image /Filter /DCTDecode >>\nstream\nRAWIMAGE\nendstream\n",
);
pdf.extend_from_slice(b"<< /Filter /FlateDecode >>\nstream\n");
pdf.extend_from_slice(&compressed);
pdf.extend_from_slice(b"\nendstream\n");
let out = collect_stream_contents(&pdf).expect("streams present");
let s = String::from_utf8_lossy(&out);
assert!(s.contains("visible"), "inflated text missing: {s}");
assert!(
!s.contains("RAWIMAGE"),
"image stream should be skipped: {s}"
);
}
#[test]
fn classify_pdf_detects_born_digital_via_font() {
let pdf = b"%PDF-1.5\n<< /Type /Font /Subtype /Type1 /FontDescriptor 1 0 R >>";
assert_eq!(classify_pdf(pdf, b""), PdfKind::HasTextLayer);
}
#[test]
fn classify_pdf_detects_image_only() {
let pdf = b"%PDF-1.5\n<< /Subtype /Image /Filter /DCTDecode >>";
assert_eq!(classify_pdf(pdf, b""), PdfKind::ImageOnly);
}
#[test]
fn inflate_flate_handles_corrupt_tail_gracefully() {
let mut enc = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
enc.write_all(b"good text here").unwrap();
let mut compressed = enc.finish().unwrap();
compressed.truncate(compressed.len().saturating_sub(2)); let _ = inflate_flate(&compressed);
}
#[test]
fn pdf_light_supported_types_is_application_pdf() {
let handler = PdfLightHandler;
assert_eq!(handler.supported_types(), &["application/pdf"]);
}
#[test]
fn count_occurrences_finds_no_match_in_empty_haystack() {
assert_eq!(count_occurrences(b"", b"needle"), 0);
}
#[test]
fn count_occurrences_finds_multiple_non_overlapping() {
let hay = b"abcabcabc";
assert_eq!(count_occurrences(hay, b"abc"), 3);
}
}