document-svg 0.1.1

Bounded PDF/OOXML to SVG conversion and SVG to OOXML packaging in Rust
Documentation
use std::fs;
use std::io::Read;

use document_svg::{ConvertOptions, OpenXmlFormat, ReverseOptions, convert_path, svg_to_openxml};
use tempfile::TempDir;
use zip::ZipArchive;

#[test]
fn packages_svg_directory_as_pptx_and_round_trips() {
    let temporary = TempDir::new().unwrap();
    let input = make_svg_pages(&temporary);
    let output = temporary.path().join("pages.pptx");

    let report = svg_to_openxml(&input, &output, &ReverseOptions::default()).unwrap();

    assert_eq!(report.output_format, OpenXmlFormat::Pptx);
    assert_eq!(report.page_count, 2);
    assert_eq!(read_zip_part(&output, "ppt/media/image1.svg"), svg_one());
    assert_eq!(read_zip_part(&output, "ppt/media/image2.svg"), svg_two());
    assert!(
        read_zip_bytes(&output, "ppt/media/image1-fallback.png").starts_with(b"\x89PNG\r\n\x1a\n")
    );
    let slide = read_zip_part(&output, "ppt/slides/slide1.xml");
    assert!(slide.contains("<mc:AlternateContent>"));
    assert!(slide.contains("<asvg:svgBlip r:embed=\"rId2\"/>"));

    let round_trip = temporary.path().join("pptx-svg");
    let converted = convert_path(&output, &round_trip, &ConvertOptions::default()).unwrap();
    assert_eq!(converted.page_count, 2);
}

#[test]
fn packages_svg_directory_as_docx_and_round_trips() {
    let temporary = TempDir::new().unwrap();
    let input = make_svg_pages(&temporary);
    let output = temporary.path().join("pages.docx");

    let report = svg_to_openxml(&input, &output, &ReverseOptions::default()).unwrap();

    assert_eq!(report.output_format, OpenXmlFormat::Docx);
    assert_eq!(report.page_count, 2);
    assert_eq!(read_zip_part(&output, "word/media/image1.svg"), svg_one());
    assert_eq!(read_zip_part(&output, "word/media/image2.svg"), svg_two());
    assert!(
        read_zip_bytes(&output, "word/media/image1-fallback.png").starts_with(b"\x89PNG\r\n\x1a\n")
    );
    assert!(read_zip_part(&output, "word/document.xml").contains("<asvg:svgBlip"));

    let round_trip = temporary.path().join("docx-svg");
    let converted = convert_path(&output, &round_trip, &ConvertOptions::default()).unwrap();
    assert_eq!(converted.page_count, 2);
}

#[test]
fn packages_mixed_orientation_svg_pages_as_docx_without_blank_pages() {
    let temporary = TempDir::new().unwrap();
    let input = temporary.path().join("mixed-pages");
    fs::create_dir(&input).unwrap();
    fs::write(input.join("page-0001.svg"), svg_one()).unwrap();
    fs::write(input.join("page-0002.svg"), svg_landscape()).unwrap();
    let output = temporary.path().join("mixed.docx");

    let report = svg_to_openxml(&input, &output, &ReverseOptions::default()).unwrap();

    assert_eq!(report.page_count, 2);
    let document = read_zip_part(&output, "word/document.xml");
    assert!(document.contains("w:orient=\"landscape\""));
    assert_eq!(document.matches("<w:sectPr>").count(), 2);
    let round_trip = temporary.path().join("mixed-svg");
    let converted = convert_path(&output, &round_trip, &ConvertOptions::default()).unwrap();
    assert_eq!(converted.page_count, 2);
    assert_eq!(converted.pages[0].width_points, 612.0);
    assert_eq!(converted.pages[0].height_points, 792.0);
    assert_eq!(converted.pages[1].width_points, 792.0);
    assert_eq!(converted.pages[1].height_points, 612.0);
}

#[test]
fn packages_svg_directory_as_xlsx_and_round_trips() {
    let temporary = TempDir::new().unwrap();
    let input = make_svg_pages(&temporary);
    let output = temporary.path().join("pages.xlsx");

    let report = svg_to_openxml(&input, &output, &ReverseOptions::default()).unwrap();

    assert_eq!(report.output_format, OpenXmlFormat::Xlsx);
    assert_eq!(report.page_count, 2);
    assert_eq!(read_zip_part(&output, "xl/media/image1.svg"), svg_one());
    assert_eq!(read_zip_part(&output, "xl/media/image2.svg"), svg_two());
    assert!(
        read_zip_bytes(&output, "xl/media/image1-fallback.png").starts_with(b"\x89PNG\r\n\x1a\n")
    );
    assert!(read_zip_part(&output, "xl/drawings/drawing1.xml").contains("<asvg:svgBlip"));
    assert!(read_zip_part(&output, "xl/worksheets/sheet1.xml").contains("fitToPage=\"1\""));

    let round_trip = temporary.path().join("xlsx-svg");
    let converted = convert_path(&output, &round_trip, &ConvertOptions::default()).unwrap();
    assert_eq!(converted.page_count, 2);
}

