easypdf_markdown/processors/
heading_detector.rs1use easypdf_core::PdfInput;
4use easypdf_core::Result;
5use easypdf_core::{PdfBlock, PdfDocumentModel, PdfPageModel};
6
7use crate::{MarkdownProcessorCapabilities, MarkdownWarning, PdfMarkdownProcessor};
8
9#[derive(Clone, Copy, Debug)]
30pub struct HeadingDetectorProcessor {
31 max_heading_length: usize,
33}
34
35impl HeadingDetectorProcessor {
36 #[must_use]
38 pub const fn new() -> Self {
39 Self {
40 max_heading_length: 80,
41 }
42 }
43
44 #[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 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 if trimmed.contains('.') || trimmed.contains('。') {
59 return false;
60 }
61 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 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 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 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}