1use crate::segments::Segment;
23
24use super::anydoc_log::capture_anydoc;
25use super::{ConvertError, 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 is_invisible_format(c: char) -> bool {
90 matches!(
91 c as u32,
92 0x00AD | 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2064 | 0xFEFF
93 )
94}
95
96fn is_meaningful(c: char) -> bool {
100 !c.is_whitespace() && !c.is_control() && !is_invisible_format(c)
101}
102
103fn recover_text_layer(source: &[u8]) -> Option<String> {
116 let raw = pdf_inspector::extractor::extract_text_mem(source).ok()?;
117 let text = normalize_text(&raw);
118 if text.chars().any(is_meaningful) {
119 Some(text)
120 } else {
121 None
122 }
123}
124
125pub fn convert_pdf(source: &[u8]) -> Result<PdfConversion, ConvertError> {
129 let (converted, captured) =
130 capture_anydoc(|| anydoc::to_markdown_bytes(source, anydoc::Format::Pdf));
131 match converted {
132 Ok(markdown) => {
133 let text = normalize_text(&markdown_to_plain(&markdown));
134 if !source.is_empty() && text.is_empty() {
135 return Err(empty_output(source.len()));
136 }
137 let warnings = captured
138 .iter()
139 .map(|message| promote_warning(message))
140 .collect();
141 let segments = vec![Segment::span(0, text.len(), "document")];
142 Ok(PdfConversion {
143 text,
144 warnings,
145 segments,
146 recovered: false,
147 })
148 }
149 Err(error) if matches!(error, anydoc::ConvertError::Unsupported(_)) => {
155 match recover_text_layer(source) {
156 Some(text) => {
157 let mut warnings: Vec<String> = captured
158 .iter()
159 .map(|message| promote_warning(message))
160 .collect();
161 if !warnings
167 .iter()
168 .any(|warning| warning.starts_with("pdf_partial_text:"))
169 {
170 warnings.push(RECOVERED_TEXT_WARNING.to_string());
171 }
172 let segments = vec![Segment::span(0, text.len(), "document")];
173 Ok(PdfConversion {
174 text,
175 warnings,
176 segments,
177 recovered: true,
178 })
179 }
180 None => Err(ConvertError {
181 code: "pdf_no_text_layer",
182 message: error.to_string(),
183 }),
184 }
185 }
186 Err(error) => Err(ConvertError {
187 code: error_code(&error),
188 message: error.to_string(),
189 }),
190 }
191}
192
193fn empty_output(source_len: usize) -> ConvertError {
194 ConvertError {
195 code: "empty_output",
196 message: format!("source is {source_len} bytes but conversion produced no text"),
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 fn build_pdf(content: &str) -> Vec<u8> {
215 let objects = [
216 "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
217 "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
218 "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
219 /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>"
220 .to_string(),
221 format!(
222 "<< /Length {} >>\nstream\n{content}\nendstream",
223 content.len()
224 ),
225 "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
226 /Encoding /WinAnsiEncoding >>"
227 .to_string(),
228 ];
229 let mut pdf = String::from("%PDF-1.6\n");
230 let mut offsets = Vec::new();
231 for (index, body) in objects.iter().enumerate() {
232 offsets.push(pdf.len());
233 pdf.push_str(&format!("{} 0 obj\n{body}\nendobj\n", index + 1));
234 }
235 let xref_at = pdf.len();
236 pdf.push_str(&format!("xref\n0 {}\n", objects.len() + 1));
237 pdf.push_str("0000000000 65535 f \n");
238 for offset in &offsets {
239 pdf.push_str(&format!("{offset:010} 00000 n \n"));
240 }
241 pdf.push_str(&format!(
242 "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_at}\n%%EOF\n",
243 objects.len() + 1
244 ));
245 pdf.into_bytes()
246 }
247
248 fn vector_paths(count: usize) -> String {
251 let mut stream = String::new();
252 for step in 0..count {
253 let n = (step % 500) + 50;
254 stream.push_str(&format!("{n} {n} m {n} {n} l S\n"));
255 }
256 stream
257 }
258
259 fn text_block(lines: &[&str]) -> String {
261 let mut stream = String::from("BT /F1 12 Tf 72 700 Td\n");
262 for (index, line) in lines.iter().enumerate() {
263 if index > 0 {
264 stream.push_str("0 -14 Td\n");
265 }
266 stream.push_str(&format!("({line}) Tj\n"));
267 }
268 stream.push_str("ET\n");
269 stream
270 }
271
272 fn image_based_with_glyph_decoding_to(dst: &str) -> Vec<u8> {
280 let cmap = format!(
281 "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n\
282 /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n\
283 /CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n\
284 1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n\
285 1 beginbfchar\n<0001> <{dst}>\nendbfchar\nendcmap\n\
286 CMapName currentdict /CMap defineresource pop\nend\nend"
287 );
288 let content = format!("{}BT /F1 12 Tf 72 700 Td <0001> Tj ET\n", vector_paths(600));
289 let objects = [
290 "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
291 "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
292 "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
293 /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>"
294 .to_string(),
295 format!(
296 "<< /Length {} >>\nstream\n{content}\nendstream",
297 content.len()
298 ),
299 "<< /Type /Font /Subtype /Type0 /BaseFont /ABCDEE+Test \
300 /Encoding /Identity-H /DescendantFonts [7 0 R] /ToUnicode 6 0 R >>"
301 .to_string(),
302 format!("<< /Length {} >>\nstream\n{cmap}\nendstream", cmap.len()),
303 "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /ABCDEE+Test \
304 /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> \
305 /FontDescriptor 8 0 R /CIDToGIDMap /Identity /DW 1000 >>"
306 .to_string(),
307 "<< /Type /FontDescriptor /FontName /ABCDEE+Test /Flags 4 \
308 /FontBBox [0 0 1000 1000] /ItalicAngle 0 /Ascent 1000 /Descent 0 \
309 /CapHeight 1000 /StemV 80 >>"
310 .to_string(),
311 ];
312 let mut pdf = String::from("%PDF-1.6\n");
313 let mut offsets = Vec::new();
314 for (index, body) in objects.iter().enumerate() {
315 offsets.push(pdf.len());
316 pdf.push_str(&format!("{} 0 obj\n{body}\nendobj\n", index + 1));
317 }
318 let xref_at = pdf.len();
319 pdf.push_str(&format!("xref\n0 {}\n", objects.len() + 1));
320 pdf.push_str("0000000000 65535 f \n");
321 for offset in &offsets {
322 pdf.push_str(&format!("{offset:010} 00000 n \n"));
323 }
324 pdf.push_str(&format!(
325 "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_at}\n%%EOF\n",
326 objects.len() + 1
327 ));
328 pdf.into_bytes()
329 }
330
331 fn image_based_with_text_layer() -> Vec<u8> {
335 build_pdf(&format!(
336 "{}{}",
337 vector_paths(600),
338 text_block(&["Small text layer only"])
339 ))
340 }
341
342 fn text_rich_with_vector_paths() -> Vec<u8> {
345 let lines = [
346 "Quarterly report of the working group on data handling",
347 "The committee reviewed the summary and approved the plan",
348 "Revenue increased while costs held steady this period",
349 "Next review is scheduled for the following fiscal quarter",
350 "Signatures and approvals are recorded on the final page",
351 ];
352 build_pdf(&format!("{}{}", vector_paths(80), text_block(&lines)))
353 }
354
355 fn vector_only_no_text() -> Vec<u8> {
358 build_pdf(&vector_paths(300))
359 }
360
361 #[test]
362 fn partial_pdf_warnings_keep_their_prefix() {
363 assert_eq!(
364 promote_warning("1 of 2 pages need OCR and were not extracted"),
365 "pdf_partial_text: 1 of 2 pages need OCR"
366 );
367 assert_eq!(
368 promote_warning("recovered a broken xref"),
369 "anydoc_recovery: recovered a broken xref"
370 );
371 }
372
373 #[test]
374 fn image_based_page_with_text_layer_recovers_with_the_ocr_warning() {
375 let conversion = convert_pdf(&image_based_with_text_layer())
376 .expect("a real text layer behind vector artwork is recovered, not failed closed");
377 assert!(conversion.recovered, "the recovery path produced the text");
378 assert!(
379 conversion.text.contains("Small text layer only"),
380 "recovered text: {:?}",
381 conversion.text
382 );
383 assert!(
384 conversion
385 .warnings
386 .iter()
387 .any(|warning| warning.starts_with("pdf_partial_text:")),
388 "a disputed page is always routed to OCR: {:?}",
389 conversion.warnings
390 );
391 }
392
393 #[test]
394 fn text_rich_page_converts_without_warning_spam() {
395 let conversion = convert_pdf(&text_rich_with_vector_paths())
396 .expect("a text-based page converts through the markdown path");
397 assert!(
398 !conversion.recovered,
399 "a text-based page does not use the recovery path"
400 );
401 assert!(
402 conversion.text.contains("Quarterly report"),
403 "text: {:?}",
404 conversion.text
405 );
406 assert!(
407 conversion.warnings.is_empty(),
408 "a text-rich page carries no OCR-routing warning: {:?}",
409 conversion.warnings
410 );
411 }
412
413 #[test]
414 fn vector_only_page_without_text_fails_closed() {
415 let error = convert_pdf(&vector_only_no_text())
416 .expect_err("a page with no text layer has nothing to recover");
417 assert_eq!(
418 error.code, "pdf_no_text_layer",
419 "the genuine no-text class still fails closed: {error:?}"
420 );
421 }
422
423 #[test]
424 fn a_page_whose_glyphs_decode_to_control_only_fails_closed() {
425 let error = convert_pdf(&image_based_with_glyph_decoding_to("0007"))
431 .expect_err("a page of control characters has nothing meaningful to recover");
432 assert_eq!(error.code, "pdf_no_text_layer", "error: {error:?}");
433 }
434
435 #[test]
436 fn a_page_of_zero_width_spaces_only_fails_closed() {
437 let error = convert_pdf(&image_based_with_glyph_decoding_to("200B"))
441 .expect_err("a page of zero-width spaces has nothing meaningful to recover");
442 assert_eq!(error.code, "pdf_no_text_layer", "error: {error:?}");
443 }
444
445 #[test]
446 fn outcomes_are_byte_reproducible() {
447 assert_eq!(
450 image_based_with_text_layer(),
451 image_based_with_text_layer(),
452 "the fixture generator is deterministic"
453 );
454 let first = convert_pdf(&image_based_with_text_layer()).unwrap();
455 let second = convert_pdf(&image_based_with_text_layer()).unwrap();
456 assert_eq!(first.text, second.text);
457 assert_eq!(first.warnings, second.warnings);
458 assert_eq!(first.recovered, second.recovered);
459 assert_eq!(first.segments.len(), second.segments.len());
460 }
461}