Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/office2pdf/src/ir/document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ pub enum FixedElementKind {
Image(super::elements::ImageData),
Shape(super::elements::Shape),
Table(super::elements::Table),
SmartArt(super::elements::SmartArt),
}

/// A table-based page (XLSX sheets).
Expand Down
11 changes: 11 additions & 0 deletions crates/office2pdf/src/ir/elements.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,17 @@ impl ImageFormat {
}
}

/// SmartArt diagram content extracted from a presentation.
///
/// Contains text items extracted from the SmartArt data model.
/// Rendered as a simplified list or boxed layout since full SmartArt
/// layout engines are not feasible in a pure-Rust converter.
#[derive(Debug, Clone)]
pub struct SmartArt {
/// Text items extracted from SmartArt data points (type="node").
pub items: Vec<String>,
}

/// Basic geometric shape.
#[derive(Debug, Clone)]
pub struct Shape {
Expand Down
1 change: 1 addition & 0 deletions crates/office2pdf/src/parser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ pub(crate) mod chart;
pub mod docx;
pub(crate) mod omml;
pub mod pptx;
pub(crate) mod smartart;
pub mod xlsx;

use crate::config::ConvertOptions;
Expand Down
252 changes: 249 additions & 3 deletions crates/office2pdf/src/parser/pptx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ use crate::error::{ConvertError, ConvertWarning};
use crate::ir::{
Alignment, Block, BorderSide, CellBorder, Color, Document, FixedElement, FixedElementKind,
FixedPage, ImageData, ImageFormat, Metadata, Page, PageSize, Paragraph, ParagraphStyle, Run,
Shape, ShapeKind, StyleSheet, Table, TableCell, TableRow, TextStyle,
Shape, ShapeKind, SmartArt, StyleSheet, Table, TableCell, TableRow, TextStyle,
};
use crate::parser::Parser;
use crate::parser::smartart;

/// Map from relationship ID → (image bytes, format).
type SlideImageMap = HashMap<String, (Vec<u8>, ImageFormat)>;
Expand Down Expand Up @@ -155,6 +156,26 @@ fn parse_single_slide<R: Read + std::io::Seek>(
// Slide elements (on top)
elements.extend(slide_elements);

// SmartArt diagrams: scan for dgm:relIds in graphicFrames,
// resolve data files from .rels, and add as SmartArt elements.
let smartart_refs = smartart::scan_smartart_refs(&slide_xml);
if !smartart_refs.is_empty() {
let smartart_data = load_smartart_data(slide_path, archive);
for sa_ref in &smartart_refs {
if let Some(items) = smartart_data.get(&sa_ref.data_rid) {
elements.push(FixedElement {
x: emu_to_pt(sa_ref.x),
y: emu_to_pt(sa_ref.y),
width: emu_to_pt(sa_ref.cx),
height: emu_to_pt(sa_ref.cy),
kind: FixedElementKind::SmartArt(SmartArt {
items: items.clone(),
}),
});
}
}
}

// Resolve background color: slide → layout → master
let background_color = parse_background_color(&slide_xml, theme)
.or_else(|| resolve_inherited_background(slide_path, theme, archive));
Expand Down Expand Up @@ -308,6 +329,46 @@ fn load_slide_images<R: Read + std::io::Seek>(
images
}

/// Map from relationship ID → list of extracted text items from SmartArt data.
type SmartArtMap = HashMap<String, Vec<String>>;

/// Pre-load SmartArt diagram data for a slide by scanning its .rels file
/// for diagram/data relationships and parsing the data XML files.
fn load_smartart_data<R: Read + std::io::Seek>(
slide_path: &str,
archive: &mut ZipArchive<R>,
) -> SmartArtMap {
let mut map = SmartArtMap::new();

let rels_xml = match read_zip_entry(archive, &rels_path_for(slide_path)) {
Ok(xml) => xml,
Err(_) => return map,
};

let slide_dir = slide_path.rsplit_once('/').map(|(d, _)| d).unwrap_or("");

// parse_rels_xml gives Id→Target; scan for targets pointing to diagrams/data
let rels = parse_rels_xml(&rels_xml);
for (id, target) in &rels {
if !target.contains("diagrams/data") && !target.contains("diagram/data") {
continue;
}
let data_path = if let Some(stripped) = target.strip_prefix('/') {
stripped.to_string()
} else {
resolve_relative_path(slide_dir, target)
};
if let Ok(data_xml) = read_zip_entry(archive, &data_path) {
let texts = smartart::parse_smartart_data_xml(&data_xml);
if !texts.is_empty() {
map.insert(id.clone(), texts);
}
}
}

map
}

/// Resolve a relative path against a base directory.
/// e.g., base="ppt/slides", relative="../media/image1.png" → "ppt/media/image1.png"
fn resolve_relative_path(base_dir: &str, relative: &str) -> String {
Expand Down Expand Up @@ -1136,7 +1197,7 @@ fn parse_slide_xml(
let mut blip_embed: Option<String> = None;
let mut in_pic_xfrm = false;

// ── GraphicFrame-level state (for tables) ───────────────────────────
// ── GraphicFrame-level state (for tables and SmartArt) ─────────────
let mut in_graphic_frame = false;
let mut gf_x: i64 = 0;
let mut gf_y: i64 = 0;
Expand Down Expand Up @@ -1173,7 +1234,6 @@ fn parse_slide_xml(
});
}
}

// ── Group shape start ────────────────────────────
b"grpSp" if !in_shape && !in_pic && !in_graphic_frame => {
if let Ok(group_elems) = parse_group_shape(&mut reader, xml, images, theme)
Expand Down Expand Up @@ -4266,4 +4326,190 @@ mod tests {
);
assert!(matches!(s.kind, ShapeKind::Ellipse));
}

// ── SmartArt test helpers ───────────────────────────────────────────

/// Create SmartArt data model XML with the given text items.
fn make_smartart_data_xml(items: &[&str]) -> String {
let mut xml = String::from(
r#"<?xml version="1.0" encoding="UTF-8"?><dgm:dataModel xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><dgm:ptLst>"#,
);
// Root doc node
xml.push_str(
r#"<dgm:pt modelId="0" type="doc"><dgm:prSet/><dgm:spPr/><dgm:t><a:bodyPr/><a:p><a:r><a:t>Root</a:t></a:r></a:p></dgm:t></dgm:pt>"#,
);
for (i, item) in items.iter().enumerate() {
xml.push_str(&format!(
r#"<dgm:pt modelId="{}" type="node"><dgm:prSet/><dgm:spPr/><dgm:t><a:bodyPr/><a:p><a:r><a:t>{item}</a:t></a:r></a:p></dgm:t></dgm:pt>"#,
i + 1
));
}
xml.push_str("</dgm:ptLst></dgm:dataModel>");
xml
}

/// Create a SmartArt graphicFrame XML element for a slide.
fn make_smartart_graphic_frame(x: i64, y: i64, cx: i64, cy: i64, dm_rid: &str) -> String {
format!(
r#"<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="4" name="SmartArt"/><p:cNvGraphicFramePr><a:graphicFrameLocks noGrp="1"/></p:cNvGraphicFramePr><p:nvPr/></p:nvGraphicFramePr><p:xfrm><a:off x="{x}" y="{y}"/><a:ext cx="{cx}" cy="{cy}"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/diagram"><dgm:relIds xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram" r:dm="{dm_rid}" r:lo="rId99" r:qs="rId98" r:cs="rId97"/></a:graphicData></a:graphic></p:graphicFrame>"#
)
}

/// Build a PPTX with SmartArt diagram data embedded.
fn build_test_pptx_with_smartart(
slide_cx_emu: i64,
slide_cy_emu: i64,
slide_xml: &str,
data_rid: &str,
data_xml: &str,
) -> Vec<u8> {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let opts = FileOptions::default();

// [Content_Types].xml
zip.start_file("[Content_Types].xml", opts).unwrap();
zip.write_all(
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>"#,
)
.unwrap();

// _rels/.rels
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();

// ppt/presentation.xml
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_emu}" cy="{slide_cy_emu}"/><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();

// ppt/_rels/presentation.xml.rels
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"/></Relationships>"#,
)
.unwrap();

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

