Skip to main content

easypdf_markdown/processors/
link_extractor.rs

1//! 链接提取处理器。
2
3use easypdf_core::PdfInput;
4use easypdf_core::Result;
5use easypdf_core::{PdfBlock, PdfDocumentModel, SourceLocation};
6
7use crate::{MarkdownProcessorCapabilities, MarkdownWarning, PdfMarkdownProcessor};
8
9/// 链接提取处理器(Heuristic 级别)。
10///
11/// 扫描文档中的 [`PdfBlock::Paragraph`] 块,检测内嵌的 URL
12/// 并将其转换为 [`PdfBlock::Link`] 块。
13///
14/// 当前实现识别以 `http://` 或 `https://` 开头的 URL。
15///
16/// TODO: 后续增强——从 PDF 注解(Annotation)中提取 URI,
17/// 支持相对链接与邮件链接(`mailto:`)。
18///
19/// # Examples
20///
21/// ```
22/// use easypdf_markdown::processors::LinkExtractorProcessor;
23/// use easypdf_markdown::PdfMarkdownProcessor;
24///
25/// let proc = LinkExtractorProcessor;
26/// assert!(proc.capabilities().link());
27/// ```
28#[derive(Clone, Copy, Debug, Default)]
29pub struct LinkExtractorProcessor;
30
31impl PdfMarkdownProcessor for LinkExtractorProcessor {
32    fn capabilities(&self) -> MarkdownProcessorCapabilities {
33        MarkdownProcessorCapabilities::new().with_link()
34    }
35
36    fn process(
37        &self,
38        _input: &PdfInput,
39        document: PdfDocumentModel,
40    ) -> Result<(PdfDocumentModel, Vec<MarkdownWarning>)> {
41        let mut new_pages = Vec::with_capacity(document.page_count());
42        for page in document.pages() {
43            let mut new_blocks = Vec::new();
44            for block in page.blocks() {
45                match block {
46                    PdfBlock::Paragraph { text, source } => {
47                        let links = extract_links(text, *source);
48                        if links.is_empty() {
49                            new_blocks.push(block.clone());
50                        } else {
51                            new_blocks.extend(links);
52                        }
53                    }
54                    other => new_blocks.push(other.clone()),
55                }
56            }
57            let mut new_page = easypdf_core::PdfPageModel::new(page.index());
58            if let (Some(w), Some(h)) = (page.width_pt(), page.height_pt()) {
59                new_page = new_page.with_dimensions(w, h);
60            }
61            new_page = new_page.with_rotation(page.rotation());
62            for block in new_blocks {
63                new_page = new_page.with_block(block);
64            }
65            new_pages.push(new_page);
66        }
67        Ok((
68            PdfDocumentModel::new(document.metadata().clone(), new_pages),
69            Vec::new(),
70        ))
71    }
72}
73
74/// 从文本中提取 URL,生成 `PdfBlock::Link` 或保留原始段落。
75fn extract_links(text: &str, source: SourceLocation) -> Vec<PdfBlock> {
76    let mut blocks = Vec::new();
77    let mut remaining = text;
78
79    while !remaining.is_empty() {
80        if let Some(pos) = find_url_start(remaining) {
81            // 前面的文本作为段落。
82            if pos > 0 {
83                let before = remaining[..pos].trim();
84                if !before.is_empty() {
85                    blocks.push(PdfBlock::paragraph(before, source));
86                }
87            }
88            // 提取 URL。
89            let url_end = find_url_end(&remaining[pos..]);
90            let url = &remaining[pos..pos + url_end];
91            blocks.push(PdfBlock::link(url, url, source));
92            remaining = &remaining[pos + url_end..];
93        } else {
94            // 没有更多 URL,剩余文本作为段落。
95            let trimmed = remaining.trim();
96            if !trimmed.is_empty() {
97                blocks.push(PdfBlock::paragraph(trimmed, source));
98            }
99            break;
100        }
101    }
102
103    blocks
104}
105
106/// 查找文本中第一个 URL 的起始位置。
107fn find_url_start(text: &str) -> Option<usize> {
108    // 查找 http:// 或 https://
109    if let Some(pos) = text.find("https://") {
110        return Some(pos);
111    }
112    text.find("http://")
113}
114
115/// 从 URL 起始位置查找 URL 的结束位置。
116fn find_url_end(text: &str) -> usize {
117    // URL 在空白字符、括号、引号处结束。
118    text.chars()
119        .take_while(|c| !c.is_whitespace() && !matches!(c, ')' | ']' | '>' | '"' | '\''))
120        .count()
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use easypdf_core::PdfPageModel;
127    use easypdf_core::{PageIndex, PdfMetadata};
128
129    #[test]
130    fn capabilities_include_link() {
131        let proc = LinkExtractorProcessor;
132        assert!(proc.capabilities().link());
133    }
134
135    #[test]
136    fn extracts_url_from_paragraph() {
137        let proc = LinkExtractorProcessor;
138        let loc = SourceLocation::new(PageIndex::new(0), 1.0);
139        let page = PdfPageModel::new(PageIndex::new(0)).with_block(PdfBlock::paragraph(
140            "Visit https://example.com for more",
141            loc,
142        ));
143        let doc = PdfDocumentModel::new(PdfMetadata::default(), vec![page]);
144        let (result, warnings) = proc.process(&PdfInput::from_bytes(vec![]), doc).unwrap();
145        assert!(warnings.is_empty());
146        let blocks: Vec<_> = result.iter_all_blocks().collect();
147        // Should have: paragraph "Visit", link "https://example.com", paragraph "for more"
148        assert!(blocks.len() >= 2);
149        // Check that a Link block exists
150        let has_link = blocks
151            .iter()
152            .any(|(_, b)| matches!(b, PdfBlock::Link { .. }));
153        assert!(has_link, "expected at least one Link block");
154    }
155
156    #[test]
157    fn no_url_keeps_paragraph() {
158        let proc = LinkExtractorProcessor;
159        let loc = SourceLocation::new(PageIndex::new(0), 1.0);
160        let page = PdfPageModel::new(PageIndex::new(0))
161            .with_block(PdfBlock::paragraph("No links here", loc));
162        let doc = PdfDocumentModel::new(PdfMetadata::default(), vec![page]);
163        let (result, _) = proc.process(&PdfInput::from_bytes(vec![]), doc).unwrap();
164        let blocks: Vec<_> = result.iter_all_blocks().collect();
165        assert_eq!(blocks.len(), 1);
166        assert!(matches!(blocks[0].1, PdfBlock::Paragraph { .. }));
167    }
168
169    #[test]
170    fn find_url_start_finds_https() {
171        assert_eq!(find_url_start("go to https://x.com now"), Some(6));
172    }
173
174    #[test]
175    fn find_url_start_finds_http() {
176        assert_eq!(find_url_start("see http://x.com"), Some(4));
177    }
178
179    #[test]
180    fn find_url_end_stops_at_space() {
181        assert_eq!(find_url_end("https://x.com more"), 13);
182    }
183
184    #[test]
185    fn find_url_end_stops_at_paren() {
186        assert_eq!(find_url_end("https://x.com)"), 13);
187    }
188}