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 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
26/// Maximum number of pages to convert to DOCX. Massive documents (e.g. 1000+
27/// pages) are rarely useful as documents and cause timeouts.
28const MAX_DOCX_PAGES: u32 = 1000;
29
30/// Convert a PDF document to DOCX format.
31///
32/// Returns the DOCX file contents as bytes.
33pub fn pdf_to_docx(doc: &Document) -> Result<Vec<u8>> {
34    pdf_to_docx_inner(doc, false)
35}
36
37/// Convert a PDF document to DOCX format, text only (no images).
38///
39/// Skips image extraction for faster conversion when only text content
40/// is needed (e.g. text-similarity tests).
41pub fn pdf_to_docx_text_only(doc: &Document) -> Result<Vec<u8>> {
42    pdf_to_docx_sequential(doc)
43}
44
45/// Convert PDF to DOCX preserving text in extraction (content-stream) order.
46///
47/// Unlike `pdf_to_docx` which sorts text spatially for visual layout,
48/// this version writes text blocks in the order they appear in the content
49/// stream. This produces a DOCX whose text content matches `extract_text`
50/// ordering, improving roundtrip similarity scores.
51fn 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        // Write blocks in extraction order as individual paragraphs
67        // (no spatial sorting, no table detection).
68        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    // Extract text blocks for all pages at once.
100    let text_blocks = extract_text(doc);
101
102    for page_num in 1..=total_pages {
103        // Get text blocks for this page.
104        let page_blocks: Vec<_> = text_blocks
105            .iter()
106            .filter(|b| b.page == page_num)
107            .cloned()
108            .collect();
109
110        // Layout analysis.
111        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        // Extract images for this page (unless skip_images is set).
118        if !skip_images {
119            if let Ok(images) = extract_page_images(doc, page_num) {
120                for img in images {
121                    // Re-encode raw PDF samples into a real PNG/JPEG. Skipping an
122                    // unsupported image is correct: embedding raw bytes labelled
123                    // `image/png` makes Word/PowerPoint reject the whole package.
124                    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(), // data stored in all_images
147                        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
164/// Convert a PDF file (bytes) to DOCX format.
165pub 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    /// A content stream for a grid of `rows` x `columns` cells, in which every
220    /// cell holds several separate words.
221    ///
222    /// The separate words are the whole point. A fixture whose cells are a
223    /// single block at exactly one x per column passes even when the detection
224    /// counts every x as a column -- there are then precisely as many x-values
225    /// as columns, and the test measures nothing. That is what the first
226    /// version of this test looked like, and the mutation "every x is a column"
227    /// simply survived it.
228    ///
229    /// Real tables do not look like that. A cell carries words, and each word
230    /// starts at its own x. What separates a column from a word is that the
231    /// column x recurs on several lines and the word x does not, so the word
232    /// spacing here shifts a little per row: the second and third word x-values
233    /// then do not coincide between rows.
234    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                // Three words per cell. The first sits on the column x -- that
241                // is what makes a column a column. The other two shift per row,
242                // so they can never become a second column.
243                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    /// How many columns the DOCX declares for its first table.
260    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    /// A four-column table must come back with four columns, not twenty-two.
266    ///
267    /// The old column detection took every x-position it met, so a table got
268    /// roughly one column per word. The file was valid and opened cleanly; the
269    /// layout was unusable. See #161.
270    ///
271    /// The grid is the comparison: four columns in, four out. Without putting
272    /// those two numbers side by side, "the table looks better" is an opinion.
273    #[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    /// And the content must sit in the right cell, not merely in the right count.
288    ///
289    /// A table with the right number of columns in which everything landed in
290    /// the first cell counts just as well as a table -- and is just as unusable.
291    #[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        // Every cell appears exactly once.
300        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        // And in the right order: R0C0 before R0C1, R0C3 before R1C0.
313        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"); // ZIP magic bytes
352    }
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        // Verify it parses as valid XML.
398        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        // Table or paragraph content should contain the text.
459        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        // Extract text from the source PDF via pdf-extract.
469        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        // Convert and extract text from DOCX XML.
477        let docx = pdf_to_docx(&doc).unwrap();
478        let xml = read_zip_entry(&docx, "word/document.xml").unwrap();
479
480        // Extract text content from w:t elements.
481        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        // Also check the known input text appears.
513        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    // ── embedded-image validity (the DOCX corruption regression) ───────
530
531    /// Build a one-page PDF whose single content stream draws image `Im0`,
532    /// using the given image XObject dict + (possibly compressed) stream data.
533    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        // 4x4 DeviceRGB raw samples, flate-compressed — the Canva case that
591        // previously produced an unreadable .png and made Word reject the file.
592        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}