// ppt/slides/_rels/slide1.xml.rels — links data_rid to diagram data
let slide_rels = format!(
r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="{data_rid}" Type="http://schemas.microsoft.com/office/2007/relationships/diagramData" Target="../diagrams/data1.xml"/></Relationships>"#
);
zip.start_file("ppt/slides/_rels/slide1.xml.rels", opts)
.unwrap();
zip.write_all(slide_rels.as_bytes()).unwrap();

// ppt/diagrams/data1.xml — the SmartArt data model
zip.start_file("ppt/diagrams/data1.xml", opts).unwrap();
zip.write_all(data_xml.as_bytes()).unwrap();

let cursor = zip.finish().unwrap();
cursor.into_inner()
}

/// Extract SmartArt from a FixedElement.
fn get_smartart(elem: &FixedElement) -> &SmartArt {
match &elem.kind {
FixedElementKind::SmartArt(sa) => sa,
_ => panic!("Expected SmartArt, got {:?}", elem.kind),
}
}

// ── SmartArt integration tests ──────────────────────────────────────

#[test]
fn test_slide_with_smartart_produces_items() {
let smartart_frame = make_smartart_graphic_frame(
914_400, // x = 72pt
1_828_800, // y = 144pt
5_486_400, // cx = 432pt
3_086_100, // cy ≈ 243pt
"rId5",
);
let slide_xml = make_slide_xml(&[smartart_frame]);
let data_xml = make_smartart_data_xml(&["Step 1", "Step 2", "Step 3"]);
let data = build_test_pptx_with_smartart(SLIDE_CX, SLIDE_CY, &slide_xml, "rId5", &data_xml);

let parser = PptxParser;
let (doc, _warnings) = parser.parse(&data, &ConvertOptions::default()).unwrap();

let page = first_fixed_page(&doc);
// Find the SmartArt element
let sa_elems: Vec<_> = page
.elements
.iter()
.filter(|e| matches!(e.kind, FixedElementKind::SmartArt(_)))
.collect();
assert_eq!(sa_elems.len(), 1, "Expected 1 SmartArt element");

let sa = get_smartart(sa_elems[0]);
assert_eq!(sa.items, vec!["Step 1", "Step 2", "Step 3"]);

// Check position
assert!((sa_elems[0].x - 72.0).abs() < 0.1);
assert!((sa_elems[0].y - 144.0).abs() < 0.1);
}

