From 5d5fa7d1dc84496b44c1e70c657c3eb30b3ae187 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 07:18:36 -0400 Subject: [PATCH] Render: presets PowerPoint mitres by default get sharp corners; miter limit 8 An outline naming no join was always rounded. PowerPoint rounds most presets but mitres some by default: classified per preset by comparing its export (8 pt outline, no join) with round- and miter-joined renders: the plus, right/up/down block arrows (not the left one), chevrons, stars from five points up, the equation shapes, curved ribbons and the action buttons. Miter joins also take PowerPoint's default limit of 8 widths (kurbo's default was 4), so sharp star points no longer bevel off. Wild synthetic set: joins slide 0.43 -> 0.21, outline-on-pattern slide 3.95 -> 3.76, preset galleries slightly better. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/geom/src/preset.rs | 39 +++++++++++++++++++++++++++++++++ crates/render/src/lib.rs | 10 ++++++++- crates/render/src/tests_wild.rs | 21 ++++++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/crates/geom/src/preset.rs b/crates/geom/src/preset.rs index 2424bda..33575bf 100644 --- a/crates/geom/src/preset.rs +++ b/crates/geom/src/preset.rs @@ -339,6 +339,45 @@ pub fn info(name: &str) -> Option<&'static PresetInfo> { CATALOG.iter().find(|p| p.name == name) } +/// Whether PowerPoint joins this preset's outline with sharp (miter) corners when the line names +/// no join (it rounds them otherwise). Observed per preset on its export: the plus, the right, +/// up and down block arrows (not the left one), chevrons, stars from five points up, the +/// equation shapes, curved ribbons and the action buttons are mitred. +pub fn mitred_by_default(name: &str) -> bool { + matches!( + name, + "plus" + | "rightArrow" + | "upArrow" + | "downArrow" + | "upDownArrow" + | "leftUpArrow" + | "bentUpArrow" + | "stripedRightArrow" + | "notchedRightArrow" + | "chevron" + | "leftArrowCallout" + | "leftRightArrowCallout" + | "upDownArrowCallout" + | "quadArrowCallout" + | "mathPlus" + | "mathMinus" + | "mathMultiply" + | "mathEqual" + | "mathNotEqual" + | "star5" + | "star6" + | "star8" + | "star10" + | "star12" + | "star16" + | "star24" + | "star32" + | "ellipseRibbon" + | "ellipseRibbon2" + ) || name.starts_with("actionButton") +} + pub fn is_line_like(name: &str) -> bool { matches!(name, "line" | "straightConnector1" | "bentConnector2" | "bentConnector3" | "curvedConnector3" | "arc") || name.starts_with("bentConnector") diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c4da515..3ad77fd 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -998,6 +998,13 @@ impl Renderer { ctx.pop_layer(); } } + // Some presets' outlines default to sharp corners (PowerPoint's own per-shape default). + let line = match (&s.geom, line.join) { + (Geom::Preset { name, .. }, None) if preset::mitred_by_default(name) => { + Line { join: Some(deckcraft_model::style::LineJoin::Miter), ..line } + } + _ => line, + }; // A 3-D bevel: the face and a band inside the outline lit as surfaces. if let Some(bv) = effects.as_ref().and_then(|e| e.raw3d.as_deref()).and_then(bevel::from_raw) { let solid = match &fill { @@ -1404,7 +1411,8 @@ pub(crate) fn stroke( // Hairlines stay visible at any zoom. let min_w = 0.5 / scale_of(m).max(1e-6); let sw = lw.max(min_w); - let mut sk = kurbo::Stroke::new(sw).with_caps(cap).with_join(join); + // PowerPoint's default miter limit is 8 widths (`a:miter lim="800000"`). + let mut sk = kurbo::Stroke::new(sw).with_caps(cap).with_join(join).with_miter_limit(8.0); // Square caps on a dashed line square off only the line's own two ends (where a dash is // drawn): each dash itself ends flat, as PowerPoint draws them. let mut square_ends = None; diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index 6d09dd4..66e73ef 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -662,3 +662,24 @@ fn a_gradient_not_rotating_with_its_shape_keeps_to_the_slide() { } } } + +#[test] +fn stars_and_right_arrows_have_sharp_corners_by_default() { + let shape = |name: &str, join: Option| Shape { + id: ShapeId(29), + xfrm: Some(Xfrm::new(100.0, 100.0, 100.0, 100.0)), + geom: Geom::preset(name), + fill: solid(WHITE), + line: Some(Line { join, ..Line::solid(ColorRef::rgb(RED), 10.0) }), + ..Default::default() + }; + // A star5's top point mitred reaches about 16 pt above the box; rounded only 5. + let img = render(vec![shape("star5", None)]); + assert!(close(img.pixel(150, 90), RED, 40), "mitred point {:?}", img.pixel(150, 90)); + let img = render(vec![shape("star5", Some(deckcraft_model::style::LineJoin::Round))]); + assert!(close(img.pixel(150, 90), WHITE, 10), "an explicit round join stays round"); + // A rectangle keeps PowerPoint's round default: its corner is cut round. + let img = render(vec![shape("rect", None)]); + assert!(close(img.pixel(95, 95), WHITE, 30), "{:?}", img.pixel(95, 95)); + assert!(deckcraft_geom::preset::mitred_by_default("rightArrow") && !deckcraft_geom::preset::mitred_by_default("leftArrow")); +}