1use crate::segments::Segment;
23
24use super::anydoc_log::capture_anydoc;
25use super::{ConvertError, is_meaningful, markdown_to_plain, normalize_text};
26
27#[derive(Debug)]
29pub struct PdfConversion {
30 pub text: String,
32 pub warnings: Vec<String>,
34 pub segments: Vec<Segment>,
36 pub recovered: bool,
41}
42
43const RECOVERED_TEXT_WARNING: &str = "pdf_partial_text: text recovered from a page reported as \
49image-based; route to OCR to confirm completeness";
50
51fn error_code(error: &anydoc::ConvertError) -> &'static str {
55 if matches!(error, anydoc::ConvertError::Unsupported(_)) {
56 return "pdf_no_text_layer";
57 }
58 match error.code() {
59 "unsupported" => "unsupported",
60 "malformed" => "malformed",
61 "encrypted" => "encrypted",
62 "resourceLimit" => "resourceLimit",
63 "missingPart" => "missingPart",
64 _ => "io",
65 }
66}
67
68fn promote_warning(message: &str) -> String {
72 if message.contains("pages need OCR") {
73 let head = message.split(" and ").next().unwrap_or(message);
74 return format!("pdf_partial_text: {head}");
75 }
76 format!("anydoc_recovery: {message}")
77}
78
79fn recover_text_layer(source: &[u8]) -> Option<String> {
92 let raw = pdf_inspector::extractor::extract_text_mem(source).ok()?;
93 let text = normalize_text(&raw);
94 if text.chars().any(is_meaningful) {
95 Some(text)
96 } else {
97 None
98 }
99}
100
101pub fn convert_pdf(source: &[u8]) -> Result<PdfConversion, ConvertError> {
105 let (converted, captured) =
106 capture_anydoc(|| anydoc::to_markdown_bytes(source, anydoc::Format::Pdf));
107 match converted {
108 Ok(markdown) => {
109 let text = normalize_text(&markdown_to_plain(&markdown));
110 if !source.is_empty() && text.is_empty() {
111 return Err(empty_output(source.len()));
112 }
113 let warnings = captured
114 .iter()
115 .map(|message| promote_warning(message))
116 .collect();
117 let segments = vec![Segment::span(0, text.len(), "document")];
118 Ok(PdfConversion {
119 text,
120 warnings,
121 segments,
122 recovered: false,
123 })
124 }
125 Err(error) if matches!(error, anydoc::ConvertError::Unsupported(_)) => {
131 match recover_text_layer(source) {
132 Some(text) => {
133 let mut warnings: Vec<String> = captured
134 .iter()
135 .map(|message| promote_warning(message))
136 .collect();
137 if !warnings
143 .iter()
144 .any(|warning| warning.starts_with("pdf_partial_text:"))
145 {
146 warnings.push(RECOVERED_TEXT_WARNING.to_string());
147 }
148 let segments = vec![Segment::span(0, text.len(), "document")];
149 Ok(PdfConversion {
150 text,
151 warnings,
152 segments,
153 recovered: true,
154 })
155 }
156 None => Err(ConvertError {
157 code: "pdf_no_text_layer",
158 message: error.to_string(),
159 }),
160 }
161 }
162 Err(error) => Err(ConvertError {
163 code: error_code(&error),
164 message: error.to_string(),
165 }),
166 }
167}
168
169fn empty_output(source_len: usize) -> ConvertError {
170 ConvertError {
171 code: "empty_output",
172 message: format!("source is {source_len} bytes but conversion produced no text"),
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 fn build_pdf(content: &str) -> Vec<u8> {
191 let objects = [
192 "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
193 "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
194 "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
195 /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>"
196 .to_string(),
197 format!(
198 "<< /Length {} >>\nstream\n{content}\nendstream",
199 content.len()
200 ),
201 "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
202 /Encoding /WinAnsiEncoding >>"
203 .to_string(),
204 ];
205 let mut pdf = String::from("%PDF-1.6\n");
206 let mut offsets = Vec::new();
207 for (index, body) in objects.iter().enumerate() {
208 offsets.push(pdf.len());
209 pdf.push_str(&format!("{} 0 obj\n{body}\nendobj\n", index + 1));
210 }
211 let xref_at = pdf.len();
212 pdf.push_str(&format!("xref\n0 {}\n", objects.len() + 1));
213 pdf.push_str("0000000000 65535 f \n");
214 for offset in &offsets {
215 pdf.push_str(&format!("{offset:010} 00000 n \n"));
216 }
217 pdf.push_str(&format!(
218 "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_at}\n%%EOF\n",
219 objects.len() + 1
220 ));
221 pdf.into_bytes()
222 }
223
224 fn vector_paths(count: usize) -> String {
227 let mut stream = String::new();
228 for step in 0..count {
229 let n = (step % 500) + 50;
230 stream.push_str(&format!("{n} {n} m {n} {n} l S\n"));
231 }
232 stream
233 }
234
235 fn text_block(lines: &[&str]) -> String {
237 let mut stream = String::from("BT /F1 12 Tf 72 700 Td\n");
238 for (index, line) in lines.iter().enumerate() {
239 if index > 0 {
240 stream.push_str("0 -14 Td\n");
241 }
242 stream.push_str(&format!("({line}) Tj\n"));
243 }
244 stream.push_str("ET\n");
245 stream
246 }
247
248 fn image_based_with_glyph_decoding_to(dst: &str) -> Vec<u8> {
256 let cmap = format!(
257 "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n\
258 /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n\
259 /CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n\
260 1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n\
261 1 beginbfchar\n<0001> <{dst}>\nendbfchar\nendcmap\n\
262 CMapName currentdict /CMap defineresource pop\nend\nend"
263 );
264 let content = format!("{}BT /F1 12 Tf 72 700 Td <0001> Tj ET\n", vector_paths(600));
265 let objects = [
266 "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
267 "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
268 "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
269 /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>"
270 .to_string(),
271 format!(
272 "<< /Length {} >>\nstream\n{content}\nendstream",
273 content.len()
274 ),
275 "<< /Type /Font /Subtype /Type0 /BaseFont /ABCDEE+Test \
276 /Encoding /Identity-H /DescendantFonts [7 0 R] /ToUnicode 6 0 R >>"
277 .to_string(),
278 format!("<< /Length {} >>\nstream\n{cmap}\nendstream", cmap.len()),
279 "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /ABCDEE+Test \
280 /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> \
281 /FontDescriptor 8 0 R /CIDToGIDMap /Identity /DW 1000 >>"
282 .to_string(),
283 "<< /Type /FontDescriptor /FontName /ABCDEE+Test /Flags 4 \
284 /FontBBox [0 0 1000 1000] /ItalicAngle 0 /Ascent 1000 /Descent 0 \
285 /CapHeight 1000 /StemV 80 >>"
286 .to_string(),
287 ];
288 let mut pdf = String::from("%PDF-1.6\n");
289 let mut offsets = Vec::new();
290 for (index, body) in objects.iter().enumerate() {
291 offsets.push(pdf.len());
292 pdf.push_str(&format!("{} 0 obj\n{body}\nendobj\n", index + 1));
293 }
294 let xref_at = pdf.len();
295 pdf.push_str(&format!("xref\n0 {}\n", objects.len() + 1));
296 pdf.push_str("0000000000 65535 f \n");
297 for offset in &offsets {
298 pdf.push_str(&format!("{offset:010} 00000 n \n"));
299 }
300 pdf.push_str(&format!(
301 "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_at}\n%%EOF\n",
302 objects.len() + 1
303 ));
304 pdf.into_bytes()
305 }
306
307 fn image_based_with_text_layer() -> Vec<u8> {
311 build_pdf(&format!(
312 "{}{}",
313 vector_paths(600),
314 text_block(&["Small text layer only"])
315 ))
316 }
317
318 fn text_rich_with_vector_paths() -> Vec<u8> {
321 let lines = [
322 "Quarterly report of the working group on data handling",
323 "The committee reviewed the summary and approved the plan",
324 "Revenue increased while costs held steady this period",
325 "Next review is scheduled for the following fiscal quarter",
326 "Signatures and approvals are recorded on the final page",
327 ];
328 build_pdf(&format!("{}{}", vector_paths(80), text_block(&lines)))
329 }
330
331 fn vector_only_no_text() -> Vec<u8> {
334 build_pdf(&vector_paths(300))
335 }
336
337 #[test]
338 fn partial_pdf_warnings_keep_their_prefix() {
339 assert_eq!(
340 promote_warning("1 of 2 pages need OCR and were not extracted"),
341 "pdf_partial_text: 1 of 2 pages need OCR"
342 );
343 assert_eq!(
344 promote_warning("recovered a broken xref"),
345 "anydoc_recovery: recovered a broken xref"
346 );
347 }
348
349 #[test]
350 fn image_based_page_with_text_layer_recovers_with_the_ocr_warning() {
351 let conversion = convert_pdf(&image_based_with_text_layer())
352 .expect("a real text layer behind vector artwork is recovered, not failed closed");
353 assert!(conversion.recovered, "the recovery path produced the text");
354 assert!(
355 conversion.text.contains("Small text layer only"),
356 "recovered text: {:?}",
357 conversion.text
358 );
359 assert!(
360 conversion
361 .warnings
362 .iter()
363 .any(|warning| warning.starts_with("pdf_partial_text:")),
364 "a disputed page is always routed to OCR: {:?}",
365 conversion.warnings
366 );
367 }
368
369 #[test]
370 fn text_rich_page_converts_without_warning_spam() {
371 let conversion = convert_pdf(&text_rich_with_vector_paths())
372 .expect("a text-based page converts through the markdown path");
373 assert!(
374 !conversion.recovered,
375 "a text-based page does not use the recovery path"
376 );
377 assert!(
378 conversion.text.contains("Quarterly report"),
379 "text: {:?}",
380 conversion.text
381 );
382 assert!(
383 conversion.warnings.is_empty(),
384 "a text-rich page carries no OCR-routing warning: {:?}",
385 conversion.warnings
386 );
387 }
388
389 #[test]
390 fn vector_only_page_without_text_fails_closed() {
391 let error = convert_pdf(&vector_only_no_text())
392 .expect_err("a page with no text layer has nothing to recover");
393 assert_eq!(
394 error.code, "pdf_no_text_layer",
395 "the genuine no-text class still fails closed: {error:?}"
396 );
397 }
398
399 #[test]
400 fn a_page_whose_glyphs_decode_to_control_only_fails_closed() {
401 let error = convert_pdf(&image_based_with_glyph_decoding_to("0007"))
407 .expect_err("a page of control characters has nothing meaningful to recover");
408 assert_eq!(error.code, "pdf_no_text_layer", "error: {error:?}");
409 }
410
411 #[test]
412 fn a_page_of_zero_width_spaces_only_fails_closed() {
413 let error = convert_pdf(&image_based_with_glyph_decoding_to("200B"))
417 .expect_err("a page of zero-width spaces has nothing meaningful to recover");
418 assert_eq!(error.code, "pdf_no_text_layer", "error: {error:?}");
419 }
420
421 #[test]
422 fn outcomes_are_byte_reproducible() {
423 assert_eq!(
426 image_based_with_text_layer(),
427 image_based_with_text_layer(),
428 "the fixture generator is deterministic"
429 );
430 let first = convert_pdf(&image_based_with_text_layer()).unwrap();
431 let second = convert_pdf(&image_based_with_text_layer()).unwrap();
432 assert_eq!(first.text, second.text);
433 assert_eq!(first.warnings, second.warnings);
434 assert_eq!(first.recovered, second.recovered);
435 assert_eq!(first.segments.len(), second.segments.len());
436 }
437}