oxidize-pdf 2.5.0

A pure Rust PDF generation and manipulation library with zero external dependencies
Documentation
// Rigorous tests for incremental update form filling functionality
// NO SMOKE TESTS - All tests verify ACTUAL functionality

#[cfg(test)]
mod form_filling_tests {
    use crate::document::Document;
    use crate::page::Page;
    use crate::text::Font;
    use crate::writer::{PdfWriter, WriterConfig};
    use std::fs;
    use std::io::BufWriter;
    use std::process::Command;
    use tempfile::TempDir;

    /// Helper: Extract text from PDF using pdftotext
    fn extract_pdf_text(pdf_path: &std::path::Path) -> String {
        let output = Command::new("pdftotext")
            .arg(pdf_path)
            .arg("-")
            .output()
            .expect("pdftotext must be installed for tests");

        String::from_utf8_lossy(&output.stdout).to_string()
    }

    #[test]
    #[ignore] // Requires pdftotext (not available in CI)
    fn test_form_filling_preserves_template_and_adds_data() {
        let temp_dir = TempDir::new().unwrap();
        let base_path = temp_dir.path().join("template.pdf");
        let filled_path = temp_dir.path().join("filled.pdf");

        // Create base PDF with form template
        let mut base_doc = Document::new();
        let mut template_page = Page::a4();

        template_page
            .text()
            .set_font(Font::Helvetica, 12.0)
            .at(50.0, 700.0)
            .write("Name: _______________")
            .unwrap();

        template_page
            .text()
            .set_font(Font::Helvetica, 12.0)
            .at(50.0, 670.0)
            .write("Email: ______________")
            .unwrap();

        base_doc.add_page(template_page);
        base_doc.save(&base_path).unwrap();

        // Fill form via page replacement
        let mut filled_doc = Document::new();
        let mut filled_page = Page::a4();

        // Reproduce template
        filled_page
            .text()
            .set_font(Font::Helvetica, 12.0)
            .at(50.0, 700.0)
            .write("Name: _______________")
            .unwrap();

        filled_page
            .text()
            .set_font(Font::Helvetica, 12.0)
            .at(50.0, 670.0)
            .write("Email: ______________")
            .unwrap();

        // Add filled data
        filled_page
            .text()
            .set_font(Font::Helvetica, 12.0)
            .at(110.0, 700.0)
            .write("Jane Doe")
            .unwrap();

        filled_page
            .text()
            .set_font(Font::Helvetica, 12.0)
            .at(115.0, 670.0)
            .write("jane@example.com")
            .unwrap();

        filled_doc.add_page(filled_page);

        let file = fs::File::create(&filled_path).unwrap();
        let writer = BufWriter::new(file);
        let mut pdf_writer = PdfWriter::with_config(writer, WriterConfig::incremental());

        pdf_writer
            .write_incremental_with_page_replacement(&base_path, &mut filled_doc)
            .unwrap();

        // RIGOROUS verification: Check actual text content
        let extracted_text = extract_pdf_text(&filled_path);

        // Verify template is present
        assert!(
            extracted_text.contains("Name:"),
            "Template 'Name:' field missing. Extracted: {}",
            extracted_text
        );
        assert!(
            extracted_text.contains("Email:"),
            "Template 'Email:' field missing. Extracted: {}",
            extracted_text
        );

        // Verify filled data is present
        // Note: pdftotext may add spaces between characters on some platforms (Windows)
        // so we check for the name with flexible spacing
        let name_found = extracted_text.contains("Jane Doe")
            || extracted_text.contains("J a n e D o e")
            || extracted_text.contains("J_a_n_e_D_o_e")
            || extracted_text
                .replace("_", "")
                .replace(" ", "")
                .contains("JaneDoe");

        assert!(
            name_found,
            "Filled name 'Jane Doe' missing (checked with flexible spacing). Extracted: {}",
            extracted_text
        );

        let email_found = extracted_text.contains("jane@example.com")
            || extracted_text.contains("jane@ex am ple.com")
            || extracted_text.contains("ja_n_e_@__e_x_a_m_ple.com")
            || extracted_text
                .replace("_", "")
                .replace(" ", "")
                .contains("jane@example.com");

        assert!(
            email_found,
            "Filled email missing (checked with flexible spacing). Extracted: {}",
            extracted_text
        );
    }

