docparser-core 0.2.1

docparser 独立解析核心:IR / 统一错误 / 能力 trait / Registry / Router / Policy(零 native 依赖)
Documentation
//! 统一 IR(与 Python `model.py` 语义一致,含 serde 便于 JSON/CLI 往返)。

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

/// 轴对齐矩形,坐标语义由产生方决定(页面 px / pt / 逻辑单位)。
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Rect {
    pub x0: f32,
    pub y0: f32,
    pub x1: f32,
    pub y1: f32,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BlockKind {
    Heading,
    #[default]
    Paragraph,
    List,
    Code,
    Table,
    Image,
    Quote,
    /// 图表(pptx `c:chart` 等):`content` 为图表内文本(标题 + 数据标签)
    Chart,
    /// 备注 / 批注 / 脚注等附属文本(对齐 Python `BlockKind.NOTE`):
    /// `meta["region"]` 标注来源区域(如 pptx 备注页 = `"notes"`)。
    Note,
}

/// 结构化表格:rows[row][col] = 单元格文本。
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct TableData {
    pub rows: Vec<Vec<String>>,
    /// 前 N 行为表头(0 = 未识别)。与 Python `TableData.header_rows` 同口径:
    /// 目前只有 xlsx 冻结窗格(`<pane state="frozen" ySplit>0`)会给出 1。
    #[serde(default, skip_serializing_if = "is_zero")]
    pub header_rows: usize,
}

fn is_zero(n: &usize) -> bool {
    *n == 0
}

/// 块级元信息的值(与 Python `Block.meta: Dict[str, Any]` 的**实际取值形态**同形):
/// 只允许布尔 / 字符串 / 字符串数组三种,序列化后分别是 `true` / `"x"` / `["a"]`。
///
/// 刻意不用 `serde_json::Value`:`docparser-core` 的运行时依赖只有 `serde`,
/// 为了一个 meta 字段把 JSON 值模型拉进内核,会让「裸内核依赖预算」破功。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum MetaValue {
    /// `truncated` 一类开关
    Bool(bool),
    /// `region` 一类单值标注
    Str(String),
    /// `merges` 一类字符串列表(xlsx 合并区域 `A1:B2`)
    List(Vec<String>),
}

impl From<bool> for MetaValue {
    fn from(v: bool) -> Self {
        MetaValue::Bool(v)
    }
}

impl From<String> for MetaValue {
    fn from(v: String) -> Self {
        MetaValue::Str(v)
    }
}

impl From<&str> for MetaValue {
    fn from(v: &str) -> Self {
        MetaValue::Str(v.to_string())
    }
}

impl From<Vec<String>> for MetaValue {
    fn from(v: Vec<String>) -> Self {
        MetaValue::List(v)
    }
}

#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Block {
    #[serde(rename = "kind")]
    pub kind: BlockKind,
    #[serde(default)]
    pub content: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub bbox: Option<Rect>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub level: Option<u8>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub ordered: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub lang: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub table: Option<TableData>,
    /// 块级附加信息(对齐 Python `Block.meta`):目前 xlsx 表格用
    /// `merges`(合并区域列表)+ `truncated`(是否被预算截断)。
    /// 空表不序列化,避免给所有格式的 JSON 加噪音。
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub meta: BTreeMap<String, MetaValue>,
}

#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Image {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub data: Vec<u8>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub bbox: Option<Rect>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dedup_key: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub ocr_text: Option<String>,
}

#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Page {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    #[serde(default)]
    pub blocks: Vec<Block>,
    #[serde(default)]
    pub text: String,
    #[serde(default)]
    pub images: Vec<Image>,
    /// 页面几何(有物理页概念的格式填写)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub width: Option<f32>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub height: Option<f32>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rotation: Option<i32>,
}

#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct DocMeta {
    pub file_type: String,
    pub page_count: usize,
    pub text_length: usize,
    pub image_count: usize,
    pub image_text_length: usize,
    /// 插件级附加信息:书籍元数据(书名/作者/语言/封面路径)、处理链告警等。
    /// 空时不进 JSON。与 Python 参考实现的 `meta.extra` 对齐。
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub extra: std::collections::BTreeMap<String, String>,
}

#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Document {
    pub meta: DocMeta,
    #[serde(default)]
    pub pages: Vec<Page>,
}

impl Document {
    /// 回填统计(插件组装完 pages 后调用)。
    pub fn finalize(mut self) -> Self {
        self.meta.page_count = self.pages.len();
        self.meta.text_length = self.pages.iter().map(|p| p.text.chars().count()).sum();
        self.meta.image_count = self.pages.iter().map(|p| p.images.len()).sum();
        self.meta.image_text_length = self
            .pages
            .iter()
            .flat_map(|p| &p.images)
            .map(|im| {
                im.ocr_text
                    .as_deref()
                    .map(|s| s.chars().count())
                    .unwrap_or(0)
            })
            .sum();
        self
    }
}

