diff --git a/crates/office2pdf/src/ir/document.rs b/crates/office2pdf/src/ir/document.rs index 138b69a5..3e9c5ccd 100644 --- a/crates/office2pdf/src/ir/document.rs +++ b/crates/office2pdf/src/ir/document.rs @@ -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). diff --git a/crates/office2pdf/src/ir/elements.rs b/crates/office2pdf/src/ir/elements.rs index 54fd2972..95ddd672 100644 --- a/crates/office2pdf/src/ir/elements.rs +++ b/crates/office2pdf/src/ir/elements.rs @@ -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, +} + /// Basic geometric shape. #[derive(Debug, Clone)] pub struct Shape { diff --git a/crates/office2pdf/src/parser/mod.rs b/crates/office2pdf/src/parser/mod.rs index 659207eb..78dfa3fd 100644 --- a/crates/office2pdf/src/parser/mod.rs +++ b/crates/office2pdf/src/parser/mod.rs @@ -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; diff --git a/crates/office2pdf/src/parser/pptx.rs b/crates/office2pdf/src/parser/pptx.rs index af05e24f..1cbab739 100644 --- a/crates/office2pdf/src/parser/pptx.rs +++ b/crates/office2pdf/src/parser/pptx.rs @@ -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, ImageFormat)>; @@ -155,6 +156,26 @@ fn parse_single_slide( // 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)); @@ -308,6 +329,46 @@ fn load_slide_images( images } +/// Map from relationship ID → list of extracted text items from SmartArt data. +type SmartArtMap = HashMap>; + +/// 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( + slide_path: &str, + archive: &mut ZipArchive, +) -> 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 { @@ -1136,7 +1197,7 @@ fn parse_slide_xml( let mut blip_embed: Option = 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; @@ -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) @@ -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#""#, + ); + // Root doc node + xml.push_str( + r#"Root"#, + ); + for (i, item) in items.iter().enumerate() { + xml.push_str(&format!( + r#"{item}"#, + i + 1 + )); + } + xml.push_str(""); + 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#""# + ) + } + + /// 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 { + 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#""#, + ) + .unwrap(); + + // _rels/.rels + zip.start_file("_rels/.rels", opts).unwrap(); + zip.write_all( + br#""#, + ) + .unwrap(); + + // ppt/presentation.xml + let pres = format!( + r#""# + ); + 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#""#, + ) + .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#""# + ); + 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); + } } diff --git a/crates/office2pdf/src/parser/smartart.rs b/crates/office2pdf/src/parser/smartart.rs new file mode 100644 index 00000000..1cc82449 --- /dev/null +++ b/crates/office2pdf/src/parser/smartart.rs @@ -0,0 +1,450 @@ +use quick_xml::Reader; +/// SmartArt diagram parser for PPTX files. +/// +/// Parses SmartArt data model XML to extract text content from diagram nodes. +/// SmartArt diagrams use the DrawingML Diagram namespace +/// (`http://schemas.openxmlformats.org/drawingml/2006/diagram`). +use quick_xml::events::Event; + +/// Parse SmartArt data model XML and extract text items from data points. +/// +/// The data model XML contains `` elements with `type` attributes. +/// We extract text from `type="node"` points (the actual content nodes), +/// skipping `type="doc"` (root), `type="pres"` (presentation), and +/// `type="parTrans"`/`type="sibTrans"` (transition) points. +/// +/// Text is found inside `///` within each point. +pub(crate) fn parse_smartart_data_xml(xml: &str) -> Vec { + let mut items = Vec::new(); + let mut reader = Reader::from_str(xml); + + // State tracking + let mut in_pt = false; + let mut pt_is_node = false; + let mut in_t_block = false; // inside (text body) + let mut in_a_r = false; // inside (run) + let mut in_a_t = false; // inside (text) + let mut current_text = String::new(); + let mut pt_depth: u32 = 0; + + loop { + match reader.read_event() { + Ok(Event::Start(ref e)) => { + let local = e.local_name(); + match local.as_ref() { + b"pt" if !in_pt => { + in_pt = true; + pt_depth = 1; + current_text.clear(); + + // Check type attribute — default to "node" if absent + let mut pt_type = String::from("node"); + for attr in e.attributes().flatten() { + if attr.key.local_name().as_ref() == b"type" + && let Ok(v) = attr.unescape_value() + { + pt_type = v.to_string(); + } + } + pt_is_node = pt_type == "node"; + } + b"pt" if in_pt => { + pt_depth += 1; + } + b"t" if in_a_r => { + // inside — the actual text element + in_a_t = true; + } + b"r" if in_t_block => { + in_a_r = true; + } + b"t" if in_pt && pt_is_node && !in_t_block => { + // — the text body container + in_t_block = true; + } + _ => {} + } + } + Ok(Event::Text(ref t)) if in_a_t => { + if let Ok(text) = t.xml_content() { + current_text.push_str(&text); + } + } + Ok(Event::End(ref e)) => { + let local = e.local_name(); + match local.as_ref() { + b"pt" if in_pt => { + pt_depth -= 1; + if pt_depth == 0 { + if pt_is_node { + let trimmed = current_text.trim().to_string(); + if !trimmed.is_empty() { + items.push(trimmed); + } + } + in_pt = false; + current_text.clear(); + } + } + b"t" if in_a_r => { + in_a_t = false; + } + b"r" if in_t_block => { + in_a_r = false; + } + b"t" if in_pt && !in_a_r => { + in_t_block = false; + } + _ => {} + } + } + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + } + + items +} + +/// Reference to a SmartArt found in a slide's graphicFrame. +/// +/// Contains the position/size of the graphicFrame and the relationship +/// ID for the SmartArt data model file. +#[derive(Debug)] +pub(crate) struct SmartArtRef { + /// X position in EMU. + pub x: i64, + /// Y position in EMU. + pub y: i64, + /// Width in EMU. + pub cx: i64, + /// Height in EMU. + pub cy: i64, + /// Relationship ID for the data model (r:dm from dgm:relIds). + pub data_rid: String, +} + +/// Scan slide XML for SmartArt references within graphicFrame elements. +/// +/// Returns a list of SmartArt references with position info and +/// the relationship ID needed to resolve the data file from .rels. +pub(crate) fn scan_smartart_refs(slide_xml: &str) -> Vec { + let mut refs = Vec::new(); + let mut reader = Reader::from_str(slide_xml); + + let mut in_graphic_frame = false; + let mut gf_x: i64 = 0; + let mut gf_y: i64 = 0; + let mut gf_cx: i64 = 0; + let mut gf_cy: i64 = 0; + let mut in_gf_xfrm = false; + + loop { + match reader.read_event() { + Ok(Event::Start(ref e)) => { + let local = e.local_name(); + match local.as_ref() { + b"graphicFrame" if !in_graphic_frame => { + in_graphic_frame = true; + gf_x = 0; + gf_y = 0; + gf_cx = 0; + gf_cy = 0; + in_gf_xfrm = false; + } + b"xfrm" if in_graphic_frame => { + in_gf_xfrm = true; + } + _ => {} + } + } + Ok(Event::Empty(ref e)) => { + let local = e.local_name(); + match local.as_ref() { + b"off" if in_gf_xfrm => { + for attr in e.attributes().flatten() { + match attr.key.local_name().as_ref() { + b"x" => { + if let Ok(v) = attr.unescape_value() { + gf_x = v.parse().unwrap_or(0); + } + } + b"y" => { + if let Ok(v) = attr.unescape_value() { + gf_y = v.parse().unwrap_or(0); + } + } + _ => {} + } + } + } + b"ext" if in_gf_xfrm => { + for attr in e.attributes().flatten() { + match attr.key.local_name().as_ref() { + b"cx" => { + if let Ok(v) = attr.unescape_value() { + gf_cx = v.parse().unwrap_or(0); + } + } + b"cy" => { + if let Ok(v) = attr.unescape_value() { + gf_cy = v.parse().unwrap_or(0); + } + } + _ => {} + } + } + } + b"relIds" if in_graphic_frame => { + // + let mut data_rid = None; + for attr in e.attributes().flatten() { + // r:dm is the data model relationship + if attr.key.as_ref() == b"r:dm" + && let Ok(v) = attr.unescape_value() + { + data_rid = Some(v.to_string()); + } + } + if let Some(rid) = data_rid { + refs.push(SmartArtRef { + x: gf_x, + y: gf_y, + cx: gf_cx, + cy: gf_cy, + data_rid: rid, + }); + } + } + _ => {} + } + } + Ok(Event::End(ref e)) => { + let local = e.local_name(); + match local.as_ref() { + b"graphicFrame" if in_graphic_frame => { + in_graphic_frame = false; + } + b"xfrm" if in_gf_xfrm => { + in_gf_xfrm = false; + } + _ => {} + } + } + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + } + + refs +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_smartart_data_basic_nodes() { + let xml = r#" + + + + + + Root + + + + + Step 1 + + + + + Step 2 + + + + + Step 3 + + + "#; + + let items = parse_smartart_data_xml(xml); + assert_eq!(items, vec!["Step 1", "Step 2", "Step 3"]); + } + + #[test] + fn test_parse_smartart_data_skips_transitions() { + let xml = r#" + + + + Root + + + Item A + + + Trans + + + SibTrans + + + Item B + + + "#; + + let items = parse_smartart_data_xml(xml); + assert_eq!(items, vec!["Item A", "Item B"]); + } + + #[test] + fn test_parse_smartart_data_empty_text_skipped() { + let xml = r#" + + + + + + + Valid + + + "#; + + let items = parse_smartart_data_xml(xml); + assert_eq!(items, vec!["Valid"]); + } + + #[test] + fn test_parse_smartart_data_multi_run_text() { + let xml = r#" + + + + + Hello + World + + + + "#; + + let items = parse_smartart_data_xml(xml); + assert_eq!(items, vec!["Hello World"]); + } + + #[test] + fn test_parse_smartart_data_node_without_type_defaults_to_node() { + // Points without an explicit type attribute default to "node" + let xml = r#" + + + + Implicit Node + + + "#; + + let items = parse_smartart_data_xml(xml); + assert_eq!(items, vec!["Implicit Node"]); + } + + #[test] + fn test_scan_smartart_refs_basic() { + let slide_xml = r#" + + + + + + + + + + + + + + + + + + + + "#; + + let refs = scan_smartart_refs(slide_xml); + assert_eq!(refs.len(), 1); + assert_eq!(refs[0].x, 914400); + assert_eq!(refs[0].y, 1828800); + assert_eq!(refs[0].cx, 5486400); + assert_eq!(refs[0].cy, 3086100); + assert_eq!(refs[0].data_rid, "rId5"); + } + + #[test] + fn test_scan_smartart_refs_no_smartart() { + let slide_xml = r#" + + + + + + Hello + + + "#; + + let refs = scan_smartart_refs(slide_xml); + assert!(refs.is_empty()); + } + + #[test] + fn test_scan_smartart_refs_multiple() { + let slide_xml = r#" + + + + + + + + + + + + + + + + + + "#; + + let refs = scan_smartart_refs(slide_xml); + assert_eq!(refs.len(), 2); + assert_eq!(refs[0].data_rid, "rId10"); + assert_eq!(refs[1].data_rid, "rId20"); + assert_eq!(refs[1].x, 500); + } +} diff --git a/crates/office2pdf/src/render/typst_gen.rs b/crates/office2pdf/src/render/typst_gen.rs index 4bb45467..161eaff8 100644 --- a/crates/office2pdf/src/render/typst_gen.rs +++ b/crates/office2pdf/src/render/typst_gen.rs @@ -6,7 +6,8 @@ use crate::ir::{ Alignment, Block, BorderSide, CellBorder, Chart, ChartType, Color, Document, FixedElement, FixedElementKind, FixedPage, FloatingImage, FlowPage, HFInline, HeaderFooter, ImageData, ImageFormat, LineSpacing, List, ListKind, Margins, MathEquation, Page, PageSize, Paragraph, - ParagraphStyle, Run, Shape, ShapeKind, Table, TableCell, TablePage, TextStyle, WrapMode, + ParagraphStyle, Run, Shape, ShapeKind, SmartArt, Table, TableCell, TablePage, TextStyle, + WrapMode, }; /// An image asset to be embedded in the Typst compilation. @@ -207,6 +208,9 @@ fn generate_fixed_element( FixedElementKind::Table(table) => { generate_table(out, table, ctx)?; } + FixedElementKind::SmartArt(smartart) => { + generate_smartart(out, smartart, elem.width, elem.height); + } } out.push_str("]\n"); @@ -532,6 +536,26 @@ fn generate_chart(out: &mut String, chart: &Chart) { let _ = writeln!(out, ")\n"); } +/// Generate Typst markup for a SmartArt diagram. +/// +/// Renders SmartArt as a bordered box containing a bulleted list of text items. +/// This is a simplified representation since full SmartArt layout is not feasible. +fn generate_smartart(out: &mut String, smartart: &SmartArt, width: f64, height: f64) { + let _ = writeln!( + out, + "#block(width: {}pt, height: {}pt, stroke: 0.5pt + gray, inset: 8pt)[", + format_f64(width), + format_f64(height), + ); + let _ = writeln!(out, "#align(center)[*SmartArt Diagram*]\n"); + + for item in &smartart.items { + let escaped = escape_typst(item); + let _ = writeln!(out, "- {escaped}"); + } + out.push_str("]\n"); +} + /// Generate Typst markup for a list (ordered or unordered). /// /// Uses Typst's `#enum()` for ordered lists and `#list()` for unordered lists. @@ -3918,4 +3942,105 @@ mod tests { output.source ); } + + // ── SmartArt codegen tests ────────────────────────────────────────── + + #[test] + fn test_smartart_codegen_basic() { + let doc = make_doc(vec![make_fixed_page( + 720.0, + 540.0, + vec![FixedElement { + x: 72.0, + y: 100.0, + width: 400.0, + height: 300.0, + kind: FixedElementKind::SmartArt(SmartArt { + items: vec![ + "Step 1".to_string(), + "Step 2".to_string(), + "Step 3".to_string(), + ], + }), + }], + )]); + + let output = generate_typst(&doc).unwrap(); + assert!( + output.source.contains("SmartArt Diagram"), + "Expected SmartArt header, got:\n{}", + output.source + ); + assert!( + output.source.contains("- Step 1"), + "Expected Step 1, got:\n{}", + output.source + ); + assert!( + output.source.contains("- Step 2"), + "Expected Step 2, got:\n{}", + output.source + ); + assert!( + output.source.contains("- Step 3"), + "Expected Step 3, got:\n{}", + output.source + ); + } + + #[test] + fn test_smartart_codegen_empty_items() { + let doc = make_doc(vec![make_fixed_page( + 720.0, + 540.0, + vec![FixedElement { + x: 0.0, + y: 0.0, + width: 200.0, + height: 100.0, + kind: FixedElementKind::SmartArt(SmartArt { items: vec![] }), + }], + )]); + + let output = generate_typst(&doc).unwrap(); + assert!( + output.source.contains("SmartArt Diagram"), + "Expected SmartArt header even for empty SmartArt" + ); + // No list items + assert!( + !output.source.contains("- "), + "Should not contain list items for empty SmartArt" + ); + } + + #[test] + fn test_smartart_codegen_special_chars() { + let doc = make_doc(vec![make_fixed_page( + 720.0, + 540.0, + vec![FixedElement { + x: 0.0, + y: 0.0, + width: 200.0, + height: 100.0, + kind: FixedElementKind::SmartArt(SmartArt { + items: vec!["Item #1".to_string(), "Price $10".to_string()], + }), + }], + )]); + + let output = generate_typst(&doc).unwrap(); + // # and $ should be escaped + assert!( + output.source.contains(r"\#"), + "Expected escaped #, got:\n{}", + output.source + ); + assert!( + output.source.contains(r"\$"), + "Expected escaped $, got:\n{}", + output.source + ); + } } diff --git a/scripts/ralph/prd.json b/scripts/ralph/prd.json index 45e40b97..0f5b06af 100644 --- a/scripts/ralph/prd.json +++ b/scripts/ralph/prd.json @@ -69,7 +69,7 @@ "cargo clippy --workspace -- -D warnings passes" ], "priority": 4, - "passes": false, + "passes": true, "notes": "" }, { diff --git a/scripts/ralph/progress.txt b/scripts/ralph/progress.txt index b7d9f2fe..4305096c 100644 --- a/scripts/ralph/progress.txt +++ b/scripts/ralph/progress.txt @@ -63,6 +63,7 @@ - **PPTX tables**: Tables live inside `` elements (not ``). Position from `` (not ``). Table XML: ``. Grid: ``. Rows: ``. Cells: `` with optional `gridSpan` (colspan), `rowSpan`, `hMerge`/`vMerge` (continuation). Cell content: `` with same paragraph/run structure as shapes. Cell properties: `` with `` for background, `` for borders. Maps to `FixedElementKind::Table(Table)` in IR. `parse_pptx_table()` is a standalone sub-parser called from `parse_slide_xml()` when `` is found. Test helpers: `make_table_graphic_frame(x, y, cx, cy, col_widths_emu, rows_xml)`, `make_table_row(cells_text)`, `table_element(elem)`. - **DOCX OMML math equations**: docx-rs v0.4.19 does NOT parse `m:` namespace elements — they are completely absent from `ParagraphChild`/`RunChild` enums. Use raw ZIP scanning pattern (like WrapContext/NoteContext). `MathContext` struct with `HashMap>` keyed by body child index. `build_math_context(data)` opens ZIP, reads `word/document.xml`, calls `omml::scan_math_equations(xml)`. `scan_math_equations()` tracks `depth_in_body` to count top-level body children, processes `m:oMath`/`m:oMathPara` inline via `parse_omml_children()`. IR: `Block::MathEquation(MathEquation { content: String, display: bool })`. Codegen: display → `$ content $\n`, inline → `$content$`. OMML elements handled: `m:f` (fraction→`frac()`), `m:sSup`/`m:sSub`/`m:sSubSup` (scripts→`^`/`_`), `m:rad` (radical→`sqrt()`/`root()`), `m:d` (delimiter→parens), `m:nary` (n-ary→`sum`/`product`/`integral`), `m:func`, `m:limLow`/`m:limUpp`, `m:acc`, `m:bar`, `m:eqArr`. Math run text: `m:r`→`m:t` via `xml_content()`. Test DOCX with math: `build_docx_with_math(document_xml)` creates minimal ZIP with custom `word/document.xml`. - **DOCX embedded charts**: docx-rs does NOT parse chart drawings — `DrawingData::Pic` handler silently skips charts. Use raw ZIP scanning. `ChartContext` with `HashMap>` keyed by body child index. `build_chart_context(data)` reads `word/_rels/document.xml.rels` for chart relationships (`scan_chart_rels`), scans `word/document.xml` for `` in `` (`scan_chart_references`), reads each `word/charts/chartN.xml`, and calls `parse_chart_xml()`. IR: `Chart { chart_type: ChartType, title: Option, categories: Vec, series: Vec }`, `ChartSeries { name: Option, values: Vec }`. Chart types: `barChart`→Bar, `barChart barDir=col`→Column (but currently just Bar), `lineChart`→Line, `pieChart`→Pie, `areaChart`→Area, `scatterChart`→Scatter. Data from `/` within ``. Codegen: title as bold centered text, type label as italic, data table with `#table(columns: N, ...)`. Test DOCX with chart: `build_docx_with_chart()` creates ZIP with `[Content_Types].xml`, `_rels/.rels`, `word/_rels/document.xml.rels` (chart relationship), `word/document.xml` (with ``), `word/charts/chart1.xml` (barChart with data). +- **PPTX SmartArt diagrams**: SmartArt uses `` with `` containing ``. Detection via `smartart::scan_smartart_refs(slide_xml)` → `Vec` (position + data_rid). Data resolved by `load_smartart_data(slide_path, archive)` which reads slide .rels for targets containing "diagrams/data", then parses each data XML via `smartart::parse_smartart_data_xml()`. Data model XML (`ppt/diagrams/dataN.xml`): `` contains `` elements. Only `type="node"` (or absent type, which defaults to "node") have user content; skip `type="doc"`, `type="parTrans"`, `type="sibTrans"`. Text in `///`. IR: `SmartArt { items: Vec }`, `FixedElementKind::SmartArt`. Codegen: bordered `#block` with "SmartArt Diagram" header and `- item` list. Integration point: `parse_single_slide()` (not `parse_slide_xml()`) since SmartArt requires ZIP archive access. Test helper: `build_test_pptx_with_smartart(cx, cy, slide_xml, data_rid, data_xml)`, `make_smartart_graphic_frame(x, y, cx, cy, dm_rid)`, `make_smartart_data_xml(&["Item 1", "Item 2"])`. - **DOCX floating images**: `WrapContext` scans raw document.xml for `wp:anchor` elements in document order (similar pattern to `NoteContext`). `AnchorWrapInfo` stores wrap_mode (WrapMode) and behind_doc (bool). `scan_anchor_wrap_types()` parses wp:wrapSquare→Square, wp:wrapTight/wrapThrough→Tight, wp:wrapTopAndBottom→TopAndBottom, wp:wrapNone→depends on behindDoc. `extract_drawing_image()` checks `pic.position_type == Anchor` → consumes next wrap info → `Block::FloatingImage(FloatingImage)`. Position from `DrawingPosition::Offset(emu)` → `emu / 12700.0` points. Codegen: Square/Tight → `#place(float: true)`, TopAndBottom → `#block` with `#v()` spacer, Behind/InFront/None → `#place()` absolute. Test helpers: `patch_docx_wrap_type(data, old_wrap, new_wrap)` replaces wrap elements in ZIP, `patch_docx_behind_doc(data)` sets `behindDoc="1"`. Create floating test Pic: `Pic::new(&bmp).floating().offset_x(emu).offset_y(emu)` (default: wrapSquare if !allow_overlap), `.overlapping()` → wrapNone. --- @@ -127,3 +128,27 @@ Started: 2026년 2월 27일 금요일 12시 19분 03초 KST - Relationship targets are relative to word/ directory - barDir val="bar" means horizontal bars, val="col" means vertical columns --- + +## 2026-02-27 - US-043: PPTX parser - SmartArt diagrams (basic) +- What was implemented: + - IR: SmartArt struct (items: Vec), FixedElementKind::SmartArt variant + - Parser: smartart.rs module — parse_smartart_data_xml() extracts text from dgm:pt type="node" elements in diagram data model XML, scan_smartart_refs() detects SmartArt graphicFrames and extracts dgm:relIds r:dm relationship IDs + - Integration: parse_single_slide() scans slide XML for SmartArt references, resolves data files via slide .rels (targets containing "diagrams/data"), adds FixedElementKind::SmartArt elements at graphicFrame position + - Codegen: generate_smartart() renders as bordered box with "SmartArt Diagram" header and Typst bulleted list (- item) format + - Tests: 8 unit tests (data parsing including type filtering, multi-run text, ref scanning), 3 PPTX integration tests (SmartArt produces elements, mixed with text box, no SmartArt slide), 3 codegen tests (basic, empty, special chars escaping) +- Files changed: + - crates/office2pdf/src/parser/smartart.rs (new — parse_smartart_data_xml, scan_smartart_refs, SmartArtRef) + - crates/office2pdf/src/parser/mod.rs (module declaration) + - crates/office2pdf/src/parser/pptx.rs (SmartArtMap, load_smartart_data, integration in parse_single_slide, test helpers and tests) + - crates/office2pdf/src/ir/elements.rs (SmartArt struct) + - crates/office2pdf/src/ir/document.rs (FixedElementKind::SmartArt variant) + - crates/office2pdf/src/render/typst_gen.rs (generate_smartart, SmartArt codegen tests) +- Dependencies added: none +- **Learnings for future iterations:** + - SmartArt in PPTX uses dgm:relIds inside graphicFrame's graphicData (URI contains "diagram") + - SmartArt data model XML has dgm:pt elements with type attribute: "doc" (root), "node" (content), "parTrans"/"sibTrans" (transitions) — only "node" types contain user content + - Points without explicit type attribute default to "node" + - Text is nested deeply: dgm:pt → dgm:t → a:p → a:r → a:t — careful match arm ordering needed to avoid matching nested a:t as dgm:t + - SmartArt data files are referenced in slide .rels with targets like "../diagrams/data1.xml" + - Detection handled outside parse_slide_xml (in parse_single_slide) since SmartArt requires ZIP archive access for data files, keeping parse_slide_xml focused on XML-only parsing +---