Skip to main content

pdf_docx/
lib.rs

1#![warn(missing_docs)]
2//! PDF to DOCX conversion with text, tables, and images.
3//!
4//! Extracts text blocks, images, and spatial layout from PDF documents
5//! and produces valid OOXML (.docx) files.
6
7// Copyright (c) 2026 Innovation Trigger B.V.
8//
9// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
10// the PDFluent Commercial Licence. See the LICENSE file in this repository --
11// that file travels with the copy you received, which a URL does not.
12
13pub 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
25/// Maximum number of pages to convert to DOCX. Massive documents (e.g. 1000+
26/// pages) are rarely useful as documents and cause timeouts.
27const MAX_DOCX_PAGES: u32 = 1000;
28
29/// Convert a PDF document to DOCX format.
30///
31/// Returns the DOCX file contents as bytes.
32pub fn pdf_to_docx(doc: &Document) -> Result<Vec<u8>> {
33    pdf_to_docx_inner(doc, false)
34}
35
36/// Convert a PDF document to DOCX format, text only (no images).
37///
38/// Skips image extraction for faster conversion when only text content
39/// is needed (e.g. text-similarity tests).
40pub fn pdf_to_docx_text_only(doc: &Document) -> Result<Vec<u8>> {
41    pdf_to_docx_sequential(doc)
42}
43
44/// Convert PDF to DOCX preserving text in extraction (content-stream) order.
45///
46/// Unlike `pdf_to_docx` which sorts text spatially for visual layout,
47/// this version writes text blocks in the order they appear in the content
48/// stream. This produces a DOCX whose text content matches `extract_text`
49/// ordering, improving roundtrip similarity scores.
50fn 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        // Write blocks in extraction order as individual paragraphs
66        // (no spatial sorting, no table detection).
67        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    // Extract text blocks for all pages at once.
99    let text_blocks = extract_text(doc);
100
101    for page_num in 1..=total_pages {
102        // Get text blocks for this page.
103        let page_blocks: Vec<_> = text_blocks
104            .iter()
105            .filter(|b| b.page == page_num)
106            .cloned()
107            .collect();
108
109        // Layout analysis.
110        let mut elements = analyze_page(&page_blocks);
111
112        // Extract images for this page (unless skip_images is set).
113        if !skip_images {
114            if let Ok(images) = extract_page_images(doc, page_num) {
115                for img in images {
116                    // Re-encode raw PDF samples into a real PNG/JPEG. Skipping an
117                    // unsupported image is correct: embedding raw bytes labelled
118                    // `image/png` makes Word/PowerPoint reject the whole package.
119                    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(), // data stored in all_images
142                        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
159/// Convert a PDF file (bytes) to DOCX format.
160pub 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    /// A content stream for a grid of `rows` x `columns` cells, in which every
215    /// cell holds several separate words.
216    ///
217    /// The separate words are the whole point. A fixture whose cells are a
218    /// single block at exactly one x per column passes even when the detection
219    /// counts every x as a column -- there are then precisely as many x-values
220    /// as columns, and the test measures nothing. That is what the first
221    /// version of this test looked like, and the mutation "every x is a column"
222    /// simply survived it.
223    ///
224    /// Real tables do not look like that. A cell carries words, and each word
225    /// starts at its own x. What separates a column from a word is that the
226    /// column x recurs on several lines and the word x does not, so the word
227    /// spacing here shifts a little per row: the second and third word x-values
228    /// then do not coincide between rows.
229    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                // Three words per cell. The first sits on the column x -- that
236                // is what makes a column a column. The other two shift per row,
237                // so they can never become a second column.
238                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    /// How many columns the DOCX declares for its first table.
255    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    /// A four-column table must come back with four columns, not twenty-two.
261    ///
262    /// The old column detection took every x-position it met, so a table got
263    /// roughly one column per word. The file was valid and opened cleanly; the
264    /// layout was unusable. See #161.
265    ///
266    /// The grid is the comparison: four columns in, four out. Without putting
267    /// those two numbers side by side, "the table looks better" is an opinion.
268    #[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    /// And the content must sit in the right cell, not merely in the right count.
283    ///
284    /// A table with the right number of columns in which everything landed in
285    /// the first cell counts just as well as a table -- and is just as unusable.
286    #[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        // Every cell appears exactly once.
295        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        // And in the right order: R0C0 before R0C1, R0C3 before R1C0.
308        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"); // ZIP magic bytes
347    }
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        // Verify it parses as valid XML.
393        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        // Table or paragraph content should contain the text.
454        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        // Extract text from the source PDF via pdf-extract.
464        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        // Convert and extract text from DOCX XML.
472        let docx = pdf_to_docx(&doc).unwrap();
473        let xml = read_zip_entry(&docx, "word/document.xml").unwrap();
474
475        // Extract text content from w:t elements.
476        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        // Also check the known input text appears.
508        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    // ── embedded-image validity (the DOCX corruption regression) ───────
525
526    /// Build a one-page PDF whose single content stream draws image `Im0`,
527    /// using the given image XObject dict + (possibly compressed) stream data.
528    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        // 4x4 DeviceRGB raw samples, flate-compressed — the Canva case that
586        // previously produced an unreadable .png and made Word reject the file.
587        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}