Skip to main content

easypdf_markdown/
pdf_markdown_builder.rs

1//! PDF 到 Markdown 的内存转换构建器。
2
3use std::ops::Range;
4use std::sync::Arc;
5
6use easypdf_core::Result;
7use easypdf_core::{PdfInput, ResourceLimits};
8use easypdf_reader::PdfReader;
9
10use crate::{
11    ImagePolicy, MarkdownConversionResult, MarkdownExportReport, MarkdownProcessorCapabilities,
12    MarkdownProfile, MarkdownRenderer, MarkdownWarning, OcrPolicy, PdfMarkdownProcessor,
13    TablePolicy,
14};
15
16/// PDF 到 Markdown 的链式内存转换构建器。
17#[derive(Clone)]
18#[must_use]
19pub struct PdfMarkdownBuilder {
20    input: PdfInput,
21    pages: Option<Range<usize>>,
22    profile: MarkdownProfile,
23    table_policy: TablePolicy,
24    image_policy: ImagePolicy,
25    ocr_policy: OcrPolicy,
26    limits: ResourceLimits,
27    processors: Vec<Arc<dyn PdfMarkdownProcessor>>,
28}
29
30impl std::fmt::Debug for PdfMarkdownBuilder {
31    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        formatter
33            .debug_struct("PdfMarkdownBuilder")
34            .field("input", &self.input)
35            .field("pages", &self.pages)
36            .field("profile", &self.profile)
37            .field("table_policy", &self.table_policy)
38            .field("image_policy", &self.image_policy)
39            .field("ocr_policy", &self.ocr_policy)
40            .field("limits", &self.limits)
41            .field("processor_count", &self.processors.len())
42            .finish()
43    }
44}
45
46impl PdfMarkdownBuilder {
47    /// 从路径创建转换任务。
48    pub fn new(input: impl Into<std::path::PathBuf>) -> Self {
49        Self::from_input(PdfInput::from_path(input.into()))
50    }
51
52    /// 从内存 PDF 字节创建转换任务。
53    pub fn from_bytes(bytes: impl Into<Vec<u8>>) -> Self {
54        Self::from_input(PdfInput::from_bytes(bytes))
55    }
56
57    /// 从统一 PDF 输入创建转换任务。
58    #[must_use = "conversion builder must be configured or executed"]
59    pub fn from_input(input: PdfInput) -> Self {
60        Self {
61            input,
62            pages: None,
63            profile: MarkdownProfile::default(),
64            table_policy: TablePolicy::default(),
65            image_policy: ImagePolicy::default(),
66            ocr_policy: OcrPolicy::default(),
67            limits: ResourceLimits::default(),
68            processors: Vec::new(),
69        }
70    }
71
72    /// 设置零基页范围。
73    pub fn pages(mut self, pages: Range<usize>) -> Self {
74        self.pages = Some(pages);
75        self
76    }
77
78    /// 设置 Markdown 输出配置档。
79    pub const fn profile(mut self, profile: MarkdownProfile) -> Self {
80        self.profile = profile;
81        self
82    }
83
84    /// 设置表格策略。
85    pub const fn tables(mut self, policy: TablePolicy) -> Self {
86        self.table_policy = policy;
87        self
88    }
89
90    /// 设置图片策略。
91    pub fn images(mut self, policy: ImagePolicy) -> Self {
92        self.image_policy = policy;
93        self
94    }
95
96    /// 设置 OCR 回退策略。
97    pub const fn ocr(mut self, policy: OcrPolicy) -> Self {
98        self.ocr_policy = policy;
99        self
100    }
101
102    /// 设置资源限制。
103    pub const fn resource_limits(mut self, limits: ResourceLimits) -> Self {
104        self.limits = limits;
105        self
106    }
107
108    /// 注册一个语义增强处理器。
109    pub fn processor(mut self, processor: Arc<dyn PdfMarkdownProcessor>) -> Self {
110        self.processors.push(processor);
111        self
112    }
113
114    /// 解析一次 PDF,并在内存中返回 Markdown 和结构化报告。
115    ///
116    /// # Errors
117    ///
118    /// 输入读取、PDF 解析、文本提取或任一处理器失败时返回错误。
119    pub fn do_convert(self) -> Result<MarkdownConversionResult> {
120        let mut reader = PdfReader::open_with_limits(&self.input, self.limits)?;
121        if let Some(pages) = self.pages {
122            reader = reader.try_pages(pages)?;
123        }
124        let mut document = reader.extract_document_model()?;
125        let mut warnings = Vec::new();
126        let mut capabilities = MarkdownProcessorCapabilities::new();
127        for processor in &self.processors {
128            capabilities = capabilities.union(processor.capabilities());
129            let (processed, mut processor_warnings) = processor.process(&self.input, document)?;
130            document = processed;
131            warnings.append(&mut processor_warnings);
132        }
133
134        append_capability_warnings(
135            &document,
136            self.table_policy,
137            &self.image_policy,
138            self.ocr_policy,
139            capabilities,
140            &mut warnings,
141        );
142        let renderer = MarkdownRenderer::new(self.profile)
143            .with_table_policy(self.table_policy)
144            .with_image_policy(self.image_policy);
145        let markdown = renderer.render(&document);
146        let blocks_written = document
147            .pages()
148            .iter()
149            .map(|page| page.blocks().len())
150            .sum();
151        let report = MarkdownExportReport::new(
152            document.page_count(),
153            blocks_written,
154            markdown.len(),
155            warnings,
156        );
157        Ok(MarkdownConversionResult::new(markdown, report))
158    }
159}
160
161fn append_capability_warnings(
162    document: &easypdf_core::PdfDocumentModel,
163    table_policy: TablePolicy,
164    image_policy: &ImagePolicy,
165    ocr_policy: OcrPolicy,
166    capabilities: MarkdownProcessorCapabilities,
167    warnings: &mut Vec<MarkdownWarning>,
168) {
169    if table_policy == TablePolicy::Detect && !capabilities.table_detection() {
170        warnings.push(MarkdownWarning::TableDetectionUnavailable);
171    }
172    if matches!(image_policy, ImagePolicy::ExtractTo(_)) && !capabilities.image_extraction() {
173        warnings.push(MarkdownWarning::ImageExtractionUnavailable);
174    }
175    for page in document
176        .pages()
177        .iter()
178        .filter(|page| page.blocks().is_empty())
179    {
180        warnings.push(MarkdownWarning::EmptyPage {
181            page_index: page.index(),
182        });
183        if ocr_policy == OcrPolicy::Auto && !capabilities.ocr() {
184            warnings.push(MarkdownWarning::OcrUnavailable {
185                page_index: page.index(),
186            });
187        }
188    }
189}
190
191#[cfg(test)]
192#[allow(clippy::uninlined_format_args, clippy::float_cmp)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn new_creates_builder() {
198        let builder = PdfMarkdownBuilder::new("/tmp/test.pdf");
199        assert!(builder.pages.is_none());
200        assert_eq!(builder.profile, MarkdownProfile::default());
201    }
202
203    #[test]
204    fn from_bytes_creates_builder() {
205        let builder = PdfMarkdownBuilder::from_bytes(vec![1, 2, 3]);
206        assert!(builder.pages.is_none());
207    }
208
209    #[test]
210    fn from_input_creates_builder() {
211        let input = PdfInput::from_bytes(vec![1, 2]);
212        let builder = PdfMarkdownBuilder::from_input(input);
213        assert!(builder.pages.is_none());
214    }
215
216    #[test]
217    fn pages_sets_range() {
218        let builder = PdfMarkdownBuilder::new("/tmp/test.pdf").pages(1..5);
219        assert_eq!(builder.pages, Some(1..5));
220    }
221
222    #[test]
223    fn profile_sets_profile() {
224        let builder = PdfMarkdownBuilder::new("/tmp/test.pdf").profile(MarkdownProfile::Llm);
225        assert_eq!(builder.profile, MarkdownProfile::Llm);
226    }
227
228    #[test]
229    fn tables_sets_policy() {
230        let builder = PdfMarkdownBuilder::new("/tmp/test.pdf").tables(TablePolicy::PlainText);
231        assert_eq!(builder.table_policy, TablePolicy::PlainText);
232    }
233
234    #[test]
235    fn images_sets_policy() {
236        let builder = PdfMarkdownBuilder::new("/tmp/test.pdf").images(ImagePolicy::Reference);
237        assert_eq!(builder.image_policy, ImagePolicy::Reference);
238    }
239
240    #[test]
241    fn ocr_sets_policy() {
242        let builder = PdfMarkdownBuilder::new("/tmp/test.pdf").ocr(OcrPolicy::Auto);
243        assert_eq!(builder.ocr_policy, OcrPolicy::Auto);
244    }
245
246    #[test]
247    fn resource_limits_sets_limits() {
248        let limits = ResourceLimits::strict();
249        let builder = PdfMarkdownBuilder::new("/tmp/test.pdf").resource_limits(limits);
250        assert_eq!(builder.limits.max_input_bytes(), limits.max_input_bytes());
251    }
252
253    #[test]
254    fn debug_format() {
255        let builder = PdfMarkdownBuilder::new("/tmp/test.pdf");
256        let dbg = format!("{:?}", builder);
257        assert!(dbg.contains("PdfMarkdownBuilder"));
258    }
259
260    #[test]
261    fn clone_preserves_values() {
262        let builder = PdfMarkdownBuilder::new("/tmp/test.pdf")
263            .profile(MarkdownProfile::Llm)
264            .tables(TablePolicy::PlainText);
265        let cloned = builder.clone();
266        assert_eq!(builder.profile, cloned.profile);
267        assert_eq!(builder.table_policy, cloned.table_policy);
268    }
269
270    #[test]
271    fn chaining_sets_all_fields() {
272        let builder = PdfMarkdownBuilder::new("/tmp/test.pdf")
273            .pages(0..10)
274            .profile(MarkdownProfile::Gfm)
275            .tables(TablePolicy::Detect)
276            .images(ImagePolicy::Ignore)
277            .ocr(OcrPolicy::Disabled)
278            .resource_limits(ResourceLimits::permissive());
279        assert_eq!(builder.pages, Some(0..10));
280        assert_eq!(builder.profile, MarkdownProfile::Gfm);
281        assert_eq!(builder.table_policy, TablePolicy::Detect);
282        assert_eq!(builder.image_policy, ImagePolicy::Ignore);
283        assert_eq!(builder.ocr_policy, OcrPolicy::Disabled);
284    }
285}