#[test]
fn accepts_single_svg_and_refuses_to_overwrite_output() {
    let temporary = TempDir::new().unwrap();
    let input = temporary.path().join("page.svg");
    let output = temporary.path().join("page.pptx");
    fs::write(&input, svg_one()).unwrap();

    let report = svg_to_openxml(&input, &output, &ReverseOptions::default()).unwrap();
    assert_eq!(report.page_count, 1);

    let error = svg_to_openxml(&input, &output, &ReverseOptions::default()).unwrap_err();
    assert!(error.to_string().contains("output already exists"));
}

#[test]
fn produces_deterministic_packages() {
    let temporary = TempDir::new().unwrap();
    let input = make_svg_pages(&temporary);
    let first = temporary.path().join("first.pptx");
    let second = temporary.path().join("second.pptx");

    svg_to_openxml(&input, &first, &ReverseOptions::default()).unwrap();
    svg_to_openxml(&input, &second, &ReverseOptions::default()).unwrap();

    assert_eq!(fs::read(first).unwrap(), fs::read(second).unwrap());
}

#[test]
fn rejects_active_and_external_svg_content() {
    let temporary = TempDir::new().unwrap();
    let samples = [
        (
            "css-escape",
            r#"<svg width="100" height="100"><style>rect{fill:u\72l(https://example.invalid/a)}</style></svg>"#,
        ),
        (
            "xml-base",
            r#"<svg width="100" height="100" xml:base="https://example.invalid/"/>"#,
        ),
        (
            "processing-instruction",
            r#"<?xml-stylesheet href="https://example.invalid/a.css"?><svg width="100" height="100"/>"#,
        ),
        (
            "script",
            r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><script>alert(1)</script></svg>"#,
        ),
        (
            "event",
            r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" onload="alert(1)"/>"#,
        ),
        (
            "external",
            r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><image href="https://example.invalid/image.png"/></svg>"#,
        ),
        (
            "foreign-object",
            r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><foreignObject/></svg>"#,
        ),
    ];
    for (name, svg) in samples {
        let input = temporary.path().join(format!("{name}.svg"));
        let output = temporary.path().join(format!("{name}.pptx"));
        fs::write(&input, svg).unwrap();

        let error = svg_to_openxml(&input, &output, &ReverseOptions::default()).unwrap_err();

        assert!(error.to_string().contains("not allowed"), "{name}: {error}");
        assert!(!output.exists());
        assert!(!fs::read_dir(temporary.path()).unwrap().any(|entry| {
            entry
                .unwrap()
                .path()
                .extension()
                .is_some_and(|ext| ext == "tmp")
        }));
    }
}

#[test]
fn accepts_scientific_notation_svg_dimensions() {
    let temporary = TempDir::new().unwrap();
    let input = temporary.path().join("scientific.svg");
    let output = temporary.path().join("scientific.pptx");
    fs::write(
        &input,
        r#"<svg xmlns="http://www.w3.org/2000/svg" width="1e3" height="9e2"><rect width="1000" height="900" fill="white"/></svg>"#,
    )
    .unwrap();

    svg_to_openxml(&input, &output, &ReverseOptions::default()).unwrap();

    let presentation = read_zip_part(&output, "ppt/presentation.xml");
    assert!(presentation.contains("cx=\"9525000\""));
    assert!(presentation.contains("cy=\"8572500\""));
}

fn make_svg_pages(temporary: &TempDir) -> std::path::PathBuf {
    let input = temporary.path().join("pages");
    fs::create_dir(&input).unwrap();
    fs::write(input.join("page-0002.svg"), svg_two()).unwrap();
    fs::write(input.join("page-0001.svg"), svg_one()).unwrap();
    input
}

fn svg_one() -> &'static str {
    r#"<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="612pt" height="792pt" viewBox="0 0 612 792"><rect width="612" height="792" fill="white"/><text x="72" y="96">One</text></svg>"#
}

fn svg_two() -> &'static str {
    r#"<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="8.5in" height="11in" viewBox="0 0 816 1056"><rect width="816" height="1056" fill="white"/><text x="96" y="128">Two</text></svg>"#
}

fn svg_landscape() -> &'static str {
    r#"<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="792pt" height="612pt" viewBox="0 0 792 612"><rect width="792" height="612" fill="white"/><text x="72" y="96">Landscape</text></svg>"#
}

fn read_zip_part(path: &std::path::Path, name: &str) -> String {
    String::from_utf8(read_zip_bytes(path, name)).unwrap()
}

fn read_zip_bytes(path: &std::path::Path, name: &str) -> Vec<u8> {
    let file = fs::File::open(path).unwrap();
    let mut archive = ZipArchive::new(file).unwrap();
    let mut part = archive.by_name(name).unwrap();
    let mut bytes = Vec::new();
    part.read_to_end(&mut bytes).unwrap();
    bytes
}