Skip to main content

easypdf_core/model/
pdf_block.rs

1//! PDF 语义内容块。
2
3use crate::{ImageData, ListItem, PdfBlockType, SourceLocation};
4
5/// 从 PDF 页面识别出的语义内容块。
6///
7/// `#[non_exhaustive]` 保证未来新增变体不会破坏下游代码。
8/// 消费方应始终包含通配分支(`_ => ...`)或使用 [`block_type`](Self::block_type)
9/// 进行分发。
10#[derive(Clone, Debug, PartialEq)]
11#[non_exhaustive]
12pub enum PdfBlock {
13    /// 分级标题。
14    Heading {
15        /// 标题级别,范围为 1 到 6。
16        level: u8,
17        /// 标题文本。
18        text: String,
19        /// 源位置。
20        source: SourceLocation,
21    },
22    /// 普通段落。
23    Paragraph {
24        /// 段落文本。
25        text: String,
26        /// 源位置。
27        source: SourceLocation,
28    },
29    /// 有序或无序列表。
30    List {
31        /// 是否为有序列表。
32        ordered: bool,
33        /// 列表项(支持嵌套)。
34        items: Vec<ListItem>,
35        /// 源位置。
36        source: SourceLocation,
37    },
38    /// 表格数据。
39    Table {
40        /// 表头。
41        headers: Vec<String>,
42        /// 表格行。
43        rows: Vec<Vec<String>>,
44        /// 源位置。
45        source: SourceLocation,
46    },
47    /// 图片引用。
48    Image {
49        /// 图片元数据。
50        data: ImageData,
51        /// 源位置。
52        source: SourceLocation,
53    },
54    /// 代码块。
55    Code {
56        /// 代码语言标识(如 `"rust"`、`"python"`)。
57        language: Option<String>,
58        /// 代码文本。
59        text: String,
60        /// 源位置。
61        source: SourceLocation,
62    },
63    /// 数学公式(LaTeX 语法)。
64    Formula {
65        /// LaTeX 源码。
66        latex: String,
67        /// 源位置。
68        source: SourceLocation,
69    },
70    /// 分页符。
71    PageBreak {
72        /// 源位置。
73        source: SourceLocation,
74    },
75    /// 脚注。
76    Footnote {
77        /// 脚注引用标识。
78        reference_id: String,
79        /// 脚注正文。
80        text: String,
81        /// 源位置。
82        source: SourceLocation,
83    },
84    /// 表格单元格(细粒度识别)。
85    TableCell {
86        /// 行跨度。
87        row_span: u32,
88        /// 列跨度。
89        col_span: u32,
90        /// 单元格文本。
91        text: String,
92        /// 源位置。
93        source: SourceLocation,
94    },
95    /// 引用块。
96    BlockQuote {
97        /// 引用文本。
98        text: String,
99        /// 源位置。
100        source: SourceLocation,
101    },
102    /// 水平分隔线。
103    HorizontalRule {
104        /// 源位置。
105        source: SourceLocation,
106    },
107    /// 超链接。
108    Link {
109        /// 链接地址。
110        url: String,
111        /// 链接显示文本。
112        text: String,
113        /// 源位置。
114        source: SourceLocation,
115    },
116    /// 无法识别的内容。
117    Unknown {
118        /// 原始文本。
119        raw: String,
120        /// 源位置。
121        source: SourceLocation,
122    },
123}
124
125impl PdfBlock {
126    // ------------------------------------------------------------------
127    //  便捷构造方法
128    // ------------------------------------------------------------------
129
130    /// 创建分级标题。
131    #[must_use]
132    pub fn heading(level: u8, text: impl Into<String>, source: SourceLocation) -> Self {
133        Self::Heading {
134            level,
135            text: text.into(),
136            source,
137        }
138    }
139
140    /// 创建普通段落。
141    #[must_use]
142    pub fn paragraph(text: impl Into<String>, source: SourceLocation) -> Self {
143        Self::Paragraph {
144            text: text.into(),
145            source,
146        }
147    }
148
149    /// 创建有序或无序列表。
150    #[must_use]
151    pub fn list(ordered: bool, items: Vec<ListItem>, source: SourceLocation) -> Self {
152        Self::List {
153            ordered,
154            items,
155            source,
156        }
157    }
158
159    /// 创建表格。
160    #[must_use]
161    pub fn table(headers: Vec<String>, rows: Vec<Vec<String>>, source: SourceLocation) -> Self {
162        Self::Table {
163            headers,
164            rows,
165            source,
166        }
167    }
168
169    /// 创建图片引用。
170    #[must_use]
171    pub fn image(data: ImageData, source: SourceLocation) -> Self {
172        Self::Image { data, source }
173    }
174
175    /// 创建代码块。
176    #[must_use]
177    pub fn code(text: impl Into<String>, source: SourceLocation) -> Self {
178        Self::Code {
179            language: None,
180            text: text.into(),
181            source,
182        }
183    }
184
185    /// 创建带语言标识的代码块。
186    #[must_use]
187    pub fn code_with_language(
188        language: impl Into<String>,
189        text: impl Into<String>,
190        source: SourceLocation,
191    ) -> Self {
192        Self::Code {
193            language: Some(language.into()),
194            text: text.into(),
195            source,
196        }
197    }
198
199    /// 创建数学公式。
200    #[must_use]
201    pub fn formula(latex: impl Into<String>, source: SourceLocation) -> Self {
202        Self::Formula {
203            latex: latex.into(),
204            source,
205        }
206    }
207
208    /// 创建分页符。
209    #[must_use]
210    pub const fn page_break(source: SourceLocation) -> Self {
211        Self::PageBreak { source }
212    }
213
214    /// 创建脚注。
215    #[must_use]
216    pub fn footnote(
217        ref_id: impl Into<String>,
218        text: impl Into<String>,
219        source: SourceLocation,
220    ) -> Self {
221        Self::Footnote {
222            reference_id: ref_id.into(),
223            text: text.into(),
224            source,
225        }
226    }
227
228    /// 创建表格单元格。
229    #[must_use]
230    pub fn table_cell(
231        row_span: u32,
232        col_span: u32,
233        text: impl Into<String>,
234        source: SourceLocation,
235    ) -> Self {
236        Self::TableCell {
237            row_span,
238            col_span,
239            text: text.into(),
240            source,
241        }
242    }
243
244    /// 创建引用块。
245    #[must_use]
246    pub fn block_quote(text: impl Into<String>, source: SourceLocation) -> Self {
247        Self::BlockQuote {
248            text: text.into(),
249            source,
250        }
251    }
252
253    /// 创建水平分隔线。
254    #[must_use]
255    pub const fn horizontal_rule(source: SourceLocation) -> Self {
256        Self::HorizontalRule { source }
257    }
258
259    /// 创建超链接。
260    #[must_use]
261    pub fn link(url: impl Into<String>, text: impl Into<String>, source: SourceLocation) -> Self {
262        Self::Link {
263            url: url.into(),
264            text: text.into(),
265            source,
266        }
267    }
268
269    /// 创建无法识别的内容。
270    #[must_use]
271    pub fn unknown(raw: impl Into<String>, source: SourceLocation) -> Self {
272        Self::Unknown {
273            raw: raw.into(),
274            source,
275        }
276    }
277
278    // ------------------------------------------------------------------
279    //  查询方法
280    // ------------------------------------------------------------------
281
282    /// 返回内容块的源位置。
283    #[must_use]
284    pub const fn source(&self) -> &SourceLocation {
285        match self {
286            Self::Heading { source, .. }
287            | Self::Paragraph { source, .. }
288            | Self::List { source, .. }
289            | Self::Table { source, .. }
290            | Self::Image { source, .. }
291            | Self::Code { source, .. }
292            | Self::Formula { source, .. }
293            | Self::PageBreak { source }
294            | Self::Footnote { source, .. }
295            | Self::TableCell { source, .. }
296            | Self::BlockQuote { source, .. }
297            | Self::HorizontalRule { source }
298            | Self::Link { source, .. }
299            | Self::Unknown { source, .. } => source,
300        }
301    }
302
303    /// 返回内容块的语义分类。
304    ///
305    /// # Examples
306    ///
307    /// ```
308    /// use easypdf_core::{PdfBlock, PdfBlockType, SourceLocation};
309    /// use easypdf_core::PageIndex;
310    ///
311    /// let loc = SourceLocation::new(PageIndex::new(0), 1.0);
312    /// let block = PdfBlock::heading(1, "Title", loc);
313    /// assert_eq!(block.block_type(), PdfBlockType::Heading);
314    /// ```
315    #[must_use]
316    pub const fn block_type(&self) -> PdfBlockType {
317        match self {
318            Self::Heading { .. } => PdfBlockType::Heading,
319            Self::Paragraph { .. } => PdfBlockType::Paragraph,
320            Self::List { .. } => PdfBlockType::List,
321            Self::Table { .. } => PdfBlockType::Table,
322            Self::Image { .. } => PdfBlockType::Image,
323            Self::Code { .. } => PdfBlockType::Code,
324            Self::Formula { .. } => PdfBlockType::Formula,
325            Self::PageBreak { .. } => PdfBlockType::PageBreak,
326            Self::Footnote { .. } => PdfBlockType::Footnote,
327            Self::TableCell { .. } => PdfBlockType::TableCell,
328            Self::BlockQuote { .. } => PdfBlockType::BlockQuote,
329            Self::HorizontalRule { .. } => PdfBlockType::HorizontalRule,
330            Self::Link { .. } => PdfBlockType::Link,
331            Self::Unknown { .. } => PdfBlockType::Unknown,
332        }
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use crate::PageIndex;
340
341    fn loc() -> SourceLocation {
342        SourceLocation::new(PageIndex::new(0), 1.0)
343    }
344
345    #[test]
346    fn heading_construction() {
347        let b = PdfBlock::heading(2, "Section", loc());
348        assert_eq!(b.block_type(), PdfBlockType::Heading);
349        assert_eq!(b.source().page_index().value(), 0);
350    }
351
352    #[test]
353    fn paragraph_construction() {
354        let b = PdfBlock::paragraph("Hello", loc());
355        assert_eq!(b.block_type(), PdfBlockType::Paragraph);
356    }
357
358    #[test]
359    fn list_construction() {
360        let items = vec![ListItem::new("A"), ListItem::new("B")];
361        let b = PdfBlock::list(false, items, loc());
362        assert_eq!(b.block_type(), PdfBlockType::List);
363    }
364
365    #[test]
366    fn table_construction() {
367        let b = PdfBlock::table(vec!["H".into()], vec![vec!["C".into()]], loc());
368        assert_eq!(b.block_type(), PdfBlockType::Table);
369    }
370
371    #[test]
372    fn image_construction() {
373        let data = ImageData::new(crate::ImageFormat::Png);
374        let b = PdfBlock::image(data, loc());
375        assert_eq!(b.block_type(), PdfBlockType::Image);
376    }
377
378    #[test]
379    fn code_construction() {
380        let b = PdfBlock::code("fn main() {}", loc());
381        assert_eq!(b.block_type(), PdfBlockType::Code);
382    }
383
384    #[test]
385    fn code_with_language_construction() {
386        let b = PdfBlock::code_with_language("rust", "fn main() {}", loc());
387        assert_eq!(b.block_type(), PdfBlockType::Code);
388    }
389
390    #[test]
391    fn formula_construction() {
392        let b = PdfBlock::formula("E = mc^2", loc());
393        assert_eq!(b.block_type(), PdfBlockType::Formula);
394    }
395
396    #[test]
397    fn page_break_construction() {
398        let b = PdfBlock::page_break(loc());
399        assert_eq!(b.block_type(), PdfBlockType::PageBreak);
400    }
401
402    #[test]
403    fn footnote_construction() {
404        let b = PdfBlock::footnote("1", "See page 5", loc());
405        assert_eq!(b.block_type(), PdfBlockType::Footnote);
406    }
407
408    #[test]
409    fn table_cell_construction() {
410        let b = PdfBlock::table_cell(2, 1, "Merged", loc());
411        assert_eq!(b.block_type(), PdfBlockType::TableCell);
412    }
413
414    #[test]
415    fn block_quote_construction() {
416        let b = PdfBlock::block_quote("A wise saying", loc());
417        assert_eq!(b.block_type(), PdfBlockType::BlockQuote);
418    }
419
420    #[test]
421    fn horizontal_rule_construction() {
422        let b = PdfBlock::horizontal_rule(loc());
423        assert_eq!(b.block_type(), PdfBlockType::HorizontalRule);
424    }
425
426    #[test]
427    fn link_construction() {
428        let b = PdfBlock::link("https://example.com", "Example", loc());
429        assert_eq!(b.block_type(), PdfBlockType::Link);
430    }
431
432    #[test]
433    fn unknown_construction() {
434        let b = PdfBlock::unknown("???binary???", loc());
435        assert_eq!(b.block_type(), PdfBlockType::Unknown);
436    }
437
438    #[test]
439    fn source_returns_correct_location() {
440        let loc2 = SourceLocation::new(PageIndex::new(3), 0.85);
441        let b = PdfBlock::paragraph("test", loc2);
442        assert_eq!(b.source().page_index().value(), 3);
443        assert!((b.source().confidence() - 0.85).abs() < f32::EPSILON);
444    }
445
446    #[test]
447    fn block_type_covers_all_variants() {
448        let loc = loc();
449        let variants = [
450            PdfBlock::heading(1, "h", loc),
451            PdfBlock::paragraph("p", loc),
452            PdfBlock::list(false, vec![], loc),
453            PdfBlock::table(vec![], vec![], loc),
454            PdfBlock::image(ImageData::new(crate::ImageFormat::Png), loc),
455            PdfBlock::code("c", loc),
456            PdfBlock::formula("f", loc),
457            PdfBlock::page_break(loc),
458            PdfBlock::footnote("1", "t", loc),
459            PdfBlock::table_cell(1, 1, "t", loc),
460            PdfBlock::block_quote("q", loc),
461            PdfBlock::horizontal_rule(loc),
462            PdfBlock::link("u", "t", loc),
463            PdfBlock::unknown("r", loc),
464        ];
465        let types: Vec<_> = variants.iter().map(PdfBlock::block_type).collect();
466        assert_eq!(types.len(), 14);
467        // 每个变体应映射到不同分类
468        for i in 0..types.len() {
469            for j in (i + 1)..types.len() {
470                assert_ne!(types[i], types[j], "duplicate at {i} vs {j}");
471            }
472        }
473    }
474
475    #[test]
476    fn clone_and_eq() {
477        let a = PdfBlock::paragraph("hello", loc());
478        let b = a.clone();
479        assert_eq!(a, b);
480    }
481}