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