easypdf_markdown/processors/
link_extractor.rs1use easypdf_core::PdfInput;
4use easypdf_core::Result;
5use easypdf_core::{PdfBlock, PdfDocumentModel, SourceLocation};
6
7use crate::{MarkdownProcessorCapabilities, MarkdownWarning, PdfMarkdownProcessor};
8
9#[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
74fn 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 if pos > 0 {
83 let before = remaining[..pos].trim();
84 if !before.is_empty() {
85 blocks.push(PdfBlock::paragraph(before, source));
86 }
87 }
88 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 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
106fn find_url_start(text: &str) -> Option<usize> {
108 if let Some(pos) = text.find("https://") {
110 return Some(pos);
111 }
112 text.find("http://")
113}
114
115fn find_url_end(text: &str) -> usize {
117 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 assert!(blocks.len() >= 2);
149 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}