Skip to main content

docparser_pdf/
lib.rs

1//! docparser PDF 后端:`pdfium-worker`(PDFium 常驻 worker 线程 + 通道请求/响应)。
2//!
3//! 架构(发布形态 06 + PDF_TODO"native 调用进 worker 线程"):
4//!   * 一条 worker 线程独占持有 `Pdfium`(含裸指针,非 Sync),进程内只此一处 native
5//!     状态;能力对象经 `mpsc` 通道发路径、收 `RawDoc`——避开了在 trait 对象里持有
6//!     Pdfium 的 Send/Sync 困境,也与 Python 参考实现的 PDF-0.1 语义对齐。
7//!   * 逐行抽取 text + bbox(pt,左上原点):走 text page 的 rect 分段
8//!     (`FPDFText_CountRects` / `FPDFText_GetBoundedText`),与 Python 参考实现同粒度,
9//!     且**包含 Form XObject 内文本**(代码清单/图表标注/水印——页对象列表看不到这些);
10//!     支持 CJK CID 字体(PDFium 内建 CMap,不依赖 ToUnicode),解决 pdf-extract v1 的中文盲区。
11//!   * 运行时 PDFium 库发现:`DOCPARSER_PDFIUM_LIB` → `<exe>/../lib` → home 下 conda 布局
12//!     → 扫描常见 Python site-packages(Windows 上 `HOME` 往往为空,需 `USERPROFILE`)。
13//!   * 错误分类:ENCRYPTED / CORRUPT。
14//!   * 兜底后端 `pdfium-text-v1`(pdf-extract,纯 Rust):worker 缺失或解析失败时
15//!     按 BackendPolicy 链回退(encrypted/unsupported 为终止码,不回退)。
16//!
17//! # 示例
18//!
19//! ```no_run
20//! use docparser_core::{plugins, DocumentEngine, Registry};
21//! use docparser_pdf::{LayoutMode, PdfOptions};
22//!
23//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
24//! let mut registry = Registry::new();
25//! plugins::text::register_defaults(&mut registry);
26//!
27//! // 不重排版式 + 只解析前 20 页(等价 CLI `--layout-mode raw --max-pages 20`)
28//! let options = PdfOptions::new()
29//!     .with_layout_mode(LayoutMode::Raw)
30//!     .with_max_pages(20);
31//! docparser_pdf::register_with(&mut registry, &options);
32//!
33//! let doc = DocumentEngine::new(registry).parse("report.pdf")?;
34//! println!("{} 页 / {} 字符", doc.pages.len(), doc.meta.text_length);
35//! if let Some(pdf) = doc.meta.extra.get("pdf") {
36//!     println!("pdf 元数据: {pdf}"); // pdf_version / complexity / truncated / layout_modes
37//! }
38//! # Ok(())
39//! # }
40//! ```
41
42use 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/// 版式模式(对齐 Python `layout_mode`:`auto` / `rebuild` / `raw`)。
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58pub enum LayoutMode {
59    /// 自动(默认):行归并 + 表格识别 + 多栏排序。
60    ///
61    /// Rust 逐文本对象取 bbox,没有 Python 的「整页文本直取」路径,因此
62    /// `Auto` 与 `Rebuild` 行为一致(保留三档是为了 CLI/配置与 Python 对齐)。
63    #[default]
64    Auto,
65    /// 强制重排版式(同 `Auto`)。
66    Rebuild,
67    /// 不重排:按内容流顺序输出段落,不做行归并 / 表格 / 分栏。
68    Raw,
69}
70
71impl LayoutMode {
72    /// 模式名(CLI、日志、`meta.extra.pdf.layout_modes` 用)。
73    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    /// 解析模式名(大小写不敏感);非法值返回 `None`。
82    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    /// 是否做版式重排(`raw` 为 false)。
92    pub fn reflows(self) -> bool {
93        !matches!(self, LayoutMode::Raw)
94    }
95}
96
97/// PDF 解析选项:后端(页数截断)与插件(版式模式)共享同一份配置。
98#[derive(Debug, Clone, Default)]
99pub struct PdfOptions {
100    /// 版式模式,默认 `Auto`
101    pub layout_mode: LayoutMode,
102    /// 只解析前 N 页(对齐 Python `max_pages`);超出时结果标 `truncated`
103    pub max_pages: Option<usize>,
104}
105
106impl PdfOptions {
107    /// 默认选项(`Auto` + 不限页数)。
108    pub fn new() -> Self {
109        Self::default()
110    }
111
112    /// 设置版式模式(链式)。
113    pub fn with_layout_mode(mut self, mode: LayoutMode) -> Self {
114        self.layout_mode = mode;
115        self
116    }
117
118    /// 设置页数上限(链式)。
119    pub fn with_max_pages(mut self, pages: usize) -> Self {
120        self.max_pages = Some(pages);
121        self
122    }
123}
124
125/// 运行时 PDFium 库发现:`DOCPARSER_PDFIUM_LIB` → 发布 bundle → 开发环境
126/// pypdfium2 / liteparse 自带 → 扫描常见 Python site-packages → 无。
127pub 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    // P7 自包含发布:<exe>/../lib/<libpdfium> 优先(bundle 无 env 也可跑)
141    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    // 开发环境:miniconda 固定布局(历史路径,保留)+ 各 home 目录下的 conda 环境
150    for home in home_dirs() {
151        for base in [
152            // liteparse 附带库(实测可与 pdfium-render 绑定成功)
153            home.join("miniconda3/lib/python3.13/site-packages/liteparse"),
154            // pypdfium2 v5 原生库独立分发包
155            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    // 通用兜底:扫常见 Python 安装根下的 site-packages(Windows 上 HOME 常常为空,
166    // 只有 USERPROFILE;pypdfium2_raw 自带 pdfium.dll,可直接绑定)
167    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
178/// 可能的用户主目录:`HOME`(Unix/部分 Windows 配置)、`USERPROFILE`(Windows)、
179/// `HOMEDRIVE`+`HOMEPATH`。去重且只保留存在的目录。
180fn 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
199/// 常见 Python site-packages 目录(存在才返回),用于自动发现 pypdfium2 自带库。
200fn 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            // `...\Programs\Python\Python312\Lib\site-packages` 与 `...\Python312\Lib\...`
216            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    // 逐层展开:root 自身与其下形如 `pythonX.Y` / `Python3xx` 的子目录 → site-packages
224    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    // 平台可执行后缀:Windows 是 `.exe`,其余为空(漏掉后缀会导致 OCR 永远「未找到」)
274    let name = format!("docparser-ocr{}", std::env::consts::EXE_SUFFIX);
275    // P7 自包含发布:与 CLI 同 bin 目录的 docparser-ocr
276    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    // 开发环境:workspace target(CARGO_MANIFEST_DIR 相对 = rust/pdf → rust/target)
285    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    // P7 自包含发布:<exe>/../models/ocr/ppocrv5
303    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
318/// 乱码字符判定(对齐 Python `_is_garbled_char`):替换符 U+FFFD 与私用区 U+E000–U+F8FF。
319fn is_garbled_char(ch: char) -> bool {
320    let cp = ch as u32;
321    ch == '\u{fffd}' || (0xE000..=0xF8FF).contains(&cp)
322}
323
324/// 页级复杂度判定(对齐 Python `compute_complexity`):
325/// needs_ocr = chars<10(no-text / sparse-text)或 garbled_ratio>0.3。
326fn 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
339/// 常见位图扩展名嗅探(png/jpg/bmp/gif/webp);否则 None(走位图回退)。
340fn 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
359/// 嵌入图去重键(对齐 Python `_page_images` 的 `dedup_key`):有字节 → **md5 十六进制**
360/// (同一页里重复出现的 logo/水印出同键,消费方据此只存一份);无字节(lazy 路径未解码)
361/// → `"{w}x{h}"` / `"{w}x{h}@{ext}"` 兜底,与 Python 的 `f"{px_w}x{px_h}@{fmt}"` 同形。
362///
363/// 注:md5 只在**同为原始流**时与 Python 逐字节一致(两侧都优先 `get_raw_image_data` /
364/// `obj.extract()`);位图回退路径两侧的 PNG 编码器不同(Rust `image` vs Pillow),
365/// 键会不同——同进程内去重仍然正确,跨载体只保证「有键、键形态一致」。
366fn image_dedup_key(im: &RawImage) -> Option<String> {
367    if im.data.is_empty() {
368        let (w, h) = im.px?;
369        // Python 恒为 `f"{px_w}x{px_h}@{fmt}"`(fmt 可能是空串)——保留尾部 `@` 以便逐键对齐
370        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
376/// 嵌入图 OCR 过滤(对齐 Python `_ocrable_image`):ext 白名单 + min(px)≥50。
377fn 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
393/// 收集页内嵌入图片(含 Form XObject 嵌套,深度≤2,对齐 Python `_page_images`)。
394/// bbox 换算页面 pt(左上原点)。
395///
396/// lazy 语义:默认(`ocr_images=false`)只取元数据(bbox/px)不解码字节,避免文本优先
397/// 路径为图付解码成本(liteparse 关 OCR 语义);`extract_data=true` 时才取原始流/回退
398/// 处理位图转 PNG(供嵌入图 OCR 使用)。
399fn 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                // 原始流优先;未命中则回退处理位图 → PNG
437                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
477/// `DOCPARSER_OCR_IMAGES=1` → 开嵌入图 OCR(对齐 Python `policy.ocr_images` / bench env)。
478fn ocr_images_enabled() -> bool {
479    std::env::var("DOCPARSER_OCR_IMAGES").as_deref() == Ok("1")
480}
481
482/// OCR 总开关:`DOCPARSER_OCR_ENABLED=0/false` → 关闭(纯文本路径,与 lp OCR-off 同口径)。
483/// 默认开启(对齐既有行为:needs_ocr 页自动整页渲染 OCR)。
484fn ocr_enabled() -> bool {
485    !matches!(
486        std::env::var("DOCPARSER_OCR_ENABLED").as_deref(),
487        Ok("0") | Ok("false")
488    )
489}
490
491/// 逐行抽取页面文本,返回 `(items, texts)`。
492///
493/// 走 text page 的 rect 分段(`FPDFText_CountRects` / `FPDFText_GetBoundedText`),
494/// 与 Python 参考实现同粒度;关键是**能拿到 Form XObject 内的文本**(代码清单、
495/// 图表标注、印章/水印)——`page.objects()` 的页对象列表不含嵌入表单内部对象。
496///
497/// 对齐 Python `_page_text_rebuild`:rect 高度超过页高 25% 视为跨高条块
498/// (页边徽标 / 竖排水印)跳过,否则 `get_text_bounded` 会把叠压正文一并吞入。
499/// text page 不可用或分段为空时回退旧路径(逐对象),再退回整页文本。
500fn 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            // 整页都是跨高条块 / 无 rect:整页文本兜底(对齐 Python 的回退分支)
529            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        // text page 不可用:回退逐对象(无 Form XObject 覆盖,但保住基础文本)
545        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    // max_pages(对齐 Python):只解析前 N 页,超出标 truncated
592    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    // 嵌入图 OCR 任务:全局序号 → (page_idx, images 内 idx, 临时文件)
602    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        // 逐行 rect 抽取(含 Form XObject 内文本;PDF 左下原点 → 左上原点已在函数内换算)
612        let (items, texts) = extract_text_items(&page, h);
613        let images = collect_page_images(&doc, &page, h, ocr_images);
614        // 扫描/稀疏/乱码页:整页渲染(供 OCR 兜底,对应 Python needs_ocr 页);
615        // OCR 总开关关闭时跳过(纯文本路径)
616        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                // 单页渲染失败不拖垮整篇(对齐 Python `page_errors`)
652                Err(e) => page_errors.push(serde_json::json!({
653                    "page": pi, "error": format!("{e}"),
654                })),
655            }
656        } else if ocr_on && ocr_images {
657            // 非扫描页嵌入图 OCR:needs_ocr 页整页渲染已覆盖图内容,跳过避免重复
658            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        // 页级复杂度(对齐 Python `compute_complexity`:chars / garbled_ratio / needs_ocr)
682        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        // 旋转角(对齐 Python `page.rotation`)
695        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    // OCR 回填(needs_ocr 页):调 docparser-ocr(子进程,模型常驻于其自身)
713    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    // 嵌入图 OCR 回填(非 needs_ocr 页,opt-in:DOCPARSER_OCR_IMAGES=1)
739    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    // 跨页页眉页脚剔除(多页 stateful,顶/底 7% 边带 + 数字归一化重复)
765    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
794/// `PdfDocumentVersion` → `"1.7"` 形式(对齐 Python `pdf_version`);未知返回空串。
795fn 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
830/// 剔除在多页顶部/底部窄边带内重复出现的行;返回被剔除的去重文本。
831/// 无 bbox / 单页 / 边带外不动作。band = 0.07 页高(与 Python `_strip_headers_footers` 一致)。
832///
833/// 注意:**先收集再重建**,不能边算边 `Vec::remove`——同一条边带内往往有多个重复文本
834/// (页眉左标题 + 右页码、页脚作者 + 页码),第一个文本删完后面的索引就整体前移,
835/// 第二个文本按旧索引再删会越界 panic(真实语料 2610.32982v1:`removal index (is 80)
836/// should be < len (is 79)`)。Python 侧用 `(page, text, bbox)` 集合 + 重建,这里等价处理。
837fn 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(); // (page_idx, item_idx)
849        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        // 按「首次出现位置」排序处理,输出顺序与 Python 的 dict 插入顺序一致(可复现)
867        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
903/// 常驻 OCR 引擎(`docparser-ocr serve`,stdin/stdout 行协议,模型只加载一次)。
904/// 与 Python `_ResidentEngine` 同一契约:写一行图片路径 → 读一行
905/// `{"file":..., "lines":[{box,text,score}...]}`。
906struct 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
956/// 用常驻引擎识别一批图片;任一环节失败返回 None(调用方回退批量子进程)。
957fn 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                // 进程异常:丢弃,让调用方回退批量模式
972                if let Some(mut dead) = guard.take() {
973                    let _ = dead.child.kill();
974                }
975                return None;
976            }
977        }
978    }
979    Some(out)
980}
981
982/// OCR 行解析:score ≥0.8 过滤 + 按 min-y 排序 → (text, quad)。
983fn 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
1022/// 一行 OCR 结果:文本 + 四边形(图像像素坐标,左上原点)。
1023type OcrLine = (String, Vec<[f32; 2]>);
1024
1025/// 一次 OCR 调用的结果:输入序号 → 该页/该图的识别行。
1026type OcrPageLines = Vec<(usize, Vec<OcrLine>)>;
1027
1028/// 对渲染出的扫描页/嵌入图批量 OCR:返回 (page_idx, [(text, quad), ...])。
1029/// quad 为图像像素坐标(左上原点),由调用方换算页面 pt。
1030/// 优先常驻 serve(模型只加载一次),不可用/失败回退批量子进程。
1031fn 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
1119/// worker 请求:PDF 路径 + 回包通道。
1120type WorkerReq = (String, PdfOptions, mpsc::Sender<Resp>);
1121
1122/// worker 发送端(进程内单例持有,长驻线程消费)。
1123type 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/// 主后端:PDFium worker(库可用才 available)。
1153#[derive(Debug, Clone, Default)]
1154pub struct PdfiumWorkerBackend {
1155    options: PdfOptions,
1156}
1157
1158impl PdfiumWorkerBackend {
1159    /// 默认选项(`Auto`、不限页数)。
1160    pub fn new() -> Self {
1161        Self::default()
1162    }
1163
1164    /// 指定解析选项(链式)。
1165    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/// 兜底后端 v1:pdf-extract(纯 Rust,无 dylib;CJK 无 ToUnicode 局限)。
1199#[derive(Debug, Clone, Default)]
1200pub struct PdfTextBackend {
1201    options: PdfOptions,
1202}
1203
1204impl PdfTextBackend {
1205    /// 默认选项(不限页数)。
1206    pub fn new() -> Self {
1207        Self::default()
1208    }
1209
1210    /// 指定解析选项(链式);本后端只用到 `max_pages`。
1211    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        // max_pages(对齐 Python):只取前 N 页
1244        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
1285// ---------------------------------------------------------------- layout(对照 Python process/layout.py)
1286
1287/// 行级归一:把逐字/逐词 rect 合并成行级 item(对照 Python `_merge_line_fragments`)。
1288/// 无 bbox 的项(如 v1 兜底后端)原样保留,排在结果头部。
1289fn 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            // 基线对齐判同行(对齐 Python `_line_bottom_height` + `_merge_line_fragments`):
1314            // 底边差 ≤ 0.3×(行代表高 + 候选高)。用纵向重叠会在密排行距≈字高时
1315            // 把相邻行误并(Python 侧已实测过这个坑)。
1316            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); // 无 bbox 项置于头部(顺序即原内容序)
1375    out
1376}
1377
1378fn detect_table(lines: &[RawTextItem]) -> Option<(Vec<Vec<String>>, Vec<bool>)> {
1379    // y 聚行:与 Python `_group_lines` 同口径——**基线对齐**(底边差 ≤ 0.3×(行高+字高))。
1380    // 用「纵向重叠 > 50%」在密排行距≈字高时会把相邻行并成一行(真实语料 05404 整栏坍缩);
1381    // arxiv p8(Transformer 变体表)会因此丢掉 (A)/(B)/(D) 行(该行两个单元格被并成一格 <3 格)。
1382    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    // x0 聚类(列锚点,容差 8pt)
1425    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    // 表格区域自洽性:真表格的 bbox 内几乎只有表格行。若 bbox 内还散落着大量未入表的
1504    // 文本(正文/图注),说明这是「正文行 + 图内小字号标签」被并成多列造成的误判——
1505    // 典型后果是正文句子被逗号拼接的标签掰断(2026-10-26 tracemonkey p9)。
1506    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    // 行内「长句独大」:一行里 ≥1 个 ≥40 字符的单元格,且比该行其余单元格都大 3 倍以上
1547    // —— 真表格(尤其数字表)不会这样;这是「正文一列 + 图内短标签」的典型形态
1548    //(tracemonkey p1:`inner loop header, but the outer loop he` + `Bytecodes` + `Overhead`)。
1549    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
1579/// 多栏阅读顺序:只对「原序明显左右逐行交错」的页重排(与 Python `_column_order` 同口径)。
1580///
1581/// 很多 PDF(LaTeX/Word 导出)的内容流本身就是栏序,盲目重排只会把好顺序改坏
1582/// (实测 arxiv 论文会从 sim 0.99 掉到 0.86)。因此先用「相邻行同 y 且 x0 相差 > 半页宽」
1583/// 的比例做交错指纹,低于阈值一律保持原序。
1584///
1585/// 确认交错后与 Python 同序走两条路:
1586/// ① **沟槽法**(`order_by_gutter`):窄项的 x 占位直方图找栏间零占位带——LaTeX/双栏论文
1587///    的常态,最稳;
1588/// ② 沟槽被跨栏元素填平/切分失败时回退**行首 x0 聚类**(`order_by_leads`)。
1589///
1590/// 只有一条路(聚类)时,表格页的多列 x0(页眉单元格 210/233/270/320/359…)会在
1591/// `0.25×页宽` 的阈值下链式并成一栏 → 整页放弃重排(真实语料 2610.32874v1 p7)。
1592fn 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
1610/// 沟槽法(对齐 Python `_column_order` 的 `occ` 直方图分支):窄项在 x 轴上的零占位带
1611/// 即栏沟;按沟槽切出的每段各成一栏,栏内自上而下。跨栏元素(宽度 > 0.6×内容宽)不参与
1612/// 占位统计(否则跨栏标题/图注会把栏沟填平),但仍参与按中心点归栏。
1613fn 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    // 跨栏元素(中心落在栏沟内)不在任何栏内 → 沟槽法不适用,交给聚类回退
1702    if out.len() != items.len() {
1703        return None;
1704    }
1705    Some(out)
1706}
1707
1708/// 视觉行行首 x0 列表 `(行首 x0, 行宽)`(对齐 Python `_group_lines` + `_split_visual_lines`):
1709/// 先按基线聚组成"行",再在横向间隙处切开——CJK 逐字 rect / 单词级 fragment 的小间隙不切,
1710/// 左栏行尾与右栏行首、表格单元格之间的大间隙切开。切开后每段的行首 x0 才代表"栏的起点"。
1711fn 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
1759/// 行首 x0 单链接聚类分栏(对齐 Python `_cluster_by_x`)。
1760/// 不能用 `[x0, x1]` 区间合并:居中跨栏图注/窄跨栏图虽然宽度 < 60% 页宽,却横跨栏沟,
1761/// 区间合并会把右栏起点并进左栏(tracemonkey p11:图注 162..446 吞掉 317 的右栏)。
1762///
1763/// 聚类输入必须是**视觉行行首**(`visual_line_leads`),不是逐项 x0:表格页逐项 x0 有
1764/// 十几个不同值(单元格 210/233/270/320/359…),在 `0.25×页宽` 阈值下会链式并成一栏,
1765/// 整页放弃重排(真实语料 2610.32874v1 p7 `sim` 0.318)。成员 < 3 的孤立起点
1766/// (居中图注、短缩进)不作栏锚点,交给「最后一个 ≤ x0 的栏起点」归栏。
1767fn 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    // 阈值用**行首 x0 自己的跨度**(对齐 Python `_cluster_by_x`:`span = xs[-1] - xs[0]`),
1783    // 不能用整页内容跨度:表格页 bbox span(504)会把 thr 抬到 126pt,行首 118 → 244 这一步
1784    // 就跨过去了,十几个起点链成一簇 → 放弃重排(2610.32874 p7 `sim` 0.318)。
1785    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    // 归栏:按「最后一个 ≤ x0 的栏起点」归档(跨栏元素 x0 在首栏 → 归首栏)
1807    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    // 双栏硬校验(对齐 Python `_cluster_by_x`):前两栏各 ≥3 行,且次栏要有足够多「与首栏
1820    // 同一水平带(|Δy0| < 12pt)」的行。单栏文档的缩进/居中标题也会聚出第二个簇,但它们与
1821    // 首栏不同行——不校验就重排,正文会被读成「先全部首栏、再全部次栏」。
1822    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
1852/// 相邻行中「同 y 但 x0 相差超过半页宽」的比例——左右逐行交错的指纹。
1853fn 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            // ① 行级归一(CJK 逐字 rect → 行级 item);`raw` 模式跳过
1875            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            // ② 表格检测(≥3 列 + ≥60% 行一致性);有任一无 bbox 项 / raw 模式则跳过
1883            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            // ③ 剩余段落 → 多栏阅读顺序
1909            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            // ④ 块与页文本:段落保持(栏序)顺序;表格按 y0 插入首个更低段落之前
1917            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                // 表格插入到首个 y 更低段落之前;空页/整页表格直接置首
1946                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            // ④ 嵌入图 OCR 文本并入页文本(KB 语义:页文本 + "\n" + 图 OCR;有才并入)
1956            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    // 逐项对齐 Python `meta.extra["pdf"]` 白名单(值是 JSON,避免改 core 的 String map)
1995    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/// PDF 插件:`assemble` 里做行归并 / 表格 / 分栏(版式模式可配)。
2041#[derive(Debug, Clone, Default)]
2042pub struct PdfPlugin {
2043    options: PdfOptions,
2044}
2045
2046impl PdfPlugin {
2047    /// 默认插件(`LayoutMode::Auto`)。
2048    pub fn new() -> Self {
2049        Self::default()
2050    }
2051
2052    /// 指定选项(链式),常用 `with_layout_mode(LayoutMode::Raw)` 关掉版式重排。
2053    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        // worker 优先;worker 缺失或解析失败时回退 v1(encrypted/unsupported 终止码由 engine 处理)
2068        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
2078/// 注册 PDF 后端(worker 可用才注册;v1 恒可用)与插件(默认选项)。
2079pub fn register_defaults(reg: &mut Registry) {
2080    register_with(reg, &PdfOptions::default());
2081}
2082
2083/// 注册 PDF 后端与插件,并注入解析选项(版式模式 / 页数上限)。
2084pub 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
2100/// 便于外部核对终止码语义。
2101pub 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    /// 去重键:有字节 → md5 十六进制(与 Python `hashlib.md5(data).hexdigest()` 同值)
2132    #[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        // 同字节 → 同键(同页重复 logo 只存一份的前提)
2140        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    /// 无字节(lazy 路径未解码)→ `"{w}x{h}"` / `"{w}x{h}@{ext}"` 兜底,与 Python 同形;
2147    /// 连像素尺寸都没有时给 None(不编造键,避免把装饰图都聚成同一个键)。
2148    #[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    /// 同一条边带内多个重复文本(页眉标题 + 页码)必须全部剔除且不 panic:
2187    /// 旧实现按 HashMap 顺序边算边 `Vec::remove`,先删的会让后一个 key 的索引整体前移
2188    /// → 越界(真实语料 2610.32982v1:`removal index (is 80) should be < len (is 79)`)。
2189    #[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    /// 同一行的逐字 fragment(基线一致、间隙小)合并成一个 item。
2212    #[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    /// 基线对齐判同行:行距紧(≈字高)时不能把相邻两行并成一行
2224    /// (旧口径「纵向重叠 > 50%」会误并,Python 侧已实测)。
2225    #[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    /// 同一基线但水平间隙超过 max(6pt, 0.6×字高):不并(跨栏/跨单元格场景)。
2235    #[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    /// 左右逐行交错的双栏页:按栏重排成「左栏读完再右栏」。
2247    #[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    /// 居中窄图注(宽 < 60% 页宽,但横跨栏沟)不能把右栏并入左栏
2265    /// (tracemonkey p11 回归:旧口径按 [x0,x1] 区间合并锚点,图注 162..446 吞掉 317 的右栏)。
2266    #[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    /// 聚类阈值必须用**行首 x0 自己的跨度**(Python `_cluster_by_x` 口径),不能用整页内容跨度:
2296    /// 表格页内容跨度大、行首分布窄,用内容跨度会把 54→150 这种 96pt 的间隔一起吞掉,
2297    /// 十几个起点链成一簇后放弃重排(2610.32874 p7 `sim` 0.318)。
2298    #[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        // 内容跨度 506(旧口径 thr=126 → 54/150 链成一栏);行首跨度 265(新口径 thr≈66)
2311        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    /// 单栏页 + 居中大标题:标题行首 x0 也会聚成第二簇,但没有「与首栏同一水平带」的行,
2322    /// 必须放弃重排(对齐 Python `_cluster_by_x` 的双栏硬校验)。
2323    /// 旧实现只判 `cols.len() >= 2`,会把正文读成「先全部正文行、再把标题搬到末尾」。
2324    #[test]
2325    fn order_by_leads_rejects_centered_title_single_column() {
2326        let mut items = Vec::new();
2327        // 3 行居中标题(行首 200),下方 6 行正文(行首 54);行宽都 ≤0.6×内容跨度,
2328        // 才不会被 `visual_line_leads` 的「宽项不作栏锚点」过滤掉(否则函数直接早退、测不到校验)
2329        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    /// 内容流本身已是栏序(LaTeX/Word 导出)的页不重排——交错指纹低于阈值即保持原序。
2344    #[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    /// 行内单元格基线参差(行首标签比数字高 1.5pt)时仍按视觉行分组
2362    /// (arxiv p8 表 3 回归:旧的「纵向重叠 > 50%」口径把 (A)/(B)/(D) 行并没了,8 行只剩 5 行)。
2363    #[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    /// 无 bbox 项(v1 兜底后端)原样保留在头部,且不参与合并。
2380    #[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                    // 间隙 4pt:> 0.2×字高 → 补一个空格(西文单词分隔)
2400                    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    /// `Auto` 走版式重排(同行 fragment 合并成一个段落);`Raw` 保持内容流原样。
2412    #[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    /// 短单元格 + 稳定列锚点 → 认表(不能因为收紧判据把真表格判掉)。
2433    #[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    /// 正文长句 + 右侧图内小字号标签被并成 3 列:bbox 内仍散落正文,长句占比高 → 不认表。
2452    /// 回归来源:tracemonkey p9(图区误判,正文被逗号拼接的标签掰断,见 REAL_CHECKLIST)。
2453    #[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        // 只占一列的正文行:不满足 3 列 → 不进表格,但落在表格 bbox 内
2468        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    /// 行内「长句独大」:正文长句 + 两个短标签凑成的行 → 不认表。
2478    /// 回归来源:tracemonkey p1(图 1 的代码清单标签与正文行凑成 3 列)。
2479    #[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    /// 选项构造:默认 Auto/不限页;`with_*` 链式覆盖。
2499    #[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}