office2pdf 0.6.3

Convert DOCX, XLSX, and PPTX files to PDF using pure Rust
Documentation
use super::*;
use std::io::Write;
use zip::write::FileOptions;

// ── Helpers ──────────────────────────────────────────────────────────

const LAYOUT_HEADER: &str = r#"<?xml version="1.0" encoding="UTF-8"?><p:sldLayout 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/>"#;
const LAYOUT_FOOTER: &str = "</p:spTree></p:cSld></p:sldLayout>";

const MASTER_HEADER: &str = r#"<?xml version="1.0" encoding="UTF-8"?><p:sldMaster 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/>"#;
const MASTER_FOOTER: &str = "</p:spTree></p:cSld></p:sldMaster>";

/// A placeholder `<p:sp>` for a slide, layout, or master.
/// `ph_attrs` is the raw attribute string of `<p:ph>` (e.g. `type="title"` or `idx="1"`).
/// `xfrm_emu` is `Some((x, y, cx, cy))` for an explicit `<a:xfrm>`, or `None` to inherit.
fn make_placeholder_sp(
    ph_attrs: &str,
    xfrm_emu: Option<(i64, i64, i64, i64)>,
    text: &str,
) -> String {
    let sp_pr: String = match xfrm_emu {
        Some((x, y, cx, cy)) => format!(
            r#"<p:spPr><a:xfrm><a:off x="{x}" y="{y}"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm></p:spPr>"#
        ),
        None => "<p:spPr/>".to_string(),
    };
    format!(
        r#"<p:sp><p:nvSpPr><p:cNvPr id="2" name="Placeholder"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph {ph_attrs}/></p:nvPr></p:nvSpPr>{sp_pr}<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr lang="en-US"/><a:t>{text}</a:t></a:r></a:p></p:txBody></p:sp>"#
    )
}

fn make_slide_with_shapes(shapes: &[String]) -> String {
    let mut xml = String::from(
        r#"<?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/>"#,
    );
    for shape in shapes {
        xml.push_str(shape);
    }
    xml.push_str("</p:spTree></p:cSld></p:sld>");
    xml
}

fn make_layout_with_shapes(shapes: &[String]) -> String {
    let mut xml = String::from(LAYOUT_HEADER);
    for shape in shapes {
        xml.push_str(shape);
    }
    xml.push_str(LAYOUT_FOOTER);
    xml
}

fn make_master_with_shapes(shapes: &[String]) -> String {
    let mut xml = String::from(MASTER_HEADER);
    for shape in shapes {
        xml.push_str(shape);
    }
    xml.push_str(MASTER_FOOTER);
    xml
}

fn parse_document(data: &[u8]) -> Document {
    let parser = PptxParser;
    let (doc, _warnings) = parser.parse(data, &ConvertOptions::default()).unwrap();
    doc
}

/// Find the text box element containing the given text.
fn find_text_box_with_text<'a>(page: &'a FixedPage, needle: &str) -> &'a FixedElement {
    page.elements
        .iter()
        .find(|element| {
            if let FixedElementKind::TextBox(text_box) = &element.kind {
                text_box.content.iter().any(|block| match block {
                    Block::Paragraph(paragraph) => {
                        paragraph.runs.iter().any(|run| run.text.contains(needle))
                    }
                    _ => false,
                })
            } else {
                false
            }
        })
        .unwrap_or_else(|| panic!("no text box containing {needle:?}"))
}

fn assert_geometry(element: &FixedElement, x_emu: i64, y_emu: i64, cx_emu: i64, cy_emu: i64) {
    let expected: [f64; 4] = [
        emu_to_pt(x_emu),
        emu_to_pt(y_emu),
        emu_to_pt(cx_emu),
        emu_to_pt(cy_emu),
    ];
    let actual: [f64; 4] = [element.x, element.y, element.width, element.height];
    for (index, (value, want)) in actual.iter().zip(expected.iter()).enumerate() {
        assert!(
            (value - want).abs() < 0.01,
            "geometry component {index}: got {value}, want {want} (element: x={} y={} w={} h={})",
            element.x,
            element.y,
            element.width,
            element.height
        );
    }
}

// ── Slide → layout inheritance ───────────────────────────────────────

