Skip to main content

office2pdf/
lib.rs

1//! Pure-Rust conversion of Office documents (DOCX, PPTX, XLSX) to PDF.
2//!
3//! # Quick start (native only)
4//!
5//! ```no_run
6//! # #[cfg(not(target_arch = "wasm32"))]
7//! # {
8//! let result = office2pdf::convert("report.docx").unwrap();
9//! std::fs::write("report.pdf", &result.pdf).unwrap();
10//! # }
11//! ```
12//!
13//! # With options (native only)
14//!
15//! ```no_run
16//! # #[cfg(not(target_arch = "wasm32"))]
17//! # {
18//! use office2pdf::config::{ConvertOptions, PaperSize, SlideRange};
19//!
20//! let options = ConvertOptions {
21//!     paper_size: Some(PaperSize::A4),
22//!     slide_range: Some(SlideRange::new(1, 5)),
23//!     ..Default::default()
24//! };
25//! let result = office2pdf::convert_with_options("slides.pptx", &options).unwrap();
26//! std::fs::write("slides.pdf", &result.pdf).unwrap();
27//! # }
28//! ```
29//!
30//! # In-memory conversion (works on all targets including WASM)
31//!
32//! ```no_run
33//! use office2pdf::config::{ConvertOptions, Format};
34//!
35//! let docx_bytes = std::fs::read("report.docx").unwrap();
36//! let result = office2pdf::convert_bytes(&docx_bytes, Format::Docx, &ConvertOptions::default()).unwrap();
37//! std::fs::write("report.pdf", &result.pdf).unwrap();
38//! ```
39
40pub mod config;
41pub mod error;
42pub mod ir;
43pub mod parser;
44pub mod render;
45#[cfg(feature = "wasm")]
46pub mod wasm;
47
48use config::{ConvertOptions, Format};
49use error::{ConvertError, ConvertResult};
50use parser::Parser;
51
52/// Convert a file at the given path to PDF bytes with warnings.
53///
54/// Detects the format from the file extension (`.docx`, `.pptx`, `.xlsx`).
55///
56/// This function is not available on `wasm32` targets because it reads from the
57/// filesystem. Use [`convert_bytes`] for in-memory conversion on WASM.
58///
59/// # Errors
60///
61/// Returns [`ConvertError::UnsupportedFormat`] if the extension is unrecognized,
62/// [`ConvertError::Io`] if the file cannot be read, or other variants for
63/// parse/render failures.
64#[cfg(not(target_arch = "wasm32"))]
65pub fn convert(path: impl AsRef<std::path::Path>) -> Result<ConvertResult, ConvertError> {
66    convert_with_options(path, &ConvertOptions::default())
67}
68
69/// Convert a file at the given path to PDF bytes with options.
70///
71/// See [`ConvertOptions`] for available settings (paper size, sheet filter, etc.).
72///
73/// This function is not available on `wasm32` targets because it reads from the
74/// filesystem. Use [`convert_bytes`] for in-memory conversion on WASM.
75///
76/// # Errors
77///
78/// Returns [`ConvertError`] on unsupported format, I/O, parse, or render failure.
79#[cfg(not(target_arch = "wasm32"))]
80pub fn convert_with_options(
81    path: impl AsRef<std::path::Path>,
82    options: &ConvertOptions,
83) -> Result<ConvertResult, ConvertError> {
84    let path = path.as_ref();
85    let ext = path
86        .extension()
87        .and_then(|e| e.to_str())
88        .ok_or_else(|| ConvertError::UnsupportedFormat("no file extension".to_string()))?;
89
90    let format = Format::from_extension(ext)
91        .ok_or_else(|| ConvertError::UnsupportedFormat(ext.to_string()))?;
92
93    let data = std::fs::read(path)?;
94    convert_bytes(&data, format, options)
95}
96
97/// Convert raw bytes of a known format to PDF bytes with warnings.
98///
99/// Use this when you already have the file contents in memory and know the
100/// [`Format`].
101///
102/// # Errors
103///
104/// Returns [`ConvertError`] on parse or render failure.
105pub fn convert_bytes(
106    data: &[u8],
107    format: Format,
108    options: &ConvertOptions,
109) -> Result<ConvertResult, ConvertError> {
110    let parser: Box<dyn Parser> = match format {
111        Format::Docx => Box::new(parser::docx::DocxParser),
112        Format::Pptx => Box::new(parser::pptx::PptxParser),
113        Format::Xlsx => Box::new(parser::xlsx::XlsxParser),
114    };
115
116    let (doc, warnings) = parser.parse(data, options)?;
117    let output = render::typst_gen::generate_typst_with_options(&doc, options)?;
118    let pdf = render::pdf::compile_to_pdf(
119        &output.source,
120        &output.images,
121        options.pdf_standard,
122        &options.font_paths,
123    )?;
124    Ok(ConvertResult { pdf, warnings })
125}
126
127/// Render an IR Document to PDF bytes.
128///
129///// Render an IR [`Document`](ir::Document) directly to PDF bytes.
130///
131/// Takes a fully constructed [`ir::Document`] and runs it through
132/// the Typst codegen → PDF compilation pipeline.
133///
134/// # Errors
135///
136/// Returns [`ConvertError::Render`] if Typst compilation or PDF export fails.
137pub fn render_document(doc: &ir::Document) -> Result<Vec<u8>, ConvertError> {
138    let output = render::typst_gen::generate_typst(doc)?;
139    render::pdf::compile_to_pdf(&output.source, &output.images, None, &[])
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use ir::*;
146
147    /// Helper: create a minimal IR Document with a single FlowPage containing one paragraph.
148    fn make_simple_document(text: &str) -> Document {
149        Document {
150            metadata: Metadata::default(),
151            pages: vec![Page::Flow(FlowPage {
152                size: PageSize::default(),
153                margins: Margins::default(),
154                content: vec![Block::Paragraph(Paragraph {
155                    style: ParagraphStyle::default(),
156                    runs: vec![Run {
157                        text: text.to_string(),
158                        style: TextStyle::default(),
159                        href: None,
160                        footnote: None,
161                    }],
162                })],
163                header: None,
164                footer: None,
165            })],
166            styles: StyleSheet::default(),
167        }
168    }
169
170    // --- Format detection tests ---
171
172    #[test]
173    fn test_convert_unsupported_format() {
174        let result = convert("test.txt");
175        assert!(result.is_err());
176        let err = result.unwrap_err();
177        assert!(matches!(err, ConvertError::UnsupportedFormat(_)));
178    }
179
180    #[test]
181    fn test_convert_no_extension() {
182        let result = convert("test");
183        assert!(result.is_err());
184        assert!(matches!(
185            result.unwrap_err(),
186            ConvertError::UnsupportedFormat(_)
187        ));
188    }
189
190    #[test]
191    fn test_format_detection_all_supported_extensions() {
192        // Tested via config.rs, but verify pipeline dispatches correctly
193        assert!(convert_bytes(b"fake", Format::Docx, &ConvertOptions::default()).is_err());
194        assert!(convert_bytes(b"fake", Format::Pptx, &ConvertOptions::default()).is_err());
195        assert!(convert_bytes(b"fake", Format::Xlsx, &ConvertOptions::default()).is_err());
196    }
197
198    #[test]
199    fn test_convert_bytes_propagates_parse_error() {
200        // All stub parsers should return Parse errors
201        for format in [Format::Docx, Format::Pptx, Format::Xlsx] {
202            let result = convert_bytes(b"fake", format, &ConvertOptions::default());
203            assert!(result.is_err());
204            assert!(
205                matches!(result.unwrap_err(), ConvertError::Parse(_)),
206                "Expected Parse error for {format:?}"
207            );
208        }
209    }
210
211    #[test]
212    fn test_convert_nonexistent_file_returns_io_error() {
213        let result = convert("nonexistent_file.docx");
214        assert!(result.is_err());
215        assert!(matches!(result.unwrap_err(), ConvertError::Io(_)));
216    }
217
218    // --- Pipeline integration tests with mock IR documents ---
219
220    #[test]
221    fn test_render_document_empty_document() {
222        let doc = Document {
223            metadata: Metadata::default(),
224            pages: vec![],
225            styles: StyleSheet::default(),
226        };
227        let pdf = render_document(&doc).unwrap();
228        assert!(!pdf.is_empty(), "PDF bytes should not be empty");
229        assert!(pdf.starts_with(b"%PDF"), "Should be valid PDF");
230    }
231
232    #[test]
233    fn test_render_document_single_paragraph() {
234        let doc = make_simple_document("Hello, World!");
235        let pdf = render_document(&doc).unwrap();
236        assert!(!pdf.is_empty());
237        assert!(pdf.starts_with(b"%PDF"));
238    }
239
240    #[test]
241    fn test_render_document_styled_text() {
242        let doc = Document {
243            metadata: Metadata::default(),
244            pages: vec![Page::Flow(FlowPage {
245                size: PageSize::default(),
246                margins: Margins::default(),
247                content: vec![Block::Paragraph(Paragraph {
248                    style: ParagraphStyle {
249                        alignment: Some(Alignment::Center),
250                        ..ParagraphStyle::default()
251                    },
252                    runs: vec![
253                        Run {
254                            text: "Bold text ".to_string(),
255                            style: TextStyle {
256                                bold: Some(true),
257                                font_size: Some(16.0),
258                                ..TextStyle::default()
259                            },
260                            href: None,
261                            footnote: None,
262                        },
263                        Run {
264                            text: "and italic".to_string(),
265                            style: TextStyle {
266                                italic: Some(true),
267                                color: Some(Color::new(255, 0, 0)),
268                                ..TextStyle::default()
269                            },
270                            href: None,
271                            footnote: None,
272                        },
273                    ],
274                })],
275                header: None,
276                footer: None,
277            })],
278            styles: StyleSheet::default(),
279        };
280        let pdf = render_document(&doc).unwrap();
281        assert!(!pdf.is_empty());
282        assert!(pdf.starts_with(b"%PDF"));
283    }
284
285    #[test]
286    fn test_render_document_multiple_flow_pages() {
287        let doc = Document {
288            metadata: Metadata::default(),
289            pages: vec![
290                Page::Flow(FlowPage {
291                    size: PageSize::default(),
292                    margins: Margins::default(),
293                    content: vec![Block::Paragraph(Paragraph {
294                        style: ParagraphStyle::default(),
295                        runs: vec![Run {
296                            text: "Page 1".to_string(),
297                            style: TextStyle::default(),
298                            href: None,
299                            footnote: None,
300                        }],
301                    })],
302                    header: None,
303                    footer: None,
304                }),
305                Page::Flow(FlowPage {
306                    size: PageSize::default(),
307                    margins: Margins::default(),
308                    content: vec![Block::Paragraph(Paragraph {
309                        style: ParagraphStyle::default(),
310                        runs: vec![Run {
311                            text: "Page 2".to_string(),
312                            style: TextStyle::default(),
313                            href: None,
314                            footnote: None,
315                        }],
316                    })],
317                    header: None,
318                    footer: None,
319                }),
320            ],
321            styles: StyleSheet::default(),
322        };
323        let pdf = render_document(&doc).unwrap();
324        assert!(!pdf.is_empty());
325        assert!(pdf.starts_with(b"%PDF"));
326    }
327
328    #[test]
329    fn test_render_document_page_break() {
330        let doc = Document {
331            metadata: Metadata::default(),
332            pages: vec![Page::Flow(FlowPage {
333                size: PageSize::default(),
334                margins: Margins::default(),
335                content: vec![
336                    Block::Paragraph(Paragraph {
337                        style: ParagraphStyle::default(),
338                        runs: vec![Run {
339                            text: "Before break".to_string(),
340                            style: TextStyle::default(),
341                            href: None,
342                            footnote: None,
343                        }],
344                    }),
345                    Block::PageBreak,
346                    Block::Paragraph(Paragraph {
347                        style: ParagraphStyle::default(),
348                        runs: vec![Run {
349                            text: "After break".to_string(),
350                            style: TextStyle::default(),
351                            href: None,
352                            footnote: None,
353                        }],
354                    }),
355                ],
356                header: None,
357                footer: None,
358            })],
359            styles: StyleSheet::default(),
360        };
361        let pdf = render_document(&doc).unwrap();
362        assert!(!pdf.is_empty());
363        assert!(pdf.starts_with(b"%PDF"));
364    }
365
366    #[test]
367    fn test_convert_with_options_delegates_to_convert_bytes() {
368        // convert_with_options on a nonexistent file should produce an IO error,
369        // confirming it reads the file before calling convert_bytes
370        let result = convert_with_options("nonexistent.docx", &ConvertOptions::default());
371        assert!(matches!(result.unwrap_err(), ConvertError::Io(_)));
372    }
373
374    #[test]
375    fn test_convert_delegates_to_convert_with_options() {
376        // convert("nonexistent.docx") should behave same as convert_with_options
377        let result = convert("nonexistent.docx");
378        assert!(matches!(result.unwrap_err(), ConvertError::Io(_)));
379    }
380
381    // --- Image pipeline integration tests ---
382
383    /// Build a minimal valid 1×1 red PNG with correct CRC checksums.
384    fn make_test_png() -> Vec<u8> {
385        /// Compute CRC32 over PNG chunk type + data.
386        fn png_crc32(chunk_type: &[u8], data: &[u8]) -> u32 {
387            let mut crc: u32 = 0xFFFF_FFFF;
388            for &byte in chunk_type.iter().chain(data.iter()) {
389                crc ^= byte as u32;
390                for _ in 0..8 {
391                    if crc & 1 != 0 {
392                        crc = (crc >> 1) ^ 0xEDB8_8320;
393                    } else {
394                        crc >>= 1;
395                    }
396                }
397            }
398            crc ^ 0xFFFF_FFFF
399        }
400
401        let mut png = Vec::new();
402        png.extend_from_slice(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
403        let ihdr_data: [u8; 13] = [
404            0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00,
405        ];
406        let ihdr_type = b"IHDR";
407        png.extend_from_slice(&(ihdr_data.len() as u32).to_be_bytes());
408        png.extend_from_slice(ihdr_type);
409        png.extend_from_slice(&ihdr_data);
410        png.extend_from_slice(&png_crc32(ihdr_type, &ihdr_data).to_be_bytes());
411        let idat_data: [u8; 15] = [
412            0x78, 0x01, 0x01, 0x04, 0x00, 0xFB, 0xFF, 0x00, 0xFF, 0x00, 0x00, 0x03, 0x01, 0x01,
413            0x00,
414        ];
415        let idat_type = b"IDAT";
416        png.extend_from_slice(&(idat_data.len() as u32).to_be_bytes());
417        png.extend_from_slice(idat_type);
418        png.extend_from_slice(&idat_data);
419        png.extend_from_slice(&png_crc32(idat_type, &idat_data).to_be_bytes());
420        let iend_type = b"IEND";
421        png.extend_from_slice(&0u32.to_be_bytes());
422        png.extend_from_slice(iend_type);
423        png.extend_from_slice(&png_crc32(iend_type, &[]).to_be_bytes());
424        png
425    }
426
427    #[test]
428    fn test_render_document_with_image() {
429        let doc = Document {
430            metadata: Metadata::default(),
431            pages: vec![Page::Flow(FlowPage {
432                size: PageSize::default(),
433                margins: Margins::default(),
434                content: vec![Block::Image(ImageData {
435                    data: make_test_png(),
436                    format: ImageFormat::Png,
437                    width: Some(100.0),
438                    height: Some(80.0),
439                })],
440                header: None,
441                footer: None,
442            })],
443            styles: StyleSheet::default(),
444        };
445        let pdf = render_document(&doc).unwrap();
446        assert!(!pdf.is_empty(), "PDF should not be empty");
447        assert!(pdf.starts_with(b"%PDF"), "Should be valid PDF");
448    }
449
450    #[test]
451    fn test_render_document_image_mixed_with_text() {
452        let doc = Document {
453            metadata: Metadata::default(),
454            pages: vec![Page::Flow(FlowPage {
455                size: PageSize::default(),
456                margins: Margins::default(),
457                content: vec![
458                    Block::Paragraph(Paragraph {
459                        style: ParagraphStyle::default(),
460                        runs: vec![Run {
461                            text: "Image below:".to_string(),
462                            style: TextStyle::default(),
463                            href: None,
464                            footnote: None,
465                        }],
466                    }),
467                    Block::Image(ImageData {
468                        data: make_test_png(),
469                        format: ImageFormat::Png,
470                        width: Some(200.0),
471                        height: None,
472                    }),
473                    Block::Paragraph(Paragraph {
474                        style: ParagraphStyle::default(),
475                        runs: vec![Run {
476                            text: "Image above.".to_string(),
477                            style: TextStyle::default(),
478                            href: None,
479                            footnote: None,
480                        }],
481                    }),
482                ],
483                header: None,
484                footer: None,
485            })],
486            styles: StyleSheet::default(),
487        };
488        let pdf = render_document(&doc).unwrap();
489        assert!(!pdf.is_empty());
490        assert!(pdf.starts_with(b"%PDF"));
491    }
492
493    // --- End-to-end integration tests: raw document bytes → PDF ---
494
495    /// Build a minimal DOCX as bytes using docx-rs builder.
496    fn build_test_docx() -> Vec<u8> {
497        use std::io::Cursor;
498        let docx = docx_rs::Docx::new()
499            .add_paragraph(
500                docx_rs::Paragraph::new().add_run(docx_rs::Run::new().add_text("Hello from DOCX")),
501            )
502            .add_paragraph(
503                docx_rs::Paragraph::new()
504                    .add_run(docx_rs::Run::new().add_text("Second paragraph").bold()),
505            );
506        let mut cursor = Cursor::new(Vec::new());
507        docx.build().pack(&mut cursor).unwrap();
508        cursor.into_inner()
509    }
510
511    /// Build a minimal XLSX as bytes using umya-spreadsheet.
512    fn build_test_xlsx() -> Vec<u8> {
513        use std::io::Cursor;
514        let mut book = umya_spreadsheet::new_file();
515        {
516            let sheet = book.get_sheet_mut(&0).unwrap();
517            sheet.get_cell_mut("A1").set_value("Name");
518            sheet.get_cell_mut("B1").set_value("Value");
519            sheet.get_cell_mut("A2").set_value("Item 1");
520            sheet.get_cell_mut("B2").set_value("100");
521        }
522        let mut cursor = Cursor::new(Vec::new());
523        umya_spreadsheet::writer::xlsx::write_writer(&book, &mut cursor).unwrap();
524        cursor.into_inner()
525    }
526
527    /// Build a minimal PPTX as bytes using zip + raw XML.
528    fn build_test_pptx() -> Vec<u8> {
529        use std::io::{Cursor, Write};
530        let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
531        let opts = zip::write::FileOptions::default();
532
533        // [Content_Types].xml
534        zip.start_file("[Content_Types].xml", opts).unwrap();
535        zip.write_all(
536            br#"<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/></Types>"#,
537        ).unwrap();
538
539        // _rels/.rels
540        zip.start_file("_rels/.rels", opts).unwrap();
541        zip.write_all(
542            br#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/></Relationships>"#,
543        ).unwrap();
544
545        // ppt/presentation.xml
546        zip.start_file("ppt/presentation.xml", opts).unwrap();
547        zip.write_all(
548            br#"<?xml version="1.0" encoding="UTF-8"?><p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:sldSz cx="9144000" cy="6858000"/><p:sldIdLst><p:sldId id="256" r:id="rId2"/></p:sldIdLst></p:presentation>"#,
549        ).unwrap();
550
551        // ppt/_rels/presentation.xml.rels
552        zip.start_file("ppt/_rels/presentation.xml.rels", opts)
553            .unwrap();
554        zip.write_all(
555            br#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/></Relationships>"#,
556        ).unwrap();
557
558        // ppt/slides/slide1.xml — one text box with "Hello from PPTX"
559        zip.start_file("ppt/slides/slide1.xml", opts).unwrap();
560        zip.write_all(
561            br#"<?xml version="1.0" encoding="UTF-8"?><p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/><p:sp><p:nvSpPr><p:cNvPr id="2" name="TextBox 1"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="457200" y="274638"/><a:ext cx="8229600" cy="1143000"/></a:xfrm></p:spPr><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>Hello from PPTX</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld></p:sld>"#,
562        ).unwrap();
563
564        // ppt/slides/_rels/slide1.xml.rels (empty rels)
565        zip.start_file("ppt/slides/_rels/slide1.xml.rels", opts)
566            .unwrap();
567        zip.write_all(
568            br#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>"#,
569        ).unwrap();
570
571        zip.finish().unwrap().into_inner()
572    }
573
574    #[test]
575    fn test_e2e_docx_to_pdf() {
576        let docx_bytes = build_test_docx();
577        let result = convert_bytes(&docx_bytes, Format::Docx, &ConvertOptions::default()).unwrap();
578        assert!(
579            !result.pdf.is_empty(),
580            "DOCX→PDF should produce non-empty output"
581        );
582        assert!(
583            result.pdf.starts_with(b"%PDF"),
584            "Output should be valid PDF"
585        );
586        assert!(
587            result.warnings.is_empty(),
588            "Normal DOCX should produce no warnings"
589        );
590    }
591
592    #[test]
593    fn test_e2e_xlsx_to_pdf() {
594        let xlsx_bytes = build_test_xlsx();
595        let result = convert_bytes(&xlsx_bytes, Format::Xlsx, &ConvertOptions::default()).unwrap();
596        assert!(
597            !result.pdf.is_empty(),
598            "XLSX→PDF should produce non-empty output"
599        );
600        assert!(
601            result.pdf.starts_with(b"%PDF"),
602            "Output should be valid PDF"
603        );
604    }
605
606    #[test]
607    fn test_e2e_pptx_to_pdf() {
608        let pptx_bytes = build_test_pptx();
609        let result = convert_bytes(&pptx_bytes, Format::Pptx, &ConvertOptions::default()).unwrap();
610        assert!(
611            !result.pdf.is_empty(),
612            "PPTX→PDF should produce non-empty output"
613        );
614        assert!(
615            result.pdf.starts_with(b"%PDF"),
616            "Output should be valid PDF"
617        );
618    }
619
620    #[test]
621    fn test_e2e_docx_with_table_to_pdf() {
622        use std::io::Cursor;
623        let table = docx_rs::Table::new(vec![docx_rs::TableRow::new(vec![
624            docx_rs::TableCell::new().add_paragraph(
625                docx_rs::Paragraph::new().add_run(docx_rs::Run::new().add_text("Cell A")),
626            ),
627            docx_rs::TableCell::new().add_paragraph(
628                docx_rs::Paragraph::new().add_run(docx_rs::Run::new().add_text("Cell B")),
629            ),
630        ])]);
631        let docx = docx_rs::Docx::new()
632            .add_paragraph(
633                docx_rs::Paragraph::new().add_run(docx_rs::Run::new().add_text("Table below:")),
634            )
635            .add_table(table);
636        let mut cursor = Cursor::new(Vec::new());
637        docx.build().pack(&mut cursor).unwrap();
638        let data = cursor.into_inner();
639
640        let result = convert_bytes(&data, Format::Docx, &ConvertOptions::default()).unwrap();
641        assert!(!result.pdf.is_empty());
642        assert!(result.pdf.starts_with(b"%PDF"));
643    }
644
645    #[test]
646    fn test_e2e_convert_with_options_from_temp_file() {
647        let docx_bytes = build_test_docx();
648        let dir = std::env::temp_dir();
649        let input = dir.join("office2pdf_test_input.docx");
650        let output = dir.join("office2pdf_test_output.pdf");
651        std::fs::write(&input, &docx_bytes).unwrap();
652
653        let result = convert(&input).unwrap();
654        assert!(!result.pdf.is_empty());
655        assert!(result.pdf.starts_with(b"%PDF"));
656
657        // Also test convert_with_options with the file path
658        let result2 = convert_with_options(&input, &ConvertOptions::default()).unwrap();
659        assert!(!result2.pdf.is_empty());
660        assert!(result2.pdf.starts_with(b"%PDF"));
661
662        // Write PDF to output and verify file exists
663        std::fs::write(&output, &result.pdf).unwrap();
664        assert!(output.exists());
665        let written = std::fs::read(&output).unwrap();
666        assert!(written.starts_with(b"%PDF"));
667
668        // Cleanup
669        let _ = std::fs::remove_file(&input);
670        let _ = std::fs::remove_file(&output);
671    }
672
673    #[test]
674    fn test_e2e_unsupported_format_error_message() {
675        let result = convert("document.odt");
676        let err = result.unwrap_err();
677        match err {
678            ConvertError::UnsupportedFormat(ref ext) => {
679                assert_eq!(ext, "odt", "Error should mention the unsupported extension");
680            }
681            _ => panic!("Expected UnsupportedFormat error, got {err:?}"),
682        }
683    }
684
685    #[test]
686    fn test_e2e_missing_file_error() {
687        let result = convert("nonexistent_document.docx");
688        assert!(
689            matches!(result.unwrap_err(), ConvertError::Io(_)),
690            "Missing file should produce IO error"
691        );
692    }
693
694    // --- US-018: Font fallback - system font discovery tests ---
695
696    #[test]
697    fn test_render_document_with_system_font_in_ir() {
698        // A Document with a system font name (e.g., "Arial") in the IR should
699        // compile to valid PDF. With system font discovery enabled, the font
700        // is used if available; otherwise Typst falls back to embedded fonts.
701        let doc = Document {
702            metadata: Metadata::default(),
703            pages: vec![Page::Flow(FlowPage {
704                size: PageSize::default(),
705                margins: Margins::default(),
706                content: vec![Block::Paragraph(Paragraph {
707                    style: ParagraphStyle::default(),
708                    runs: vec![Run {
709                        text: "Hello with system font".to_string(),
710                        style: TextStyle {
711                            font_family: Some("Arial".to_string()),
712                            ..TextStyle::default()
713                        },
714                        href: None,
715                        footnote: None,
716                    }],
717                })],
718                header: None,
719                footer: None,
720            })],
721            styles: StyleSheet::default(),
722        };
723        let pdf = render_document(&doc).unwrap();
724        assert!(!pdf.is_empty());
725        assert!(pdf.starts_with(b"%PDF"));
726    }
727
728    #[test]
729    fn test_render_document_with_multiple_font_families() {
730        // Different runs can specify different system fonts — all should compile
731        let doc = Document {
732            metadata: Metadata::default(),
733            pages: vec![Page::Flow(FlowPage {
734                size: PageSize::default(),
735                margins: Margins::default(),
736                content: vec![Block::Paragraph(Paragraph {
737                    style: ParagraphStyle::default(),
738                    runs: vec![
739                        Run {
740                            text: "Calibri text ".to_string(),
741                            style: TextStyle {
742                                font_family: Some("Calibri".to_string()),
743                                ..TextStyle::default()
744                            },
745                            href: None,
746                            footnote: None,
747                        },
748                        Run {
749                            text: "and Times New Roman text".to_string(),
750                            style: TextStyle {
751                                font_family: Some("Times New Roman".to_string()),
752                                ..TextStyle::default()
753                            },
754                            href: None,
755                            footnote: None,
756                        },
757                    ],
758                })],
759                header: None,
760                footer: None,
761            })],
762            styles: StyleSheet::default(),
763        };
764        let pdf = render_document(&doc).unwrap();
765        assert!(!pdf.is_empty());
766        assert!(pdf.starts_with(b"%PDF"));
767    }
768
769    // --- US-017: Enhanced error handling tests ---
770
771    /// Build a PPTX with two slides: one valid, one with broken XML.
772    /// The parser should skip the broken slide with a warning and still produce a PDF.
773    fn build_pptx_with_broken_slide() -> Vec<u8> {
774        use std::io::{Cursor, Write};
775        let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
776        let opts = zip::write::FileOptions::default();
777
778        zip.start_file("[Content_Types].xml", opts).unwrap();
779        zip.write_all(
780            br#"<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/><Override PartName="/ppt/slides/slide2.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/></Types>"#,
781        ).unwrap();
782
783        zip.start_file("_rels/.rels", opts).unwrap();
784        zip.write_all(
785            br#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/></Relationships>"#,
786        ).unwrap();
787
788        // Two slides referenced
789        zip.start_file("ppt/presentation.xml", opts).unwrap();
790        zip.write_all(
791            br#"<?xml version="1.0" encoding="UTF-8"?><p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:sldSz cx="9144000" cy="6858000"/><p:sldIdLst><p:sldId id="256" r:id="rId2"/><p:sldId id="257" r:id="rId3"/></p:sldIdLst></p:presentation>"#,
792        ).unwrap();
793
794        zip.start_file("ppt/_rels/presentation.xml.rels", opts)
795            .unwrap();
796        zip.write_all(
797            br#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide2.xml"/></Relationships>"#,
798        ).unwrap();
799
800        // Slide 1: valid
801        zip.start_file("ppt/slides/slide1.xml", opts).unwrap();
802        zip.write_all(
803            br#"<?xml version="1.0" encoding="UTF-8"?><p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/><p:sp><p:nvSpPr><p:cNvPr id="2" name="TextBox 1"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="457200" y="274638"/><a:ext cx="8229600" cy="1143000"/></a:xfrm></p:spPr><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>Valid slide content</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld></p:sld>"#,
804        ).unwrap();
805
806        zip.start_file("ppt/slides/_rels/slide1.xml.rels", opts)
807            .unwrap();
808        zip.write_all(
809            br#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>"#,
810        ).unwrap();
811
812        // Slide 2: intentionally missing from the ZIP archive.
813        // The presentation.xml references it via rId3, but no slide2.xml exists.
814        // This should trigger a warning (missing slide file), not a fatal error.
815
816        zip.finish().unwrap().into_inner()
817    }
818
819    #[test]
820    fn test_convert_result_has_pdf_and_warnings() {
821        let docx_bytes = build_test_docx();
822        let result = convert_bytes(&docx_bytes, Format::Docx, &ConvertOptions::default()).unwrap();
823        // ConvertResult has both pdf and warnings fields
824        assert!(result.pdf.starts_with(b"%PDF"));
825        let _warnings: &Vec<error::ConvertWarning> = &result.warnings;
826    }
827
828    #[test]
829    fn test_pptx_broken_slide_emits_warning_and_produces_pdf() {
830        let pptx_bytes = build_pptx_with_broken_slide();
831        let result = convert_bytes(&pptx_bytes, Format::Pptx, &ConvertOptions::default()).unwrap();
832
833        // Should still produce a valid PDF (from the good slide)
834        assert!(
835            !result.pdf.is_empty(),
836            "Should produce PDF despite broken slide"
837        );
838        assert!(
839            result.pdf.starts_with(b"%PDF"),
840            "Output should be valid PDF"
841        );
842
843        // Should have at least one warning about the broken slide
844        assert!(
845            !result.warnings.is_empty(),
846            "Should emit warning for broken slide"
847        );
848        // Verify the warning mentions the broken element
849        let warning_text = result.warnings[0].to_string();
850        assert!(
851            warning_text.contains("slide") || warning_text.contains("Slide"),
852            "Warning should mention the problematic slide: {warning_text}"
853        );
854    }
855
856    #[test]
857    fn test_render_document_with_list() {
858        use crate::ir::{
859            Document, FlowPage, List, ListItem, ListKind, Margins, Metadata, Page, PageSize,
860            Paragraph, ParagraphStyle, Run, StyleSheet, TextStyle,
861        };
862        let doc = Document {
863            metadata: Metadata::default(),
864            pages: vec![Page::Flow(FlowPage {
865                size: PageSize::default(),
866                margins: Margins::default(),
867                content: vec![ir::Block::List(List {
868                    kind: ListKind::Unordered,
869                    items: vec![
870                        ListItem {
871                            content: vec![Paragraph {
872                                style: ParagraphStyle::default(),
873                                runs: vec![Run {
874                                    text: "Hello".to_string(),
875                                    style: TextStyle::default(),
876                                    href: None,
877                                    footnote: None,
878                                }],
879                            }],
880                            level: 0,
881                        },
882                        ListItem {
883                            content: vec![Paragraph {
884                                style: ParagraphStyle::default(),
885                                runs: vec![Run {
886                                    text: "World".to_string(),
887                                    style: TextStyle::default(),
888                                    href: None,
889                                    footnote: None,
890                                }],
891                            }],
892                            level: 0,
893                        },
894                    ],
895                })],
896                header: None,
897                footer: None,
898            })],
899            styles: StyleSheet::default(),
900        };
901        let pdf = render_document(&doc).unwrap();
902        assert!(
903            pdf.starts_with(b"%PDF"),
904            "Should produce valid PDF with list"
905        );
906    }
907
908    #[test]
909    fn test_e2e_docx_with_list_produces_pdf() {
910        use std::io::Cursor;
911        // Build a DOCX with a bulleted list and verify it converts to PDF
912        let abstract_num = docx_rs::AbstractNumbering::new(0).add_level(docx_rs::Level::new(
913            0,
914            docx_rs::Start::new(1),
915            docx_rs::NumberFormat::new("bullet"),
916            docx_rs::LevelText::new("•"),
917            docx_rs::LevelJc::new("left"),
918        ));
919        let numbering = docx_rs::Numbering::new(1, 0);
920        let nums = docx_rs::Numberings::new()
921            .add_abstract_numbering(abstract_num)
922            .add_numbering(numbering);
923
924        let docx = docx_rs::Docx::new()
925            .numberings(nums)
926            .add_paragraph(
927                docx_rs::Paragraph::new()
928                    .add_run(docx_rs::Run::new().add_text("Bullet 1"))
929                    .numbering(docx_rs::NumberingId::new(1), docx_rs::IndentLevel::new(0)),
930            )
931            .add_paragraph(
932                docx_rs::Paragraph::new()
933                    .add_run(docx_rs::Run::new().add_text("Bullet 2"))
934                    .numbering(docx_rs::NumberingId::new(1), docx_rs::IndentLevel::new(0)),
935            )
936            .add_paragraph(
937                docx_rs::Paragraph::new().add_run(docx_rs::Run::new().add_text("Regular text")),
938            );
939
940        let mut cursor = Cursor::new(Vec::new());
941        docx.build().pack(&mut cursor).unwrap();
942        let data = cursor.into_inner();
943
944        let result = convert_bytes(&data, Format::Docx, &ConvertOptions::default()).unwrap();
945        assert!(
946            result.pdf.starts_with(b"%PDF"),
947            "Should produce valid PDF with list content"
948        );
949    }
950
951    #[test]
952    fn test_normal_docx_has_no_warnings() {
953        let docx_bytes = build_test_docx();
954        let result = convert_bytes(&docx_bytes, Format::Docx, &ConvertOptions::default()).unwrap();
955        assert!(
956            result.warnings.is_empty(),
957            "Normal DOCX should produce no warnings"
958        );
959    }
960
961    // --- US-020: Header/footer integration tests ---
962
963    #[test]
964    fn test_render_document_with_header() {
965        let doc = Document {
966            metadata: Metadata::default(),
967            pages: vec![Page::Flow(FlowPage {
968                size: PageSize::default(),
969                margins: Margins::default(),
970                content: vec![Block::Paragraph(Paragraph {
971                    style: ParagraphStyle::default(),
972                    runs: vec![Run {
973                        text: "Body content".to_string(),
974                        style: TextStyle::default(),
975                        href: None,
976                        footnote: None,
977                    }],
978                })],
979                header: Some(ir::HeaderFooter {
980                    paragraphs: vec![ir::HeaderFooterParagraph {
981                        style: ParagraphStyle::default(),
982                        elements: vec![ir::HFInline::Run(Run {
983                            text: "My Header".to_string(),
984                            style: TextStyle::default(),
985                            href: None,
986                            footnote: None,
987                        })],
988                    }],
989                }),
990                footer: None,
991            })],
992            styles: StyleSheet::default(),
993        };
994        let pdf = render_document(&doc).unwrap();
995        assert!(!pdf.is_empty());
996        assert!(pdf.starts_with(b"%PDF"));
997    }
998
999    #[test]
1000    fn test_render_document_with_page_number_footer() {
1001        let doc = Document {
1002            metadata: Metadata::default(),
1003            pages: vec![Page::Flow(FlowPage {
1004                size: PageSize::default(),
1005                margins: Margins::default(),
1006                content: vec![Block::Paragraph(Paragraph {
1007                    style: ParagraphStyle::default(),
1008                    runs: vec![Run {
1009                        text: "Body content".to_string(),
1010                        style: TextStyle::default(),
1011                        href: None,
1012                        footnote: None,
1013                    }],
1014                })],
1015                header: None,
1016                footer: Some(ir::HeaderFooter {
1017                    paragraphs: vec![ir::HeaderFooterParagraph {
1018                        style: ParagraphStyle::default(),
1019                        elements: vec![
1020                            ir::HFInline::Run(Run {
1021                                text: "Page ".to_string(),
1022                                style: TextStyle::default(),
1023                                href: None,
1024                                footnote: None,
1025                            }),
1026                            ir::HFInline::PageNumber,
1027                        ],
1028                    }],
1029                }),
1030            })],
1031            styles: StyleSheet::default(),
1032        };
1033        let pdf = render_document(&doc).unwrap();
1034        assert!(!pdf.is_empty());
1035        assert!(pdf.starts_with(b"%PDF"));
1036    }
1037
1038    #[test]
1039    fn test_e2e_docx_with_header_footer_to_pdf() {
1040        use std::io::Cursor;
1041        let header = docx_rs::Header::new().add_paragraph(
1042            docx_rs::Paragraph::new().add_run(docx_rs::Run::new().add_text("Document Title")),
1043        );
1044        let footer = docx_rs::Footer::new().add_paragraph(
1045            docx_rs::Paragraph::new().add_run(
1046                docx_rs::Run::new()
1047                    .add_text("Page ")
1048                    .add_field_char(docx_rs::FieldCharType::Begin, false)
1049                    .add_instr_text(docx_rs::InstrText::PAGE(docx_rs::InstrPAGE::new()))
1050                    .add_field_char(docx_rs::FieldCharType::Separate, false)
1051                    .add_text("1")
1052                    .add_field_char(docx_rs::FieldCharType::End, false),
1053            ),
1054        );
1055        let docx = docx_rs::Docx::new()
1056            .header(header)
1057            .footer(footer)
1058            .add_paragraph(
1059                docx_rs::Paragraph::new().add_run(docx_rs::Run::new().add_text("Body paragraph")),
1060            );
1061        let mut cursor = Cursor::new(Vec::new());
1062        docx.build().pack(&mut cursor).unwrap();
1063        let data = cursor.into_inner();
1064
1065        let result = convert_bytes(&data, Format::Docx, &ConvertOptions::default()).unwrap();
1066        assert!(
1067            result.pdf.starts_with(b"%PDF"),
1068            "DOCX with header/footer should produce valid PDF"
1069        );
1070    }
1071
1072    // --- US-021: Page orientation (landscape/portrait) tests ---
1073
1074    #[test]
1075    fn test_render_document_with_landscape_page() {
1076        // A landscape FlowPage should render to valid PDF
1077        let doc = Document {
1078            metadata: Metadata::default(),
1079            pages: vec![Page::Flow(FlowPage {
1080                size: PageSize {
1081                    width: 841.9, // A4 landscape
1082                    height: 595.3,
1083                },
1084                margins: Margins::default(),
1085                content: vec![Block::Paragraph(Paragraph {
1086                    runs: vec![Run {
1087                        text: "Landscape page".to_string(),
1088                        style: TextStyle::default(),
1089                        href: None,
1090                        footnote: None,
1091                    }],
1092                    style: ParagraphStyle::default(),
1093                })],
1094                header: None,
1095                footer: None,
1096            })],
1097            styles: StyleSheet::default(),
1098        };
1099        let pdf = render_document(&doc).unwrap();
1100        assert!(
1101            !pdf.is_empty(),
1102            "Landscape FlowPage should produce non-empty PDF"
1103        );
1104        assert!(pdf.starts_with(b"%PDF"), "Should produce valid PDF");
1105    }
1106
1107    #[test]
1108    fn test_e2e_landscape_docx_to_pdf() {
1109        use std::io::Cursor;
1110        // Build a landscape DOCX with swapped dimensions
1111        let docx = docx_rs::Docx::new()
1112            .page_size(16838, 11906)
1113            .page_orient(docx_rs::PageOrientationType::Landscape)
1114            .page_margin(
1115                docx_rs::PageMargin::new()
1116                    .top(1440)
1117                    .bottom(1440)
1118                    .left(1440)
1119                    .right(1440),
1120            )
1121            .add_paragraph(
1122                docx_rs::Paragraph::new()
1123                    .add_run(docx_rs::Run::new().add_text("Landscape document")),
1124            );
1125        let mut cursor = Cursor::new(Vec::new());
1126        docx.build().pack(&mut cursor).unwrap();
1127        let data = cursor.into_inner();
1128
1129        let result = convert_bytes(&data, Format::Docx, &ConvertOptions::default()).unwrap();
1130        assert!(
1131            result.pdf.starts_with(b"%PDF"),
1132            "Landscape DOCX should produce valid PDF"
1133        );
1134    }
1135
1136    #[test]
1137    fn test_docx_toc_pipeline_produces_pdf() {
1138        use std::io::Cursor;
1139        let toc = docx_rs::TableOfContents::new()
1140            .heading_styles_range(1, 3)
1141            .alias("Table of contents")
1142            .add_item(
1143                docx_rs::TableOfContentsItem::new()
1144                    .text("Chapter 1")
1145                    .toc_key("_Toc00000001")
1146                    .level(1)
1147                    .page_ref("2"),
1148            )
1149            .add_item(
1150                docx_rs::TableOfContentsItem::new()
1151                    .text("Chapter 2")
1152                    .toc_key("_Toc00000002")
1153                    .level(1)
1154                    .page_ref("5"),
1155            );
1156
1157        let docx = docx_rs::Docx::new()
1158            .add_style(
1159                docx_rs::Style::new("Heading1", docx_rs::StyleType::Paragraph).name("Heading 1"),
1160            )
1161            .add_table_of_contents(toc)
1162            .add_paragraph(
1163                docx_rs::Paragraph::new()
1164                    .add_run(docx_rs::Run::new().add_text("Chapter 1"))
1165                    .style("Heading1"),
1166            )
1167            .add_paragraph(
1168                docx_rs::Paragraph::new().add_run(docx_rs::Run::new().add_text("Some body text")),
1169            );
1170
1171        let mut cursor = Cursor::new(Vec::new());
1172        docx.build().pack(&mut cursor).unwrap();
1173        let data = cursor.into_inner();
1174
1175        let result = convert_bytes(&data, Format::Docx, &ConvertOptions::default()).unwrap();
1176        assert!(
1177            result.pdf.starts_with(b"%PDF"),
1178            "DOCX with TOC should produce valid PDF"
1179        );
1180    }
1181
1182    #[test]
1183    fn test_convert_bytes_with_pdfa_option() {
1184        use std::io::Cursor;
1185        let docx = docx_rs::Docx::new().add_paragraph(
1186            docx_rs::Paragraph::new().add_run(docx_rs::Run::new().add_text("PDF/A test")),
1187        );
1188        let mut cursor = Cursor::new(Vec::new());
1189        docx.build().pack(&mut cursor).unwrap();
1190        let data = cursor.into_inner();
1191
1192        let options = ConvertOptions {
1193            pdf_standard: Some(config::PdfStandard::PdfA2b),
1194            ..Default::default()
1195        };
1196        let result = convert_bytes(&data, Format::Docx, &options).unwrap();
1197        assert!(result.pdf.starts_with(b"%PDF"));
1198        // PDF/A output should contain PDF/A identification
1199        let pdf_str = String::from_utf8_lossy(&result.pdf);
1200        assert!(
1201            pdf_str.contains("pdfaid") || pdf_str.contains("PDF/A"),
1202            "PDF/A conversion should include PDF/A metadata"
1203        );
1204    }
1205
1206    #[test]
1207    fn test_render_document_default_no_pdfa() {
1208        let doc = make_simple_document("No PDF/A");
1209        let pdf = render_document(&doc).unwrap();
1210        let pdf_str = String::from_utf8_lossy(&pdf);
1211        assert!(
1212            !pdf_str.contains("pdfaid:conformance"),
1213            "Default render_document should not produce PDF/A"
1214        );
1215    }
1216
1217    #[test]
1218    fn test_convert_bytes_with_paper_size_override() {
1219        use std::io::Cursor;
1220        let docx = docx_rs::Docx::new().add_paragraph(
1221            docx_rs::Paragraph::new().add_run(docx_rs::Run::new().add_text("Paper size test")),
1222        );
1223        let mut cursor = Cursor::new(Vec::new());
1224        docx.build().pack(&mut cursor).unwrap();
1225        let data = cursor.into_inner();
1226
1227        let options = ConvertOptions {
1228            paper_size: Some(config::PaperSize::Letter),
1229            ..Default::default()
1230        };
1231        let result = convert_bytes(&data, Format::Docx, &options).unwrap();
1232        assert!(
1233            result.pdf.starts_with(b"%PDF"),
1234            "DOCX with Letter paper override should produce valid PDF"
1235        );
1236    }
1237
1238    #[test]
1239    fn test_convert_bytes_with_landscape_override() {
1240        use std::io::Cursor;
1241        let docx = docx_rs::Docx::new().add_paragraph(
1242            docx_rs::Paragraph::new()
1243                .add_run(docx_rs::Run::new().add_text("Landscape override test")),
1244        );
1245        let mut cursor = Cursor::new(Vec::new());
1246        docx.build().pack(&mut cursor).unwrap();
1247        let data = cursor.into_inner();
1248
1249        let options = ConvertOptions {
1250            landscape: Some(true),
1251            ..Default::default()
1252        };
1253        let result = convert_bytes(&data, Format::Docx, &options).unwrap();
1254        assert!(
1255            result.pdf.starts_with(b"%PDF"),
1256            "DOCX with landscape override should produce valid PDF"
1257        );
1258    }
1259
1260    // --- US-048: Edge case handling and robustness tests ---
1261
1262    #[test]
1263    fn test_edge_empty_docx_produces_valid_pdf() {
1264        use std::io::Cursor;
1265        let docx = docx_rs::Docx::new();
1266        let mut cursor = Cursor::new(Vec::new());
1267        docx.build().pack(&mut cursor).unwrap();
1268        let data = cursor.into_inner();
1269        let result = convert_bytes(&data, Format::Docx, &ConvertOptions::default()).unwrap();
1270        assert!(
1271            result.pdf.starts_with(b"%PDF"),
1272            "Empty DOCX should produce valid PDF"
1273        );
1274    }
1275
1276    #[test]
1277    fn test_edge_empty_xlsx_produces_valid_pdf() {
1278        use std::io::Cursor;
1279        let book = umya_spreadsheet::new_file();
1280        let mut cursor = Cursor::new(Vec::new());
1281        umya_spreadsheet::writer::xlsx::write_writer(&book, &mut cursor).unwrap();
1282        let data = cursor.into_inner();
1283        let result = convert_bytes(&data, Format::Xlsx, &ConvertOptions::default()).unwrap();
1284        assert!(
1285            result.pdf.starts_with(b"%PDF"),
1286            "Empty XLSX should produce valid PDF"
1287        );
1288    }
1289
1290    #[test]
1291    fn test_edge_empty_pptx_produces_valid_pdf() {
1292        use std::io::{Cursor, Write};
1293        // Build a PPTX with no slides
1294        let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
1295        let opts = zip::write::FileOptions::default();
1296        zip.start_file("[Content_Types].xml", opts).unwrap();
1297        zip.write_all(
1298            br#"<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/></Types>"#
1299        ).unwrap();
1300        zip.start_file("_rels/.rels", opts).unwrap();
1301        zip.write_all(
1302            br#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/></Relationships>"#,
1303        ).unwrap();
1304        zip.start_file("ppt/presentation.xml", opts).unwrap();
1305        zip.write_all(
1306            br#"<?xml version="1.0" encoding="UTF-8"?><p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:sldSz cx="9144000" cy="6858000"/><p:sldIdLst/></p:presentation>"#,
1307        ).unwrap();
1308        zip.start_file("ppt/_rels/presentation.xml.rels", opts)
1309            .unwrap();
1310        zip.write_all(
1311            br#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>"#,
1312        ).unwrap();
1313        let data = zip.finish().unwrap().into_inner();
1314        let result = convert_bytes(&data, Format::Pptx, &ConvertOptions::default()).unwrap();
1315        assert!(
1316            result.pdf.starts_with(b"%PDF"),
1317            "Empty PPTX should produce valid PDF"
1318        );
1319    }
1320
1321    #[test]
1322    fn test_edge_long_paragraph_no_panic() {
1323        use std::io::Cursor;
1324        // Create a very long paragraph (10,000 characters)
1325        let long_text: String = "Lorem ipsum dolor sit amet. ".repeat(400);
1326        let docx = docx_rs::Docx::new().add_paragraph(
1327            docx_rs::Paragraph::new().add_run(docx_rs::Run::new().add_text(&long_text)),
1328        );
1329        let mut cursor = Cursor::new(Vec::new());
1330        docx.build().pack(&mut cursor).unwrap();
1331        let data = cursor.into_inner();
1332        let result = convert_bytes(&data, Format::Docx, &ConvertOptions::default()).unwrap();
1333        assert!(
1334            result.pdf.starts_with(b"%PDF"),
1335            "Long paragraph should produce valid PDF"
1336        );
1337    }
1338
1339    #[test]
1340    fn test_edge_large_table_no_panic() {
1341        use std::io::Cursor;
1342        let mut book = umya_spreadsheet::new_file();
1343        {
1344            let sheet = book.get_sheet_mut(&0).unwrap();
1345            // 100 rows x 20 columns
1346            for row in 1..=100u32 {
1347                for col in 1..=20u32 {
1348                    let coord = format!("{}{}", (b'A' + ((col - 1) % 26) as u8) as char, row);
1349                    sheet
1350                        .get_cell_mut(coord.as_str())
1351                        .set_value(format!("R{row}C{col}"));
1352                }
1353            }
1354        }
1355        let mut cursor = Cursor::new(Vec::new());
1356        umya_spreadsheet::writer::xlsx::write_writer(&book, &mut cursor).unwrap();
1357        let data = cursor.into_inner();
1358        let result = convert_bytes(&data, Format::Xlsx, &ConvertOptions::default()).unwrap();
1359        assert!(
1360            result.pdf.starts_with(b"%PDF"),
1361            "Large table should produce valid PDF"
1362        );
1363    }
1364
1365    #[test]
1366    fn test_edge_corrupted_docx_returns_error() {
1367        let data = b"not a valid ZIP file at all";
1368        let result = convert_bytes(data, Format::Docx, &ConvertOptions::default());
1369        assert!(result.is_err(), "Corrupted DOCX should return an error");
1370        let err = result.unwrap_err();
1371        match err {
1372            ConvertError::Parse(msg) => {
1373                assert!(!msg.is_empty(), "Error message should not be empty");
1374            }
1375            _ => panic!("Expected Parse error for corrupted DOCX, got {err:?}"),
1376        }
1377    }
1378
1379    #[test]
1380    fn test_edge_corrupted_xlsx_returns_error() {
1381        let data = b"this is not an xlsx file";
1382        let result = convert_bytes(data, Format::Xlsx, &ConvertOptions::default());
1383        assert!(result.is_err(), "Corrupted XLSX should return an error");
1384    }
1385
1386    #[test]
1387    fn test_edge_corrupted_pptx_returns_error() {
1388        let data = b"garbage data that is not a pptx";
1389        let result = convert_bytes(data, Format::Pptx, &ConvertOptions::default());
1390        assert!(result.is_err(), "Corrupted PPTX should return an error");
1391    }
1392
1393    #[test]
1394    fn test_edge_truncated_zip_returns_error() {
1395        // Create a valid DOCX then truncate it
1396        let full_data = build_test_docx();
1397        let truncated = &full_data[..full_data.len() / 2];
1398        let result = convert_bytes(truncated, Format::Docx, &ConvertOptions::default());
1399        assert!(result.is_err(), "Truncated DOCX should return an error");
1400    }
1401
1402    #[test]
1403    fn test_edge_unicode_cjk_text() {
1404        use std::io::Cursor;
1405        let docx = docx_rs::Docx::new().add_paragraph(
1406            docx_rs::Paragraph::new()
1407                .add_run(docx_rs::Run::new().add_text("中文测试 日本語テスト 한국어 테스트")),
1408        );
1409        let mut cursor = Cursor::new(Vec::new());
1410        docx.build().pack(&mut cursor).unwrap();
1411        let data = cursor.into_inner();
1412        let result = convert_bytes(&data, Format::Docx, &ConvertOptions::default()).unwrap();
1413        assert!(
1414            result.pdf.starts_with(b"%PDF"),
1415            "CJK text should produce valid PDF"
1416        );
1417    }
1418
1419    #[test]
1420    fn test_edge_unicode_emoji_text() {
1421        use std::io::Cursor;
1422        let docx = docx_rs::Docx::new().add_paragraph(
1423            docx_rs::Paragraph::new().add_run(docx_rs::Run::new().add_text("Hello 🌍🎉💡 World")),
1424        );
1425        let mut cursor = Cursor::new(Vec::new());
1426        docx.build().pack(&mut cursor).unwrap();
1427        let data = cursor.into_inner();
1428        // Emoji may render with fallback font, but should not crash
1429        let result = convert_bytes(&data, Format::Docx, &ConvertOptions::default()).unwrap();
1430        assert!(
1431            result.pdf.starts_with(b"%PDF"),
1432            "Emoji text should produce valid PDF"
1433        );
1434    }
1435
1436    #[test]
1437    fn test_edge_unicode_rtl_text() {
1438        use std::io::Cursor;
1439        let docx = docx_rs::Docx::new().add_paragraph(
1440            docx_rs::Paragraph::new().add_run(docx_rs::Run::new().add_text("مرحبا بالعالم")), // Arabic: Hello World
1441        );
1442        let mut cursor = Cursor::new(Vec::new());
1443        docx.build().pack(&mut cursor).unwrap();
1444        let data = cursor.into_inner();
1445        let result = convert_bytes(&data, Format::Docx, &ConvertOptions::default()).unwrap();
1446        assert!(
1447            result.pdf.starts_with(b"%PDF"),
1448            "RTL text should produce valid PDF"
1449        );
1450    }
1451
1452    #[test]
1453    fn test_edge_image_only_docx() {
1454        // A DOCX with only an image (no text paragraphs) should convert
1455        let doc = Document {
1456            metadata: Metadata::default(),
1457            pages: vec![Page::Flow(FlowPage {
1458                size: PageSize::default(),
1459                margins: Margins::default(),
1460                content: vec![Block::Image(ImageData {
1461                    data: vec![0x89, 0x50, 0x4E, 0x47], // Minimal PNG header (won't render but shouldn't panic)
1462                    format: ir::ImageFormat::Png,
1463                    width: Some(100.0),
1464                    height: Some(100.0),
1465                })],
1466                header: None,
1467                footer: None,
1468            })],
1469            styles: StyleSheet::default(),
1470        };
1471        // This tests the render pipeline with image-only content
1472        // It may fail to compile the image (invalid PNG) but should not panic
1473        let _result = render_document(&doc);
1474    }
1475}