From 1a4b6ba5ad1064b32a7b2118ba20839ab2e52e86 Mon Sep 17 00:00:00 2001 From: Yonghye Kwon Date: Sat, 14 Mar 2026 00:27:00 +0900 Subject: [PATCH 1/5] feat: parse PPTX connector shapes (cxnSp) PPTX connector shapes () were completely ignored by the parser, causing all connecting lines and arrows in diagrams to disappear in the converted PDF output. Key changes: - Recognize elements alongside in the XML event loop - Parse flipH/flipV attributes on for correct line direction - Resolve fallback line color from scheme references - Map bent/curved connector presets to straight line approximations Signed-off-by: Yonghye Kwon Co-Authored-By: Claude Opus 4.6 Signed-off-by: Yonghye Kwon --- .../src/parser/pptx_connector_tests.rs | 391 ++++++++++++++++++ crates/office2pdf/src/parser/pptx_shapes.rs | 23 +- crates/office2pdf/src/parser/pptx_slides.rs | 46 ++- crates/office2pdf/src/parser/pptx_tests.rs | 3 + 4 files changed, 450 insertions(+), 13 deletions(-) create mode 100644 crates/office2pdf/src/parser/pptx_connector_tests.rs 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..e9f5e8b --- /dev/null +++ b/crates/office2pdf/src/parser/pptx_connector_tests.rs @@ -0,0 +1,391 @@ +use super::*; + +// ── Connector shape XML builders ──────────────────────────────────── + +/// Create a straight connector shape XML (mirrors real PPTX `` structure). +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 { + 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(); + + format!( + r#"{fill_xml}{dash_xml}"# + ) +} + +/// Create a connector with a `` section for theme-based line color. +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 { x2, y2 } => { + assert!((*x2 - emu_to_pt(3_000_000)).abs() < 0.1); + assert!((*y2 - 0.0).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() { + // Some connectors use prst="line" instead of "straightConnector1" + 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() { + // flipH means the line goes from right-to-left within the bounding box + let connector = make_connector( + 1_000_000, + 2_000_000, + 4_000_000, + 2_000_000, + "straightConnector1", + Some("0000FF"), + Some(12700), + None, + true, // flipH + 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); + let shape = get_shape(&page.elements[0]); + + // With flipH, line should go from (width, 0) to (0, height) + match &shape.kind { + ShapeKind::Line { x2, y2 } => { + let width = emu_to_pt(4_000_000); + let height = emu_to_pt(2_000_000); + // flipH: start at (width, 0), end at (0, height) + // which means x2 = -width (going left), y2 = height (going down) + assert!( + (*x2 - (-width)).abs() < 0.1, + "flipH: x2 should be -{width}, 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 + ); + 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 { x2, y2 } => { + let width = emu_to_pt(3_000_000); + let height = emu_to_pt(2_000_000); + // flipV: start at (0, height), end at (width, 0) + // which means x2 = width, y2 = -height + assert!( + (*x2 - width).abs() < 0.1, + "flipV: x2 should be {width}, got {x2}" + ); + assert!( + (*y2 - (-height)).abs() < 0.1, + "flipV: y2 should be -{height}, 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, // flipH + true, // flipV + ); + 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 { x2, y2 } => { + let width = emu_to_pt(3_000_000); + let height = emu_to_pt(2_000_000); + // flipH+flipV: start at (width, height), end at (0, 0) + // which means x2 = -width, y2 = -height + assert!( + (*x2 - (-width)).abs() < 0.1, + "flipH+V: x2 should be -{width}, got {x2}" + ); + assert!( + (*y2 - (-height)).abs() < 0.1, + "flipH+V: y2 should be -{height}, 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"); + + // First element: rectangle + let s0 = get_shape(&page.elements[0]); + assert!(matches!(s0.kind, ShapeKind::Rectangle)); + + // Second element: connector line + let s1 = get_shape(&page.elements[1]); + assert!(matches!(s1.kind, ShapeKind::Line { .. })); +} + +#[test] +fn test_connector_with_style_based_line_color() { + // Connectors often inherit line color from when + // has no explicit . + let connector = make_connector_with_style( + 0, + 0, + 3_000_000, + 0, + "straightConnector1", + "accent1", + Some("dash"), + false, + false, + ); + let slide = make_slide_xml(&[connector]); + // Use theme builder with known accent1 color + 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]); + // Should get accent1 = #4472C4 as line color from style + let stroke = shape.stroke.as_ref().expect("Expected stroke from style"); + assert_eq!( + stroke.color, + Color::new(0x44, 0x72, 0xC4), + "Line color should come from accent1 theme color" + ); + assert_eq!(stroke.style, BorderLineStyle::Dashed); +} + +#[test] +fn test_bent_connector_parsed_as_line() { + // bentConnector3 should render as a straight line approximation for now + 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 straight lines (approximation) + assert!( + matches!(shape.kind, ShapeKind::Line { .. }), + "bentConnector3 should be parsed as Line shape" + ); + let stroke = shape.stroke.as_ref().expect("Expected stroke"); + assert_eq!(stroke.color, Color::new(255, 0, 0)); +} diff --git a/crates/office2pdf/src/parser/pptx_shapes.rs b/crates/office2pdf/src/parser/pptx_shapes.rs index 84499f4..bb14e8f 100644 --- a/crates/office2pdf/src/parser/pptx_shapes.rs +++ b/crates/office2pdf/src/parser/pptx_shapes.rs @@ -208,13 +208,26 @@ 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. +pub(super) fn prst_to_shape_kind( + prst: &str, + width: f64, + height: f64, + flip_h: bool, + flip_v: bool, +) -> ShapeKind { match prst { "ellipse" => ShapeKind::Ellipse, - "line" | "straightConnector1" => ShapeKind::Line { - x2: width, - y2: height, - }, + // Straight lines and connectors (including bent connectors approximated as straight lines) + "line" | "straightConnector1" | "bentConnector2" | "bentConnector3" | "bentConnector4" + | "bentConnector5" | "curvedConnector2" | "curvedConnector3" | "curvedConnector4" + | "curvedConnector5" => { + let x2: f64 = if flip_h { -width } else { width }; + let y2: f64 = if flip_v { -height } else { height }; + ShapeKind::Line { x2, y2 } + } "roundRect" => ShapeKind::RoundedRectangle { radius_fraction: 0.1, }, diff --git a/crates/office2pdf/src/parser/pptx_slides.rs b/crates/office2pdf/src/parser/pptx_slides.rs index 68793b6..d9e202b 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,8 @@ struct ShapeState { ln_width_emu: i64, ln_color: Option, ln_dash_style: BorderLineStyle, + /// Fallback line color from `` scheme reference. + style_ln_color: Option, } impl Default for ShapeState { @@ -420,6 +424,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 +437,7 @@ impl Default for ShapeState { ln_width_emu: 0, ln_color: None, ln_dash_style: BorderLineStyle::Solid, + style_ln_color: None, } } } @@ -477,8 +484,13 @@ 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); + // 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 +498,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 +632,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 +690,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 +741,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 +750,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 +761,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") { @@ -919,6 +938,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 +1050,10 @@ impl<'a> SlideXmlParser<'a> { .map(pptx_dash_to_border_style) .unwrap_or(BorderLineStyle::Solid); } + 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 +1161,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 +1231,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..ccb2605 100644 --- a/crates/office2pdf/src/parser/pptx_tests.rs +++ b/crates/office2pdf/src/parser/pptx_tests.rs @@ -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; From 318d6502ca956a599fea89eb5c8ea857392d255a Mon Sep 17 00:00:00 2001 From: Yonghye Kwon Date: Sat, 14 Mar 2026 00:47:57 +0900 Subject: [PATCH 2/5] feat: add arrowheads, polyline connectors, and fix line flip positioning - Change ShapeKind::Line to use (x1,y1)-(x2,y2) start/end points so flipped lines render within their bounding box correctly - Add ShapeKind::Polyline for multi-segment bent connectors - Add ArrowHead enum and parse / from - Render triangle arrowheads as filled polygons at line endpoints - Map bentConnector2-5 to proper Z-shaped polyline paths with adjustable bend points from values - Render polylines as consecutive #line() segments in Typst Signed-off-by: Yonghye Kwon Co-Authored-By: Claude Opus 4.6 Signed-off-by: Yonghye Kwon --- crates/office2pdf/src/ir/elements.rs | 19 + crates/office2pdf/src/parser/pptx.rs | 14 +- .../src/parser/pptx_connector_tests.rs | 344 +++++++++--------- crates/office2pdf/src/parser/pptx_shapes.rs | 128 ++++++- crates/office2pdf/src/parser/pptx_slides.rs | 47 ++- crates/office2pdf/src/parser/pptx_tests.rs | 2 +- crates/office2pdf/src/render/typst_gen.rs | 10 +- .../src/render/typst_gen_fixed_page_tests.rs | 2 +- .../office2pdf/src/render/typst_gen_shapes.rs | 108 +++++- .../render/typst_gen_text_pipeline_tests.rs | 2 +- 10 files changed, 481 insertions(+), 195 deletions(-) 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..2019083 100644 --- a/crates/office2pdf/src/parser/pptx.rs +++ b/crates/office2pdf/src/parser/pptx.rs @@ -11,11 +11,12 @@ 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, - 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, + 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, }; use crate::parser::Parser; use crate::parser::smartart; @@ -25,7 +26,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 index e9f5e8b..97e5faa 100644 --- a/crates/office2pdf/src/parser/pptx_connector_tests.rs +++ b/crates/office2pdf/src/parser/pptx_connector_tests.rs @@ -2,7 +2,8 @@ use super::*; // ── Connector shape XML builders ──────────────────────────────────── -/// Create a straight connector shape XML (mirrors real PPTX `` structure). +/// Create a connector shape XML (mirrors real PPTX `` structure). +#[allow(clippy::too_many_arguments)] fn make_connector( x: i64, y: i64, @@ -14,6 +15,47 @@ fn make_connector( 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""#, @@ -34,8 +76,14 @@ fn make_connector( .map(|d| format!(r#""#)) .unwrap_or_default(); + let av_lst = if adj_xml.is_empty() { + "".to_string() + } else { + format!("{adj_xml}") + }; + format!( - r#"{fill_xml}{dash_xml}"# + r#"{av_lst}{fill_xml}{dash_xml}{extra_ln_xml}"# ) } @@ -72,16 +120,9 @@ fn make_connector_with_style( #[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, + 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]); @@ -97,9 +138,11 @@ fn test_straight_connector_parsed_as_line() { let shape = get_shape(elem); match &shape.kind { - ShapeKind::Line { x2, y2 } => { + 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 - 0.0).abs() < 0.1); + assert!((*y2).abs() < 0.1); } _ => panic!("Expected Line shape, got {:?}", shape.kind), } @@ -110,18 +153,10 @@ fn test_straight_connector_parsed_as_line() { #[test] fn test_connector_with_line_preset() { - // Some connectors use prst="line" instead of "straightConnector1" let connector = make_connector( - 0, - 0, - 5_000_000, - 2_000, - "line", - Some("FF0000"), - Some(25400), - Some("dash"), - false, - false, + 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]); @@ -140,18 +175,10 @@ fn test_connector_with_line_preset() { #[test] fn test_connector_flip_h_reverses_line_direction() { - // flipH means the line goes from right-to-left within the bounding box let connector = make_connector( - 1_000_000, - 2_000_000, - 4_000_000, - 2_000_000, - "straightConnector1", - Some("0000FF"), - Some(12700), - None, - true, // flipH - false, + 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]); @@ -160,22 +187,16 @@ fn test_connector_flip_h_reverses_line_direction() { 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); - // With flipH, line should go from (width, 0) to (0, height) + // flipH: start at (width, 0), end at (0, height) match &shape.kind { - ShapeKind::Line { x2, y2 } => { - let width = emu_to_pt(4_000_000); - let height = emu_to_pt(2_000_000); - // flipH: start at (width, 0), end at (0, height) - // which means x2 = -width (going left), y2 = height (going down) - assert!( - (*x2 - (-width)).abs() < 0.1, - "flipH: x2 should be -{width}, got {x2}" - ); - assert!( - (*y2 - height).abs() < 0.1, - "flipH: y2 should be {height}, got {y2}" - ); + 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"), } @@ -184,16 +205,9 @@ fn test_connector_flip_h_reverses_line_direction() { #[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 + 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]); @@ -202,21 +216,16 @@ fn test_connector_flip_v_reverses_line_direction() { 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 { x2, y2 } => { - let width = emu_to_pt(3_000_000); - let height = emu_to_pt(2_000_000); - // flipV: start at (0, height), end at (width, 0) - // which means x2 = width, y2 = -height - assert!( - (*x2 - width).abs() < 0.1, - "flipV: x2 should be {width}, got {x2}" - ); - assert!( - (*y2 - (-height)).abs() < 0.1, - "flipV: y2 should be -{height}, got {y2}" - ); + 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"), } @@ -225,16 +234,9 @@ fn test_connector_flip_v_reverses_line_direction() { #[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, // flipH - true, // flipV + 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]); @@ -243,21 +245,16 @@ fn test_connector_flip_h_and_v() { 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 { x2, y2 } => { - let width = emu_to_pt(3_000_000); - let height = emu_to_pt(2_000_000); - // flipH+flipV: start at (width, height), end at (0, 0) - // which means x2 = -width, y2 = -height - assert!( - (*x2 - (-width)).abs() < 0.1, - "flipH+V: x2 should be -{width}, got {x2}" - ); - assert!( - (*y2 - (-height)).abs() < 0.1, - "flipH+V: y2 should be -{height}, got {y2}" - ); + 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"), } @@ -265,27 +262,11 @@ fn test_connector_flip_h_and_v() { #[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 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, + 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]); @@ -294,47 +275,24 @@ fn test_connector_mixed_with_regular_shapes() { let page = first_fixed_page(&doc); assert_eq!(page.elements.len(), 2, "Should have rect + connector"); - - // First element: rectangle - let s0 = get_shape(&page.elements[0]); - assert!(matches!(s0.kind, ShapeKind::Rectangle)); - - // Second element: connector line - let s1 = get_shape(&page.elements[1]); - assert!(matches!(s1.kind, ShapeKind::Line { .. })); + 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() { - // Connectors often inherit line color from when - // has no explicit . let connector = make_connector_with_style( - 0, - 0, - 3_000_000, - 0, - "straightConnector1", - "accent1", - Some("dash"), - false, - false, + 0, 0, 3_000_000, 0, + "straightConnector1", "accent1", Some("dash"), + false, false, ); let slide = make_slide_xml(&[connector]); - // Use theme builder with known accent1 color 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"), + ("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", "맑은 고딕", @@ -345,32 +303,18 @@ fn test_connector_with_style_based_line_color() { let page = first_fixed_page(&doc); assert_eq!(page.elements.len(), 1); - let shape = get_shape(&page.elements[0]); - // Should get accent1 = #4472C4 as line color from style let stroke = shape.stroke.as_ref().expect("Expected stroke from style"); - assert_eq!( - stroke.color, - Color::new(0x44, 0x72, 0xC4), - "Line color should come from accent1 theme color" - ); + assert_eq!(stroke.color, Color::new(0x44, 0x72, 0xC4)); assert_eq!(stroke.style, BorderLineStyle::Dashed); } #[test] -fn test_bent_connector_parsed_as_line() { - // bentConnector3 should render as a straight line approximation for now +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, + 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]); @@ -381,11 +325,71 @@ fn test_bent_connector_parsed_as_line() { assert_eq!(page.elements.len(), 1, "bentConnector3 should produce 1 element"); let shape = get_shape(&page.elements[0]); - // Bent connectors are rendered as straight lines (approximation) + // Bent connectors are rendered as polylines (Z-shaped paths) assert!( - matches!(shape.kind, ShapeKind::Line { .. }), - "bentConnector3 should be parsed as Line shape" + 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 bb14e8f..8e688c1 100644 --- a/crates/office2pdf/src/parser/pptx_shapes.rs +++ b/crates/office2pdf/src/parser/pptx_shapes.rs @@ -211,22 +211,72 @@ pub(super) fn parse_src_rect(e: &quick_xml::events::BytesStart) -> Option` 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, - // Straight lines and connectors (including bent connectors approximated as straight lines) - "line" | "straightConnector1" | "bentConnector2" | "bentConnector3" | "bentConnector4" - | "bentConnector5" | "curvedConnector2" | "curvedConnector3" | "curvedConnector4" - | "curvedConnector5" => { - let x2: f64 = if flip_h { -width } else { width }; - let y2: f64 = if flip_v { -height } else { height }; - ShapeKind::Line { x2, y2 } + "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, @@ -325,3 +375,67 @@ 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 d9e202b..edfbb65 100644 --- a/crates/office2pdf/src/parser/pptx_slides.rs +++ b/crates/office2pdf/src/parser/pptx_slides.rs @@ -410,6 +410,12 @@ 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, } @@ -437,6 +443,9 @@ 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, } } @@ -486,8 +495,16 @@ fn finalize_shape( } else if let Some(ref geom) = shape.prst_geom { 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); + 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 { @@ -797,6 +814,14 @@ 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; } @@ -1050,6 +1075,24 @@ 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(fmla) = get_attr_str(e, b"fmla") { + if let Some(val_str) = fmla.strip_prefix("val ") { + if let Ok(val) = val_str.parse::() { + 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; diff --git a/crates/office2pdf/src/parser/pptx_tests.rs b/crates/office2pdf/src/parser/pptx_tests.rs index ccb2605..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); } 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..40201e2 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,7 @@ 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, diff --git a/crates/office2pdf/src/render/typst_gen_shapes.rs b/crates/office2pdf/src/render/typst_gen_shapes.rs index 9b96a0c..f3b9efa 100644 --- a/crates/office2pdf/src/render/typst_gen_shapes.rs +++ b/crates/office2pdf/src/render/typst_gen_shapes.rs @@ -24,16 +24,50 @@ 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, + } => { 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 *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 +327,73 @@ 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. +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("#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("#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..86f427c 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,7 @@ 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, }, From 006f688a47c4c774d982d877a6e5f202923a6eb6 Mon Sep 17 00:00:00 2001 From: Yonghye Kwon Date: Sat, 14 Mar 2026 01:00:49 +0900 Subject: [PATCH 3/5] fix: wrap connector arrowheads and polyline segments in #place() overlay Arrowhead polygons and polyline line segments were rendered sequentially inside a #place() block, causing Typst to stack them vertically instead of overlaying at the correct coordinates. Wrap each #polygon() and #line() segment in #place(top + left)[...] so they overlay correctly. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Yonghye Kwon --- .../src/render/typst_gen_fixed_page_tests.rs | 88 +++++++++++++++++++ .../office2pdf/src/render/typst_gen_shapes.rs | 22 +++-- 2 files changed, 105 insertions(+), 5 deletions(-) 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 40201e2..8047dc8 100644 --- a/crates/office2pdf/src/render/typst_gen_fixed_page_tests.rs +++ b/crates/office2pdf/src/render/typst_gen_fixed_page_tests.rs @@ -238,6 +238,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 f3b9efa..938d05e 100644 --- a/crates/office2pdf/src/render/typst_gen_shapes.rs +++ b/crates/office2pdf/src/render/typst_gen_shapes.rs @@ -32,6 +32,14 @@ pub(super) fn generate_shape(out: &mut String, shape: &Shape, width: f64, height 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, @@ -43,6 +51,9 @@ pub(super) fn generate_shape(out: &mut String, shape: &Shape, width: f64, height ); 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)); } @@ -330,12 +341,13 @@ pub(super) fn write_gradient_fill(out: &mut String, gradient: &GradientFill) { // ── Polyline & arrowhead rendering ────────────────────────────────── -/// Render a multi-segment polyline as consecutive `#line()` calls. +/// 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("#line("); + out.push_str("#place(top + left)[#line("); let _ = write!( out, "start: ({}pt, {}pt), end: ({}pt, {}pt)", @@ -345,7 +357,7 @@ fn write_polyline(out: &mut String, stroke: &Option, points: &[(f64, format_f64(y2), ); write_shape_stroke(out, stroke); - out.push_str(")\n"); + out.push_str(")]\n"); } } @@ -381,7 +393,7 @@ fn write_arrowhead_at( 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("#polygon("); + out.push_str("#place(top + left)[#polygon("); let _ = write!( out, "({}pt, {}pt), ({}pt, {}pt), ({}pt, {}pt), fill: rgb({}, {}, {})", @@ -395,5 +407,5 @@ fn write_arrowhead_at( stroke.color.g, stroke.color.b, ); - out.push_str(")\n"); + out.push_str(")]\n"); } From 2b437b5c99af390dc276a6247acf8122a95438f2 Mon Sep 17 00:00:00 2001 From: Yonghye Kwon Date: Sat, 14 Mar 2026 01:09:48 +0900 Subject: [PATCH 4/5] style: fix clippy and rustfmt warnings Collapse nested if-let chain in adj value parsing, suppress too_many_arguments for test helper, and apply rustfmt. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Yonghye Kwon --- crates/office2pdf/src/parser/pptx.rs | 11 +- .../src/parser/pptx_connector_tests.rs | 240 ++++++++++++++---- crates/office2pdf/src/parser/pptx_slides.rs | 32 +-- .../src/render/typst_gen_fixed_page_tests.rs | 9 +- .../office2pdf/src/render/typst_gen_shapes.rs | 3 +- 5 files changed, 220 insertions(+), 75 deletions(-) diff --git a/crates/office2pdf/src/parser/pptx.rs b/crates/office2pdf/src/parser/pptx.rs index 2019083..5edddc1 100644 --- a/crates/office2pdf/src/parser/pptx.rs +++ b/crates/office2pdf/src/parser/pptx.rs @@ -11,12 +11,11 @@ use zip::ZipArchive; use crate::config::ConvertOptions; use crate::error::{ConvertError, ConvertWarning}; use crate::ir::{ - 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, + 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, }; use crate::parser::Parser; use crate::parser::smartart; diff --git a/crates/office2pdf/src/parser/pptx_connector_tests.rs b/crates/office2pdf/src/parser/pptx_connector_tests.rs index 97e5faa..57adab7 100644 --- a/crates/office2pdf/src/parser/pptx_connector_tests.rs +++ b/crates/office2pdf/src/parser/pptx_connector_tests.rs @@ -16,7 +16,20 @@ fn make_connector( 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, "", "") + make_connector_full( + x, + y, + cx, + cy, + prst, + border_hex, + border_width_emu, + dash, + flip_h, + flip_v, + "", + "", + ) } /// Create a connector with arrowhead attributes. @@ -39,7 +52,20 @@ fn make_connector_with_arrows( } else { format!(r#""#) }; - make_connector_full(x, y, cx, cy, prst, border_hex, border_width_emu, dash, flip_h, flip_v, "", &tail_xml) + 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)] @@ -88,6 +114,7 @@ fn make_connector_full( } /// 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, @@ -120,9 +147,16 @@ fn make_connector_with_style( #[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, + 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]); @@ -154,9 +188,16 @@ fn test_straight_connector_parsed_as_line() { #[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, + 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]); @@ -176,9 +217,16 @@ fn test_connector_with_line_preset() { #[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 + 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]); @@ -193,10 +241,16 @@ fn test_connector_flip_h_reverses_line_direction() { // 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!( + (*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}"); + assert!( + (*y2 - height).abs() < 0.1, + "flipH: y2 should be {height}, got {y2}" + ); } _ => panic!("Expected Line shape"), } @@ -205,9 +259,16 @@ fn test_connector_flip_h_reverses_line_direction() { #[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 + 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]); @@ -223,8 +284,14 @@ fn test_connector_flip_v_reverses_line_direction() { 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!( + (*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"), @@ -234,9 +301,16 @@ fn test_connector_flip_v_reverses_line_direction() { #[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 + 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]); @@ -252,7 +326,10 @@ fn test_connector_flip_h_and_v() { 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!( + (*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}"); } @@ -262,11 +339,27 @@ fn test_connector_flip_h_and_v() { #[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 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, + 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]); @@ -275,24 +368,44 @@ fn test_connector_mixed_with_regular_shapes() { 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 { .. })); + 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, + 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"), + ("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", "맑은 고딕", @@ -312,9 +425,16 @@ fn test_connector_with_style_based_line_color() { #[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, + 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]); @@ -322,7 +442,11 @@ fn test_bent_connector3_parsed_as_polyline() { 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"); + 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) @@ -338,9 +462,16 @@ fn test_bent_connector3_parsed_as_polyline() { #[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, + 0, + 0, + 3_000_000, + 0, + "straightConnector1", + Some("0000FF"), + Some(12700), + None, + false, + false, "triangle", ); let slide = make_slide_xml(&[connector]); @@ -351,7 +482,9 @@ fn test_connector_tail_end_triangle() { let page = first_fixed_page(&doc); let shape = get_shape(&page.elements[0]); match &shape.kind { - ShapeKind::Line { tail_end, head_end, .. } => { + 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"); } @@ -364,10 +497,18 @@ 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, "", + 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]); @@ -385,7 +526,10 @@ fn test_bent_connector3_with_adj_value() { 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[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"); diff --git a/crates/office2pdf/src/parser/pptx_slides.rs b/crates/office2pdf/src/parser/pptx_slides.rs index edfbb65..67b55b9 100644 --- a/crates/office2pdf/src/parser/pptx_slides.rs +++ b/crates/office2pdf/src/parser/pptx_slides.rs @@ -778,10 +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"); + 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") { @@ -815,12 +815,10 @@ impl<'a> SlideXmlParser<'a> { .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()); + 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()); + 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; @@ -1076,21 +1074,19 @@ impl<'a> SlideXmlParser<'a> { .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()); + 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()); + 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(fmla) = get_attr_str(e, b"fmla") { - if let Some(val_str) = fmla.strip_prefix("val ") { - if let Ok(val) = val_str.parse::() { - self.shape.adj_values.push(val); - } - } + 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 => { 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 8047dc8..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 { x1: 0.0, y1: 0.0, x2: 300.0, y2: 0.0, head_end: ArrowHead::None, tail_end: ArrowHead::None }, + 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, diff --git a/crates/office2pdf/src/render/typst_gen_shapes.rs b/crates/office2pdf/src/render/typst_gen_shapes.rs index 938d05e..90888d4 100644 --- a/crates/office2pdf/src/render/typst_gen_shapes.rs +++ b/crates/office2pdf/src/render/typst_gen_shapes.rs @@ -32,8 +32,7 @@ pub(super) fn generate_shape(out: &mut String, shape: &Shape, width: f64, height head_end, tail_end, } => { - let has_arrowheads: bool = - *tail_end != ArrowHead::None || *head_end != ArrowHead::None; + 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. From 83b35bb8062ce86e42b7c9c20542c6deedb912c3 Mon Sep 17 00:00:00 2001 From: Yonghye Kwon Date: Sat, 14 Mar 2026 01:19:23 +0900 Subject: [PATCH 5/5] style: apply nightly rustfmt formatting Co-Authored-By: Claude Opus 4.6 Signed-off-by: Yonghye Kwon --- crates/office2pdf/src/parser/pptx_shapes.rs | 10 ++-------- .../src/render/typst_gen_text_pipeline_tests.rs | 9 ++++++++- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/crates/office2pdf/src/parser/pptx_shapes.rs b/crates/office2pdf/src/parser/pptx_shapes.rs index 8e688c1..7b9e656 100644 --- a/crates/office2pdf/src/parser/pptx_shapes.rs +++ b/crates/office2pdf/src/parser/pptx_shapes.rs @@ -237,8 +237,7 @@ pub(super) fn prst_to_shape_kind( } // Bent connectors: L-shaped or Z-shaped paths "bentConnector2" => { - let points: Vec<(f64, f64)> = - bent_connector2_points(width, height, flip_h, flip_v); + let points: Vec<(f64, f64)> = bent_connector2_points(width, height, flip_h, flip_v); ShapeKind::Polyline { points, head_end, @@ -391,12 +390,7 @@ fn line_endpoints(width: f64, height: f64, flip_h: bool, flip_v: bool) -> (f64, /// 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)> { +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)] } 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 86f427c..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 { x1: 0.0, y1: 0.0, x2: 100.0, y2: 0.0, head_end: ArrowHead::None, tail_end: ArrowHead::None }, + 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, },