diff --git a/crates/office2pdf/src/ir/elements.rs b/crates/office2pdf/src/ir/elements.rs index 9729f39..3c0bf10 100644 --- a/crates/office2pdf/src/ir/elements.rs +++ b/crates/office2pdf/src/ir/elements.rs @@ -446,9 +446,20 @@ pub struct Shape { pub enum ShapeKind { Rectangle, Ellipse, + /// Straight line from `(x1,y1)` to `(x2,y2)` in points, relative to element's top-left. Line { + x1: f64, + y1: f64, x2: f64, y2: f64, + head_end: ArrowHead, + tail_end: ArrowHead, + }, + /// Multi-segment polyline in points, relative to element's top-left. + Polyline { + points: Vec<(f64, f64)>, + head_end: ArrowHead, + tail_end: ArrowHead, }, /// Rectangle with rounded corners. `radius_fraction` is relative to `min(width, height)`. RoundedRectangle { @@ -460,6 +471,14 @@ pub enum ShapeKind { }, } +/// Arrowhead decoration on a line endpoint. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ArrowHead { + #[default] + None, + Triangle, +} + #[cfg(test)] #[path = "elements_tests.rs"] mod tests; diff --git a/crates/office2pdf/src/parser/pptx.rs b/crates/office2pdf/src/parser/pptx.rs index 9088c34..5edddc1 100644 --- a/crates/office2pdf/src/parser/pptx.rs +++ b/crates/office2pdf/src/parser/pptx.rs @@ -11,8 +11,8 @@ use zip::ZipArchive; use crate::config::ConvertOptions; use crate::error::{ConvertError, ConvertWarning}; use crate::ir::{ - Alignment, Block, BorderLineStyle, BorderSide, CellBorder, CellVerticalAlign, Chart, Color, - Document, FixedElement, FixedElementKind, FixedPage, GradientFill, ImageCrop, ImageData, + Alignment, ArrowHead, Block, BorderLineStyle, BorderSide, CellBorder, CellVerticalAlign, Chart, + Color, Document, FixedElement, FixedElementKind, FixedPage, GradientFill, ImageCrop, ImageData, ImageFormat, Insets, LineSpacing, List, ListItem, ListKind, ListLevelStyle, Page, PageSize, Paragraph, ParagraphStyle, Run, Shadow, Shape, ShapeKind, SmartArt, SmartArtNode, StyleSheet, Table, TableCell, TableRow, TextBoxData, TextBoxVerticalAlign, TextDirection, TextStyle, @@ -25,7 +25,8 @@ use self::package::{load_theme, parse_presentation_xml, parse_rels_xml, read_zip #[cfg(test)] use self::package::{resolve_relative_path, scan_chart_refs}; use self::shapes::{ - parse_group_shape, parse_src_rect, pptx_dash_to_border_style, prst_to_shape_kind, + parse_arrow_head, parse_group_shape, parse_src_rect, pptx_dash_to_border_style, + prst_to_shape_kind, }; use self::slides::{parse_single_slide, parse_slide_xml}; use self::tables::{parse_pptx_table, scale_pptx_table_geometry_to_frame}; diff --git a/crates/office2pdf/src/parser/pptx_connector_tests.rs b/crates/office2pdf/src/parser/pptx_connector_tests.rs new file mode 100644 index 0000000..57adab7 --- /dev/null +++ b/crates/office2pdf/src/parser/pptx_connector_tests.rs @@ -0,0 +1,539 @@ +use super::*; + +// ── Connector shape XML builders ──────────────────────────────────── + +/// Create a connector shape XML (mirrors real PPTX `` structure). +#[allow(clippy::too_many_arguments)] +fn make_connector( + x: i64, + y: i64, + cx: i64, + cy: i64, + prst: &str, + border_hex: Option<&str>, + border_width_emu: Option, + dash: Option<&str>, + flip_h: bool, + flip_v: bool, +) -> String { + make_connector_full( + x, + y, + cx, + cy, + prst, + border_hex, + border_width_emu, + dash, + flip_h, + flip_v, + "", + "", + ) +} + +/// Create a connector with arrowhead attributes. +#[allow(clippy::too_many_arguments)] +fn make_connector_with_arrows( + x: i64, + y: i64, + cx: i64, + cy: i64, + prst: &str, + border_hex: Option<&str>, + border_width_emu: Option, + dash: Option<&str>, + flip_h: bool, + flip_v: bool, + tail_type: &str, +) -> String { + let tail_xml = if tail_type.is_empty() { + String::new() + } else { + format!(r#""#) + }; + make_connector_full( + x, + y, + cx, + cy, + prst, + border_hex, + border_width_emu, + dash, + flip_h, + flip_v, + "", + &tail_xml, + ) +} + +#[allow(clippy::too_many_arguments)] +fn make_connector_full( + x: i64, + y: i64, + cx: i64, + cy: i64, + prst: &str, + border_hex: Option<&str>, + border_width_emu: Option, + dash: Option<&str>, + flip_h: bool, + flip_v: bool, + adj_xml: &str, + extra_ln_xml: &str, +) -> String { + let flip_attrs = match (flip_h, flip_v) { + (true, true) => r#" flipH="1" flipV="1""#, + (true, false) => r#" flipH="1""#, + (false, true) => r#" flipV="1""#, + (false, false) => "", + }; + + let w_attr = border_width_emu + .map(|w| format!(r#" w="{w}""#)) + .unwrap_or_default(); + + let fill_xml = border_hex + .map(|h| format!(r#""#)) + .unwrap_or_default(); + + let dash_xml = dash + .map(|d| format!(r#""#)) + .unwrap_or_default(); + + let av_lst = if adj_xml.is_empty() { + "".to_string() + } else { + format!("{adj_xml}") + }; + + format!( + r#"{av_lst}{fill_xml}{dash_xml}{extra_ln_xml}"# + ) +} + +/// Create a connector with a `` section for theme-based line color. +#[allow(clippy::too_many_arguments)] +fn make_connector_with_style( + x: i64, + y: i64, + cx: i64, + cy: i64, + prst: &str, + scheme_color: &str, + dash: Option<&str>, + flip_h: bool, + flip_v: bool, +) -> String { + let flip_attrs = match (flip_h, flip_v) { + (true, true) => r#" flipH="1" flipV="1""#, + (true, false) => r#" flipH="1""#, + (false, true) => r#" flipV="1""#, + (false, false) => "", + }; + + let dash_xml = dash + .map(|d| format!(r#""#)) + .unwrap_or_default(); + + format!( + r#"{dash_xml}"# + ) +} + +// ── Tests ─────────────────────────────────────────────────────────── + +#[test] +fn test_straight_connector_parsed_as_line() { + let connector = make_connector( + 500_000, + 1_000_000, + 3_000_000, + 0, + "straightConnector1", + Some("0F6CFE"), + Some(12700), + Some("solid"), + false, + false, + ); + let slide = make_slide_xml(&[connector]); + let data = build_test_pptx(SLIDE_CX, SLIDE_CY, &[slide]); + let parser = PptxParser; + let (doc, _warnings) = parser.parse(&data, &ConvertOptions::default()).unwrap(); + + let page = first_fixed_page(&doc); + assert_eq!(page.elements.len(), 1, "Connector should produce 1 element"); + + let elem = &page.elements[0]; + assert!((elem.x - emu_to_pt(500_000)).abs() < 0.1); + assert!((elem.y - emu_to_pt(1_000_000)).abs() < 0.1); + + let shape = get_shape(elem); + match &shape.kind { + ShapeKind::Line { x1, y1, x2, y2, .. } => { + assert!((*x1).abs() < 0.1, "x1 should be 0"); + assert!((*y1).abs() < 0.1, "y1 should be 0"); + assert!((*x2 - emu_to_pt(3_000_000)).abs() < 0.1); + assert!((*y2).abs() < 0.1); + } + _ => panic!("Expected Line shape, got {:?}", shape.kind), + } + let stroke = shape.stroke.as_ref().expect("Expected stroke on connector"); + assert!((stroke.width - 1.0).abs() < 0.1); + assert_eq!(stroke.color, Color::new(0x0F, 0x6C, 0xFE)); +} + +#[test] +fn test_connector_with_line_preset() { + let connector = make_connector( + 0, + 0, + 5_000_000, + 2_000, + "line", + Some("FF0000"), + Some(25400), + Some("dash"), + false, + false, + ); + let slide = make_slide_xml(&[connector]); + let data = build_test_pptx(SLIDE_CX, SLIDE_CY, &[slide]); + let parser = PptxParser; + let (doc, _warnings) = parser.parse(&data, &ConvertOptions::default()).unwrap(); + + let page = first_fixed_page(&doc); + assert_eq!(page.elements.len(), 1); + + let shape = get_shape(&page.elements[0]); + assert!(matches!(shape.kind, ShapeKind::Line { .. })); + let stroke = shape.stroke.as_ref().expect("Expected stroke"); + assert_eq!(stroke.color, Color::new(255, 0, 0)); + assert_eq!(stroke.style, BorderLineStyle::Dashed); +} + +#[test] +fn test_connector_flip_h_reverses_line_direction() { + let connector = make_connector( + 1_000_000, + 2_000_000, + 4_000_000, + 2_000_000, + "straightConnector1", + Some("0000FF"), + Some(12700), + None, + true, + false, // flipH only + ); + let slide = make_slide_xml(&[connector]); + let data = build_test_pptx(SLIDE_CX, SLIDE_CY, &[slide]); + let parser = PptxParser; + let (doc, _warnings) = parser.parse(&data, &ConvertOptions::default()).unwrap(); + + let page = first_fixed_page(&doc); + let shape = get_shape(&page.elements[0]); + let width: f64 = emu_to_pt(4_000_000); + let height: f64 = emu_to_pt(2_000_000); + + // flipH: start at (width, 0), end at (0, height) + match &shape.kind { + ShapeKind::Line { x1, y1, x2, y2, .. } => { + assert!( + (*x1 - width).abs() < 0.1, + "flipH: x1 should be {width}, got {x1}" + ); + assert!((*y1).abs() < 0.1, "flipH: y1 should be 0, got {y1}"); + assert!((*x2).abs() < 0.1, "flipH: x2 should be 0, got {x2}"); + assert!( + (*y2 - height).abs() < 0.1, + "flipH: y2 should be {height}, got {y2}" + ); + } + _ => panic!("Expected Line shape"), + } +} + +#[test] +fn test_connector_flip_v_reverses_line_direction() { + let connector = make_connector( + 0, + 0, + 3_000_000, + 2_000_000, + "straightConnector1", + Some("0000FF"), + Some(12700), + None, + false, + true, // flipV only + ); + let slide = make_slide_xml(&[connector]); + let data = build_test_pptx(SLIDE_CX, SLIDE_CY, &[slide]); + let parser = PptxParser; + let (doc, _warnings) = parser.parse(&data, &ConvertOptions::default()).unwrap(); + + let page = first_fixed_page(&doc); + let shape = get_shape(&page.elements[0]); + let width: f64 = emu_to_pt(3_000_000); + let height: f64 = emu_to_pt(2_000_000); + + // flipV: start at (0, height), end at (width, 0) + match &shape.kind { + ShapeKind::Line { x1, y1, x2, y2, .. } => { + assert!((*x1).abs() < 0.1, "flipV: x1 should be 0, got {x1}"); + assert!( + (*y1 - height).abs() < 0.1, + "flipV: y1 should be {height}, got {y1}" + ); + assert!( + (*x2 - width).abs() < 0.1, + "flipV: x2 should be {width}, got {x2}" + ); + assert!((*y2).abs() < 0.1, "flipV: y2 should be 0, got {y2}"); + } + _ => panic!("Expected Line shape"), + } +} + +#[test] +fn test_connector_flip_h_and_v() { + let connector = make_connector( + 0, + 0, + 3_000_000, + 2_000_000, + "straightConnector1", + Some("0000FF"), + Some(12700), + None, + true, + true, // both flips + ); + let slide = make_slide_xml(&[connector]); + let data = build_test_pptx(SLIDE_CX, SLIDE_CY, &[slide]); + let parser = PptxParser; + let (doc, _warnings) = parser.parse(&data, &ConvertOptions::default()).unwrap(); + + let page = first_fixed_page(&doc); + let shape = get_shape(&page.elements[0]); + let width: f64 = emu_to_pt(3_000_000); + let height: f64 = emu_to_pt(2_000_000); + + // flipH+V: start at (width, height), end at (0, 0) + match &shape.kind { + ShapeKind::Line { x1, y1, x2, y2, .. } => { + assert!((*x1 - width).abs() < 0.1, "x1 should be {width}, got {x1}"); + assert!( + (*y1 - height).abs() < 0.1, + "y1 should be {height}, got {y1}" + ); + assert!((*x2).abs() < 0.1, "x2 should be 0, got {x2}"); + assert!((*y2).abs() < 0.1, "y2 should be 0, got {y2}"); + } + _ => panic!("Expected Line shape"), + } +} + +#[test] +fn test_connector_mixed_with_regular_shapes() { + let rect = make_shape( + 0, + 0, + 1_000_000, + 1_000_000, + "rect", + Some("FF0000"), + None, + None, + ); + let connector = make_connector( + 1_000_000, + 500_000, + 2_000_000, + 0, + "straightConnector1", + Some("0000FF"), + Some(12700), + None, + false, + false, + ); + let slide = make_slide_xml(&[rect, connector]); + let data = build_test_pptx(SLIDE_CX, SLIDE_CY, &[slide]); + let parser = PptxParser; + let (doc, _warnings) = parser.parse(&data, &ConvertOptions::default()).unwrap(); + + let page = first_fixed_page(&doc); + assert_eq!(page.elements.len(), 2, "Should have rect + connector"); + assert!(matches!( + get_shape(&page.elements[0]).kind, + ShapeKind::Rectangle + )); + assert!(matches!( + get_shape(&page.elements[1]).kind, + ShapeKind::Line { .. } + )); +} + +#[test] +fn test_connector_with_style_based_line_color() { + let connector = make_connector_with_style( + 0, + 0, + 3_000_000, + 0, + "straightConnector1", + "accent1", + Some("dash"), + false, + false, + ); + let slide = make_slide_xml(&[connector]); + let theme_xml = make_theme_xml( + &[ + ("dk1", "000000"), + ("lt1", "FFFFFF"), + ("dk2", "44546A"), + ("lt2", "E7E6E6"), + ("accent1", "4472C4"), + ("accent2", "ED7D31"), + ("accent3", "A5A5A5"), + ("accent4", "FFC000"), + ("accent5", "5B9BD5"), + ("accent6", "70AD47"), + ("hlink", "0563C1"), + ("folHlink", "954F72"), + ], + "Calibri", + "맑은 고딕", + ); + let data = build_test_pptx_with_theme(SLIDE_CX, SLIDE_CY, &[slide], &theme_xml); + let parser = PptxParser; + let (doc, _warnings) = parser.parse(&data, &ConvertOptions::default()).unwrap(); + + let page = first_fixed_page(&doc); + assert_eq!(page.elements.len(), 1); + let shape = get_shape(&page.elements[0]); + let stroke = shape.stroke.as_ref().expect("Expected stroke from style"); + assert_eq!(stroke.color, Color::new(0x44, 0x72, 0xC4)); + assert_eq!(stroke.style, BorderLineStyle::Dashed); +} + +#[test] +fn test_bent_connector3_parsed_as_polyline() { + let connector = make_connector( + 1_000_000, + 2_000_000, + 500_000, + 300_000, + "bentConnector3", + Some("FF0000"), + Some(12700), + None, + false, + false, + ); + let slide = make_slide_xml(&[connector]); + let data = build_test_pptx(SLIDE_CX, SLIDE_CY, &[slide]); + let parser = PptxParser; + let (doc, _warnings) = parser.parse(&data, &ConvertOptions::default()).unwrap(); + + let page = first_fixed_page(&doc); + assert_eq!( + page.elements.len(), + 1, + "bentConnector3 should produce 1 element" + ); + + let shape = get_shape(&page.elements[0]); + // Bent connectors are rendered as polylines (Z-shaped paths) + assert!( + matches!(shape.kind, ShapeKind::Polyline { .. }), + "bentConnector3 should be parsed as Polyline, got {:?}", + shape.kind + ); + let stroke = shape.stroke.as_ref().expect("Expected stroke"); + assert_eq!(stroke.color, Color::new(255, 0, 0)); +} + +#[test] +fn test_connector_tail_end_triangle() { + let connector = make_connector_with_arrows( + 0, + 0, + 3_000_000, + 0, + "straightConnector1", + Some("0000FF"), + Some(12700), + None, + false, + false, + "triangle", + ); + let slide = make_slide_xml(&[connector]); + let data = build_test_pptx(SLIDE_CX, SLIDE_CY, &[slide]); + let parser = PptxParser; + let (doc, _warnings) = parser.parse(&data, &ConvertOptions::default()).unwrap(); + + let page = first_fixed_page(&doc); + let shape = get_shape(&page.elements[0]); + match &shape.kind { + ShapeKind::Line { + tail_end, head_end, .. + } => { + assert_eq!(*tail_end, ArrowHead::Triangle, "tail should be Triangle"); + assert_eq!(*head_end, ArrowHead::None, "head should be None"); + } + _ => panic!("Expected Line shape"), + } +} + +#[test] +fn test_bent_connector3_with_adj_value() { + // bentConnector3 with adj1=74340 (74.34% of width for the bend point) + let adj_xml = r#""#; + let connector = make_connector_full( + 0, + 0, + 1_000_000, + 500_000, + "bentConnector3", + Some("FF0000"), + Some(12700), + None, + false, + false, + adj_xml, + "", + ); + let slide = make_slide_xml(&[connector]); + let data = build_test_pptx(SLIDE_CX, SLIDE_CY, &[slide]); + let parser = PptxParser; + let (doc, _warnings) = parser.parse(&data, &ConvertOptions::default()).unwrap(); + + let page = first_fixed_page(&doc); + let shape = get_shape(&page.elements[0]); + let width: f64 = emu_to_pt(1_000_000); + let height: f64 = emu_to_pt(500_000); + + match &shape.kind { + ShapeKind::Polyline { points, .. } => { + // bentConnector3 with adj=0.7434: start → (mid_x, start_y) → (mid_x, end_y) → end + assert_eq!(points.len(), 4, "bentConnector3 should have 4 points"); + let mid_x: f64 = width * 0.7434; + assert!((points[0].0).abs() < 0.1, "start x should be 0"); + assert!( + (points[1].0 - mid_x).abs() < 0.5, + "bend x should be at adj point" + ); + assert!((points[2].0 - mid_x).abs() < 0.5, "bend x should match"); + assert!((points[3].0 - width).abs() < 0.1, "end x should be width"); + assert!((points[3].1 - height).abs() < 0.1, "end y should be height"); + } + _ => panic!("Expected Polyline shape, got {:?}", shape.kind), + } +} diff --git a/crates/office2pdf/src/parser/pptx_shapes.rs b/crates/office2pdf/src/parser/pptx_shapes.rs index 84499f4..7b9e656 100644 --- a/crates/office2pdf/src/parser/pptx_shapes.rs +++ b/crates/office2pdf/src/parser/pptx_shapes.rs @@ -208,13 +208,75 @@ pub(super) fn parse_src_rect(e: &quick_xml::events::BytesStart) -> Option ShapeKind { +/// +/// `flip_h`/`flip_v` from `` reverse the line endpoint direction, +/// which matters for connectors drawn right-to-left or bottom-to-top. +#[allow(clippy::too_many_arguments)] +pub(super) fn prst_to_shape_kind( + prst: &str, + width: f64, + height: f64, + flip_h: bool, + flip_v: bool, + head_end: ArrowHead, + tail_end: ArrowHead, + adj_values: &[f64], +) -> ShapeKind { match prst { "ellipse" => ShapeKind::Ellipse, - "line" | "straightConnector1" => ShapeKind::Line { - x2: width, - y2: height, - }, + "line" | "straightConnector1" => { + let (x1, y1, x2, y2) = line_endpoints(width, height, flip_h, flip_v); + ShapeKind::Line { + x1, + y1, + x2, + y2, + head_end, + tail_end, + } + } + // Bent connectors: L-shaped or Z-shaped paths + "bentConnector2" => { + let points: Vec<(f64, f64)> = bent_connector2_points(width, height, flip_h, flip_v); + ShapeKind::Polyline { + points, + head_end, + tail_end, + } + } + "bentConnector3" => { + let adj: f64 = adj_values.first().copied().unwrap_or(50_000.0) / 100_000.0; + let points: Vec<(f64, f64)> = + bent_connector3_points(width, height, flip_h, flip_v, adj); + ShapeKind::Polyline { + points, + head_end, + tail_end, + } + } + "bentConnector4" | "bentConnector5" => { + let adj1: f64 = adj_values.first().copied().unwrap_or(50_000.0) / 100_000.0; + let adj2: f64 = adj_values.get(1).copied().unwrap_or(50_000.0) / 100_000.0; + let points: Vec<(f64, f64)> = + bent_connector4_points(width, height, flip_h, flip_v, adj1, adj2); + ShapeKind::Polyline { + points, + head_end, + tail_end, + } + } + // Curved connectors: approximated as bent for now + "curvedConnector2" | "curvedConnector3" | "curvedConnector4" | "curvedConnector5" => { + let (x1, y1, x2, y2) = line_endpoints(width, height, flip_h, flip_v); + ShapeKind::Line { + x1, + y1, + x2, + y2, + head_end, + tail_end, + } + } "roundRect" => ShapeKind::RoundedRectangle { radius_fraction: 0.1, }, @@ -312,3 +374,62 @@ fn star_vertices(n: usize) -> Vec<(f64, f64)> { } vertices } + +// ── Connector geometry helpers ────────────────────────────────────── + +/// Compute line start/end points within the bounding box, accounting for flips. +/// +/// Without flip: (0,0) → (w,h). With flipH: (w,0) → (0,h). +/// With flipV: (0,h) → (w,0). Both: (w,h) → (0,0). +fn line_endpoints(width: f64, height: f64, flip_h: bool, flip_v: bool) -> (f64, f64, f64, f64) { + let (x1, x2): (f64, f64) = if flip_h { (width, 0.0) } else { (0.0, width) }; + let (y1, y2): (f64, f64) = if flip_v { (height, 0.0) } else { (0.0, height) }; + (x1, y1, x2, y2) +} + +/// bentConnector2: simple L-shape (one bend). +/// +/// Without flip: right then down → (0,0) → (w,0) → (w,h). +fn bent_connector2_points(width: f64, height: f64, flip_h: bool, flip_v: bool) -> Vec<(f64, f64)> { + let (x1, y1, x2, y2) = line_endpoints(width, height, flip_h, flip_v); + vec![(x1, y1), (x2, y1), (x2, y2)] +} + +/// bentConnector3: Z-shape with one adjustable midpoint. +/// +/// `adj` is the fraction (0.0–1.0) along the primary axis where the bend occurs. +/// Without flip: right to adj%, then vertical, then right to end. +fn bent_connector3_points( + width: f64, + height: f64, + flip_h: bool, + flip_v: bool, + adj: f64, +) -> Vec<(f64, f64)> { + let (x1, y1, x2, y2) = line_endpoints(width, height, flip_h, flip_v); + let mid_x: f64 = x1 + (x2 - x1) * adj; + vec![(x1, y1), (mid_x, y1), (mid_x, y2), (x2, y2)] +} + +/// bentConnector4: S-shape with two adjustable midpoints. +fn bent_connector4_points( + width: f64, + height: f64, + flip_h: bool, + flip_v: bool, + adj1: f64, + adj2: f64, +) -> Vec<(f64, f64)> { + let (x1, y1, x2, y2) = line_endpoints(width, height, flip_h, flip_v); + let mid_x: f64 = x1 + (x2 - x1) * adj1; + let mid_y: f64 = y1 + (y2 - y1) * adj2; + vec![(x1, y1), (mid_x, y1), (mid_x, mid_y), (x2, mid_y), (x2, y2)] +} + +/// Parse OOXML arrowhead type attribute to IR ArrowHead. +pub(super) fn parse_arrow_head(type_val: Option<&str>) -> ArrowHead { + match type_val { + Some("triangle" | "stealth" | "arrow" | "diamond" | "oval") => ArrowHead::Triangle, + _ => ArrowHead::None, + } +} diff --git a/crates/office2pdf/src/parser/pptx_slides.rs b/crates/office2pdf/src/parser/pptx_slides.rs index 68793b6..67b55b9 100644 --- a/crates/office2pdf/src/parser/pptx_slides.rs +++ b/crates/office2pdf/src/parser/pptx_slides.rs @@ -388,7 +388,7 @@ impl GraphicFrameState { } } -/// Accumulated state for a `` (shape) element and its nested properties. +/// Accumulated state for a `` or `` element and its nested properties. struct ShapeState { depth: usize, x: i64, @@ -397,6 +397,8 @@ struct ShapeState { cy: i64, has_placeholder: bool, rotation_deg: Option, + flip_h: bool, + flip_v: bool, opacity: Option, shadow: Option, in_sp_pr: bool, @@ -408,6 +410,14 @@ struct ShapeState { ln_width_emu: i64, ln_color: Option, ln_dash_style: BorderLineStyle, + /// Arrowhead at line start. + head_end: ArrowHead, + /// Arrowhead at line end. + tail_end: ArrowHead, + /// Adjustment values from `` for connector bend points. + adj_values: Vec, + /// Fallback line color from `` scheme reference. + style_ln_color: Option, } impl Default for ShapeState { @@ -420,6 +430,8 @@ impl Default for ShapeState { cy: 0, has_placeholder: false, rotation_deg: None, + flip_h: false, + flip_v: false, opacity: None, shadow: None, in_sp_pr: false, @@ -431,6 +443,10 @@ impl Default for ShapeState { ln_width_emu: 0, ln_color: None, ln_dash_style: BorderLineStyle::Solid, + head_end: ArrowHead::None, + tail_end: ArrowHead::None, + adj_values: Vec::new(), + style_ln_color: None, } } } @@ -477,8 +493,21 @@ fn finalize_shape( }), }) } else if let Some(ref geom) = shape.prst_geom { - let kind = prst_to_shape_kind(geom, emu_to_pt(shape.cx), emu_to_pt(shape.cy)); - let stroke = shape.ln_color.map(|color| BorderSide { + let width: f64 = emu_to_pt(shape.cx); + let height: f64 = emu_to_pt(shape.cy); + let kind: ShapeKind = prst_to_shape_kind( + geom, + width, + height, + shape.flip_h, + shape.flip_v, + shape.head_end, + shape.tail_end, + &shape.adj_values, + ); + // Use explicit line color, falling back to style-based color from . + let effective_ln_color: Option = shape.ln_color.or(shape.style_ln_color); + let stroke: Option = effective_ln_color.map(|color| BorderSide { width: emu_to_pt(shape.ln_width_emu), color, style: shape.ln_dash_style, @@ -486,8 +515,8 @@ fn finalize_shape( Some(FixedElement { x: emu_to_pt(shape.x), y: emu_to_pt(shape.y), - width: emu_to_pt(shape.cx), - height: emu_to_pt(shape.cy), + width, + height, kind: FixedElementKind::Shape(Shape { kind, fill: shape.fill, @@ -620,6 +649,8 @@ struct SlideXmlParser<'a> { in_rpr: bool, in_end_para_rpr: bool, solid_fill_ctx: SolidFillCtx, + /// Inside `` within `` — for resolving fallback line color. + in_style_ln_ref: bool, // ── Picture state (``) ─────────────────────────────────── in_pic: bool, @@ -676,6 +707,7 @@ impl<'a> SlideXmlParser<'a> { in_rpr: false, in_end_para_rpr: false, solid_fill_ctx: SolidFillCtx::None, + in_style_ln_ref: false, in_pic: false, pic: PictureState::default(), @@ -726,7 +758,7 @@ impl<'a> SlideXmlParser<'a> { self.warnings.extend(group_warnings); } } - b"sp" if !self.in_shape && !self.in_pic => { + b"sp" | b"cxnSp" if !self.in_shape && !self.in_pic => { self.in_shape = true; self.shape.reset(); self.shape.depth = 1; @@ -735,7 +767,7 @@ impl<'a> SlideXmlParser<'a> { self.text_box_padding = default_pptx_text_box_padding(); self.text_box_vertical_align = TextBoxVerticalAlign::Top; } - b"sp" if self.in_shape => { + b"sp" | b"cxnSp" if self.in_shape => { self.shape.depth += 1; } b"spPr" if self.in_shape && !self.in_txbody => { @@ -746,6 +778,10 @@ impl<'a> SlideXmlParser<'a> { if let Some(rot) = get_attr_i64(e, b"rot") { self.shape.rotation_deg = Some(rot as f64 / 60_000.0); } + self.shape.flip_h = + get_attr_str(e, b"flipH").is_some_and(|v| v == "1" || v == "true"); + self.shape.flip_v = + get_attr_str(e, b"flipV").is_some_and(|v| v == "1" || v == "true"); } b"prstGeom" if self.shape.in_sp_pr => { if let Some(prst) = get_attr_str(e, b"prst") { @@ -778,6 +814,12 @@ impl<'a> SlideXmlParser<'a> { .map(pptx_dash_to_border_style) .unwrap_or(BorderLineStyle::Solid); } + b"tailEnd" if self.shape.in_ln => { + self.shape.tail_end = parse_arrow_head(get_attr_str(e, b"type").as_deref()); + } + b"headEnd" if self.shape.in_ln => { + self.shape.head_end = parse_arrow_head(get_attr_str(e, b"type").as_deref()); + } b"solidFill" if self.shape.in_ln => { self.solid_fill_ctx = SolidFillCtx::LineFill; } @@ -919,6 +961,10 @@ impl<'a> SlideXmlParser<'a> { &mut self.pic, ); } + // `` inside `` provides fallback line color. + b"lnRef" if self.in_shape && !self.shape.in_sp_pr && !self.in_txbody => { + self.in_style_ln_ref = true; + } b"t" if self.in_run => { self.in_text = true; } @@ -1027,6 +1073,26 @@ impl<'a> SlideXmlParser<'a> { .map(pptx_dash_to_border_style) .unwrap_or(BorderLineStyle::Solid); } + b"tailEnd" if self.shape.in_ln => { + self.shape.tail_end = parse_arrow_head(get_attr_str(e, b"type").as_deref()); + } + b"headEnd" if self.shape.in_ln => { + self.shape.head_end = parse_arrow_head(get_attr_str(e, b"type").as_deref()); + } + // Adjustment values for connector bend points (inside ). + b"gd" if self.in_shape && self.shape.in_sp_pr => { + if let Some(val) = get_attr_str(e, b"fmla") + .as_deref() + .and_then(|f| f.strip_prefix("val ")) + .and_then(|s| s.parse::().ok()) + { + self.shape.adj_values.push(val); + } + } + b"srgbClr" | b"schemeClr" | b"sysClr" if self.in_style_ln_ref => { + let parsed = parse_color_from_empty(e, self.theme, self.color_map); + self.shape.style_ln_color = parsed.color; + } b"srgbClr" | b"schemeClr" | b"sysClr" if self.solid_fill_ctx != SolidFillCtx::None => { let parsed = parse_color_from_empty(e, self.theme, self.color_map); apply_solid_fill_color( @@ -1134,7 +1200,7 @@ impl<'a> SlideXmlParser<'a> { /// Handle an `Event::End` element. fn handle_end(&mut self, local_name: &[u8]) { match local_name { - b"sp" if self.in_shape => { + b"sp" | b"cxnSp" if self.in_shape => { self.shape.depth -= 1; if self.shape.depth == 0 { if let Some(element) = finalize_shape( @@ -1204,6 +1270,9 @@ impl<'a> SlideXmlParser<'a> { b"solidFill" if self.solid_fill_ctx != SolidFillCtx::None => { self.solid_fill_ctx = SolidFillCtx::None; } + b"lnRef" if self.in_style_ln_ref => { + self.in_style_ln_ref = false; + } b"t" if self.in_text => { self.in_text = false; } diff --git a/crates/office2pdf/src/parser/pptx_tests.rs b/crates/office2pdf/src/parser/pptx_tests.rs index f1ff2c1..535cc55 100644 --- a/crates/office2pdf/src/parser/pptx_tests.rs +++ b/crates/office2pdf/src/parser/pptx_tests.rs @@ -362,7 +362,7 @@ fn test_shape_line() { let page = first_fixed_page(&doc); let s = get_shape(&page.elements[0]); match &s.kind { - ShapeKind::Line { x2, y2 } => { + ShapeKind::Line { x2, y2, .. } => { assert!((*x2 - emu_to_pt(4_000_000)).abs() < 0.1); assert!((*y2 - 0.0).abs() < 0.1); } @@ -512,3 +512,6 @@ mod metadata_tests; #[path = "pptx_preset_shape_tests.rs"] mod preset_shape_tests; + +#[path = "pptx_connector_tests.rs"] +mod connector_tests; diff --git a/crates/office2pdf/src/render/typst_gen.rs b/crates/office2pdf/src/render/typst_gen.rs index 6ce327b..d1c4de7 100644 --- a/crates/office2pdf/src/render/typst_gen.rs +++ b/crates/office2pdf/src/render/typst_gen.rs @@ -6,11 +6,11 @@ use image::{GenericImageView, ImageFormat as RasterImageFormat}; use crate::config::ConvertOptions; use crate::error::ConvertError; use crate::ir::{ - Alignment, Block, BorderLineStyle, BorderSide, CellBorder, CellVerticalAlign, Chart, ChartType, - Color, ColumnLayout, Document, FixedElement, FixedElementKind, FixedPage, FloatingImage, - FloatingTextBox, FlowPage, GradientFill, HFInline, HeaderFooter, ImageCrop, ImageData, - ImageFormat, Insets, LineSpacing, List, ListKind, Margins, MathEquation, Metadata, Page, - PageSize, Paragraph, ParagraphStyle, Run, Shadow, Shape, ShapeKind, SheetPage, SmartArt, + Alignment, ArrowHead, Block, BorderLineStyle, BorderSide, CellBorder, CellVerticalAlign, Chart, + ChartType, Color, ColumnLayout, Document, FixedElement, FixedElementKind, FixedPage, + FloatingImage, FloatingTextBox, FlowPage, GradientFill, HFInline, HeaderFooter, ImageCrop, + ImageData, ImageFormat, Insets, LineSpacing, List, ListKind, Margins, MathEquation, Metadata, + Page, PageSize, Paragraph, ParagraphStyle, Run, Shadow, Shape, ShapeKind, SheetPage, SmartArt, TabAlignment, TabLeader, TabStop, Table, TableCell, TableRow, TextBoxData, TextBoxVerticalAlign, TextDirection, TextStyle, VerticalTextAlign, WrapMode, }; diff --git a/crates/office2pdf/src/render/typst_gen_fixed_page_tests.rs b/crates/office2pdf/src/render/typst_gen_fixed_page_tests.rs index c6ca098..787b205 100644 --- a/crates/office2pdf/src/render/typst_gen_fixed_page_tests.rs +++ b/crates/office2pdf/src/render/typst_gen_fixed_page_tests.rs @@ -77,7 +77,14 @@ fn test_fixed_page_line_shape() { 0.0, 300.0, 0.0, - ShapeKind::Line { x2: 300.0, y2: 0.0 }, + ShapeKind::Line { + x1: 0.0, + y1: 0.0, + x2: 300.0, + y2: 0.0, + head_end: ArrowHead::None, + tail_end: ArrowHead::None, + }, None, Some(BorderSide { width: 2.0, @@ -238,6 +245,94 @@ fn test_fixed_page_mixed_elements() { assert_eq!(output.images.len(), 1); } +#[test] +fn test_line_arrowhead_uses_place_overlay() { + let doc = make_doc(vec![make_fixed_page( + 960.0, + 540.0, + vec![FixedElement { + x: 10.0, + y: 20.0, + width: 200.0, + height: 0.0, + kind: FixedElementKind::Shape(Shape { + kind: ShapeKind::Line { + x1: 0.0, + y1: 0.0, + x2: 200.0, + y2: 0.0, + head_end: ArrowHead::None, + tail_end: ArrowHead::Triangle, + }, + fill: None, + gradient_fill: None, + stroke: Some(BorderSide { + width: 2.0, + color: Color::black(), + style: BorderLineStyle::Solid, + }), + rotation_deg: None, + opacity: None, + shadow: None, + }), + }], + )]); + let output = generate_typst(&doc).unwrap(); + // Arrowhead polygon must be inside #place(top + left) so it overlays + // on the line rather than stacking below it in the layout. + assert!( + output.source.contains("#place(top + left)[#polygon("), + "Arrowhead polygon must use #place overlay, got: {}", + output.source, + ); +} + +#[test] +fn test_polyline_segments_use_place_overlay() { + let doc = make_doc(vec![make_fixed_page( + 960.0, + 540.0, + vec![FixedElement { + x: 10.0, + y: 20.0, + width: 200.0, + height: 100.0, + kind: FixedElementKind::Shape(Shape { + kind: ShapeKind::Polyline { + points: vec![(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (200.0, 100.0)], + head_end: ArrowHead::None, + tail_end: ArrowHead::Triangle, + }, + fill: None, + gradient_fill: None, + stroke: Some(BorderSide { + width: 1.5, + color: Color::new(0, 0, 255), + style: BorderLineStyle::Solid, + }), + rotation_deg: None, + opacity: None, + shadow: None, + }), + }], + )]); + let output = generate_typst(&doc).unwrap(); + // Each polyline segment must use #place overlay for correct positioning. + let segment_count = output.source.matches("#place(top + left)[#line(").count(); + assert!( + segment_count >= 3, + "Expected 3 polyline segments with #place overlay, found {}: {}", + segment_count, + output.source, + ); + // Arrowhead must also use #place overlay. + assert!( + output.source.contains("#place(top + left)[#polygon("), + "Arrowhead polygon must use #place overlay, got: {}", + output.source, + ); +} + #[test] fn test_fixed_page_multiple_text_boxes() { let doc = make_doc(vec![make_fixed_page( diff --git a/crates/office2pdf/src/render/typst_gen_shapes.rs b/crates/office2pdf/src/render/typst_gen_shapes.rs index 9b96a0c..90888d4 100644 --- a/crates/office2pdf/src/render/typst_gen_shapes.rs +++ b/crates/office2pdf/src/render/typst_gen_shapes.rs @@ -24,16 +24,60 @@ pub(super) fn generate_shape(out: &mut String, shape: &Shape, width: f64, height write_shape_params(out, shape, width, height); out.push_str(")\n"); } - ShapeKind::Line { x2, y2 } => { + ShapeKind::Line { + x1, + y1, + x2, + y2, + head_end, + tail_end, + } => { + let has_arrowheads: bool = *tail_end != ArrowHead::None || *head_end != ArrowHead::None; + // When arrowheads follow the line, wrap everything in #place() + // so that Typst overlays them at the same origin instead of + // stacking sequentially. + if has_arrowheads { + out.push_str("#place(top + left)["); + } out.push_str("#line("); let _ = write!( out, - "start: (0pt, 0pt), end: ({}pt, {}pt)", + "start: ({}pt, {}pt), end: ({}pt, {}pt)", + format_f64(*x1), + format_f64(*y1), format_f64(*x2), format_f64(*y2), ); write_shape_stroke(out, &shape.stroke); out.push_str(")\n"); + if has_arrowheads { + out.push_str("]\n"); + } + if *tail_end != ArrowHead::None { + write_arrowhead_at(out, &shape.stroke, (*x1, *y1), (*x2, *y2)); + } + if *head_end != ArrowHead::None { + write_arrowhead_at(out, &shape.stroke, (*x2, *y2), (*x1, *y1)); + } + } + ShapeKind::Polyline { + points, + head_end, + tail_end, + } => { + write_polyline(out, &shape.stroke, points); + if points.len() >= 2 { + if *tail_end != ArrowHead::None { + let last = points[points.len() - 1]; + let second_last = points[points.len() - 2]; + write_arrowhead_at(out, &shape.stroke, second_last, last); + } + if *head_end != ArrowHead::None { + let first = points[0]; + let second = points[1]; + write_arrowhead_at(out, &shape.stroke, second, first); + } + } } ShapeKind::RoundedRectangle { radius_fraction } => { let radius = radius_fraction * width.min(height); @@ -293,3 +337,74 @@ pub(super) fn write_gradient_fill(out: &mut String, gradient: &GradientFill) { } out.push(')'); } + +// ── Polyline & arrowhead rendering ────────────────────────────────── + +/// Render a multi-segment polyline as consecutive `#line()` calls, +/// each wrapped in `#place(top + left)` so they overlay at the same origin. +fn write_polyline(out: &mut String, stroke: &Option, points: &[(f64, f64)]) { + for segment in points.windows(2) { + let (x1, y1) = segment[0]; + let (x2, y2) = segment[1]; + out.push_str("#place(top + left)[#line("); + let _ = write!( + out, + "start: ({}pt, {}pt), end: ({}pt, {}pt)", + format_f64(x1), + format_f64(y1), + format_f64(x2), + format_f64(y2), + ); + write_shape_stroke(out, stroke); + out.push_str(")]\n"); + } +} + +/// Draw a triangle arrowhead at `tip`, pointing in the direction from `from` → `tip`. +fn write_arrowhead_at( + out: &mut String, + stroke: &Option, + from: (f64, f64), + tip: (f64, f64), +) { + let Some(stroke) = stroke else { return }; + let dx: f64 = tip.0 - from.0; + let dy: f64 = tip.1 - from.1; + let len: f64 = (dx * dx + dy * dy).sqrt(); + if len < 0.001 { + return; + } + // Arrow size proportional to stroke width, with min/max bounds. + let arrow_len: f64 = (stroke.width * 4.0).clamp(3.0, 12.0); + let arrow_half_w: f64 = arrow_len * 0.45; + + // Unit direction vector from `from` toward `tip`. + let ux: f64 = dx / len; + let uy: f64 = dy / len; + // Perpendicular vector. + let px: f64 = -uy; + let py: f64 = ux; + + // Three vertices: tip, and two base corners. + let base_x: f64 = tip.0 - ux * arrow_len; + let base_y: f64 = tip.1 - uy * arrow_len; + let v1 = (tip.0, tip.1); + let v2 = (base_x + px * arrow_half_w, base_y + py * arrow_half_w); + let v3 = (base_x - px * arrow_half_w, base_y - py * arrow_half_w); + + out.push_str("#place(top + left)[#polygon("); + let _ = write!( + out, + "({}pt, {}pt), ({}pt, {}pt), ({}pt, {}pt), fill: rgb({}, {}, {})", + format_f64(v1.0), + format_f64(v1.1), + format_f64(v2.0), + format_f64(v2.1), + format_f64(v3.0), + format_f64(v3.1), + stroke.color.r, + stroke.color.g, + stroke.color.b, + ); + out.push_str(")]\n"); +} diff --git a/crates/office2pdf/src/render/typst_gen_text_pipeline_tests.rs b/crates/office2pdf/src/render/typst_gen_text_pipeline_tests.rs index 0d3f08e..8927814 100644 --- a/crates/office2pdf/src/render/typst_gen_text_pipeline_tests.rs +++ b/crates/office2pdf/src/render/typst_gen_text_pipeline_tests.rs @@ -330,7 +330,14 @@ fn test_generate_shape_shadow_all_kinds() { let shape_kinds = vec![ ShapeKind::Rectangle, ShapeKind::Ellipse, - ShapeKind::Line { x2: 100.0, y2: 0.0 }, + ShapeKind::Line { + x1: 0.0, + y1: 0.0, + x2: 100.0, + y2: 0.0, + head_end: ArrowHead::None, + tail_end: ArrowHead::None, + }, ShapeKind::RoundedRectangle { radius_fraction: 0.1, },