mdcast 0.3.0

Markdown → DOCX/ODT/PDF/PPTX/HTML-reveal with per-page layout templates, powered by pandoc and typst
Documentation
use super::*;
use std::io::Write;

/// Build a minimal single-slide pptx zip with the given `ppt/slides/slide1.xml`
/// body, plus one untouched sibling entry — enough to exercise the patcher
/// without depending on pandoc being installed.
fn make_pptx(slide_xml: &str) -> Vec<u8> {
    let mut buf = Vec::new();
    {
        let mut zip = ZipWriter::new(Cursor::new(&mut buf));
        let options = SimpleFileOptions::default();
        zip.start_file("ppt/slides/slide1.xml", options).unwrap();
        zip.write_all(slide_xml.as_bytes()).unwrap();
        zip.start_file("ppt/presentation.xml", options).unwrap();
        zip.write_all(b"<p:presentation/>").unwrap();
        zip.finish().unwrap();
    }
    buf
}

fn slide1_xml(patched: &[u8]) -> String {
    let mut archive = ZipArchive::new(Cursor::new(patched)).unwrap();
    let mut file = archive.by_name("ppt/slides/slide1.xml").unwrap();
    let mut s = String::new();
    std::io::Read::read_to_string(&mut file, &mut s).unwrap();
    s
}

const TITLE_AND_BODY: &str = r#"<?xml version="1.0" encoding="UTF-8"?><p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:spTree><p:sp><p:nvSpPr><p:cNvPr id="2" name="Title 1" /><p:nvPr><p:ph type="title" /></p:nvPr></p:nvSpPr><p:spPr /><p:txBody><a:bodyPr /><a:p><a:r><a:t>Title</a:t></a:r></a:p></p:txBody></p:sp><p:sp><p:nvSpPr><p:cNvPr id="3" name="Content Placeholder 2" /><p:nvPr><p:ph idx="1" /></p:nvPr></p:nvSpPr><p:spPr /><p:txBody><a:bodyPr /><a:p><a:r><a:t>bullet</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld></p:sld>"#;

