Skip to main content

mcd_core/
pdf.rs

1//! Simple PDF-to-MCD conversion.
2
3use std::io::{Cursor, Write};
4
5use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
6
7use crate::{
8    errors::{Diagnostic, McdError},
9    manifest::{AssetManifestEntry, ConformanceClaim, LayoutManifestEntry, Manifest, McdProfile},
10    package::MCD_MIMETYPE,
11};
12
13/// Options for converting a PDF into a minimal MCD package.
14#[derive(Debug, Clone, Default, PartialEq, Eq)]
15pub struct PdfConversionOptions {
16    /// Optional document title for the MCD manifest and Markdown heading.
17    pub title: Option<String>,
18    /// Optional original file name used for the embedded PDF asset path.
19    pub source_filename: Option<String>,
20}
21
22/// Convert PDF bytes into a minimal MCD archive.
23///
24/// The converter extracts text into `content/main.md` and embeds the original
25/// PDF as `assets/<source_filename>`. It does not attempt OCR, table recovery,
26/// or layout reconstruction.
27pub fn pdf_to_mcd_bytes(pdf: &[u8], options: PdfConversionOptions) -> crate::Result<Vec<u8>> {
28    if !pdf.starts_with(b"%PDF-") {
29        return Err(McdError::from_diagnostic(Diagnostic::error(
30            "pdf.signature.invalid",
31            "Input does not look like a PDF file.",
32        )));
33    }
34
35    let pages = extract_pdf_pages(pdf)?;
36    let asset_path = format!(
37        "assets/{}",
38        sanitize_pdf_filename(options.source_filename.as_deref())
39    );
40    let title = options
41        .title
42        .filter(|title| !title.trim().is_empty())
43        .unwrap_or_else(|| title_from_asset_path(&asset_path));
44    let markdown = markdown_from_pdf_pages(&title, &asset_path, &pages);
45    let manifest = Manifest {
46        format: "MCD".to_owned(),
47        version: "0.1".to_owned(),
48        profile: McdProfile::Core,
49        conformance: vec![ConformanceClaim::Core],
50        entrypoint: "content/main.md".to_owned(),
51        title: Some(title),
52        encoding: Some("utf-8".to_owned()),
53        tables: Vec::new(),
54        images: Vec::new(),
55        annotations: Vec::new(),
56        assets: vec![AssetManifestEntry {
57            id: Some("source-pdf".to_owned()),
58            path: asset_path.clone(),
59        }],
60        layout: None::<LayoutManifestEntry>,
61    };
62
63    let manifest_json = serde_json::to_vec_pretty(&manifest)?;
64    let mut archive = ZipWriter::new(Cursor::new(Vec::new()));
65    let stored = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
66    let deflated = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
67
68    archive.start_file("mimetype", stored)?;
69    archive.write_all(MCD_MIMETYPE.as_bytes())?;
70    archive.write_all(b"\n")?;
71    archive.start_file("manifest.json", deflated)?;
72    archive.write_all(&manifest_json)?;
73    archive.start_file("content/main.md", deflated)?;
74    archive.write_all(markdown.as_bytes())?;
75    archive.start_file(asset_path, deflated)?;
76    archive.write_all(pdf)?;
77
78    Ok(archive.finish()?.into_inner())
79}
80
81fn extract_pdf_pages(pdf: &[u8]) -> crate::Result<Vec<String>> {
82    #[cfg(not(target_arch = "wasm32"))]
83    {
84        match pdf_extract::extract_text_from_mem_by_pages(pdf) {
85            Ok(pages) => Ok(pages),
86            Err(err) => {
87                let fallback_pages = fallback_extract_literal_text(pdf);
88                if fallback_pages.is_empty() {
89                    Err(pdf_error(err))
90                } else {
91                    Ok(fallback_pages)
92                }
93            }
94        }
95    }
96    #[cfg(target_arch = "wasm32")]
97    {
98        Ok(fallback_extract_literal_text(pdf))
99    }
100}
101
102#[cfg(not(target_arch = "wasm32"))]
103fn pdf_error(err: pdf_extract::OutputError) -> McdError {
104    McdError::from_diagnostic(Diagnostic::error(
105        "pdf.text.extract.failed",
106        format!("Failed to extract text from PDF: {err}"),
107    ))
108}
109
110fn fallback_extract_literal_text(pdf: &[u8]) -> Vec<String> {
111    let mut strings = Vec::new();
112    let mut index = 0;
113    while index < pdf.len() {
114        if pdf[index] != b'(' {
115            index += 1;
116            continue;
117        }
118
119        if let Some((value, end)) = parse_pdf_literal_string(pdf, index + 1) {
120            if looks_like_text_showing_operator(pdf, end) && !value.trim().is_empty() {
121                strings.push(value);
122            }
123            index = end + 1;
124        } else {
125            index += 1;
126        }
127    }
128
129    if strings.is_empty() {
130        Vec::new()
131    } else {
132        vec![strings.join("\n")]
133    }
134}
135
136fn parse_pdf_literal_string(pdf: &[u8], mut index: usize) -> Option<(String, usize)> {
137    let mut value = Vec::new();
138    let mut depth = 1_u32;
139    while index < pdf.len() {
140        let byte = pdf[index];
141        match byte {
142            b'\\' => {
143                index += 1;
144                if index >= pdf.len() {
145                    return None;
146                }
147                match pdf[index] {
148                    b'n' => value.push(b'\n'),
149                    b'r' => value.push(b'\r'),
150                    b't' => value.push(b'\t'),
151                    b'b' => value.push(0x08),
152                    b'f' => value.push(0x0c),
153                    b'\n' => {}
154                    b'\r' => {
155                        if pdf.get(index + 1) == Some(&b'\n') {
156                            index += 1;
157                        }
158                    }
159                    escaped => value.push(escaped),
160                }
161            }
162            b'(' => {
163                depth += 1;
164                value.push(byte);
165            }
166            b')' => {
167                depth -= 1;
168                if depth == 0 {
169                    return Some((String::from_utf8_lossy(&value).into_owned(), index));
170                }
171                value.push(byte);
172            }
173            _ => value.push(byte),
174        }
175        index += 1;
176    }
177    None
178}
179
180fn looks_like_text_showing_operator(pdf: &[u8], end: usize) -> bool {
181    let tail_start = end.saturating_add(1);
182    let tail_end = tail_start.saturating_add(32).min(pdf.len());
183    let tail = &pdf[tail_start..tail_end];
184    let tail = trim_ascii_start(tail);
185    tail.starts_with(b"Tj")
186        || tail.starts_with(b"'")
187        || tail.starts_with(b"\"")
188        || tail.windows(2).take(16).any(|window| window == b"TJ")
189}
190
191fn trim_ascii_start(mut bytes: &[u8]) -> &[u8] {
192    while let Some((first, rest)) = bytes.split_first() {
193        if !first.is_ascii_whitespace() {
194            break;
195        }
196        bytes = rest;
197    }
198    bytes
199}
200
201fn markdown_from_pdf_pages(title: &str, asset_path: &str, pages: &[String]) -> String {
202    let mut markdown = String::new();
203    markdown.push_str("# ");
204    markdown.push_str(&escape_heading(title));
205    markdown.push_str("\n\n");
206    markdown.push_str("Source PDF asset: `");
207    markdown.push_str(asset_path);
208    markdown.push_str("`.\n");
209
210    if pages.is_empty() || pages.iter().all(|page| page.trim().is_empty()) {
211        markdown.push_str("\n_No extractable text was found in the PDF._\n");
212        return markdown;
213    }
214
215    for (index, page) in pages.iter().enumerate() {
216        let text = normalize_pdf_text(page);
217        if text.trim().is_empty() {
218            continue;
219        }
220        markdown.push_str("\n\n## Page ");
221        markdown.push_str(&(index + 1).to_string());
222        markdown.push_str("\n\n");
223        markdown.push_str(&text);
224    }
225    markdown.push('\n');
226    markdown
227}
228
229fn normalize_pdf_text(text: &str) -> String {
230    text.replace("\r\n", "\n")
231        .replace('\r', "\n")
232        .lines()
233        .map(str::trim_end)
234        .collect::<Vec<_>>()
235        .join("\n")
236        .trim()
237        .to_owned()
238}
239
240fn sanitize_pdf_filename(source_filename: Option<&str>) -> String {
241    let source_filename = source_filename
242        .and_then(|path| {
243            path.rsplit(['/', '\\'])
244                .find(|part| !part.trim().is_empty())
245        })
246        .unwrap_or("source.pdf");
247    let mut sanitized = source_filename
248        .chars()
249        .map(|character| {
250            if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') {
251                character
252            } else {
253                '_'
254            }
255        })
256        .collect::<String>();
257
258    while sanitized.contains("..") {
259        sanitized = sanitized.replace("..", ".");
260    }
261    sanitized = sanitized.trim_matches('.').to_owned();
262    if sanitized.is_empty() {
263        sanitized = "source.pdf".to_owned();
264    }
265    if !sanitized.to_ascii_lowercase().ends_with(".pdf") {
266        sanitized.push_str(".pdf");
267    }
268    sanitized
269}
270
271fn title_from_asset_path(asset_path: &str) -> String {
272    let file_name = asset_path.rsplit('/').next().unwrap_or("source.pdf");
273    let stem = file_name
274        .strip_suffix(".pdf")
275        .or_else(|| file_name.strip_suffix(".PDF"))
276        .unwrap_or(file_name);
277    let title = stem.replace(['_', '-'], " ");
278    if title.trim().is_empty() {
279        "Converted PDF".to_owned()
280    } else {
281        title.trim().to_owned()
282    }
283}
284
285fn escape_heading(value: &str) -> String {
286    value.replace('\n', " ").trim().to_owned()
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn rejects_non_pdf_input() {
295        let err = pdf_to_mcd_bytes(b"not a pdf", PdfConversionOptions::default())
296            .expect_err("non-pdf should fail");
297
298        assert_eq!(
299            err.diagnostic().map(|diagnostic| diagnostic.code.as_str()),
300            Some("pdf.signature.invalid")
301        );
302    }
303
304    #[test]
305    fn sanitizes_asset_file_names() {
306        assert_eq!(
307            sanitize_pdf_filename(Some(r"..\Quarterly Report 2026.pdf")),
308            "Quarterly_Report_2026.pdf"
309        );
310        assert_eq!(sanitize_pdf_filename(Some("report")), "report.pdf");
311    }
312
313    #[test]
314    fn renders_empty_pdf_text_as_valid_markdown() {
315        let markdown = markdown_from_pdf_pages("Report", "assets/report.pdf", &[]);
316
317        assert!(markdown.contains("# Report"));
318        assert!(markdown.contains("_No extractable text was found"));
319    }
320
321    #[test]
322    fn converts_simple_pdf_to_valid_mcd_package() {
323        let pdf = minimal_pdf("Hello from PDF");
324        let mcd = pdf_to_mcd_bytes(
325            &pdf,
326            PdfConversionOptions {
327                title: Some("PDF Import".to_owned()),
328                source_filename: Some("import.pdf".to_owned()),
329            },
330        )
331        .expect("pdf converts");
332        let package = crate::McdPackage::from_bytes(&mcd).expect("mcd opens");
333        crate::validate::validate_package(&package).expect("mcd validates");
334        let markdown = package
335            .read_to_string("content/main.md")
336            .expect("markdown exists");
337
338        assert!(markdown.contains("# PDF Import"));
339        assert!(markdown.contains("Hello from PDF"));
340        assert!(package.contains("assets/import.pdf"));
341    }
342
343    fn minimal_pdf(text: &str) -> Vec<u8> {
344        let escaped = text
345            .replace('\\', r"\\")
346            .replace('(', r"\(")
347            .replace(')', r"\)");
348        let content = format!("BT /F1 24 Tf 100 700 Td ({escaped}) Tj ET");
349        let objects = [
350            "<< /Type /Catalog /Pages 2 0 R >>".to_owned(),
351            "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_owned(),
352            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>".to_owned(),
353            "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_owned(),
354            format!("<< /Length {} >>\nstream\n{}\nendstream", content.len(), content),
355        ];
356        let mut bytes = b"%PDF-1.4\n".to_vec();
357        let mut offsets = Vec::new();
358        for (index, object) in objects.iter().enumerate() {
359            offsets.push(bytes.len());
360            bytes
361                .extend_from_slice(format!("{} 0 obj\n{}\nendobj\n", index + 1, object).as_bytes());
362        }
363        let xref_offset = bytes.len();
364        bytes.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes());
365        bytes.extend_from_slice(b"0000000000 65535 f \n");
366        for offset in offsets {
367            bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
368        }
369        bytes.extend_from_slice(
370            format!(
371                "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{}\n%%EOF\n",
372                objects.len() + 1,
373                xref_offset
374            )
375            .as_bytes(),
376        );
377        bytes
378    }
379}