    #[test]
    #[ignore] // Requires pdfinfo (not available in CI)
    fn test_page_replacement_keeps_correct_page_count() {
        let temp_dir = TempDir::new().unwrap();
        let base_path = temp_dir.path().join("base.pdf");
        let updated_path = temp_dir.path().join("updated.pdf");

        // Create base with 1 page
        let mut base_doc = Document::new();
        base_doc.add_page(Page::a4());
        base_doc.save(&base_path).unwrap();

        // Replace page 0
        let mut updated_doc = Document::new();
        let mut page = Page::a4();
        page.text()
            .set_font(Font::Helvetica, 12.0)
            .at(100.0, 700.0)
            .write("Replaced content")
            .unwrap();
        updated_doc.add_page(page);

        let file = fs::File::create(&updated_path).unwrap();
        let writer = BufWriter::new(file);
        let mut pdf_writer = PdfWriter::with_config(writer, WriterConfig::incremental());

        pdf_writer
            .write_incremental_with_page_replacement(&base_path, &mut updated_doc)
            .unwrap();

        // Verify page count using pdfinfo
        let output = Command::new("pdfinfo")
            .arg(&updated_path)
            .output()
            .expect("pdfinfo must be installed");

        let info = String::from_utf8_lossy(&output.stdout);
        let page_count_line = info.lines().find(|line| line.contains("Pages:")).unwrap();

        assert!(
            page_count_line.contains("Pages:           1"),
            "Expected 1 page, got: {}",
            page_count_line
        );
    }

    #[test]
    #[ignore] // Requires pdfinfo/pdftotext (not available in CI)
    fn test_multiple_page_replacement_preserves_unmodified_pages() {
        let temp_dir = TempDir::new().unwrap();
        let base_path = temp_dir.path().join("base.pdf");
        let updated_path = temp_dir.path().join("updated.pdf");

        // Create base with 3 pages
        let mut base_doc = Document::new();
        for i in 1..=3 {
            let mut page = Page::a4();
            page.text()
                .set_font(Font::Helvetica, 12.0)
                .at(100.0, 700.0)
                .write(&format!("Original Page {}", i))
                .unwrap();
            base_doc.add_page(page);
        }
        base_doc.save(&base_path).unwrap();

        // Replace only page 0
        let mut updated_doc = Document::new();
        let mut page1 = Page::a4();
        page1
            .text()
            .set_font(Font::Helvetica, 12.0)
            .at(100.0, 700.0)
            .write("MODIFIED Page 1")
            .unwrap();
        updated_doc.add_page(page1);

        let file = fs::File::create(&updated_path).unwrap();
        let writer = BufWriter::new(file);
        let mut pdf_writer = PdfWriter::with_config(writer, WriterConfig::incremental());

        pdf_writer
            .write_incremental_with_page_replacement(&base_path, &mut updated_doc)
            .unwrap();

        // Verify all 3 pages exist
        let output = Command::new("pdfinfo")
            .arg(&updated_path)
            .output()
            .expect("pdfinfo required");

        let info = String::from_utf8_lossy(&output.stdout);
        assert!(info.contains("Pages:           3"));

        // Verify page 1 was modified
        let page1_text = Command::new("pdftotext")
            .args(&["-f", "1", "-l", "1"])
            .arg(&updated_path)
            .arg("-")
            .output()
            .unwrap();

        let page1_content = String::from_utf8_lossy(&page1_text.stdout);
        assert!(page1_content.contains("MODIFIED Page 1"));

        // Verify page 2 is unchanged
        let page2_text = Command::new("pdftotext")
            .args(&["-f", "2", "-l", "2"])
            .arg(&updated_path)
            .arg("-")
            .output()
            .unwrap();

        let page2_content = String::from_utf8_lossy(&page2_text.stdout);
        assert!(page2_content.contains("Original Page 2"));

        // Verify page 3 is unchanged
        let page3_text = Command::new("pdftotext")
            .args(&["-f", "3", "-l", "3"])
            .arg(&updated_path)
            .arg("-")
            .output()
            .unwrap();

        let page3_content = String::from_utf8_lossy(&page3_text.stdout);
        assert!(page3_content.contains("Original Page 3"));
    }

    #[test]
    fn test_incremental_update_structure_compliance() {
        let temp_dir = TempDir::new().unwrap();
        let base_path = temp_dir.path().join("base.pdf");
        let updated_path = temp_dir.path().join("updated.pdf");

        let mut base_doc = Document::new();
        base_doc.add_page(Page::a4());
        base_doc.save(&base_path).unwrap();

        let base_size = fs::metadata(&base_path).unwrap().len();

        let mut updated_doc = Document::new();
        updated_doc.add_page(Page::a4());

        let file = fs::File::create(&updated_path).unwrap();
        let writer = BufWriter::new(file);
        let mut pdf_writer = PdfWriter::with_config(writer, WriterConfig::incremental());

        pdf_writer
            .write_incremental_with_page_replacement(&base_path, &mut updated_doc)
            .unwrap();

        let updated_size = fs::metadata(&updated_path).unwrap().len();

        // Verify size increased (appended content)
        assert!(updated_size > base_size);

        // Verify base content preserved byte-for-byte
        let base_bytes = fs::read(&base_path).unwrap();
        let updated_bytes = fs::read(&updated_path).unwrap();

        assert_eq!(
            &updated_bytes[0..base_size as usize],
            &base_bytes[..],
            "Base PDF content must be preserved byte-for-byte"
        );

        // Verify /Prev pointer exists
        let updated_str = String::from_utf8_lossy(&updated_bytes);
        assert!(
            updated_str.contains("/Prev"),
            "/Prev pointer required for ISO 32000-1 ยง7.5.6 compliance"
        );
    }
}