Skip to main content

easypdf_markdown/processors/
heading_detector.rs

1//! 标题检测处理器。
2
3use easypdf_core::PdfInput;
4use easypdf_core::Result;
5use easypdf_core::{PdfBlock, PdfDocumentModel, PdfPageModel};
6
7use crate::{MarkdownProcessorCapabilities, MarkdownWarning, PdfMarkdownProcessor};
8
9/// 标题检测处理器(Heuristic 级别)。
10///
11/// 扫描文档中的 [`PdfBlock::Paragraph`] 块,根据启发式规则
12/// 将短文本段落提升为 [`PdfBlock::Heading`]:
13///
14/// - 文本长度 <= `max_heading_length`(默认 80 字符)
15/// - 文本不含句号(非完整句子)
16/// - 文本首字母大写或全大写
17///
18/// TODO: 后续增强——基于字体大小与加粗样式识别标题层级。
19///
20/// # Examples
21///
22/// ```
23/// use easypdf_markdown::processors::HeadingDetectorProcessor;
24/// use easypdf_markdown::PdfMarkdownProcessor;
25///
26/// let proc = HeadingDetectorProcessor::new();
27/// assert!(proc.capabilities().reading_order());
28/// ```
29#[derive(Clone, Copy, Debug)]
30pub struct HeadingDetectorProcessor {
31    /// 段落被视为标题候选的最大字符数。
32    max_heading_length: usize,
33}
34
35impl HeadingDetectorProcessor {
36    /// 创建默认配置的标题检测处理器。
37    #[must_use]
38    pub const fn new() -> Self {
39        Self {
40            max_heading_length: 80,
41        }
42    }
43
44    /// 设置标题候选的最大字符数。
45    #[must_use]
46    pub const fn with_max_length(mut self, max: usize) -> Self {
47        self.max_heading_length = max;
48        self
49    }
50
51    /// 判断文本是否符合标题候选条件。
52    fn is_heading_candidate(self, text: &str) -> bool {
53        let trimmed = text.trim();
54        if trimmed.is_empty() || trimmed.len() > self.max_heading_length {
55            return false;
56        }
57        // 不含句号(排除完整句子)
58        if trimmed.contains('.') || trimmed.contains('。') {
59            return false;
60        }
61        // 首字母大写或全大写
62        trimmed.chars().next().is_some_and(char::is_uppercase)
63    }
64}
65
66impl Default for HeadingDetectorProcessor {
67    fn default() -> Self {
68        Self::new()
69    }
70}
71
72impl PdfMarkdownProcessor for HeadingDetectorProcessor {
73    fn capabilities(&self) -> MarkdownProcessorCapabilities {
74        // 标题检测也属于结构识别,归入 reading_order 能力。
75        MarkdownProcessorCapabilities::new().with_reading_order()
76    }
77
78    fn process(
79        &self,
80        _input: &PdfInput,
81        document: PdfDocumentModel,
82    ) -> Result<(PdfDocumentModel, Vec<MarkdownWarning>)> {
83        let mut new_pages = Vec::with_capacity(document.page_count());
84        for page in document.pages() {
85            let mut new_blocks = Vec::new();
86            for block in page.blocks() {
87                match block {
88                    PdfBlock::Paragraph { text, source } => {
89                        if self.is_heading_candidate(text) {
90                            // TODO: 基于字体大小确定 level
91                            new_blocks.push(PdfBlock::heading(2, text, *source));
92                        } else {
93                            new_blocks.push(block.clone());
94                        }
95                    }
96                    other => new_blocks.push(other.clone()),
97                }
98            }
99            let mut new_page = PdfPageModel::new(page.index());
100            if let (Some(w), Some(h)) = (page.width_pt(), page.height_pt()) {
101                new_page = new_page.with_dimensions(w, h);
102            }
103            new_page = new_page.with_rotation(page.rotation());
104            for block in new_blocks {
105                new_page = new_page.with_block(block);
106            }
107            new_pages.push(new_page);
108        }
109        Ok((
110            PdfDocumentModel::new(document.metadata().clone(), new_pages),
111            Vec::new(),
112        ))
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use easypdf_core::{PageIndex, PdfMetadata};
120    use easypdf_core::{PdfPageModel, SourceLocation};
121
122    fn loc() -> SourceLocation {
123        SourceLocation::new(PageIndex::new(0), 1.0)
124    }
125
126    #[test]
127    fn capabilities_include_reading_order() {
128        let proc = HeadingDetectorProcessor::new();
129        assert!(proc.capabilities().reading_order());
130    }
131
132    #[test]
133    fn short_uppercase_becomes_heading() {
134        let proc = HeadingDetectorProcessor::new();
135        let page = PdfPageModel::new(PageIndex::new(0))
136            .with_block(PdfBlock::paragraph("INTRODUCTION", loc()));
137        let doc = PdfDocumentModel::new(PdfMetadata::default(), vec![page]);
138        let (result, _) = proc.process(&PdfInput::from_bytes(vec![]), doc).unwrap();
139        let blocks: Vec<_> = result.iter_all_blocks().collect();
140        assert_eq!(blocks.len(), 1);
141        assert!(matches!(blocks[0].1, PdfBlock::Heading { .. }));
142    }
143
144    #[test]
145    fn sentence_with_period_stays_paragraph() {
146        let proc = HeadingDetectorProcessor::new();
147        let page = PdfPageModel::new(PageIndex::new(0))
148            .with_block(PdfBlock::paragraph("This is a full sentence.", loc()));
149        let doc = PdfDocumentModel::new(PdfMetadata::default(), vec![page]);
150        let (result, _) = proc.process(&PdfInput::from_bytes(vec![]), doc).unwrap();
151        let blocks: Vec<_> = result.iter_all_blocks().collect();
152        assert!(matches!(blocks[0].1, PdfBlock::Paragraph { .. }));
153    }
154
155    #[test]
156    fn long_text_stays_paragraph() {
157        let proc = HeadingDetectorProcessor::new();
158        let long_text = "A".repeat(100);
159        let page =
160            PdfPageModel::new(PageIndex::new(0)).with_block(PdfBlock::paragraph(&long_text, loc()));
161        let doc = PdfDocumentModel::new(PdfMetadata::default(), vec![page]);
162        let (result, _) = proc.process(&PdfInput::from_bytes(vec![]), doc).unwrap();
163        let blocks: Vec<_> = result.iter_all_blocks().collect();
164        assert!(matches!(blocks[0].1, PdfBlock::Paragraph { .. }));
165    }
166
167    #[test]
168    fn empty_text_stays_paragraph() {
169        let proc = HeadingDetectorProcessor::new();
170        let page = PdfPageModel::new(PageIndex::new(0)).with_block(PdfBlock::paragraph("", loc()));
171        let doc = PdfDocumentModel::new(PdfMetadata::default(), vec![page]);
172        let (result, _) = proc.process(&PdfInput::from_bytes(vec![]), doc).unwrap();
173        let blocks: Vec<_> = result.iter_all_blocks().collect();
174        assert_eq!(blocks.len(), 1);
175    }
176
177    #[test]
178    fn custom_max_length() {
179        let proc = HeadingDetectorProcessor::new().with_max_length(10);
180        // 11 chars, exceeds custom max
181        let page = PdfPageModel::new(PageIndex::new(0))
182            .with_block(PdfBlock::paragraph("ABCDEFGHIJK", loc()));
183        let doc = PdfDocumentModel::new(PdfMetadata::default(), vec![page]);
184        let (result, _) = proc.process(&PdfInput::from_bytes(vec![]), doc).unwrap();
185        let blocks: Vec<_> = result.iter_all_blocks().collect();
186        assert!(matches!(blocks[0].1, PdfBlock::Paragraph { .. }));
187    }
188
189    #[test]
190    fn is_heading_candidate_rules() {
191        let proc = HeadingDetectorProcessor::new();
192        assert!(proc.is_heading_candidate("Introduction"));
193        assert!(proc.is_heading_candidate("CHAPTER ONE"));
194        assert!(!proc.is_heading_candidate("this is lowercase"));
195        assert!(!proc.is_heading_candidate("Has a period."));
196        assert!(!proc.is_heading_candidate(""));
197        assert!(!proc.is_heading_candidate(&"A".repeat(100)));
198    }
199}