#[test]
fn inserts_normautofit_into_self_closing_body_placeholder_bodypr() {
    let pptx = make_pptx(TITLE_AND_BODY);
    let patched = add_autofit(&pptx).unwrap();
    let xml = slide1_xml(&patched);

    // Body placeholder's bodyPr gained the element (the trailing space before
    // `>` is preserved from the original self-closing `<a:bodyPr />` bytes).
    assert!(
        xml.contains(r#"<p:ph idx="1" /></p:nvPr></p:nvSpPr><p:spPr /><p:txBody><a:bodyPr ><a:normAutofit/></a:bodyPr>"#),
        "{xml}"
    );
    // ...but the title placeholder's bodyPr did not.
    assert!(
        xml.contains(
            r#"<p:ph type="title" /></p:nvPr></p:nvSpPr><p:spPr /><p:txBody><a:bodyPr /><a:p>"#
        ),
        "{xml}"
    );
}

#[test]
fn expanded_bodypr_gains_normautofit_child() {
    let xml = r#"<?xml version="1.0" encoding="UTF-8"?><p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:spTree><p:sp><p:nvSpPr><p:cNvPr id="3" name="Content Placeholder 2" /><p:nvPr><p:ph idx="1" /></p:nvPr></p:nvSpPr><p:spPr /><p:txBody><a:bodyPr wrap="square"><a:prstTxWarp prst="textNoShape" /></a:bodyPr><a:p><a:r><a:t>bullet</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld></p:sld>"#;
    let pptx = make_pptx(xml);

    let patched = add_autofit(&pptx).unwrap();
    let out = slide1_xml(&patched);

    assert!(
        out.contains(r#"<a:bodyPr wrap="square"><a:prstTxWarp prst="textNoShape" /><a:normAutofit/></a:bodyPr>"#),
        "{out}"
    );
}

#[test]
fn existing_autofit_element_is_replaced() {
    let xml = r#"<?xml version="1.0" encoding="UTF-8"?><p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:spTree><p:sp><p:nvSpPr><p:cNvPr id="3" name="Content Placeholder 2" /><p:nvPr><p:ph idx="1" /></p:nvPr></p:nvSpPr><p:spPr /><p:txBody><a:bodyPr><a:noAutofit /></a:bodyPr><a:p><a:r><a:t>bullet</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld></p:sld>"#;
    let pptx = make_pptx(xml);

    let patched = add_autofit(&pptx).unwrap();
    let out = slide1_xml(&patched);

    assert!(
        out.contains("<a:bodyPr><a:normAutofit/></a:bodyPr>"),
        "{out}"
    );
    assert!(!out.contains("noAutofit"), "{out}");
}

#[test]
fn existing_spautofit_with_fontscale_is_replaced() {
    let xml = r#"<?xml version="1.0" encoding="UTF-8"?><p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:spTree><p:sp><p:nvSpPr><p:cNvPr id="3" name="Content Placeholder 2" /><p:nvPr><p:ph idx="1" /></p:nvPr></p:nvSpPr><p:spPr /><p:txBody><a:bodyPr><a:normAutofit fontScale="62500" lnSpcReduction="20000" /></a:bodyPr><a:p><a:r><a:t>bullet</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld></p:sld>"#;
    let pptx = make_pptx(xml);

    let patched = add_autofit(&pptx).unwrap();
    let out = slide1_xml(&patched);

    assert!(
        out.contains("<a:bodyPr><a:normAutofit/></a:bodyPr>"),
        "{out}"
    );
    assert!(!out.contains("fontScale"), "{out}");
}

#[test]
fn title_and_non_placeholder_shapes_are_untouched() {
    let xml = r#"<?xml version="1.0" encoding="UTF-8"?><p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:spTree><p:sp><p:nvSpPr><p:cNvPr id="2" name="Title 1" /><p:nvPr><p:ph type="title" /></p:nvPr></p:nvSpPr><p:spPr /><p:txBody><a:bodyPr /><a:p><a:r><a:t>Title</a:t></a:r></a:p></p:txBody></p:sp><p:sp><p:nvSpPr><p:cNvPr id="4" name="TextBox 3" /><p:nvPr /></p:nvSpPr><p:spPr /><p:txBody><a:bodyPr /><a:p><a:r><a:t>free text</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld></p:sld>"#;
    let pptx = make_pptx(xml);

    let patched = add_autofit(&pptx).unwrap();
    let out = slide1_xml(&patched);

    assert_eq!(out, xml, "no placeholder shape should gain normAutofit");
}

#[test]
fn non_slide_zip_entries_are_byte_identical() {
    let pptx = make_pptx(TITLE_AND_BODY);
    let patched = add_autofit(&pptx).unwrap();

    let mut before = ZipArchive::new(Cursor::new(&pptx)).unwrap();
    let mut after = ZipArchive::new(Cursor::new(&patched)).unwrap();
    let mut before_bytes = Vec::new();
    let mut after_bytes = Vec::new();
    std::io::Read::read_to_end(
        &mut before.by_name("ppt/presentation.xml").unwrap(),
        &mut before_bytes,
    )
    .unwrap();
    std::io::Read::read_to_end(
        &mut after.by_name("ppt/presentation.xml").unwrap(),
        &mut after_bytes,
    )
    .unwrap();

    assert_eq!(before_bytes, after_bytes);
}

#[test]
fn malformed_zip_errors_instead_of_panicking() {
    let err = add_autofit(b"not a zip file").unwrap_err();
    assert!(err.to_string().contains("zip"), "{err}");
}

#[test]
fn malformed_slide_xml_errors_instead_of_panicking() {
    let pptx = make_pptx("<p:sld><unclosed");
    let err = add_autofit(&pptx).unwrap_err();
    assert!(err.to_string().contains("slide1.xml"), "{err}");
}

#[test]
fn is_slide_xml_matches_only_top_level_numbered_slides() {
    assert!(is_slide_xml("ppt/slides/slide1.xml"));
    assert!(is_slide_xml("ppt/slides/slide42.xml"));
    assert!(!is_slide_xml("ppt/slides/_rels/slide1.xml.rels"));
    assert!(!is_slide_xml("ppt/slideLayouts/slideLayout1.xml"));
    assert!(!is_slide_xml("ppt/slideMasters/slideMaster1.xml"));
    assert!(!is_slide_xml("ppt/slides/slide.xml"));
}