#[test]
fn test_title_placeholder_inherits_layout_geometry() {
    let slide = make_slide_with_shapes(&[make_placeholder_sp(r#"type="title""#, None, "Hello")]);
    let layout = make_layout_with_shapes(&[make_placeholder_sp(
        r#"type="title""#,
        Some((457_200, 274_638, 8_229_600, 1_143_000)),
        "Layout title",
    )]);
    let master = make_master_with_shapes(&[]);
    let data = build_test_pptx_with_layout_master(SLIDE_CX, SLIDE_CY, &slide, &layout, &master);

    let doc = parse_document(&data);
    let page = first_fixed_page(&doc);
    let element = find_text_box_with_text(page, "Hello");
    assert_geometry(element, 457_200, 274_638, 8_229_600, 1_143_000);
}

#[test]
fn test_ctr_title_placeholder_matches_layout_title_family() {
    // A slide `title` placeholder must match a layout `ctrTitle` placeholder
    // (and vice versa): both belong to the title family.
    let slide = make_slide_with_shapes(&[make_placeholder_sp(r#"type="title""#, None, "Hello")]);
    let layout = make_layout_with_shapes(&[make_placeholder_sp(
        r#"type="ctrTitle""#,
        Some((685_800, 2_130_425, 7_772_400, 1_470_025)),
        "Centered layout title",
    )]);
    let master = make_master_with_shapes(&[]);
    let data = build_test_pptx_with_layout_master(SLIDE_CX, SLIDE_CY, &slide, &layout, &master);

    let doc = parse_document(&data);
    let page = first_fixed_page(&doc);
    let element = find_text_box_with_text(page, "Hello");
    assert_geometry(element, 685_800, 2_130_425, 7_772_400, 1_470_025);
}

#[test]
fn test_body_placeholders_match_layout_by_idx() {
    let slide = make_slide_with_shapes(&[
        make_placeholder_sp(r#"type="body" idx="1""#, None, "Left"),
        make_placeholder_sp(r#"type="body" idx="2""#, None, "Right"),
    ]);
    let layout = make_layout_with_shapes(&[
        make_placeholder_sp(
            r#"type="body" idx="1""#,
            Some((457_200, 1_600_200, 4_038_600, 4_525_963)),
            "Layout left",
        ),
        make_placeholder_sp(
            r#"type="body" idx="2""#,
            Some((4_648_200, 1_600_200, 4_038_600, 4_525_963)),
            "Layout right",
        ),
    ]);
    let master = make_master_with_shapes(&[]);
    let data = build_test_pptx_with_layout_master(SLIDE_CX, SLIDE_CY, &slide, &layout, &master);

    let doc = parse_document(&data);
    let page = first_fixed_page(&doc);
    let left = find_text_box_with_text(page, "Left");
    assert_geometry(left, 457_200, 1_600_200, 4_038_600, 4_525_963);
    let right = find_text_box_with_text(page, "Right");
    assert_geometry(right, 4_648_200, 1_600_200, 4_038_600, 4_525_963);
}

// ── Layout → master fallback ─────────────────────────────────────────

#[test]
fn test_layout_placeholder_without_geometry_falls_back_to_master() {
    // The layout declares the placeholder but omits <a:xfrm>; geometry must
    // come from the master's matching placeholder.
    let slide =
        make_slide_with_shapes(&[make_placeholder_sp(r#"type="body" idx="1""#, None, "Hello")]);
    let layout = make_layout_with_shapes(&[make_placeholder_sp(
        r#"type="body" idx="1""#,
        None,
        "Layout body",
    )]);
    let master = make_master_with_shapes(&[make_placeholder_sp(
        r#"type="body" idx="1""#,
        Some((457_200, 1_600_200, 8_229_600, 4_525_963)),
        "Master body",
    )]);
    let data = build_test_pptx_with_layout_master(SLIDE_CX, SLIDE_CY, &slide, &layout, &master);

    let doc = parse_document(&data);
    let page = first_fixed_page(&doc);
    let element = find_text_box_with_text(page, "Hello");
    assert_geometry(element, 457_200, 1_600_200, 8_229_600, 4_525_963);
}

#[test]
fn test_subtitle_placeholder_falls_back_to_master_body() {
    // `subTitle` has no direct master counterpart; it must normalize to the
    // master `body` placeholder when the layout provides no geometry.
    let slide = make_slide_with_shapes(&[make_placeholder_sp(
        r#"type="subTitle" idx="1""#,
        None,
        "Hello",
    )]);
    let layout = make_layout_with_shapes(&[]);
    let master = make_master_with_shapes(&[make_placeholder_sp(
        r#"type="body" idx="1""#,
        Some((1_371_600, 3_886_200, 6_400_800, 1_752_600)),
        "Master body",
    )]);
    let data = build_test_pptx_with_layout_master(SLIDE_CX, SLIDE_CY, &slide, &layout, &master);

    let doc = parse_document(&data);
    let page = first_fixed_page(&doc);
    let element = find_text_box_with_text(page, "Hello");
    assert_geometry(element, 1_371_600, 3_886_200, 6_400_800, 1_752_600);
}

#[test]
fn test_footer_placeholder_matches_master_by_type_despite_idx_mismatch() {
    // Real decks give the footer different idx values on each level
    // (layout idx="11", master idx="4"); the footer family matches by type.
    let slide = make_slide_with_shapes(&[make_placeholder_sp(
        r#"type="ftr" sz="quarter" idx="11""#,
        None,
        "Prislista",
    )]);
    let layout = make_layout_with_shapes(&[make_placeholder_sp(
        r#"type="ftr" sz="quarter" idx="11""#,
        None,
        "Layout footer",
    )]);
    let master = make_master_with_shapes(&[make_placeholder_sp(
        r#"type="ftr" sz="quarter" idx="4""#,
        Some((3_124_200, 6_356_350, 2_895_600, 365_125)),
        "Master footer",
    )]);
    let data = build_test_pptx_with_layout_master(SLIDE_CX, SLIDE_CY, &slide, &layout, &master);

    let doc = parse_document(&data);
    let page = first_fixed_page(&doc);
    let element = find_text_box_with_text(page, "Prislista");
    assert_geometry(element, 3_124_200, 6_356_350, 2_895_600, 365_125);
}

// ── Explicit slide geometry wins ─────────────────────────────────────

#[test]
fn test_placeholder_with_explicit_xfrm_keeps_own_geometry() {
    let slide = make_slide_with_shapes(&[make_placeholder_sp(
        r#"type="title""#,
        Some((914_400, 914_400, 3_657_600, 914_400)),
        "Hello",
    )]);
    let layout = make_layout_with_shapes(&[make_placeholder_sp(
        r#"type="title""#,
        Some((457_200, 274_638, 8_229_600, 1_143_000)),
        "Layout title",
    )]);
    let master = make_master_with_shapes(&[]);
    let data = build_test_pptx_with_layout_master(SLIDE_CX, SLIDE_CY, &slide, &layout, &master);

    let doc = parse_document(&data);
    let page = first_fixed_page(&doc);
    let element = find_text_box_with_text(page, "Hello");
    assert_geometry(element, 914_400, 914_400, 3_657_600, 914_400);
}

// ── Picture placeholder ──────────────────────────────────────────────

/// Build a PPTX with one slide (with an image), one layout, and one master.
fn build_test_pptx_with_layout_master_and_image(
    slide_xml: &str,
    layout_xml: &str,
    master_xml: &str,
    image_bytes: &[u8],
) -> Vec<u8> {
    let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
    let opts = FileOptions::default();

    let ct = r#"<?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"/><Default Extension="bmp" ContentType="image/bmp"/><Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/><Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/><Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/></Types>"#;
    zip.start_file("[Content_Types].xml", opts).unwrap();
    zip.write_all(ct.as_bytes()).unwrap();

    zip.start_file("_rels/.rels", opts).unwrap();
    zip.write_all(
        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>"#,
    )
    .unwrap();

    let pres = format!(
        r#"<?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="{SLIDE_CX}" cy="{SLIDE_CY}"/><p:sldIdLst><p:sldId id="256" r:id="rId2"/></p:sldIdLst></p:presentation>"#,
    );
    zip.start_file("ppt/presentation.xml", opts).unwrap();
    zip.write_all(pres.as_bytes()).unwrap();

    zip.start_file("ppt/_rels/presentation.xml.rels", opts)
        .unwrap();
    zip.write_all(
        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/slideMaster" Target="slideMasters/slideMaster1.xml"/></Relationships>"#,
    )
    .unwrap();

    zip.start_file("ppt/slides/slide1.xml", opts).unwrap();
    zip.write_all(slide_xml.as_bytes()).unwrap();

    zip.start_file("ppt/slides/_rels/slide1.xml.rels", opts)
        .unwrap();
    zip.write_all(
        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/slideLayout" Target="../slideLayouts/slideLayout1.xml"/><Relationship Id="rId10" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/image1.bmp"/></Relationships>"#,
    )
    .unwrap();

    zip.start_file("ppt/slideLayouts/slideLayout1.xml", opts)
        .unwrap();
    zip.write_all(layout_xml.as_bytes()).unwrap();

    zip.start_file("ppt/slideLayouts/_rels/slideLayout1.xml.rels", opts)
        .unwrap();
    zip.write_all(
        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/slideMaster" Target="../slideMasters/slideMaster1.xml"/></Relationships>"#,
    )
    .unwrap();

    zip.start_file("ppt/slideMasters/slideMaster1.xml", opts)
        .unwrap();
    zip.write_all(master_xml.as_bytes()).unwrap();

    zip.start_file("ppt/media/image1.bmp", opts).unwrap();
    zip.write_all(image_bytes).unwrap();

    zip.finish().unwrap().into_inner()
}

#[test]
fn test_picture_placeholder_inherits_layout_geometry() {
    let pic = r#"<p:pic><p:nvPicPr><p:cNvPr id="3" name="Picture"/><p:cNvPicPr/><p:nvPr><p:ph type="pic" idx="1"/></p:nvPr></p:nvPicPr><p:blipFill><a:blip r:embed="rId10"/><a:stretch><a:fillRect/></a:stretch></p:blipFill><p:spPr/></p:pic>"#;
    let slide = make_slide_with_shapes(&[pic.to_string()]);
    let layout = make_layout_with_shapes(&[make_placeholder_sp(
        r#"type="pic" idx="1""#,
        Some((2_286_000, 1_143_000, 4_572_000, 3_429_000)),
        "Layout picture caption",
    )]);
    let master = make_master_with_shapes(&[]);
    let data = build_test_pptx_with_layout_master_and_image(
        &slide,
        &layout,
        &master,
        &image_tests::make_test_bmp(),
    );

    let doc = parse_document(&data);
    let page = first_fixed_page(&doc);
    let element = page
        .elements
        .iter()
        .find(|element| matches!(element.kind, FixedElementKind::Image(_)))
        .expect("no image element on page");
    assert_geometry(element, 2_286_000, 1_143_000, 4_572_000, 3_429_000);
}