#[test]
fn test_slide_with_smartart_and_text_box() {
let text_box = make_text_box(100_000, 100_000, 500_000, 200_000, "Title");
let smartart_frame =
make_smartart_graphic_frame(500_000, 500_000, 3_000_000, 2_000_000, "rId5");
let slide_xml = make_slide_xml(&[text_box, smartart_frame]);
let data_xml = make_smartart_data_xml(&["Item A", "Item B"]);
let data = build_test_pptx_with_smartart(SLIDE_CX, SLIDE_CY, &slide_xml, "rId5", &data_xml);

let parser = PptxParser;
let (doc, _warnings) = parser.parse(&data, &ConvertOptions::default()).unwrap();

let page = first_fixed_page(&doc);
// Should have at least 2 elements: text box + SmartArt
let sa_count = page
.elements
.iter()
.filter(|e| matches!(e.kind, FixedElementKind::SmartArt(_)))
.count();
let tb_count = page
.elements
.iter()
.filter(|e| matches!(e.kind, FixedElementKind::TextBox(_)))
.count();
assert_eq!(sa_count, 1);
assert!(tb_count >= 1);

// Verify SmartArt content
let sa_elem = page
.elements
.iter()
.find(|e| matches!(e.kind, FixedElementKind::SmartArt(_)))
.unwrap();
let sa = get_smartart(sa_elem);
assert_eq!(sa.items, vec!["Item A", "Item B"]);
}

#[test]
fn test_slide_without_smartart_no_smartart_elements() {
let text_box = make_text_box(0, 0, 500_000, 200_000, "No SmartArt");
let slide_xml = make_slide_xml(&[text_box]);
let data = build_test_pptx(SLIDE_CX, SLIDE_CY, &[slide_xml]);

let parser = PptxParser;
let (doc, _warnings) = parser.parse(&data, &ConvertOptions::default()).unwrap();

let page = first_fixed_page(&doc);
let sa_count = page
.elements
.iter()
.filter(|e| matches!(e.kind, FixedElementKind::SmartArt(_)))
.count();
assert_eq!(sa_count, 0);
}
}
Loading