1#![warn(missing_docs)]
2pub mod error;
14pub mod layout;
15pub mod rules;
16pub mod writer;
17
18pub use error::{DocxError, Result};
19pub use layout::{DocxImage, PageElement, Paragraph, Run, Table};
20
21use layout::analyze_page_with_rules;
22use lopdf::Document;
23use pdf_extract::{encode_image_for_document, extract_page_images, extract_text};
24use writer::write_docx;
25
26const MAX_DOCX_PAGES: u32 = 1000;
29
30pub fn pdf_to_docx(doc: &Document) -> Result<Vec<u8>> {
34 pdf_to_docx_inner(doc, false)
35}
36
37pub fn pdf_to_docx_text_only(doc: &Document) -> Result<Vec<u8>> {
42 pdf_to_docx_sequential(doc)
43}
44
45fn pdf_to_docx_sequential(doc: &Document) -> Result<Vec<u8>> {
52 let pages = doc.get_pages();
53 let total_pages = pages.len() as u32;
54 let total_pages = total_pages.min(MAX_DOCX_PAGES);
55 let text_blocks = extract_text(doc);
56
57 let mut all_elements: Vec<Vec<PageElement>> = Vec::new();
58
59 for page_num in 1..=total_pages {
60 let page_blocks: Vec<_> = text_blocks
61 .iter()
62 .filter(|b| b.page == page_num)
63 .cloned()
64 .collect();
65
66 let elements: Vec<PageElement> = page_blocks
69 .iter()
70 .map(|b| {
71 PageElement::Para(layout::Paragraph {
72 runs: vec![layout::Run {
73 text: b.text.clone(),
74 font_name: String::new(),
75 font_size: b.font_size,
76 bold: false,
77 italic: false,
78 }],
79 })
80 })
81 .collect();
82
83 all_elements.push(elements);
84 }
85
86 let mut output = Vec::new();
87 write_docx(&all_elements, &[], &mut output)?;
88 Ok(output)
89}
90
91fn pdf_to_docx_inner(doc: &Document, skip_images: bool) -> Result<Vec<u8>> {
92 let pages = doc.get_pages();
93 let total_pages = pages.len() as u32;
94 let total_pages = total_pages.min(MAX_DOCX_PAGES);
95
96 let mut all_elements: Vec<Vec<PageElement>> = Vec::new();
97 let mut all_images: Vec<DocxImage> = Vec::new();
98
99 let text_blocks = extract_text(doc);
101
102 for page_num in 1..=total_pages {
103 let page_blocks: Vec<_> = text_blocks
105 .iter()
106 .filter(|b| b.page == page_num)
107 .cloned()
108 .collect();
109
110 let rules = pages
112 .get(&page_num)
113 .map(|&id| rules::page_rules(doc, id))
114 .unwrap_or_default();
115 let mut elements = analyze_page_with_rules(&page_blocks, &rules);
116
117 if !skip_images {
119 if let Ok(images) = extract_page_images(doc, page_num) {
120 for img in images {
121 let Some(encoded) = encode_image_for_document(&img) else {
125 log::warn!(
126 "pdf-docx: skipping page {page_num} image ({}x{}, cs={}, {:?}) — unsupported for embedding",
127 img.width,
128 img.height,
129 img.color_space,
130 img.filter
131 );
132 continue;
133 };
134
135 let id = format!("image{}_{}.{}", page_num, all_images.len(), encoded.ext);
136
137 all_images.push(DocxImage {
138 data: encoded.data,
139 width: img.width,
140 height: img.height,
141 content_type: encoded.mime.to_string(),
142 id: id.clone(),
143 });
144
145 elements.push(PageElement::Img(layout::DocxImage {
146 data: Vec::new(), width: img.width,
148 height: img.height,
149 content_type: encoded.mime.to_string(),
150 id,
151 }));
152 }
153 }
154 }
155
156 all_elements.push(elements);
157 }
158
159 let mut output = Vec::new();
160 write_docx(&all_elements, &all_images, &mut output)?;
161 Ok(output)
162}
163
164pub fn convert_pdf_bytes_to_docx(pdf_bytes: &[u8]) -> Result<Vec<u8>> {
166 let doc = Document::load_mem(pdf_bytes)?;
167 pdf_to_docx(&doc)
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173 use lopdf::{dictionary, Document, Object, Stream};
174 use std::io::Read;
175
176 fn make_test_pdf(content: &[u8]) -> Document {
177 let mut doc = Document::with_version("1.7");
178
179 let content_stream = Stream::new(dictionary! {}, content.to_vec());
180 let content_id = doc.add_object(Object::Stream(content_stream));
181
182 let page_dict = dictionary! {
183 "Type" => "Page",
184 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
185 "Contents" => Object::Reference(content_id),
186 };
187 let page_id = doc.add_object(Object::Dictionary(page_dict));
188
189 let pages_dict = dictionary! {
190 "Type" => "Pages",
191 "Kids" => vec![Object::Reference(page_id)],
192 "Count" => 1_i64,
193 };
194 let pages_id = doc.add_object(Object::Dictionary(pages_dict));
195
196 if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
197 d.set("Parent", Object::Reference(pages_id));
198 }
199
200 let catalog = dictionary! {
201 "Type" => "Catalog",
202 "Pages" => Object::Reference(pages_id),
203 };
204 let catalog_id = doc.add_object(Object::Dictionary(catalog));
205 doc.trailer.set("Root", Object::Reference(catalog_id));
206
207 doc
208 }
209
210 fn read_zip_entry(data: &[u8], name: &str) -> Option<String> {
211 let cursor = std::io::Cursor::new(data);
212 let mut archive = zip::ZipArchive::new(cursor).ok()?;
213 let mut file = archive.by_name(name).ok()?;
214 let mut content = String::new();
215 file.read_to_string(&mut content).ok()?;
216 Some(content)
217 }
218
219 fn grid_content(rows: usize, columns: usize) -> Vec<u8> {
235 let mut ops = String::from("BT\n/F1 11 Tf\n");
236 for r in 0..rows {
237 for c in 0..columns {
238 let column_x = 60.0 + (c as f64) * 130.0;
239 let y = 700.0 - (r as f64) * 24.0;
240 let shift = 4.0 + (r as f64) * 3.0;
244 for w in 0..3usize {
245 let x = column_x + (w as f64) * (18.0 + shift);
246 let text = match w {
247 0 => format!("R{r}C{c}"),
248 1 => format!("w{r}{c}b"),
249 _ => format!("w{r}{c}c"),
250 };
251 ops.push_str(&format!("1 0 0 1 {x:.1} {y:.1} Tm\n({text}) Tj\n"));
252 }
253 }
254 }
255 ops.push_str("ET\n");
256 ops.into_bytes()
257 }
258
259 fn columns_in_docx(docx: &[u8]) -> usize {
261 let xml = read_zip_entry(docx, "word/document.xml").expect("word/document.xml");
262 xml.matches("<w:gridCol").count()
263 }
264
265 #[test]
274 fn a_table_keeps_the_column_count_of_the_original() {
275 for columns in [2usize, 3, 4, 5] {
276 let doc = make_test_pdf(&grid_content(4, columns));
277 let docx = pdf_to_docx(&doc).expect("conversion succeeds");
278
279 let found = columns_in_docx(&docx);
280 assert_eq!(
281 found, columns,
282 "a grid of 4 rows x {columns} columns came back with {found} columns"
283 );
284 }
285 }
286
287 #[test]
292 fn the_cells_sit_in_the_column_they_belong_to() {
293 let doc = make_test_pdf(&grid_content(3, 4));
294 let docx = pdf_to_docx(&doc).expect("conversion succeeds");
295 let xml = read_zip_entry(&docx, "word/document.xml").expect("word/document.xml");
296
297 assert_eq!(columns_in_docx(&docx), 4);
298
299 for r in 0..3 {
301 for c in 0..4 {
302 let text = format!("R{r}C{c}");
303 assert_eq!(
304 xml.matches(&text).count(),
305 1,
306 "cell {text} appears {} time(s)",
307 xml.matches(&text).count()
308 );
309 }
310 }
311
312 let at = |needle: &str| xml.find(needle).unwrap_or(usize::MAX);
314 assert!(at("R0C0") < at("R0C1"), "the columns are out of order");
315 assert!(at("R0C3") < at("R1C0"), "the rows are out of order");
316 }
317
318 fn zip_file_names(data: &[u8]) -> Vec<String> {
319 let cursor = std::io::Cursor::new(data);
320 let archive = zip::ZipArchive::new(cursor).unwrap();
321 (0..archive.len())
322 .map(|i| archive.name_for_index(i).unwrap().unwrap().to_string())
323 .collect()
324 }
325
326 fn levenshtein_similarity(a: &str, b: &str) -> f64 {
327 let a: Vec<char> = a.chars().collect();
328 let b: Vec<char> = b.chars().collect();
329 let (m, n) = (a.len(), b.len());
330 if m == 0 && n == 0 {
331 return 1.0;
332 }
333 let mut prev: Vec<usize> = (0..=n).collect();
334 let mut curr = vec![0; n + 1];
335 for i in 1..=m {
336 curr[0] = i;
337 for j in 1..=n {
338 let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
339 curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
340 }
341 std::mem::swap(&mut prev, &mut curr);
342 }
343 1.0 - (prev[n] as f64 / m.max(n) as f64)
344 }
345
346 #[test]
347 fn convert_simple_text_pdf() {
348 let doc = make_test_pdf(b"BT /F1 12 Tf (Hello World) Tj ET");
349 let docx = pdf_to_docx(&doc).unwrap();
350 assert!(docx.len() > 100);
351 assert_eq!(&docx[0..2], b"PK"); }
353
354 #[test]
355 fn convert_multiline_pdf() {
356 let doc = make_test_pdf(b"BT /F1 12 Tf 12 TL (Line 1) Tj T* (Line 2) Tj ET");
357 let docx = pdf_to_docx(&doc).unwrap();
358 assert!(docx.len() > 100);
359 }
360
361 #[test]
362 fn convert_empty_pdf() {
363 let doc = make_test_pdf(b"");
364 let docx = pdf_to_docx(&doc).unwrap();
365 assert!(docx.len() > 100);
366 }
367
368 #[test]
369 fn convert_from_bytes() {
370 let mut doc = make_test_pdf(b"BT /F1 12 Tf (Test) Tj ET");
371 let mut pdf_bytes = Vec::new();
372 doc.save_to(&mut pdf_bytes).unwrap();
373
374 let docx = convert_pdf_bytes_to_docx(&pdf_bytes).unwrap();
375 assert!(docx.len() > 100);
376 }
377
378 #[test]
379 fn docx_structure_has_required_files() {
380 let doc = make_test_pdf(b"BT /F1 12 Tf (Structure test) Tj ET");
381 let docx = pdf_to_docx(&doc).unwrap();
382 let names = zip_file_names(&docx);
383
384 assert!(names.contains(&"[Content_Types].xml".to_string()));
385 assert!(names.contains(&"_rels/.rels".to_string()));
386 assert!(names.contains(&"word/document.xml".to_string()));
387 assert!(names.contains(&"word/styles.xml".to_string()));
388 assert!(names.contains(&"word/_rels/document.xml.rels".to_string()));
389 }
390
391 #[test]
392 fn docx_document_xml_parseable() {
393 let doc = make_test_pdf(b"BT /F1 12 Tf (XML parse test) Tj ET");
394 let docx = pdf_to_docx(&doc).unwrap();
395 let xml = read_zip_entry(&docx, "word/document.xml").unwrap();
396
397 let parsed = quick_xml::Reader::from_str(&xml);
399 let mut buf = Vec::new();
400 let mut reader = parsed;
401 loop {
402 match reader.read_event_into(&mut buf) {
403 Ok(quick_xml::events::Event::Eof) => break,
404 Err(e) => panic!("Invalid XML in document.xml: {e}"),
405 _ => {}
406 }
407 buf.clear();
408 }
409 }
410
411 #[test]
412 fn docx_styles_xml_parseable() {
413 let doc = make_test_pdf(b"BT /F1 12 Tf (Styles test) Tj ET");
414 let docx = pdf_to_docx(&doc).unwrap();
415 let xml = read_zip_entry(&docx, "word/styles.xml").unwrap();
416
417 let mut reader = quick_xml::Reader::from_str(&xml);
418 let mut buf = Vec::new();
419 loop {
420 match reader.read_event_into(&mut buf) {
421 Ok(quick_xml::events::Event::Eof) => break,
422 Err(e) => panic!("Invalid XML in styles.xml: {e}"),
423 _ => {}
424 }
425 buf.clear();
426 }
427 }
428
429 #[test]
430 fn docx_text_preserved() {
431 let doc = make_test_pdf(b"BT /F1 12 Tf (Hello World) Tj ET");
432 let docx = pdf_to_docx(&doc).unwrap();
433 let xml = read_zip_entry(&docx, "word/document.xml").unwrap();
434
435 assert!(
436 xml.contains("Hello World"),
437 "Expected 'Hello World' in document.xml, got: {xml}"
438 );
439 }
440
441 #[test]
442 fn docx_multiline_text_preserved() {
443 let doc = make_test_pdf(b"BT /F1 12 Tf 12 TL (First line) Tj T* (Second line) Tj ET");
444 let docx = pdf_to_docx(&doc).unwrap();
445 let xml = read_zip_entry(&docx, "word/document.xml").unwrap();
446
447 assert!(xml.contains("First line"));
448 assert!(xml.contains("Second line"));
449 }
450
451 #[test]
452 fn docx_table_content_in_xml() {
453 let content = b"BT /F1 12 Tf 1 0 0 1 72 700 Tm (Name) Tj 1 0 0 1 200 700 Tm (Age) Tj 1 0 0 1 72 684 Tm (Alice) Tj 1 0 0 1 200 684 Tm (30) Tj ET";
454 let doc = make_test_pdf(content);
455 let docx = pdf_to_docx(&doc).unwrap();
456 let xml = read_zip_entry(&docx, "word/document.xml").unwrap();
457
458 assert!(xml.contains("Name"));
460 assert!(xml.contains("Alice"));
461 }
462
463 #[test]
464 fn docx_text_similarity_above_threshold() {
465 let input_text = "Hello World";
466 let doc = make_test_pdf(b"BT /F1 12 Tf (Hello World) Tj ET");
467
468 let blocks = pdf_extract::extract_text(&doc);
470 let pdf_text: String = blocks
471 .iter()
472 .map(|b| b.text.as_str())
473 .collect::<Vec<_>>()
474 .join(" ");
475
476 let docx = pdf_to_docx(&doc).unwrap();
478 let xml = read_zip_entry(&docx, "word/document.xml").unwrap();
479
480 let mut docx_texts = Vec::new();
482 let mut reader = quick_xml::Reader::from_str(&xml);
483 let mut buf = Vec::new();
484 let mut in_wt = false;
485 loop {
486 match reader.read_event_into(&mut buf) {
487 Ok(quick_xml::events::Event::Start(e)) => {
488 in_wt = e.name().as_ref() == b"w:t";
489 }
490 Ok(quick_xml::events::Event::Text(e)) if in_wt => {
491 docx_texts.push(e.unescape().unwrap().to_string());
492 }
493 Ok(quick_xml::events::Event::End(_)) => {
494 in_wt = false;
495 }
496 Ok(quick_xml::events::Event::Eof) => break,
497 Err(e) => panic!("XML parse error: {e}"),
498 _ => {}
499 }
500 buf.clear();
501 }
502 let docx_text = docx_texts.join(" ");
503
504 if pdf_text.len() >= 5 {
505 let similarity = levenshtein_similarity(&pdf_text, &docx_text);
506 assert!(
507 similarity >= 0.80,
508 "Text similarity {similarity:.2} below 0.80 threshold.\n PDF: '{pdf_text}'\n DOCX: '{docx_text}'"
509 );
510 }
511
512 assert!(
514 docx_text.contains(input_text),
515 "Expected '{input_text}' in DOCX text: '{docx_text}'"
516 );
517 }
518
519 #[test]
520 fn docx_content_types_valid() {
521 let doc = make_test_pdf(b"BT /F1 12 Tf (Content types test) Tj ET");
522 let docx = pdf_to_docx(&doc).unwrap();
523 let xml = read_zip_entry(&docx, "[Content_Types].xml").unwrap();
524
525 assert!(xml.contains("ContentType"));
526 assert!(xml.contains("wordprocessingml"));
527 }
528
529 fn make_pdf_with_image(img_dict: lopdf::Dictionary, stream_data: Vec<u8>) -> Document {
534 let mut doc = Document::with_version("1.7");
535 let img_id = doc.add_object(Object::Stream(Stream::new(img_dict, stream_data)));
536 let resources = dictionary! {
537 "XObject" => Object::Dictionary(dictionary! { "Im0" => Object::Reference(img_id) }),
538 };
539 let content_id = doc.add_object(Object::Stream(Stream::new(
540 dictionary! {},
541 b"q 100 0 0 100 0 0 cm /Im0 Do Q".to_vec(),
542 )));
543 let page_dict = dictionary! {
544 "Type" => "Page",
545 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
546 "Resources" => Object::Dictionary(resources),
547 "Contents" => Object::Reference(content_id),
548 };
549 let page_id = doc.add_object(Object::Dictionary(page_dict));
550 let pages_id = doc.add_object(Object::Dictionary(dictionary! {
551 "Type" => "Pages",
552 "Kids" => vec![Object::Reference(page_id)],
553 "Count" => 1_i64,
554 }));
555 if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
556 d.set("Parent", Object::Reference(pages_id));
557 }
558 let catalog_id = doc.add_object(Object::Dictionary(dictionary! {
559 "Type" => "Catalog",
560 "Pages" => Object::Reference(pages_id),
561 }));
562 doc.trailer.set("Root", Object::Reference(catalog_id));
563 doc
564 }
565
566 fn flate(raw: &[u8]) -> Vec<u8> {
567 let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
568 std::io::Write::write_all(&mut e, raw).unwrap();
569 e.finish().unwrap()
570 }
571
572 fn read_zip_bytes(data: &[u8], name: &str) -> Option<Vec<u8>> {
573 let cursor = std::io::Cursor::new(data);
574 let mut archive = zip::ZipArchive::new(cursor).ok()?;
575 let mut file = archive.by_name(name).ok()?;
576 let mut content = Vec::new();
577 file.read_to_end(&mut content).ok()?;
578 Some(content)
579 }
580
581 fn media_names(data: &[u8]) -> Vec<String> {
582 zip_file_names(data)
583 .into_iter()
584 .filter(|n| n.starts_with("word/media/"))
585 .collect()
586 }
587
588 #[test]
589 fn flate_rgb_image_is_embedded_as_valid_decodable_png() {
590 let img_dict = dictionary! {
593 "Type" => "XObject", "Subtype" => "Image",
594 "Width" => 4_i64, "Height" => 4_i64,
595 "BitsPerComponent" => 8_i64, "ColorSpace" => "DeviceRGB",
596 "Filter" => "FlateDecode",
597 };
598 let docx = pdf_to_docx(&make_pdf_with_image(img_dict, flate(&[123u8; 4 * 4 * 3]))).unwrap();
599
600 let media = media_names(&docx);
601 assert_eq!(media.len(), 1, "expected one embedded image, got {media:?}");
602 assert!(media[0].ends_with(".png"));
603 let bytes = read_zip_bytes(&docx, &media[0]).unwrap();
604 assert_eq!(
605 &bytes[..8],
606 b"\x89PNG\r\n\x1a\n",
607 "embedded media must be a real PNG, not raw samples"
608 );
609 let decoded = image::load_from_memory(&bytes).expect("embedded PNG must decode");
610 assert_eq!((decoded.width(), decoded.height()), (4, 4));
611
612 let ct = read_zip_entry(&docx, "[Content_Types].xml").unwrap();
613 assert!(ct.contains("Extension=\"png\""));
614 }
615
616 #[test]
617 fn jpeg_image_is_embedded_as_jpeg_passthrough() {
618 let jpeg = vec![
619 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, b'J', b'F', b'I', b'F', 0x00,
620 ];
621 let img_dict = dictionary! {
622 "Type" => "XObject", "Subtype" => "Image",
623 "Width" => 8_i64, "Height" => 8_i64,
624 "BitsPerComponent" => 8_i64, "ColorSpace" => "DeviceRGB",
625 "Filter" => "DCTDecode",
626 };
627 let docx = pdf_to_docx(&make_pdf_with_image(img_dict, jpeg.clone())).unwrap();
628
629 let media = media_names(&docx);
630 assert_eq!(media.len(), 1);
631 assert!(media[0].ends_with(".jpeg"));
632 let bytes = read_zip_bytes(&docx, &media[0]).unwrap();
633 assert_eq!(
634 &bytes[..2],
635 &[0xFF, 0xD8],
636 "jpeg media must keep its signature"
637 );
638 assert_eq!(bytes, jpeg, "DCTDecode stream must be embedded verbatim");
639 assert!(read_zip_entry(&docx, "[Content_Types].xml")
640 .unwrap()
641 .contains("image/jpeg"));
642 }
643
644 #[test]
645 fn every_image_embed_resolves_to_a_relationship() {
646 let img_dict = dictionary! {
647 "Type" => "XObject", "Subtype" => "Image",
648 "Width" => 4_i64, "Height" => 4_i64,
649 "BitsPerComponent" => 8_i64, "ColorSpace" => "DeviceRGB",
650 "Filter" => "FlateDecode",
651 };
652 let docx = pdf_to_docx(&make_pdf_with_image(img_dict, flate(&[7u8; 4 * 4 * 3]))).unwrap();
653 let document = read_zip_entry(&docx, "word/document.xml").unwrap();
654 let rels = read_zip_entry(&docx, "word/_rels/document.xml.rels").unwrap();
655
656 let needle = "r:embed=\"";
657 let mut idx = 0;
658 let mut embeds = 0;
659 while let Some(pos) = document[idx..].find(needle) {
660 let start = idx + pos + needle.len();
661 let end = start + document[start..].find('"').unwrap();
662 let id = &document[start..end];
663 assert!(
664 rels.contains(&format!("Id=\"{id}\"")),
665 "embed {id} has no matching relationship"
666 );
667 embeds += 1;
668 idx = end;
669 }
670 assert_eq!(embeds, 1, "expected exactly one image embed");
671 }
672
673 #[test]
674 fn text_only_pdf_has_no_media_and_stays_valid() {
675 let docx = pdf_to_docx(&make_test_pdf(b"BT /F1 12 Tf (No images here) Tj ET")).unwrap();
676 assert!(
677 media_names(&docx).is_empty(),
678 "text-only docx must embed no media"
679 );
680 assert_eq!(&docx[0..2], b"PK");
681 }
682}