1use std::sync::mpsc;
43use std::sync::{Arc, Mutex, OnceLock};
44
45use pdfium_render::prelude::*;
46
47use docparser_core::error::{DocError, ErrorCode, Result};
48use docparser_core::model::{BlockKind, Document, Page, Rect};
49use docparser_core::plugin::{
50 BackendKind, FormatPlugin, ParseCapability, RawDoc, RawImage, RawPage, RawTextItem,
51};
52use docparser_core::registry::Registry;
53
54type Resp = Result<RawDoc>;
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58pub enum LayoutMode {
59 #[default]
64 Auto,
65 Rebuild,
67 Raw,
69}
70
71impl LayoutMode {
72 pub fn as_str(self) -> &'static str {
74 match self {
75 LayoutMode::Auto => "auto",
76 LayoutMode::Rebuild => "rebuild",
77 LayoutMode::Raw => "raw",
78 }
79 }
80
81 pub fn parse(s: &str) -> Option<Self> {
83 match s.trim().to_ascii_lowercase().as_str() {
84 "auto" => Some(LayoutMode::Auto),
85 "rebuild" => Some(LayoutMode::Rebuild),
86 "raw" => Some(LayoutMode::Raw),
87 _ => None,
88 }
89 }
90
91 pub fn reflows(self) -> bool {
93 !matches!(self, LayoutMode::Raw)
94 }
95}
96
97#[derive(Debug, Clone, Default)]
99pub struct PdfOptions {
100 pub layout_mode: LayoutMode,
102 pub max_pages: Option<usize>,
104}
105
106impl PdfOptions {
107 pub fn new() -> Self {
109 Self::default()
110 }
111
112 pub fn with_layout_mode(mut self, mode: LayoutMode) -> Self {
114 self.layout_mode = mode;
115 self
116 }
117
118 pub fn with_max_pages(mut self, pages: usize) -> Self {
120 self.max_pages = Some(pages);
121 self
122 }
123}
124
125pub fn find_library() -> Option<std::path::PathBuf> {
128 if let Ok(p) = std::env::var("DOCPARSER_PDFIUM_LIB") {
129 let p = std::path::PathBuf::from(p);
130 if p.exists() {
131 return Some(p);
132 }
133 }
134 #[cfg(target_os = "macos")]
135 let name = "libpdfium.dylib";
136 #[cfg(target_os = "linux")]
137 let name = "libpdfium.so";
138 #[cfg(target_os = "windows")]
139 let name = "pdfium.dll";
140 if let Ok(exe) = std::env::current_exe() {
142 if let Some(dir) = exe.parent() {
143 let p = dir.join("..").join("lib").join(name);
144 if p.exists() {
145 return Some(p);
146 }
147 }
148 }
149 for home in home_dirs() {
151 for base in [
152 home.join("miniconda3/lib/python3.13/site-packages/liteparse"),
154 home.join("miniconda3/lib/python3.13/site-packages/pypdfium2_raw"),
156 home.join("miniconda3/envs/kb/lib/python3.13/site-packages/pypdfium2_raw"),
157 home.join("anaconda3/lib/python3.13/site-packages/pypdfium2_raw"),
158 ] {
159 let p = base.join(name);
160 if p.exists() {
161 return Some(p);
162 }
163 }
164 }
165 for sp in python_site_packages() {
168 for pkg in ["pypdfium2_raw", "liteparse"] {
169 let p = sp.join(pkg).join(name);
170 if p.exists() {
171 return Some(p);
172 }
173 }
174 }
175 None
176}
177
178fn home_dirs() -> Vec<std::path::PathBuf> {
181 let mut out: Vec<std::path::PathBuf> = Vec::new();
182 for key in ["HOME", "USERPROFILE"] {
183 if let Ok(v) = std::env::var(key) {
184 let p = std::path::PathBuf::from(v);
185 if p.is_dir() && !out.contains(&p) {
186 out.push(p);
187 }
188 }
189 }
190 if let (Ok(drive), Ok(path)) = (std::env::var("HOMEDRIVE"), std::env::var("HOMEPATH")) {
191 let p = std::path::PathBuf::from(format!("{drive}{path}"));
192 if p.is_dir() && !out.contains(&p) {
193 out.push(p);
194 }
195 }
196 out
197}
198
199fn python_site_packages() -> Vec<std::path::PathBuf> {
201 let mut roots: Vec<std::path::PathBuf> = Vec::new();
202 for home in home_dirs() {
203 for conda in ["miniconda3", "anaconda3", "miniforge3"] {
204 roots.push(home.join(conda).join("lib"));
205 roots.push(home.join(conda).join("Lib"));
206 }
207 roots.push(home.join("AppData/Local/Programs/Python"));
208 roots.push(home.join("AppData/Roaming/Python"));
209 roots.push(home.join(".local/lib"));
210 roots.push(home.join("Library/Python"));
211 }
212 #[cfg(target_os = "windows")]
213 for key in ["LOCALAPPDATA", "PROGRAMFILES", "PROGRAMFILES(X86)"] {
214 if let Ok(v) = std::env::var(key) {
215 roots.push(std::path::PathBuf::from(&v).join("Programs/Python"));
217 roots.push(std::path::PathBuf::from(&v));
218 }
219 }
220 roots.push(std::path::PathBuf::from("/usr/lib"));
221 roots.push(std::path::PathBuf::from("/usr/local/lib"));
222
223 let mut out: Vec<std::path::PathBuf> = Vec::new();
225 let mut push = |p: std::path::PathBuf| {
226 if p.is_dir() && !out.contains(&p) {
227 out.push(p);
228 }
229 };
230 for root in roots {
231 if !root.is_dir() {
232 continue;
233 }
234 push(root.join("site-packages"));
235 let Ok(entries) = std::fs::read_dir(&root) else {
236 continue;
237 };
238 for e in entries.flatten() {
239 let p = e.path();
240 if !p.is_dir() {
241 continue;
242 }
243 let name = p.file_name().and_then(|s| s.to_str()).unwrap_or_default();
244 if name.starts_with("python") || name.starts_with("Python") {
245 push(p.join("site-packages"));
246 push(p.join("Lib/site-packages"));
247 } else if name == "Lib" || name == "lib" {
248 push(p.join("site-packages"));
249 }
250 }
251 }
252 out
253}
254
255fn classify(e: &PdfiumError) -> DocError {
256 let msg = format!("{e}").to_lowercase();
257 if msg.contains("password") || msg.contains("encrypt") || msg.contains("permission") {
258 DocError::encrypted(format!("PDF 加密或权限受限: {e}"))
259 } else {
260 DocError::corrupt(format!("PDF 打开/解析失败: {e}"))
261 }
262}
263
264static RENDER_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
265
266pub fn find_ocr_binary() -> Option<std::path::PathBuf> {
267 if let Ok(p) = std::env::var("DOCPARSER_OCR_BIN") {
268 let p = std::path::PathBuf::from(p);
269 if p.exists() {
270 return Some(p);
271 }
272 }
273 let name = format!("docparser-ocr{}", std::env::consts::EXE_SUFFIX);
275 if let Ok(exe) = std::env::current_exe() {
277 if let Some(dir) = exe.parent() {
278 let p = dir.join(&name);
279 if p.exists() {
280 return Some(p);
281 }
282 }
283 }
284 let target = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../target");
286 let cands = [
287 target.join("release").join(&name),
288 target.join("debug").join(&name),
289 std::path::PathBuf::from("rust/target/release").join(&name),
290 std::path::PathBuf::from("rust/target/debug").join(&name),
291 ];
292 cands.into_iter().find(|c| c.exists())
293}
294
295pub fn find_ocr_models() -> Option<std::path::PathBuf> {
296 if let Ok(p) = std::env::var("DOCPARSER_OCR_MODELS") {
297 let p = std::path::PathBuf::from(p);
298 if p.is_dir() {
299 return Some(p);
300 }
301 }
302 if let Ok(exe) = std::env::current_exe() {
304 if let Some(dir) = exe.parent() {
305 let p = dir.join("..").join("models/ocr/ppocrv5");
306 if p.is_dir() {
307 return Some(p);
308 }
309 }
310 }
311 let cands = [
312 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../models/ocr/ppocrv5"),
313 std::path::PathBuf::from("models/ocr/ppocrv5"),
314 ];
315 cands.into_iter().find(|c| c.is_dir())
316}
317
318fn is_garbled_char(ch: char) -> bool {
320 let cp = ch as u32;
321 ch == '\u{fffd}' || (0xE000..=0xF8FF).contains(&cp)
322}
323
324fn page_needs_ocr(texts: &[String]) -> bool {
327 let chars: usize = texts.iter().map(|t| t.trim().chars().count()).sum();
328 if chars < 10 {
329 return true;
330 }
331 let garbled: usize = texts
332 .iter()
333 .flat_map(|t| t.chars())
334 .filter(|c| is_garbled_char(*c))
335 .count();
336 garbled as f32 / chars as f32 > 0.3
337}
338
339fn sniff_ext(data: &[u8]) -> Option<&'static str> {
341 if data.len() >= 8 && data.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
342 return Some("png");
343 }
344 if data.len() >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
345 return Some("jpg");
346 }
347 if data.len() >= 2 && data.starts_with(b"BM") {
348 return Some("bmp");
349 }
350 if data.len() >= 4 && data.starts_with(b"GIF8") {
351 return Some("gif");
352 }
353 if data.len() >= 12 && data.starts_with(b"RIFF") && &data[8..12] == b"WEBP" {
354 return Some("webp");
355 }
356 None
357}
358
359fn image_dedup_key(im: &RawImage) -> Option<String> {
367 if im.data.is_empty() {
368 let (w, h) = im.px?;
369 return Some(format!("{w}x{h}@{}", im.ext));
371 }
372 use md5::Digest as _;
373 Some(format!("{:x}", md5::Md5::digest(&im.data)))
374}
375
376fn ocrable_image(im: &RawImage) -> bool {
378 if im.data.is_empty() {
379 return false;
380 }
381 if !matches!(
382 im.ext.as_str(),
383 "png" | "jpg" | "jpeg" | "bmp" | "gif" | "webp"
384 ) {
385 return false;
386 }
387 if let Some((w, h)) = im.px {
388 return w.min(h) >= 50;
389 }
390 false
391}
392
393fn collect_page_images(
400 doc: &PdfDocument,
401 page: &PdfPage,
402 h: f32,
403 extract_data: bool,
404) -> Vec<RawImage> {
405 let mut out: Vec<RawImage> = Vec::new();
406 descend(doc, page.objects().iter(), h, 0, extract_data, &mut out);
407 out
408}
409
410fn descend<'a>(
411 doc: &'a PdfDocument,
412 objs: impl Iterator<Item = PdfPageObject<'a>>,
413 h: f32,
414 depth: usize,
415 extract_data: bool,
416 out: &mut Vec<RawImage>,
417) {
418 if depth > 2 {
419 return;
420 }
421 for obj in objs {
422 if let Some(img) = obj.as_image_object() {
423 let bbox = obj.bounds().ok().map(|q| Rect {
424 x0: q.left().value,
425 y0: h - q.top().value,
426 x1: q.right().value,
427 y1: h - q.bottom().value,
428 });
429 let (px_w, px_h) = (
430 img.width().ok().unwrap_or(0) as u32,
431 img.height().ok().unwrap_or(0) as u32,
432 );
433 let mut data: Vec<u8> = Vec::new();
434 let mut ext: String = String::new();
435 if extract_data {
436 if let Ok(raw) = img.get_raw_image_data() {
438 if let Some(e) = sniff_ext(&raw) {
439 data = raw;
440 ext = e.to_string();
441 }
442 }
443 if data.is_empty() {
444 if let Ok(dyn_img) = img.get_processed_image(doc) {
445 let rgba = dyn_img.to_rgba8();
446 let (bw, bh) = rgba.dimensions();
447 let mut buf: Vec<u8> = Vec::new();
448 {
449 use image::ImageEncoder as _;
450 let enc = image::codecs::png::PngEncoder::new(std::io::BufWriter::new(
451 &mut buf,
452 ));
453 if enc
454 .write_image(&rgba, bw, bh, image::ExtendedColorType::Rgba8)
455 .is_ok()
456 {
457 data = buf;
458 ext = "png".to_string();
459 }
460 }
461 }
462 }
463 }
464 out.push(RawImage {
465 data,
466 bbox,
467 ext,
468 px: Some((px_w, px_h)),
469 ocr_text: None,
470 });
471 } else if let Some(form) = obj.as_x_object_form_object() {
472 descend(doc, form.iter(), h, depth + 1, extract_data, out);
473 }
474 }
475}
476
477fn ocr_images_enabled() -> bool {
479 std::env::var("DOCPARSER_OCR_IMAGES").as_deref() == Ok("1")
480}
481
482fn ocr_enabled() -> bool {
485 !matches!(
486 std::env::var("DOCPARSER_OCR_ENABLED").as_deref(),
487 Ok("0") | Ok("false")
488 )
489}
490
491fn extract_text_items(page: &PdfPage<'_>, page_h: f32) -> (Vec<RawTextItem>, Vec<String>) {
501 let mut items: Vec<RawTextItem> = Vec::new();
502 let mut texts: Vec<String> = Vec::new();
503
504 if let Ok(text_page) = page.text() {
505 for seg in text_page.segments().iter() {
506 let s = seg.text();
507 if s.trim().is_empty() {
508 continue;
509 }
510 texts.push(s.clone());
511 let b = seg.bounds();
512 let height = (b.top().value - b.bottom().value).abs();
513 if page_h > 0.0 && height > page_h * 0.25 {
514 continue;
515 }
516 items.push(RawTextItem {
517 text: s,
518 bbox: Some(Rect {
519 x0: b.left().value,
520 y0: page_h - b.top().value,
521 x1: b.right().value,
522 y1: page_h - b.bottom().value,
523 }),
524 ..Default::default()
525 });
526 }
527 if items.is_empty() {
528 let whole = text_page.all();
530 if !whole.trim().is_empty() {
531 if texts.is_empty() {
532 texts.push(whole.clone());
533 }
534 items.push(RawTextItem {
535 text: whole,
536 bbox: None,
537 ..Default::default()
538 });
539 }
540 }
541 }
542
543 if texts.is_empty() {
544 for obj in page.objects().iter() {
546 let Some(t) = obj.as_text_object() else {
547 continue;
548 };
549 let s = t.text();
550 if s.trim().is_empty() {
551 continue;
552 }
553 texts.push(s.clone());
554 let bbox = obj.bounds().ok().map(|q| Rect {
555 x0: q.left().value,
556 y0: page_h - q.top().value,
557 x1: q.right().value,
558 y1: page_h - q.bottom().value,
559 });
560 items.push(RawTextItem {
561 text: s,
562 bbox,
563 ..Default::default()
564 });
565 }
566 }
567
568 (items, texts)
569}
570
571fn parse_one(pdfium: &Pdfium, path: &str, options: &PdfOptions) -> Resp {
572 let doc = pdfium
573 .load_pdf_from_file(path, None)
574 .map_err(|e| classify(&e))?;
575 let dpi = 200.0_f32;
576 let mut meta: std::collections::HashMap<String, String> = std::collections::HashMap::new();
577 meta.insert("pdf.version".into(), pdf_version_str(doc.version()));
578 for (key, tag) in [
579 ("pdf.title", PdfDocumentMetadataTagType::Title),
580 ("pdf.author", PdfDocumentMetadataTagType::Author),
581 ("pdf.producer", PdfDocumentMetadataTagType::Producer),
582 ("pdf.creator", PdfDocumentMetadataTagType::Creator),
583 ] {
584 if let Some(tag_value) = doc.metadata().get(tag) {
585 let v = tag_value.value().trim().to_string();
586 if !v.is_empty() {
587 meta.insert(key.into(), v);
588 }
589 }
590 }
591 let total_pages = doc.pages().len() as usize;
593 let limit = options.max_pages.unwrap_or(total_pages).min(total_pages);
594 meta.insert("pdf.page_count".into(), total_pages.to_string());
595 meta.insert("pdf.truncated".into(), (limit < total_pages).to_string());
596 let mut complexity: Vec<serde_json::Value> = Vec::new();
597 let mut page_errors: Vec<serde_json::Value> = Vec::new();
598 let mut pages = Vec::new();
599 let mut render_dir: Option<std::path::PathBuf> = None;
600 let mut render_files: Vec<(usize, std::path::PathBuf)> = Vec::new();
601 let mut img_dir: Option<std::path::PathBuf> = None;
603 let mut img_seq = 0usize;
604 let mut img_jobs: Vec<(usize, usize, usize)> = Vec::new();
605 let mut img_files: Vec<(usize, std::path::PathBuf)> = Vec::new();
606 let ocr_images = ocr_images_enabled();
607 let ocr_on = ocr_enabled();
608 for (pi, page) in doc.pages().iter().take(limit).enumerate() {
609 let w = page.width().value;
610 let h = page.height().value;
611 let (items, texts) = extract_text_items(&page, h);
613 let images = collect_page_images(&doc, &page, h, ocr_images);
614 if ocr_on && page_needs_ocr(&texts) {
617 if render_dir.is_none() {
618 let d = std::env::temp_dir().join(format!(
619 "docparser_pdf_render_{}_{}",
620 std::process::id(),
621 RENDER_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
622 ));
623 let _ = std::fs::create_dir_all(&d);
624 render_dir = Some(d);
625 }
626 let dir = render_dir.as_ref().unwrap();
627 let fn_name = format!("page-{pi:04}.png");
628 let out_path = dir.join(&fn_name);
629 let wpx = (w * dpi / 72.0).round() as i32;
630 let hpx = (h * dpi / 72.0).round() as i32;
631 match page.render(wpx, hpx, None) {
632 Ok(bitmap) => {
633 let bw = bitmap.width() as u32;
634 let bh = bitmap.height() as u32;
635 let rgba = bitmap.as_rgba_bytes();
636 use image::ImageEncoder as _;
637 let Ok(file) = std::fs::File::create(&out_path) else {
638 page_errors.push(serde_json::json!({
639 "page": pi, "error": "无法创建渲染临时文件",
640 }));
641 continue;
642 };
643 let enc = image::codecs::png::PngEncoder::new(std::io::BufWriter::new(file));
644 if enc
645 .write_image(&rgba, bw, bh, image::ExtendedColorType::Rgba8)
646 .is_ok()
647 {
648 render_files.push((pi, out_path));
649 }
650 }
651 Err(e) => page_errors.push(serde_json::json!({
653 "page": pi, "error": format!("{e}"),
654 })),
655 }
656 } else if ocr_on && ocr_images {
657 for (ki, im) in images.iter().enumerate() {
659 if !ocrable_image(im) {
660 continue;
661 }
662 if img_dir.is_none() {
663 let d = std::env::temp_dir().join(format!(
664 "docparser_pdf_img_{}_{}",
665 std::process::id(),
666 RENDER_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
667 ));
668 let _ = std::fs::create_dir_all(&d);
669 img_dir = Some(d);
670 }
671 let dir = img_dir.as_ref().unwrap();
672 let fn_name = format!("img-{img_seq:05}.{}", im.ext);
673 let out_path = dir.join(&fn_name);
674 if std::fs::write(&out_path, &im.data).is_ok() {
675 img_files.push((img_seq, out_path));
676 img_jobs.push((pi, ki, img_seq));
677 img_seq += 1;
678 }
679 }
680 }
681 let chars: usize = texts.iter().map(|t| t.trim().chars().count()).sum();
683 let garbled = texts
684 .iter()
685 .flat_map(|t| t.chars())
686 .filter(|c| is_garbled_char(*c))
687 .count();
688 complexity.push(serde_json::json!({
689 "page": pi,
690 "chars": chars,
691 "garbled_ratio": if chars == 0 { 0.0 } else { garbled as f64 / chars as f64 },
692 "needs_ocr": page_needs_ocr(&texts),
693 }));
694 let rotation = page.rotation().ok().map(|r| match r {
696 PdfPageRenderRotation::None => 0,
697 PdfPageRenderRotation::Degrees90 => 90,
698 PdfPageRenderRotation::Degrees180 => 180,
699 PdfPageRenderRotation::Degrees270 => 270,
700 });
701 pages.push(RawPage {
702 name: None,
703 items,
704 images,
705 width: Some(w),
706 height: Some(h),
707 rotation,
708 ..Default::default()
709 });
710 }
711
712 let mut any_ocr = false;
714 if !render_files.is_empty() {
715 if let Some(lines) = run_ocr(render_dir.as_ref().unwrap(), &render_files) {
716 let scale = 72.0 / dpi;
717 for (pi, items) in lines {
718 if let Some(pg) = pages.get_mut(pi) {
719 pg.items = items
720 .into_iter()
721 .map(|(text, quad)| RawTextItem {
722 text,
723 bbox: Some(Rect {
724 x0: quad.iter().map(|p| p[0]).fold(f32::MAX, f32::min) * scale,
725 y0: quad.iter().map(|p| p[1]).fold(f32::MAX, f32::min) * scale,
726 x1: quad.iter().map(|p| p[0]).fold(f32::MIN, f32::max) * scale,
727 y1: quad.iter().map(|p| p[1]).fold(f32::MIN, f32::max) * scale,
728 }),
729 ..Default::default()
730 })
731 .collect();
732 any_ocr = true;
733 }
734 }
735 }
736 }
737
738 if !img_files.is_empty() {
740 if let Some(lines) = run_ocr(img_dir.as_ref().unwrap(), &img_files) {
741 for (gi, line_items) in lines {
742 let Some(&(pi, ki, _)) = img_jobs.iter().find(|(_, _, g)| *g == gi) else {
743 continue;
744 };
745 let Some(pg) = pages.get_mut(pi) else {
746 continue;
747 };
748 let Some(im) = pg.images.get_mut(ki) else {
749 continue;
750 };
751 let ocr: Vec<&str> = line_items
752 .iter()
753 .map(|(s, _)| s.as_str())
754 .filter(|s| !s.trim().is_empty())
755 .collect();
756 if !ocr.is_empty() {
757 im.ocr_text = Some(ocr.join("\n"));
758 any_ocr = true;
759 }
760 }
761 }
762 }
763
764 let hf = strip_headers_footers(&mut pages);
766
767 let mut raw = RawDoc {
768 pages,
769 meta,
770 needs_ocr: false,
771 };
772 if !hf.is_empty() {
773 raw.meta.insert("headers_footers".into(), hf.join(" | "));
774 }
775 if let Ok(s) = serde_json::to_string(&complexity) {
776 raw.meta.insert("pdf.complexity".into(), s);
777 }
778 if !page_errors.is_empty() {
779 if let Ok(s) = serde_json::to_string(&page_errors) {
780 raw.meta.insert("pdf.page_errors".into(), s);
781 }
782 }
783 raw.needs_ocr = !any_ocr
784 && raw.pages.iter().all(|p| {
785 p.items
786 .iter()
787 .map(|i| i.text.chars().count())
788 .sum::<usize>()
789 < 10
790 });
791 Ok(raw)
792}
793
794fn pdf_version_str(v: PdfDocumentVersion) -> String {
796 match v {
797 PdfDocumentVersion::Unset => String::new(),
798 PdfDocumentVersion::Pdf1_0 => "1.0".into(),
799 PdfDocumentVersion::Pdf1_1 => "1.1".into(),
800 PdfDocumentVersion::Pdf1_2 => "1.2".into(),
801 PdfDocumentVersion::Pdf1_3 => "1.3".into(),
802 PdfDocumentVersion::Pdf1_4 => "1.4".into(),
803 PdfDocumentVersion::Pdf1_5 => "1.5".into(),
804 PdfDocumentVersion::Pdf1_6 => "1.6".into(),
805 PdfDocumentVersion::Pdf1_7 => "1.7".into(),
806 PdfDocumentVersion::Pdf2_0 => "2.0".into(),
807 PdfDocumentVersion::Other(n) => format!("{}.{}", n / 10, n % 10),
808 }
809}
810
811fn _norm_repeat(text: &str) -> String {
812 let mut out = String::with_capacity(text.len());
813 let mut last_digit = false;
814 for ch in text.chars() {
815 if ch.is_ascii_digit() {
816 if !last_digit {
817 out.push('#');
818 last_digit = true;
819 }
820 } else {
821 last_digit = false;
822 if !ch.is_whitespace() {
823 out.push(ch);
824 }
825 }
826 }
827 out
828}
829
830fn strip_headers_footers(pages: &mut [RawPage]) -> Vec<String> {
838 if pages.len() < 2 {
839 return Vec::new();
840 }
841 let n_with_h = pages.iter().filter(|p| p.height.is_some()).count();
842 if n_with_h == 0 {
843 return Vec::new();
844 }
845 let mut removed: Vec<String> = Vec::new();
846 for zone in ["top", "bottom"] {
847 let mut occ: std::collections::HashMap<String, Vec<(usize, usize)>> =
848 std::collections::HashMap::new(); for (pi, page) in pages.iter().enumerate() {
850 let Some(h) = page.height else { continue };
851 let band = if zone == "top" { h * 0.07 } else { h * 0.93 };
852 for (ii, it) in page.items.iter().enumerate() {
853 let Some(bb) = it.bbox else { continue };
854 let hit = if zone == "top" {
855 bb.y1 <= band
856 } else {
857 bb.y0 >= band
858 };
859 if hit {
860 occ.entry(_norm_repeat(&it.text))
861 .or_default()
862 .push((pi, ii));
863 }
864 }
865 }
866 let mut keys: Vec<(&String, &Vec<(usize, usize)>)> = occ.iter().collect();
868 keys.sort_by_key(|(_, hits)| hits.first().copied().unwrap_or((usize::MAX, usize::MAX)));
869 let mut drop: Vec<std::collections::BTreeSet<usize>> =
870 vec![std::collections::BTreeSet::new(); pages.len()];
871 for (norm, hits) in keys {
872 if norm.is_empty() {
873 continue;
874 }
875 let mut hit_pages: Vec<usize> = hits.iter().map(|(p, _)| *p).collect();
876 hit_pages.sort_unstable();
877 hit_pages.dedup();
878 if hit_pages.len() >= 2 && hit_pages.len() as f32 >= 0.5 * n_with_h as f32 {
879 for (pi, ii) in hits {
880 drop[*pi].insert(*ii);
881 }
882 if !removed.iter().any(|r| r == norm) {
883 removed.push(norm.clone());
884 }
885 }
886 }
887 for (pi, page) in pages.iter_mut().enumerate() {
888 if drop[pi].is_empty() {
889 continue;
890 }
891 let keep = std::mem::take(&mut page.items)
892 .into_iter()
893 .enumerate()
894 .filter(|(i, _)| !drop[pi].contains(i))
895 .map(|(_, it)| it)
896 .collect();
897 page.items = keep;
898 }
899 }
900 removed
901}
902
903struct ResidentOcr {
907 child: std::process::Child,
908 stdin: std::process::ChildStdin,
909 stdout: std::io::BufReader<std::process::ChildStdout>,
910}
911
912impl ResidentOcr {
913 fn spawn() -> Option<Self> {
914 let bin = find_ocr_binary()?;
915 let mut cmd = std::process::Command::new(&bin);
916 cmd.arg("serve")
917 .stdin(std::process::Stdio::piped())
918 .stdout(std::process::Stdio::piped())
919 .stderr(std::process::Stdio::null());
920 if let Some(m) = find_ocr_models() {
921 cmd.env("DOCPARSER_OCR_MODELS", m);
922 }
923 let mut child = cmd.spawn().ok()?;
924 let stdin = child.stdin.take()?;
925 let stdout = std::io::BufReader::new(child.stdout.take()?);
926 Some(Self {
927 child,
928 stdin,
929 stdout,
930 })
931 }
932
933 fn recognize(&mut self, path: &std::path::Path) -> Option<Vec<serde_json::Value>> {
934 use std::io::{BufRead as _, Write as _};
935 writeln!(self.stdin, "{}", path.display()).ok()?;
936 self.stdin.flush().ok()?;
937 let mut line = String::new();
938 if self.stdout.read_line(&mut line).ok()? == 0 {
939 return None;
940 }
941 let v: serde_json::Value = serde_json::from_str(&line).ok()?;
942 if v.get("error").is_some() {
943 return Some(Vec::new());
944 }
945 Some(
946 v.get("lines")
947 .and_then(|l| l.as_array())
948 .cloned()
949 .unwrap_or_default(),
950 )
951 }
952}
953
954static OCR_SERVE: OnceLock<Mutex<Option<ResidentOcr>>> = OnceLock::new();
955
956fn serve_ocr(
958 files: &[(usize, std::path::PathBuf)],
959) -> Option<Vec<(usize, Vec<serde_json::Value>)>> {
960 let lock = OCR_SERVE.get_or_init(|| Mutex::new(None));
961 let mut guard = lock.lock().ok()?;
962 if guard.is_none() {
963 *guard = ResidentOcr::spawn();
964 }
965 let srv = guard.as_mut()?;
966 let mut out = Vec::with_capacity(files.len());
967 for (key, path) in files {
968 match srv.recognize(path) {
969 Some(lines) => out.push((*key, lines)),
970 None => {
971 if let Some(mut dead) = guard.take() {
973 let _ = dead.child.kill();
974 }
975 return None;
976 }
977 }
978 }
979 Some(out)
980}
981
982fn lines_from_entries(entries: &[serde_json::Value]) -> Vec<(String, Vec<[f32; 2]>)> {
984 let mut items = Vec::new();
985 for ent in entries {
986 if let Some(s) = ent.get("score") {
987 if s.as_f64().unwrap_or(0.0) < 0.8 {
988 continue;
989 }
990 }
991 let text = ent
992 .get("text")
993 .and_then(|t| t.as_str())
994 .unwrap_or("")
995 .to_string();
996 if text.trim().is_empty() {
997 continue;
998 }
999 let mut quad: Vec<[f32; 2]> = Vec::new();
1000 if let Some(boxes) = ent.get("box").and_then(|b| b.as_array()) {
1001 for p in boxes {
1002 let mut pt = [0.0_f32; 2];
1003 if let (Some(x), Some(y)) = (
1004 p.get(0).and_then(|x| x.as_f64()),
1005 p.get(1).and_then(|y| y.as_f64()),
1006 ) {
1007 pt = [x as f32, y as f32];
1008 }
1009 quad.push(pt);
1010 }
1011 }
1012 items.push((text, quad));
1013 }
1014 items.sort_by(|a, b| {
1015 let ay = a.1.iter().map(|p| p[1]).fold(f32::MAX, f32::min);
1016 let by = b.1.iter().map(|p| p[1]).fold(f32::MAX, f32::min);
1017 ay.total_cmp(&by)
1018 });
1019 items
1020}
1021
1022type OcrLine = (String, Vec<[f32; 2]>);
1024
1025type OcrPageLines = Vec<(usize, Vec<OcrLine>)>;
1027
1028fn run_ocr(dir: &std::path::Path, files: &[(usize, std::path::PathBuf)]) -> Option<OcrPageLines> {
1032 if let Some(served) = serve_ocr(files) {
1033 let mut out = Vec::new();
1034 for (key, entries) in served {
1035 let items = lines_from_entries(&entries);
1036 if !items.is_empty() {
1037 out.push((key, items));
1038 }
1039 }
1040 return Some(out);
1041 }
1042 batch_ocr(dir, files)
1043}
1044
1045fn batch_ocr(dir: &std::path::Path, files: &[(usize, std::path::PathBuf)]) -> Option<OcrPageLines> {
1046 let bin = find_ocr_binary()?;
1047 let out_json = dir.join("ocr.json");
1048 let mut cmd = std::process::Command::new(&bin);
1049 cmd.arg(dir).arg("--out").arg(&out_json);
1050 if let Some(m) = find_ocr_models() {
1051 cmd.env("DOCPARSER_OCR_MODELS", m);
1052 }
1053 let ok = cmd.output().map(|o| o.status.success()).unwrap_or(false);
1054 if !ok {
1055 return None;
1056 }
1057 let text = std::fs::read_to_string(&out_json).ok()?;
1058 let v: serde_json::Value = serde_json::from_str(&text).ok()?;
1059 let arr = v.as_array()?;
1060 let mut by_file: std::collections::HashMap<String, Vec<serde_json::Value>> =
1061 std::collections::HashMap::new();
1062 for ent in arr {
1063 if let (Some(f), Some(s)) = (ent.get("file"), ent.get("score")) {
1064 if s.as_f64().unwrap_or(0.0) >= 0.8 {
1065 by_file
1066 .entry(f.as_str().unwrap_or("").to_string())
1067 .or_default()
1068 .push(ent.clone());
1069 }
1070 }
1071 }
1072 let mut out = Vec::new();
1073 for (pi, fp) in files {
1074 let key = fp
1075 .file_name()
1076 .and_then(|n| n.to_str())
1077 .unwrap_or("")
1078 .to_string();
1079 let Some(entries) = by_file.get(&key) else {
1080 continue;
1081 };
1082 let mut items = Vec::new();
1083 for ent in entries {
1084 let text = ent
1085 .get("text")
1086 .and_then(|t| t.as_str())
1087 .unwrap_or("")
1088 .to_string();
1089 if text.trim().is_empty() {
1090 continue;
1091 }
1092 let mut quad: Vec<[f32; 2]> = Vec::new();
1093 if let Some(boxes) = ent.get("box").and_then(|b| b.as_array()) {
1094 for p in boxes {
1095 let mut pt = [0.0_f32; 2];
1096 if let (Some(x), Some(y)) = (
1097 p.get(0).and_then(|x| x.as_f64()),
1098 p.get(1).and_then(|y| y.as_f64()),
1099 ) {
1100 pt = [x as f32, y as f32];
1101 }
1102 quad.push(pt);
1103 }
1104 }
1105 items.push((text, quad));
1106 }
1107 items.sort_by(|a, b| {
1108 let ay = a.1.iter().map(|p| p[1]).fold(f32::MAX, f32::min);
1109 let by = b.1.iter().map(|p| p[1]).fold(f32::MAX, f32::min);
1110 ay.total_cmp(&by)
1111 });
1112 if !items.is_empty() {
1113 out.push((*pi, items));
1114 }
1115 }
1116 Some(out)
1117}
1118
1119type WorkerReq = (String, PdfOptions, mpsc::Sender<Resp>);
1121
1122type WorkerTx = Arc<Mutex<mpsc::Sender<WorkerReq>>>;
1124
1125fn worker_sender() -> Result<WorkerTx> {
1126 static WORKER: OnceLock<std::result::Result<WorkerTx, String>> = OnceLock::new();
1127 WORKER
1128 .get_or_init(|| {
1129 let file = find_library().ok_or_else(|| {
1130 "未找到 PDFium 动态库(设 DOCPARSER_PDFIUM_LIB 或安装 pypdfium2)".to_string()
1131 })?;
1132 let (tx, rx) = mpsc::channel::<(String, PdfOptions, mpsc::Sender<Resp>)>();
1133 std::thread::Builder::new()
1134 .name("docparser-pdfium".into())
1135 .spawn(move || {
1136 let lib = file.to_str().ok_or("PDFium 路径非 UTF-8")?.to_string();
1137 let bindings = Pdfium::bind_to_library(&lib).map_err(|e| format!("{e}"))?;
1138 let pdfium = Pdfium::new(bindings);
1139 for (path, options, resp_tx) in rx.iter() {
1140 let _ = resp_tx.send(parse_one(&pdfium, &path, &options));
1141 }
1142 Ok::<(), String>(())
1143 })
1144 .map_err(|e| format!("{e}"))?;
1145 Ok(Arc::new(Mutex::new(tx)))
1146 })
1147 .as_ref()
1148 .map(Arc::clone)
1149 .map_err(|m| DocError::corrupt(m.clone()))
1150}
1151
1152#[derive(Debug, Clone, Default)]
1154pub struct PdfiumWorkerBackend {
1155 options: PdfOptions,
1156}
1157
1158impl PdfiumWorkerBackend {
1159 pub fn new() -> Self {
1161 Self::default()
1162 }
1163
1164 pub fn with_options(mut self, options: PdfOptions) -> Self {
1166 self.options = options;
1167 self
1168 }
1169}
1170
1171impl ParseCapability for PdfiumWorkerBackend {
1172 fn id(&self) -> &str {
1173 "pdfium-worker"
1174 }
1175 fn formats(&self) -> &[&str] {
1176 &["pdf"]
1177 }
1178 fn kind(&self) -> BackendKind {
1179 BackendKind::Bundled
1180 }
1181 fn is_available(&self) -> bool {
1182 find_library().is_some()
1183 }
1184 fn parse(&self, path: &str) -> Result<RawDoc> {
1185 let tx = worker_sender()?
1186 .lock()
1187 .map_err(|_| DocError::corrupt("worker 锁"))?
1188 .clone();
1189 let (resp_tx, resp_rx) = mpsc::channel();
1190 tx.send((path.to_string(), self.options.clone(), resp_tx))
1191 .map_err(|_| DocError::corrupt("PDFium worker 已退出"))?;
1192 resp_rx
1193 .recv()
1194 .map_err(|_| DocError::corrupt("PDFium worker 无响应"))?
1195 }
1196}
1197
1198#[derive(Debug, Clone, Default)]
1200pub struct PdfTextBackend {
1201 options: PdfOptions,
1202}
1203
1204impl PdfTextBackend {
1205 pub fn new() -> Self {
1207 Self::default()
1208 }
1209
1210 pub fn with_options(mut self, options: PdfOptions) -> Self {
1212 self.options = options;
1213 self
1214 }
1215}
1216
1217impl ParseCapability for PdfTextBackend {
1218 fn id(&self) -> &str {
1219 "pdfium-text-v1"
1220 }
1221 fn formats(&self) -> &[&str] {
1222 &["pdf"]
1223 }
1224 fn kind(&self) -> BackendKind {
1225 BackendKind::Bundled
1226 }
1227 fn parse(&self, path: &str) -> Result<RawDoc> {
1228 let per_page = std::panic::catch_unwind(|| pdf_extract::extract_text_by_pages(path))
1229 .map_err(|_| {
1230 DocError::corrupt(
1231 "pdf-extract v1 无法解析该 PDF(CJK CID/无 ToUnicode,需 pdfium-worker)",
1232 )
1233 })?
1234 .map_err(|e| {
1235 let msg = format!("{e}").to_lowercase();
1236 if msg.contains("password") || msg.contains("encrypt") || msg.contains("permission")
1237 {
1238 DocError::encrypted(format!("PDF 加密或权限受限: {e}"))
1239 } else {
1240 DocError::corrupt(format!("PDF 解析失败: {e}"))
1241 }
1242 })?;
1243 let total = per_page.len();
1245 let limit = self.options.max_pages.unwrap_or(total).min(total);
1246 let truncated = limit < total;
1247 let pages: Vec<RawPage> = per_page
1248 .into_iter()
1249 .take(limit)
1250 .map(|page_text| RawPage {
1251 name: None,
1252 items: page_text
1253 .lines()
1254 .map(|l| RawTextItem {
1255 text: l.to_string(),
1256 ..Default::default()
1257 })
1258 .filter(|it| !it.text.trim().is_empty())
1259 .collect(),
1260 ..Default::default()
1261 })
1262 .filter(|p| !p.items.is_empty())
1263 .collect();
1264 if pages.is_empty() {
1265 return Err(DocError::corrupt("PDF 无有效文本层"));
1266 }
1267 let mut raw = RawDoc {
1268 pages,
1269 meta: Default::default(),
1270 needs_ocr: false,
1271 };
1272 raw.meta
1273 .insert("pdf.truncated".into(), truncated.to_string());
1274 raw.needs_ocr = raw.pages.iter().all(|p| {
1275 p.items
1276 .iter()
1277 .map(|i| i.text.chars().count())
1278 .sum::<usize>()
1279 < 10
1280 });
1281 Ok(raw)
1282 }
1283}
1284
1285fn merge_lines(items: Vec<RawTextItem>) -> Vec<RawTextItem> {
1290 let no_bb: Vec<RawTextItem> = items
1291 .iter()
1292 .filter(|it| it.bbox.is_none())
1293 .cloned()
1294 .collect();
1295 let with_bb: Vec<(usize, RawTextItem)> = items
1296 .into_iter()
1297 .enumerate()
1298 .filter(|(_, it)| it.bbox.is_some())
1299 .collect();
1300 let mut lines: Vec<Vec<(usize, RawTextItem)>> = Vec::new();
1301 let mut sorted = with_bb.clone();
1302 sorted.sort_by(|a, b| {
1303 a.1.bbox
1304 .unwrap()
1305 .y0
1306 .total_cmp(&b.1.bbox.unwrap().y0)
1307 .then(a.1.bbox.unwrap().x0.total_cmp(&b.1.bbox.unwrap().x0))
1308 });
1309 for ent in &sorted {
1310 let bb = ent.1.bbox.unwrap();
1311 let mut placed = false;
1312 for line in lines.iter_mut() {
1313 let lb = line
1317 .iter()
1318 .map(|e| e.1.bbox.unwrap().y1)
1319 .fold(f32::MAX, f32::min);
1320 let lh = line
1321 .iter()
1322 .map(|e| e.1.bbox.unwrap().y1 - e.1.bbox.unwrap().y0)
1323 .fold(f32::MIN, f32::max);
1324 let bh = bb.y1 - bb.y0;
1325 if (lb - bb.y1).abs() <= 0.3 * (lh + bh) {
1326 line.push(ent.clone());
1327 placed = true;
1328 break;
1329 }
1330 }
1331 if !placed {
1332 lines.push(vec![ent.clone()]);
1333 }
1334 }
1335 let mut out: Vec<RawTextItem> = Vec::new();
1336 for mut line in lines {
1337 line.sort_by(|a, b| a.1.bbox.unwrap().x0.total_cmp(&b.1.bbox.unwrap().x0));
1338 let mut cur: Option<(String, Rect)> = None;
1339 for (_i, it) in line {
1340 let bb = it.bbox.unwrap();
1341 match &mut cur {
1342 None => cur = Some((it.text.clone(), bb)),
1343 Some((t, cb)) => {
1344 let gap = bb.x0 - cb.x1;
1345 let h = (cb.y1 - cb.y0).min(bb.y1 - bb.y0);
1346 if gap <= (6.0_f32).max(0.6 * h) {
1347 let sep = if gap > 0.2 * h { " " } else { "" };
1348 *t = format!("{t}{sep}{}", it.text);
1349 *cb = Rect {
1350 x0: cb.x0.min(bb.x0),
1351 y0: cb.y0.min(bb.y0),
1352 x1: cb.x1.max(bb.x1),
1353 y1: cb.y1.max(bb.y1),
1354 };
1355 } else {
1356 out.push(RawTextItem {
1357 text: std::mem::take(t),
1358 bbox: Some(*cb),
1359 ..Default::default()
1360 });
1361 cur = Some((it.text.clone(), bb));
1362 }
1363 }
1364 }
1365 }
1366 if let Some((t, cb)) = cur {
1367 out.push(RawTextItem {
1368 text: t,
1369 bbox: Some(cb),
1370 ..Default::default()
1371 });
1372 }
1373 }
1374 out.splice(0..0, no_bb); out
1376}
1377
1378fn detect_table(lines: &[RawTextItem]) -> Option<(Vec<Vec<String>>, Vec<bool>)> {
1379 let mut rows: Vec<Vec<RawTextItem>> = Vec::new();
1383 let mut sorted: Vec<&RawTextItem> = lines.iter().collect();
1384 sorted.sort_by(|a, b| {
1385 let (ab, bb) = (a.bbox.unwrap(), b.bbox.unwrap());
1386 ab.y0.total_cmp(&bb.y0).then(ab.x0.total_cmp(&bb.x0))
1387 });
1388 for it in sorted {
1389 let bb = it.bbox.unwrap();
1390 let bh = bb.y1 - bb.y0;
1391 let mut placed = false;
1392 for row in rows.iter_mut() {
1393 let lh = row
1394 .iter()
1395 .map(|r| r.bbox.unwrap().y1 - r.bbox.unwrap().y0)
1396 .fold(f32::MIN, f32::max);
1397 let lb = row
1398 .iter()
1399 .map(|r| r.bbox.unwrap().y1)
1400 .fold(f32::MAX, f32::min);
1401 if (lb - bb.y1).abs() <= 0.3 * (lh + bh) {
1402 row.push(it.clone());
1403 placed = true;
1404 break;
1405 }
1406 }
1407 if !placed {
1408 rows.push(vec![it.clone()]);
1409 }
1410 }
1411 rows.sort_by(|a, b| {
1412 a.iter()
1413 .map(|r| r.bbox.unwrap().y0)
1414 .fold(f32::MAX, f32::min)
1415 .total_cmp(
1416 &b.iter()
1417 .map(|r| r.bbox.unwrap().y0)
1418 .fold(f32::MAX, f32::min),
1419 )
1420 });
1421 if rows.len() < 2 {
1422 return None;
1423 }
1424 let mut xs: Vec<f32> = lines.iter().map(|it| it.bbox.unwrap().x0).collect();
1426 xs.sort_by(f32::total_cmp);
1427 let mut anchors: Vec<f32> = Vec::new();
1428 for x in xs {
1429 if anchors.is_empty() || x - *anchors.last().unwrap() > 8.0 {
1430 anchors.push(x);
1431 }
1432 }
1433 if anchors.len() < 3 {
1434 return None;
1435 }
1436 let col_of = |x: f32| -> usize {
1437 let mut best = 0usize;
1438 let mut bd = f32::MAX;
1439 for (i, a) in anchors.iter().enumerate() {
1440 let d = (x - a).abs();
1441 if d < bd {
1442 bd = d;
1443 best = i;
1444 }
1445 }
1446 best
1447 };
1448 let mut grid: Vec<Vec<RawTextItem>> = Vec::new();
1449 for mut row in rows {
1450 row.sort_by(|a, b| a.bbox.unwrap().x0.total_cmp(&b.bbox.unwrap().x0));
1451 let mut merged: Vec<RawTextItem> = Vec::new();
1452 for it in row {
1453 let bb = it.bbox.unwrap();
1454 match merged.last_mut() {
1455 Some(prev) if col_of(prev.bbox.unwrap().x0) == col_of(bb.x0) => {
1456 prev.text = format!("{}{}", prev.text, it.text);
1457 let pb = prev.bbox.unwrap();
1458 prev.bbox = Some(Rect {
1459 x0: pb.x0.min(bb.x0),
1460 y0: pb.y0.min(bb.y0),
1461 x1: pb.x1.max(bb.x1),
1462 y1: pb.y1.max(bb.y1),
1463 });
1464 }
1465 _ => merged.push(it),
1466 }
1467 }
1468 if merged.len() >= 3 {
1469 grid.push(merged);
1470 }
1471 }
1472 if grid.len() < 2 {
1473 return None;
1474 }
1475 let counts: Vec<usize> = grid.iter().map(|r| r.len()).collect();
1476 let modal = counts
1477 .iter()
1478 .max_by_key(|c| counts.iter().filter(|x| x == c).count())
1479 .copied()
1480 .unwrap_or(0);
1481 if modal < 3 {
1482 return None;
1483 }
1484 let consistent = counts.iter().filter(|c| **c == modal).count() as f32 / grid.len() as f32;
1485 if consistent < 0.6 {
1486 return None;
1487 }
1488 let used_keys: Vec<(f32, f32)> = grid
1489 .iter()
1490 .flat_map(|r| r.iter())
1491 .map(|it| {
1492 let b = it.bbox.unwrap();
1493 (b.x0, b.y0)
1494 })
1495 .collect();
1496 let used: Vec<bool> = lines
1497 .iter()
1498 .map(|it| {
1499 let b = it.bbox.unwrap();
1500 used_keys.contains(&(b.x0, b.y0))
1501 })
1502 .collect();
1503 let trect = {
1507 let hits: Vec<Rect> = lines
1508 .iter()
1509 .zip(&used)
1510 .filter(|(_, u)| **u)
1511 .map(|(it, _)| it.bbox.unwrap())
1512 .collect();
1513 Rect {
1514 x0: hits.iter().map(|r| r.x0).fold(f32::MAX, f32::min),
1515 y0: hits.iter().map(|r| r.y0).fold(f32::MAX, f32::min),
1516 x1: hits.iter().map(|r| r.x1).fold(f32::MIN, f32::max),
1517 y1: hits.iter().map(|r| r.y1).fold(f32::MIN, f32::max),
1518 }
1519 };
1520 let inside = |b: Rect| {
1521 b.x0 >= trect.x0 - 1.0
1522 && b.x1 <= trect.x1 + 1.0
1523 && b.y0 >= trect.y0 - 1.0
1524 && b.y1 <= trect.y1 + 1.0
1525 };
1526 let cells_len: Vec<usize> = grid
1527 .iter()
1528 .flat_map(|r| r.iter())
1529 .map(|it| it.text.chars().count())
1530 .collect();
1531 let in_table: usize = cells_len.iter().sum();
1532 let long_ratio = if cells_len.is_empty() {
1533 0.0
1534 } else {
1535 cells_len.iter().filter(|n| **n > 40).count() as f32 / cells_len.len() as f32
1536 };
1537 let stray: usize = lines
1538 .iter()
1539 .zip(&used)
1540 .filter(|(it, u)| !**u && inside(it.bbox.unwrap()))
1541 .map(|(it, _)| it.text.chars().count())
1542 .sum();
1543 if stray > in_table && long_ratio >= 0.2 {
1544 return None;
1545 }
1546 let lopsided = grid
1550 .iter()
1551 .filter(|r| {
1552 let lens: Vec<usize> = r.iter().map(|it| it.text.chars().count()).collect();
1553 if lens.len() < 2 {
1554 return false;
1555 }
1556 let max = *lens.iter().max().unwrap_or(&0);
1557 if max < 40 {
1558 return false;
1559 }
1560 let rest = lens
1561 .iter()
1562 .filter(|n| **n != max)
1563 .max()
1564 .copied()
1565 .unwrap_or(0);
1566 rest > 0 && max >= 3 * rest
1567 })
1568 .count();
1569 if !grid.is_empty() && lopsided as f32 / grid.len() as f32 >= 0.5 {
1570 return None;
1571 }
1572 let rows_out: Vec<Vec<String>> = grid
1573 .into_iter()
1574 .map(|r| r.into_iter().map(|it| it.text).collect())
1575 .collect();
1576 Some((rows_out, used))
1577}
1578
1579fn column_order(items: Vec<RawTextItem>) -> Vec<RawTextItem> {
1593 if items.len() < 4 {
1594 return items;
1595 }
1596 let (x_min, x_max) = items.iter().fold((f32::MAX, f32::MIN), |(lo, hi), it| {
1597 let b = it.bbox.unwrap();
1598 (lo.min(b.x0), hi.max(b.x1))
1599 });
1600 let span = x_max - x_min;
1601 if span <= 1.0 || interleaved_ratio(&items, span) < 0.25 {
1602 return items;
1603 }
1604 if let Some(out) = order_by_gutter(&items, x_min, x_max, span) {
1605 return out;
1606 }
1607 order_by_leads(items, span)
1608}
1609
1610fn order_by_gutter(
1614 items: &[RawTextItem],
1615 x_min: f32,
1616 x_max: f32,
1617 span: f32,
1618) -> Option<Vec<RawTextItem>> {
1619 const MIN_GUTTER: usize = 20;
1620 let n = span as usize + 1;
1621 if n <= MIN_GUTTER * 3 {
1622 return None;
1623 }
1624 let wide = span * 0.6;
1625 let mut occ = vec![0usize; n];
1626 for it in items {
1627 let b = it.bbox.unwrap();
1628 if (b.x1 - b.x0) > wide {
1629 continue;
1630 }
1631 let lo = ((b.x0 - x_min).max(0.0) as usize).min(n - 1);
1632 let hi = (((b.x1 - x_min) as usize).min(n - 1)).max(lo);
1633 for v in occ.iter_mut().take(hi + 1).skip(lo) {
1634 *v += 1;
1635 }
1636 }
1637 let mut gutters: Vec<(usize, usize)> = Vec::new();
1638 let mut run: Option<usize> = None;
1639 for (i, v) in occ.iter().enumerate() {
1640 if *v == 0 {
1641 if run.is_none() {
1642 run = Some(i);
1643 }
1644 } else if let Some(s) = run.take() {
1645 if i - s >= MIN_GUTTER {
1646 gutters.push((s, i));
1647 }
1648 }
1649 }
1650 if let Some(s) = run {
1651 if n - s >= MIN_GUTTER {
1652 gutters.push((s, n));
1653 }
1654 }
1655 if gutters.is_empty() {
1656 return None;
1657 }
1658
1659 let center = |b: Rect| (b.x0 + b.x1) / 2.0;
1660 let count_in = |lo: f32, hi: f32| {
1661 items
1662 .iter()
1663 .filter(|it| {
1664 let c = center(it.bbox.unwrap());
1665 c >= lo && c < hi
1666 })
1667 .count()
1668 };
1669 let mut bounds: Vec<(f32, f32)> = Vec::new();
1670 let mut prev = x_min;
1671 for (g0, g1) in &gutters {
1672 let seg = (prev, x_min + *g0 as f32);
1673 if count_in(seg.0, seg.1) >= 2 {
1674 bounds.push(seg);
1675 prev = x_min + *g1 as f32;
1676 }
1677 }
1678 let last = (prev, x_max);
1679 if !bounds.is_empty() && count_in(last.0, last.1) >= 2 {
1680 bounds.push(last);
1681 }
1682 if bounds.len() < 2 {
1683 return None;
1684 }
1685 let mut out: Vec<RawTextItem> = Vec::with_capacity(items.len());
1686 for (lo, hi) in &bounds {
1687 let mut col: Vec<RawTextItem> = items
1688 .iter()
1689 .filter(|it| {
1690 let c = center(it.bbox.unwrap());
1691 c >= *lo && c < *hi
1692 })
1693 .cloned()
1694 .collect();
1695 col.sort_by(|a, b| {
1696 let (ab, bb) = (a.bbox.unwrap(), b.bbox.unwrap());
1697 ab.y0.total_cmp(&bb.y0).then(ab.x0.total_cmp(&bb.x0))
1698 });
1699 out.extend(col);
1700 }
1701 if out.len() != items.len() {
1703 return None;
1704 }
1705 Some(out)
1706}
1707
1708fn visual_line_leads(items: &[RawTextItem]) -> Vec<(f32, f32)> {
1712 let mut order: Vec<&RawTextItem> = items.iter().collect();
1713 order.sort_by(|a, b| {
1714 let (ab, bb) = (a.bbox.unwrap(), b.bbox.unwrap());
1715 ab.y0.total_cmp(&bb.y0).then(ab.x0.total_cmp(&bb.x0))
1716 });
1717 let mut bands: Vec<Vec<Rect>> = Vec::new();
1718 for it in order {
1719 let b = it.bbox.unwrap();
1720 let bh = b.y1 - b.y0;
1721 let mut placed = false;
1722 for band in bands.iter_mut() {
1723 let lb = band.iter().map(|r| r.y1).fold(f32::MAX, f32::min);
1724 let lh = band.iter().map(|r| r.y1 - r.y0).fold(f32::MIN, f32::max);
1725 if (lb - b.y1).abs() <= 0.3 * (lh + bh) {
1726 band.push(b);
1727 placed = true;
1728 break;
1729 }
1730 }
1731 if !placed {
1732 bands.push(vec![b]);
1733 }
1734 }
1735 let frag = |seg: &[Rect]| -> (f32, f32) {
1736 let x0 = seg.iter().map(|r| r.x0).fold(f32::MAX, f32::min);
1737 let x1 = seg.iter().map(|r| r.x1).fold(f32::MIN, f32::max);
1738 (x0, x1 - x0)
1739 };
1740 let mut out: Vec<(f32, f32)> = Vec::new();
1741 for mut band in bands {
1742 band.sort_by(|a, b| a.x0.total_cmp(&b.x0));
1743 let mut cur: Vec<Rect> = vec![band[0]];
1744 for b in band.into_iter().skip(1) {
1745 let prev = *cur.last().unwrap();
1746 let h = ((prev.y1 - prev.y0).min(b.y1 - b.y0)).max(1.0);
1747 if b.x0 - prev.x1 > (2.0 * h).max(12.0) {
1748 out.push(frag(&cur));
1749 cur = vec![b];
1750 } else {
1751 cur.push(b);
1752 }
1753 }
1754 out.push(frag(&cur));
1755 }
1756 out
1757}
1758
1759fn order_by_leads(items: Vec<RawTextItem>, span: f32) -> Vec<RawTextItem> {
1768 let wide = span * 0.6;
1769 let per_line = visual_line_leads(&items);
1770 if per_line.len() < 4 {
1771 return items;
1772 }
1773 let mut leads: Vec<f32> = per_line
1774 .iter()
1775 .filter(|(_, w)| *w <= wide)
1776 .map(|(x, _)| *x)
1777 .collect();
1778 if leads.len() < 4 {
1779 return items;
1780 }
1781 leads.sort_by(|a, b| a.total_cmp(b));
1782 let lead_span = leads.last().copied().unwrap_or(0.0) - leads.first().copied().unwrap_or(0.0);
1786 if lead_span <= 40.0 {
1787 return items;
1788 }
1789 let thr = (lead_span * 0.25).max(40.0);
1790 let mut groups: Vec<Vec<f32>> = Vec::new();
1791 for a in leads {
1792 match groups.last_mut() {
1793 Some(g) if a - *g.last().unwrap() <= thr => g.push(a),
1794 _ => groups.push(vec![a]),
1795 }
1796 }
1797 let cols: Vec<f32> = groups
1798 .iter()
1799 .filter(|g| g.len() >= 3)
1800 .map(|g| g[0])
1801 .collect();
1802 if cols.len() < 2 {
1803 return items;
1804 }
1805
1806 let total = items.len();
1808 let mut buckets: Vec<Vec<usize>> = vec![Vec::new(); cols.len()];
1809 for (i, it) in items.iter().enumerate() {
1810 let bx = it.bbox.unwrap().x0;
1811 let mut idx = 0;
1812 for (c, s) in cols.iter().enumerate() {
1813 if bx >= *s - 0.5 {
1814 idx = c;
1815 }
1816 }
1817 buckets[idx].push(i);
1818 }
1819 if buckets[0].len() < 3 || buckets[1].len() < 3 {
1823 return items;
1824 }
1825 let left_ys: Vec<f32> = buckets[0]
1826 .iter()
1827 .map(|&i| items[i].bbox.unwrap().y0)
1828 .collect();
1829 let paired = buckets[1]
1830 .iter()
1831 .filter(|&&i| {
1832 let y = items[i].bbox.unwrap().y0;
1833 left_ys.iter().any(|ly| (y - ly).abs() < 12.0)
1834 })
1835 .count();
1836 if (paired as f32) / (buckets[1].len() as f32) < 0.3 {
1837 return items;
1838 }
1839 let mut out = Vec::with_capacity(total);
1840 for bucket in &mut buckets {
1841 bucket.sort_by(|&a, &b| {
1842 let (ay, ax) = (items[a].bbox.unwrap().y0, items[a].bbox.unwrap().x0);
1843 let (by, bx) = (items[b].bbox.unwrap().y0, items[b].bbox.unwrap().x0);
1844 ay.total_cmp(&by).then(ax.total_cmp(&bx))
1845 });
1846 out.extend(bucket.iter().map(|&i| items[i].clone()));
1847 }
1848 debug_assert_eq!(out.len(), total);
1849 out
1850}
1851
1852fn interleaved_ratio(items: &[RawTextItem], span: f32) -> f32 {
1854 if items.len() < 2 {
1855 return 0.0;
1856 }
1857 let half = (span * 0.5).max(1.0);
1858 let mut hits = 0usize;
1859 for w in items.windows(2) {
1860 let a = w[0].bbox.unwrap();
1861 let b = w[1].bbox.unwrap();
1862 if (b.y0 - a.y0).abs() < 3.0 && (b.x0 - a.x0).abs() >= half {
1863 hits += 1;
1864 }
1865 }
1866 hits as f32 / (items.len() - 1) as f32
1867}
1868
1869fn assemble(raw: &RawDoc, mode: LayoutMode) -> Document {
1870 let pages: Vec<Page> = raw
1871 .pages
1872 .iter()
1873 .map(|rp| {
1874 let (lines, all_bbox) = if mode.reflows() {
1876 let lines = merge_lines(rp.items.clone());
1877 let all_bbox = lines.iter().all(|it| it.bbox.is_some());
1878 (lines, all_bbox)
1879 } else {
1880 (rp.items.clone(), false)
1881 };
1882 let (table_rows, used, table_rect) =
1884 match if all_bbox { detect_table(&lines) } else { None } {
1885 Some((rows, used)) => {
1886 let rect = {
1887 let hits: Vec<Rect> = lines
1888 .iter()
1889 .zip(&used)
1890 .filter(|(_, u)| **u)
1891 .map(|(it, _)| it.bbox.unwrap())
1892 .collect();
1893 if hits.is_empty() {
1894 None
1895 } else {
1896 Some(Rect {
1897 x0: hits.iter().map(|r| r.x0).fold(f32::MAX, f32::min),
1898 y0: hits.iter().map(|r| r.y0).fold(f32::MAX, f32::min),
1899 x1: hits.iter().map(|r| r.x1).fold(f32::MIN, f32::max),
1900 y1: hits.iter().map(|r| r.y1).fold(f32::MIN, f32::max),
1901 })
1902 }
1903 };
1904 (Some(rows), used, rect)
1905 }
1906 None => (None, vec![false; lines.len()], None),
1907 };
1908 let paras: Vec<RawTextItem> = lines
1910 .iter()
1911 .zip(&used)
1912 .filter(|(_, u)| !**u)
1913 .map(|(it, _)| it.clone())
1914 .collect();
1915 let ordered: Vec<RawTextItem> = if all_bbox { column_order(paras) } else { paras };
1916 let mut blocks: Vec<docparser_core::model::Block> = Vec::new();
1918 if let Some(rows) = &table_rows {
1919 blocks.push(docparser_core::model::Block {
1920 kind: BlockKind::Table,
1921 table: Some(docparser_core::model::TableData {
1922 rows: rows.clone(),
1923 ..Default::default()
1924 }),
1925 bbox: table_rect,
1926 ..Default::default()
1927 });
1928 }
1929 for it in &ordered {
1930 blocks.push(docparser_core::model::Block {
1931 kind: BlockKind::Paragraph,
1932 content: it.text.clone(),
1933 bbox: it.bbox,
1934 ..Default::default()
1935 });
1936 }
1937 let mut parts: Vec<String> = ordered.iter().map(|it| it.text.clone()).collect();
1938 if let Some(rows) = &table_rows {
1939 let ty = table_rect.map(|r| r.y0).unwrap_or(0.0);
1940 let table_text = rows
1941 .iter()
1942 .map(|r| r.join(","))
1943 .collect::<Vec<_>>()
1944 .join("\n");
1945 let mut idx = parts.len();
1947 for (k, it) in ordered.iter().enumerate() {
1948 if it.bbox.map(|r| r.y0).unwrap_or(f32::MAX) > ty {
1949 idx = k;
1950 break;
1951 }
1952 }
1953 parts.insert(idx, table_text);
1954 }
1955 let imgs: Vec<docparser_core::model::Image> = rp
1957 .images
1958 .iter()
1959 .map(|im| docparser_core::model::Image {
1960 bbox: im.bbox,
1961 dedup_key: image_dedup_key(im),
1962 ocr_text: im.ocr_text.clone(),
1963 ..Default::default()
1964 })
1965 .collect();
1966 for im in &imgs {
1967 if let Some(t) = &im.ocr_text {
1968 if !t.trim().is_empty() {
1969 parts.push(t.clone());
1970 }
1971 }
1972 }
1973 let text = parts
1974 .into_iter()
1975 .filter(|s| !s.trim().is_empty())
1976 .collect::<Vec<_>>()
1977 .join("\n");
1978 Page {
1979 blocks,
1980 text,
1981 images: imgs,
1982 width: rp.width,
1983 height: rp.height,
1984 rotation: rp.rotation,
1985 ..Default::default()
1986 }
1987 })
1988 .collect();
1989
1990 let mut doc = Document {
1991 pages,
1992 ..Default::default()
1993 };
1994 let mut pdf: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
1996 for (src, dst) in [
1997 ("pdf.version", "pdf_version"),
1998 ("pdf.title", "title"),
1999 ("pdf.author", "author"),
2000 ("pdf.producer", "producer"),
2001 ("pdf.creator", "creator"),
2002 ("headers_footers", "headers_footers"),
2003 ] {
2004 if let Some(v) = raw.meta.get(src) {
2005 pdf.insert(dst.to_string(), serde_json::Value::String(v.clone()));
2006 }
2007 }
2008 for (src, dst) in [
2009 ("pdf.complexity", "complexity"),
2010 ("pdf.page_errors", "page_errors"),
2011 ] {
2012 if let Some(v) = raw.meta.get(src) {
2013 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(v) {
2014 pdf.insert(dst.to_string(), parsed);
2015 }
2016 }
2017 }
2018 pdf.insert(
2019 "truncated".to_string(),
2020 serde_json::Value::Bool(
2021 raw.meta
2022 .get("pdf.truncated")
2023 .map(|s| s == "true")
2024 .unwrap_or(false),
2025 ),
2026 );
2027 pdf.insert(
2028 "layout_modes".to_string(),
2029 serde_json::json!(vec![mode.as_str(); doc.pages.len()]),
2030 );
2031 if let Ok(s) = serde_json::to_string(&pdf) {
2032 doc.meta.extra.insert("pdf".to_string(), s);
2033 }
2034 doc.meta
2035 .extra
2036 .insert("needs_ocr".to_string(), raw.needs_ocr.to_string());
2037 doc
2038}
2039
2040#[derive(Debug, Clone, Default)]
2042pub struct PdfPlugin {
2043 options: PdfOptions,
2044}
2045
2046impl PdfPlugin {
2047 pub fn new() -> Self {
2049 Self::default()
2050 }
2051
2052 pub fn with_options(mut self, options: PdfOptions) -> Self {
2054 self.options = options;
2055 self
2056 }
2057}
2058
2059impl FormatPlugin for PdfPlugin {
2060 fn id(&self) -> &str {
2061 "pdf"
2062 }
2063 fn formats(&self) -> &[&str] {
2064 &["pdf"]
2065 }
2066 fn default_parse_chain(&self) -> Vec<&'static str> {
2067 vec!["pdfium-worker", "pdfium-text-v1"]
2069 }
2070 fn default_process_chain(&self) -> Vec<&'static str> {
2071 vec![]
2072 }
2073 fn assemble(&self, raw: &RawDoc) -> Document {
2074 assemble(raw, self.options.layout_mode)
2075 }
2076}
2077
2078pub fn register_defaults(reg: &mut Registry) {
2080 register_with(reg, &PdfOptions::default());
2081}
2082
2083pub fn register_with(reg: &mut Registry, options: &PdfOptions) {
2085 reg.register_parse(
2086 Arc::new(PdfTextBackend::new().with_options(options.clone())),
2087 None,
2088 )
2089 .unwrap();
2090 if find_library().is_some() {
2091 reg.register_parse(
2092 Arc::new(PdfiumWorkerBackend::new().with_options(options.clone())),
2093 None,
2094 )
2095 .unwrap();
2096 }
2097 reg.register_plugin(Arc::new(PdfPlugin::new().with_options(options.clone())));
2098}
2099
2100pub fn is_terminal(e: &DocError) -> bool {
2102 matches!(e.code, ErrorCode::Encrypted | ErrorCode::Unsupported)
2103}
2104
2105#[cfg(test)]
2106mod tests {
2107 use super::*;
2108
2109 fn item(text: &str, x0: f32, y0: f32, x1: f32, y1: f32) -> RawTextItem {
2110 RawTextItem {
2111 text: text.to_string(),
2112 bbox: Some(Rect { x0, y0, x1, y1 }),
2113 ..Default::default()
2114 }
2115 }
2116
2117 fn order_stream(items: Vec<RawTextItem>) -> Vec<RawTextItem> {
2118 column_order(items)
2119 }
2120
2121 fn raw_image(data: Vec<u8>, ext: &str, px: Option<(u32, u32)>) -> RawImage {
2122 RawImage {
2123 data,
2124 bbox: None,
2125 ext: ext.to_string(),
2126 px,
2127 ocr_text: None,
2128 }
2129 }
2130
2131 #[test]
2133 fn image_dedup_key_hashes_bytes() {
2134 let im = raw_image(b"abc".to_vec(), "png", Some((60, 40)));
2135 assert_eq!(
2136 image_dedup_key(&im).as_deref(),
2137 Some("900150983cd24fb0d6963f7d28e17f72")
2138 );
2139 let same = raw_image(b"abc".to_vec(), "jpg", Some((10, 10)));
2141 assert_eq!(image_dedup_key(&im), image_dedup_key(&same));
2142 let other = raw_image(b"abd".to_vec(), "png", Some((60, 40)));
2143 assert_ne!(image_dedup_key(&im), image_dedup_key(&other));
2144 }
2145
2146 #[test]
2149 fn image_dedup_key_falls_back_to_size() {
2150 assert_eq!(
2151 image_dedup_key(&raw_image(Vec::new(), "png", Some((120, 80)))).as_deref(),
2152 Some("120x80@png")
2153 );
2154 assert_eq!(
2155 image_dedup_key(&raw_image(Vec::new(), "", Some((120, 80)))).as_deref(),
2156 Some("120x80@")
2157 );
2158 assert_eq!(image_dedup_key(&raw_image(Vec::new(), "png", None)), None);
2159 }
2160
2161 #[test]
2162 fn layout_mode_parses_roundtrip() {
2163 for (s, m) in [
2164 ("auto", LayoutMode::Auto),
2165 ("REBUILD", LayoutMode::Rebuild),
2166 (" raw ", LayoutMode::Raw),
2167 ] {
2168 let parsed = LayoutMode::parse(s).expect("应可解析");
2169 assert_eq!(parsed, m);
2170 assert_eq!(parsed.as_str(), m.as_str());
2171 }
2172 assert!(LayoutMode::parse("fast").is_none());
2173 assert!(LayoutMode::Auto.reflows() && LayoutMode::Rebuild.reflows());
2174 assert!(!LayoutMode::Raw.reflows());
2175 assert_eq!(LayoutMode::default(), LayoutMode::Auto);
2176 }
2177
2178 #[test]
2179 fn pdf_version_maps_to_dotted_string() {
2180 assert_eq!(pdf_version_str(PdfDocumentVersion::Pdf1_7), "1.7");
2181 assert_eq!(pdf_version_str(PdfDocumentVersion::Pdf2_0), "2.0");
2182 assert_eq!(pdf_version_str(PdfDocumentVersion::Other(21)), "2.1");
2183 assert_eq!(pdf_version_str(PdfDocumentVersion::Unset), "");
2184 }
2185
2186 #[test]
2190 fn strip_headers_footers_removes_multiple_repeated_texts() {
2191 let page = || RawPage {
2192 items: vec![
2193 item("Conf Title", 10.0, 5.0, 300.0, 15.0),
2194 item("正文第一行", 10.0, 100.0, 300.0, 112.0),
2195 item("正文第二行", 10.0, 120.0, 300.0, 132.0),
2196 item("123", 550.0, 5.0, 580.0, 15.0),
2197 ],
2198 width: Some(595.0),
2199 height: Some(842.0),
2200 ..Default::default()
2201 };
2202 let mut pages = vec![page(), page(), page()];
2203 let removed = strip_headers_footers(&mut pages);
2204 assert_eq!(removed.len(), 2, "两条重复页眉都应记录:{removed:?}");
2205 for p in &pages {
2206 let texts: Vec<&str> = p.items.iter().map(|i| i.text.as_str()).collect();
2207 assert_eq!(texts, vec!["正文第一行", "正文第二行"], "只应保留正文行");
2208 }
2209 }
2210
2211 #[test]
2213 fn merge_lines_joins_fragments_on_same_baseline() {
2214 let out = merge_lines(vec![
2215 item("北", 10.0, 100.0, 22.0, 112.0),
2216 item("京", 22.0, 100.5, 34.0, 112.5),
2217 item("市", 34.0, 100.0, 46.0, 112.0),
2218 ]);
2219 assert_eq!(out.len(), 1);
2220 assert_eq!(out[0].text, "北京市");
2221 }
2222
2223 #[test]
2226 fn merge_lines_keeps_tightly_spaced_lines_apart() {
2227 let out = merge_lines(vec![
2228 item("第一行", 10.0, 100.0, 60.0, 112.0),
2229 item("第二行", 10.0, 113.0, 60.0, 125.0),
2230 ]);
2231 assert_eq!(out.len(), 2, "行距 1pt 也应判为两行:{:?}", out);
2232 }
2233
2234 #[test]
2236 fn merge_lines_splits_on_large_horizontal_gap() {
2237 let out = merge_lines(vec![
2238 item("左栏", 10.0, 100.0, 40.0, 112.0),
2239 item("右栏", 200.0, 100.0, 230.0, 112.0),
2240 ]);
2241 assert_eq!(out.len(), 2);
2242 assert_eq!(out[0].text, "左栏");
2243 assert_eq!(out[1].text, "右栏");
2244 }
2245
2246 #[test]
2248 fn column_order_reorders_interleaved_two_columns() {
2249 let mut items = Vec::new();
2250 for i in 0..4 {
2251 let y = 100.0 + i as f32 * 12.0;
2252 items.push(item(&format!("左{i}"), 54.0, y, 293.0, y + 8.0));
2253 items.push(item(&format!("右{i}"), 317.0, y, 556.0, y + 8.0));
2254 }
2255 let out = order_stream(items);
2256 let texts: Vec<&str> = out.iter().map(|it| it.text.as_str()).collect();
2257 assert_eq!(
2258 texts,
2259 vec!["左0", "左1", "左2", "左3", "右0", "右1", "右2", "右3"],
2260 "双栏应读成栏序而非左右逐行交错"
2261 );
2262 }
2263
2264 #[test]
2267 fn column_order_keeps_right_column_with_centered_caption() {
2268 let mut items = Vec::new();
2269 for i in 0..4 {
2270 let y = 100.0 + i as f32 * 12.0;
2271 items.push(item(&format!("左{i}"), 54.0, y, 293.0, y + 8.0));
2272 items.push(item(&format!("右{i}"), 317.0, y, 556.0, y + 8.0));
2273 }
2274 items.push(item("居中图注", 162.0, 400.0, 446.0, 408.0));
2275 let out = order_stream(items);
2276 let texts: Vec<&str> = out.iter().map(|it| it.text.as_str()).collect();
2277 assert_eq!(
2278 texts,
2279 vec![
2280 "左0",
2281 "左1",
2282 "左2",
2283 "左3",
2284 "居中图注",
2285 "右0",
2286 "右1",
2287 "右2",
2288 "右3"
2289 ],
2290 "图注按 x0 归首栏,右栏仍须整段在后:{:?}",
2291 texts
2292 );
2293 }
2294
2295 #[test]
2299 fn order_by_leads_threshold_uses_lead_span() {
2300 let mut items = Vec::new();
2301 for i in 0..5 {
2302 let y = 100.0 + i as f32 * 12.0;
2303 items.push(item(&format!("左{i}"), 54.0, y, 140.0, y + 8.0));
2304 items.push(item(&format!("右{i}"), 319.0, y, 560.0, y + 8.0));
2305 }
2306 for i in 0..3 {
2307 let y = 100.0 + i as f32 * 12.0;
2308 items.push(item(&format!("中{i}"), 160.0, y, 210.0, y + 8.0));
2309 }
2310 let out = order_by_leads(items, 506.0);
2312 let texts: Vec<&str> = out.iter().map(|it| it.text.as_str()).collect();
2313 let last_left = texts.iter().rposition(|t| t.starts_with("左")).unwrap();
2314 let first_mid = texts.iter().position(|t| t.starts_with("中")).unwrap();
2315 assert!(
2316 first_mid > last_left,
2317 "行首 150 应自成栏、不能让 54 与 150 合并:{texts:?}"
2318 );
2319 }
2320
2321 #[test]
2325 fn order_by_leads_rejects_centered_title_single_column() {
2326 let mut items = Vec::new();
2327 for i in 0..3 {
2330 let y = 100.0 + i as f32 * 12.0;
2331 items.push(item("TITLE", 200.0, y, 380.0, y + 10.0));
2332 }
2333 for i in 0..6 {
2334 let y = 150.0 + i as f32 * 12.0;
2335 items.push(item(&format!("正文{i}"), 54.0, y, 300.0, y + 8.0));
2336 }
2337 let before: Vec<String> = items.iter().map(|it| it.text.clone()).collect();
2338 let out = order_by_leads(items, 506.0);
2339 let after: Vec<String> = out.iter().map(|it| it.text.clone()).collect();
2340 assert_eq!(after, before, "居中标题 + 正文的单栏页不应被分栏重排");
2341 }
2342
2343 #[test]
2345 fn column_order_keeps_sequential_flow_untouched() {
2346 let mut items = Vec::new();
2347 for i in 0..4 {
2348 let y = 100.0 + i as f32 * 12.0;
2349 items.push(item(&format!("左{i}"), 54.0, y, 293.0, y + 8.0));
2350 }
2351 for i in 0..4 {
2352 let y = 100.0 + i as f32 * 12.0;
2353 items.push(item(&format!("右{i}"), 317.0, y, 556.0, y + 8.0));
2354 }
2355 let before: Vec<String> = items.iter().map(|it| it.text.clone()).collect();
2356 let out = order_stream(items);
2357 let after: Vec<String> = out.iter().map(|it| it.text.clone()).collect();
2358 assert_eq!(after, before, "已是栏序的页不应被重排");
2359 }
2360
2361 #[test]
2364 fn detect_table_groups_rows_with_offset_label_cells() {
2365 let mut lines = Vec::new();
2366 for i in 0..8 {
2367 let y = 100.0 + i as f32 * 10.0;
2368 lines.push(item("(A) ", 100.0, y + 0.5, 140.0, y + 9.5));
2369 lines.push(item("16", 200.0, y, 230.0, y + 8.0));
2370 lines.push(item("32", 300.0, y, 330.0, y + 8.0));
2371 lines.push(item("4.91", 400.0, y, 430.0, y + 8.0));
2372 }
2373 let (rows, used) = detect_table(&lines).expect("应认定为表格");
2374 assert_eq!(rows.len(), 8, "8 个视觉行应保持 8 行:{:?}", rows);
2375 assert_eq!(rows[0].len(), 4);
2376 assert_eq!(used.iter().filter(|u| **u).count(), 32);
2377 }
2378
2379 #[test]
2381 fn merge_lines_keeps_bboxless_items() {
2382 let out = merge_lines(vec![
2383 RawTextItem {
2384 text: "无坐标".into(),
2385 ..Default::default()
2386 },
2387 item("有坐标", 10.0, 100.0, 40.0, 112.0),
2388 ]);
2389 assert_eq!(out.len(), 2);
2390 assert_eq!(out[0].text, "无坐标");
2391 assert!(out[0].bbox.is_none());
2392 assert_eq!(out[1].text, "有坐标");
2393 }
2394
2395 fn raw_doc_with_two_lines() -> RawDoc {
2396 RawDoc {
2397 pages: vec![RawPage {
2398 items: vec![
2399 item("Hello", 10.0, 100.0, 50.0, 112.0),
2401 item("World", 54.0, 100.0, 94.0, 112.0),
2402 ],
2403 width: Some(595.0),
2404 height: Some(842.0),
2405 ..Default::default()
2406 }],
2407 ..Default::default()
2408 }
2409 }
2410
2411 #[test]
2413 fn assemble_honours_layout_mode() {
2414 let raw = raw_doc_with_two_lines();
2415
2416 let auto = assemble(&raw, LayoutMode::Auto);
2417 assert_eq!(auto.pages[0].blocks.len(), 1, "同行片段应合并");
2418 assert_eq!(auto.pages[0].blocks[0].content, "Hello World");
2419
2420 let raw_mode = assemble(&raw, LayoutMode::Raw);
2421 assert_eq!(raw_mode.pages[0].blocks.len(), 2, "raw 不合并");
2422 assert_eq!(raw_mode.pages[0].blocks[0].content, "Hello");
2423 assert_eq!(raw_mode.pages[0].blocks[1].content, "World");
2424
2425 let pdf: serde_json::Value =
2426 serde_json::from_str(&raw_mode.meta.extra["pdf"]).expect("pdf 元数据应为 JSON");
2427 assert_eq!(pdf["layout_modes"], serde_json::json!(["raw"]));
2428 assert_eq!(pdf["truncated"], serde_json::json!(false));
2429 assert_eq!(raw_mode.meta.extra["needs_ocr"], "false");
2430 }
2431
2432 #[test]
2434 fn detect_table_accepts_short_cells() {
2435 let cols = [
2436 ("Region", 54.0, 100.0),
2437 ("Share", 200.0, 60.0),
2438 ("Note", 320.0, 70.0),
2439 ];
2440 let mut lines = Vec::new();
2441 for r in 0..3 {
2442 let y = 100.0 + r as f32 * 12.0;
2443 for (t, x, w) in cols {
2444 lines.push(item(&format!("{t}{r}"), x, y, x + w, y + 8.0));
2445 }
2446 }
2447 let hit = detect_table(&lines).expect("短单元格应认表");
2448 assert_eq!(hit.0.len(), 3, "应识别出 3 行");
2449 }
2450
2451 #[test]
2454 fn detect_table_rejects_prose_rows_with_figure_labels() {
2455 let prose = "Like most interpreters, SpiderMonkey has a foreign function interface that \
2456 allows it to call C builtins and host system functions at runtime";
2457 let stray =
2458 "the interpreter API implementation functions to re-materialize the call stack \
2459 on demand, which costs extra work per exit; ";
2460 let mut lines = Vec::new();
2461 for r in 0..3 {
2462 let y = 100.0 + r as f32 * 20.0;
2463 lines.push(item(prose, 54.0, y, 292.0, y + 8.0));
2464 lines.push(item("?>9@AJ.0A:</C./8-2", 300.0, y, 380.0, y + 6.0));
2465 lines.push(item("7<>;./89-@/#3'4", 400.0, y, 470.0, y + 6.0));
2466 }
2467 for y in [110.0_f32, 130.0] {
2469 lines.push(item(&stray.repeat(4), 54.0, y, 292.0, y + 8.0));
2470 }
2471 assert!(
2472 detect_table(&lines).is_none(),
2473 "正文长句 + 图内标签不应被认成表格"
2474 );
2475 }
2476
2477 #[test]
2480 fn detect_table_rejects_lopsided_row() {
2481 let prose = "inner loop header, but the outer loop header is where the trace begins";
2482 let mut lines = Vec::new();
2483 for (r, (label_a, label_b)) in [("Bytecodes", "Overhead"), ("Record", "Enter")]
2484 .iter()
2485 .enumerate()
2486 {
2487 let y = 100.0 + r as f32 * 20.0;
2488 lines.push(item(prose, 54.0, y, 292.0, y + 8.0));
2489 lines.push(item(label_a, 300.0, y, 350.0, y + 6.0));
2490 lines.push(item(label_b, 400.0, y, 450.0, y + 6.0));
2491 }
2492 assert!(
2493 detect_table(&lines).is_none(),
2494 "正文长句 + 短标签列不应被认成表格"
2495 );
2496 }
2497
2498 #[test]
2500 fn pdf_options_builder() {
2501 let d = PdfOptions::default();
2502 assert_eq!(d.layout_mode, LayoutMode::Auto);
2503 assert_eq!(d.max_pages, None);
2504
2505 let o = PdfOptions::new()
2506 .with_layout_mode(LayoutMode::Raw)
2507 .with_max_pages(5);
2508 assert_eq!(o.layout_mode, LayoutMode::Raw);
2509 assert_eq!(o.max_pages, Some(5));
2510 }
2511
2512 #[test]
2513 #[ignore = "需本机 PDFium 动态库"]
2514 fn probe_worker_cjk() {
2515 let p = "/Users/zerojzheng/CODE/hi-agents/documents/docparser/python/bench/corpus/text_plain.pdf";
2516 match worker_sender() {
2517 Err(e) => eprintln!("worker init: {e}"),
2518 Ok(tx) => {
2519 let tx = tx.lock().unwrap().clone();
2520 let (rtx, rrx) = mpsc::channel();
2521 tx.send((p.to_string(), PdfOptions::default(), rtx))
2522 .unwrap();
2523 match rrx.recv() {
2524 Ok(Ok(raw)) => {
2525 let n: usize = raw.pages.iter().map(|pg| pg.items.len()).sum();
2526 let s: usize = raw
2527 .pages
2528 .iter()
2529 .flat_map(|pg| &pg.items)
2530 .map(|i| i.text.chars().count())
2531 .sum();
2532 eprintln!("ok: pages={} items={} chars={}", raw.pages.len(), n, s);
2533 }
2534 Ok(Err(e)) => eprintln!("worker parse err: code={} msg={}", e.code, e.message),
2535 Err(e) => eprintln!("channel: {e}"),
2536 }
2537 }
2538 }
2539 }
2540}