1use 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
15pub struct PdfConversionOptions {
16 pub title: Option<String>,
18 pub source_filename: Option<String>,
20}
21
22pub 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}