/// 渲染块序列为页文本(表格展开全部行,逗号分列——与 Python `blocks_to_text` 一致)。
pub fn blocks_to_text(blocks: &[Block], sep: &str) -> String {
    let parts: Vec<String> = blocks
        .iter()
        .filter_map(|b| match b.kind {
            BlockKind::Table => b.table.as_ref().map(|t| {
                t.rows
                    .iter()
                    .map(|r| r.join(","))
                    .collect::<Vec<_>>()
                    .join("\n")
            }),
            _ if !b.content.is_empty() => Some(b.content.clone()),
            _ => None,
        })
        .collect();
    parts.join(sep)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn document_finalize_counts() {
        let doc = Document {
            pages: vec![
                Page {
                    text: "你好".into(),
                    images: vec![Image {
                        ocr_text: Some("x".into()),
                        ..Default::default()
                    }],
                    ..Default::default()
                },
                Page {
                    text: "world".into(),
                    ..Default::default()
                },
            ],
            ..Default::default()
        }
        .finalize();
        assert_eq!(doc.meta.page_count, 2);
        assert_eq!(doc.meta.text_length, 7); // "你好world"
        assert_eq!(doc.meta.image_count, 1);
        assert_eq!(doc.meta.image_text_length, 1);
    }

    #[test]
    fn blocks_to_text_expands_tables() {
        let b = Block {
            kind: BlockKind::Table,
            table: Some(TableData {
                rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
                ..Default::default()
            }),
            ..Default::default()
        };
        let txt = blocks_to_text(&[b], "\n\n");
        assert_eq!(txt, "a,b\n1,2");
    }

    /// `Block.meta` 的 JSON 形态必须与 Python `Block.meta` 一致:
    /// bool / 字符串 / 字符串数组都是裸值,不加 `{type, value}` 包装。
    #[test]
    fn block_meta_serializes_like_python() {
        let mut meta = BTreeMap::new();
        meta.insert("merges".to_string(), vec!["A1:B1".to_string()].into());
        meta.insert("truncated".to_string(), false.into());
        meta.insert("region".to_string(), "notes".into());
        let block = Block {
            kind: BlockKind::Table,
            meta,
            ..Default::default()
        };

        let json = serde_json::to_string(&block).unwrap();
        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
        assert_eq!(v["meta"]["merges"], serde_json::json!(["A1:B1"]));
        assert_eq!(v["meta"]["truncated"], serde_json::json!(false));
        assert_eq!(v["meta"]["region"], serde_json::json!("notes"));

        let back: Block = serde_json::from_str(&json).unwrap();
        assert_eq!(back, block);
    }

    /// 空 meta 不序列化:不能给所有格式的 JSON 平白加一个 `"meta": {}`。
    #[test]
    fn empty_meta_is_omitted() {
        let json = serde_json::to_string(&Block::default()).unwrap();
        assert!(!json.contains("meta"), "{json}");
    }

    /// `BlockKind` 的线上取值必须与 Python `BlockKind` 枚举逐字一致
    /// (Python 侧有同名单测遍历枚举值域,两边必须同时绿)。
    #[test]
    fn block_kind_wire_values_match_python() {
        let cases = [
            (BlockKind::Heading, "heading"),
            (BlockKind::Paragraph, "paragraph"),
            (BlockKind::List, "list"),
            (BlockKind::Code, "code"),
            (BlockKind::Table, "table"),
            (BlockKind::Image, "image"),
            (BlockKind::Quote, "quote"),
            (BlockKind::Chart, "chart"),
            (BlockKind::Note, "note"),
        ];
        for (kind, wire) in cases {
            let json = serde_json::to_string(&kind).unwrap();
            assert_eq!(json, format!("\"{wire}\""), "{kind:?}");
            assert_eq!(serde_json::from_str::<BlockKind>(&json).unwrap(), kind);
        }
    }

    #[test]
    fn roundtrip_json() {
        let doc = Document {
            pages: vec![Page {
                text: "t".into(),
                blocks: vec![Block {
                    kind: BlockKind::Paragraph,
                    content: "hi".into(),
                    ..Default::default()
                }],
                ..Default::default()
            }],
            ..Default::default()
        };
        let s = serde_json::to_string(&doc).unwrap();
        let back: Document = serde_json::from_str(&s).unwrap();
        assert_eq!(back, doc);
    }
}