From ccd0046430dd5f3a8d74425a5b6d8629a07eeaae Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 16:40:51 -0500 Subject: [PATCH] Add craft-drawingml: units, colours, preset shapes, custom geometry and text warps (#882) * Add craft-drawingml: units, colours, preset shapes, custom geometry and text warps The parts of DrawingML that need no document model, moved from DeckCraft (60f91f78): crates/color, crates/geom (preset, warp, Xfrm and the EMU conversions) and crates/pptx/src/custgeom.rs with CustomPath from crates/model. The code is unchanged except that custgeom reads craft-xml trees and writes to a craft-xml Writer instead of DeckCraft's own XML helper, and matches elements by local name. The 59 tests of the moved code come with it; six more cover the custom geometry limits. * drawingml: evaluate a custom geometry's guide lists once per coordinate space read_cust_geom rebuilt Guides and evaluated avLst and gdLst again for every path: up to 1,000 paths times up to 10,000 guides, each name copied and hashed. Guides are now cached per path coordinate space (w, h). At most 16 spaces and 4 MiB of guide names are kept per geometry; paths in further spaces are left out. The first space is always kept, and a geometry without guides is not capped. Ported from storytold/deckcraft#1789. A list counts as having guides only when it has gd children, as that PR's review asked. --- Cargo.lock | 9 + Cargo.toml | 1 + README.md | 4 + crates/drawingml/Cargo.toml | 16 + crates/drawingml/README.md | 42 + crates/drawingml/src/color.rs | 830 ++++++++++++++++ crates/drawingml/src/custgeom.rs | 654 +++++++++++++ crates/drawingml/src/lib.rs | 164 ++++ crates/drawingml/src/preset.rs | 24 + crates/drawingml/src/preset/build.rs | 1204 ++++++++++++++++++++++++ crates/drawingml/src/preset/builder.rs | 344 +++++++ crates/drawingml/src/preset/catalog.rs | 391 ++++++++ crates/drawingml/src/preset/extra.rs | 799 ++++++++++++++++ crates/drawingml/src/preset/shapes.rs | 375 ++++++++ crates/drawingml/src/preset/tests.rs | 420 +++++++++ crates/drawingml/src/warp.rs | 1023 ++++++++++++++++++++ crates/drawingml/src/warp/tests.rs | 338 +++++++ 17 files changed, 6638 insertions(+) create mode 100644 crates/drawingml/Cargo.toml create mode 100644 crates/drawingml/README.md create mode 100644 crates/drawingml/src/color.rs create mode 100644 crates/drawingml/src/custgeom.rs create mode 100644 crates/drawingml/src/lib.rs create mode 100644 crates/drawingml/src/preset.rs create mode 100644 crates/drawingml/src/preset/build.rs create mode 100644 crates/drawingml/src/preset/builder.rs create mode 100644 crates/drawingml/src/preset/catalog.rs create mode 100644 crates/drawingml/src/preset/extra.rs create mode 100644 crates/drawingml/src/preset/shapes.rs create mode 100644 crates/drawingml/src/preset/tests.rs create mode 100644 crates/drawingml/src/warp.rs create mode 100644 crates/drawingml/src/warp/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 870e98c85..11f2b7041 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -890,6 +890,15 @@ dependencies = [ name = "craft-docpath" version = "0.1.0" +[[package]] +name = "craft-drawingml" +version = "0.1.0" +dependencies = [ + "craft-xml", + "kurbo 0.13.1", + "serde", +] + [[package]] name = "craft-dxf" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 1bfe17d77..956c970c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ craft-cms = { path = "crates/cms" } craft-crw = { path = "crates/crw" } craft-dialogs = { path = "crates/dialogs" } craft-dxf = { path = "crates/dxf" } +craft-drawingml = { path = "crates/drawingml" } craft-doc = { path = "crates/doc" } craft-docpath = { path = "crates/docpath" } craft-ecproj-format = { path = "crates/ecproj-format" } diff --git a/README.md b/README.md index a0d7ceec4..b3cfd1042 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,10 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " Self { + Rgba::BLACK + } +} + +impl Rgba { + pub const BLACK: Rgba = Rgba::rgb(0, 0, 0); + pub const WHITE: Rgba = Rgba::rgb(255, 255, 255); + pub const TRANSPARENT: Rgba = Rgba { r: 0, g: 0, b: 0, a: 0 }; + + pub const fn rgb(r: u8, g: u8, b: u8) -> Self { + Rgba { r, g, b, a: 255 } + } + // The name the apps already call, beside `rgb`. + #[allow(clippy::self_named_constructors)] + pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self { + Rgba { r, g, b, a } + } + /// `RRGGBB` or `#RRGGBB` (also `RRGGBBAA`). + pub fn from_hex(s: &str) -> Option { + let s = s.trim(); + let s = s.strip_prefix('#').unwrap_or(s); + if !s.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let byte = |i: usize| s.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok()); + match s.len() { + 6 => Some(Rgba::rgb(byte(0)?, byte(2)?, byte(4)?)), + 8 => Some(Rgba::rgba(byte(0)?, byte(2)?, byte(4)?, byte(6)?)), + 3 => { + let n = |i: usize| s.get(i..i + 1).and_then(|h| u8::from_str_radix(h, 16).ok()).map(|v| v * 17); + Some(Rgba::rgb(n(0)?, n(1)?, n(2)?)) + } + _ => None, + } + } + /// `RRGGBB` uppercase (no alpha). + pub fn hex(&self) -> String { + format!("{:02X}{:02X}{:02X}", self.r, self.g, self.b) + } + pub fn with_alpha(self, a: u8) -> Self { + Rgba { a, ..self } + } + pub fn to_f32(self) -> [f32; 4] { + [self.r as f32 / 255.0, self.g as f32 / 255.0, self.b as f32 / 255.0, self.a as f32 / 255.0] + } + pub fn from_f64(r: f64, g: f64, b: f64, a: f64) -> Self { + let c = |v: f64| if v.is_finite() { (v.clamp(0.0, 1.0) * 255.0).round() as u8 } else { 0 }; + Rgba { r: c(r), g: c(g), b: c(b), a: c(a) } + } + /// Relative luminance (WCAG), 0–1. + pub fn luminance(self) -> f64 { + let lin = |c: u8| { + let v = c as f64 / 255.0; + if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * lin(self.r) + 0.7152 * lin(self.g) + 0.0722 * lin(self.b) + } + /// WCAG contrast ratio between two colours (1–21). + pub fn contrast(self, other: Rgba) -> f64 { + let (a, b) = (self.luminance(), other.luminance()); + let (hi, lo) = if a > b { (a, b) } else { (b, a) }; + (hi + 0.05) / (lo + 0.05) + } + pub fn to_hsl(self) -> (f64, f64, f64) { + let r = self.r as f64 / 255.0; + let g = self.g as f64 / 255.0; + let b = self.b as f64 / 255.0; + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let l = (max + min) / 2.0; + if (max - min).abs() < 1e-12 { + return (0.0, 0.0, l); + } + let d = max - min; + let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) }; + let h = if (max - r).abs() < 1e-12 { + (g - b) / d + if g < b { 6.0 } else { 0.0 } + } else if (max - g).abs() < 1e-12 { + (b - r) / d + 2.0 + } else { + (r - g) / d + 4.0 + }; + (h * 60.0, s, l) + } + pub fn from_hsl(h: f64, s: f64, l: f64, a: u8) -> Self { + let h = ((h % 360.0) + 360.0) % 360.0 / 360.0; + let s = s.clamp(0.0, 1.0); + let l = l.clamp(0.0, 1.0); + if s == 0.0 { + return Rgba::from_f64(l, l, l, a as f64 / 255.0); + } + let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s }; + let p = 2.0 * l - q; + let f = |t: f64| { + let t = if t < 0.0 { + t + 1.0 + } else if t > 1.0 { + t - 1.0 + } else { + t + }; + if t < 1.0 / 6.0 { + p + (q - p) * 6.0 * t + } else if t < 0.5 { + q + } else if t < 2.0 / 3.0 { + p + (q - p) * (2.0 / 3.0 - t) * 6.0 + } else { + p + } + }; + Rgba::from_f64(f(h + 1.0 / 3.0), f(h), f(h - 1.0 / 3.0), a as f64 / 255.0) + } + /// Linear interpolation (straight alpha). + pub fn lerp(self, o: Rgba, t: f64) -> Rgba { + let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }; + let m = |a: u8, b: u8| (a as f64 + (b as f64 - a as f64) * t).round().clamp(0.0, 255.0) as u8; + Rgba { r: m(self.r, o.r), g: m(self.g, o.g), b: m(self.b, o.b), a: m(self.a, o.a) } + } +} + +/// The twelve theme colour slots, plus the mapped aliases (`bg1`, `tx1`…) and `phClr` (the +/// placeholder colour a style matrix entry is applied with). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SchemeSlot { + Dk1, + Lt1, + Dk2, + Lt2, + Accent1, + Accent2, + Accent3, + Accent4, + Accent5, + Accent6, + Hlink, + FolHlink, + Bg1, + Tx1, + Bg2, + Tx2, + PhClr, +} + +impl SchemeSlot { + pub const THEME: [SchemeSlot; 12] = [ + SchemeSlot::Dk1, + SchemeSlot::Lt1, + SchemeSlot::Dk2, + SchemeSlot::Lt2, + SchemeSlot::Accent1, + SchemeSlot::Accent2, + SchemeSlot::Accent3, + SchemeSlot::Accent4, + SchemeSlot::Accent5, + SchemeSlot::Accent6, + SchemeSlot::Hlink, + SchemeSlot::FolHlink, + ]; + pub fn xml_name(self) -> &'static str { + match self { + SchemeSlot::Dk1 => "dk1", + SchemeSlot::Lt1 => "lt1", + SchemeSlot::Dk2 => "dk2", + SchemeSlot::Lt2 => "lt2", + SchemeSlot::Accent1 => "accent1", + SchemeSlot::Accent2 => "accent2", + SchemeSlot::Accent3 => "accent3", + SchemeSlot::Accent4 => "accent4", + SchemeSlot::Accent5 => "accent5", + SchemeSlot::Accent6 => "accent6", + SchemeSlot::Hlink => "hlink", + SchemeSlot::FolHlink => "folHlink", + SchemeSlot::Bg1 => "bg1", + SchemeSlot::Tx1 => "tx1", + SchemeSlot::Bg2 => "bg2", + SchemeSlot::Tx2 => "tx2", + SchemeSlot::PhClr => "phClr", + } + } + pub fn from_xml(s: &str) -> Option { + Some(match s { + "dk1" => SchemeSlot::Dk1, + "lt1" => SchemeSlot::Lt1, + "dk2" => SchemeSlot::Dk2, + "lt2" => SchemeSlot::Lt2, + "accent1" => SchemeSlot::Accent1, + "accent2" => SchemeSlot::Accent2, + "accent3" => SchemeSlot::Accent3, + "accent4" => SchemeSlot::Accent4, + "accent5" => SchemeSlot::Accent5, + "accent6" => SchemeSlot::Accent6, + "hlink" => SchemeSlot::Hlink, + "folHlink" => SchemeSlot::FolHlink, + "bg1" => SchemeSlot::Bg1, + "tx1" => SchemeSlot::Tx1, + "bg2" => SchemeSlot::Bg2, + "tx2" => SchemeSlot::Tx2, + "phClr" => SchemeSlot::PhClr, + _ => return None, + }) + } + pub fn label(self) -> &'static str { + match self { + SchemeSlot::Dk1 | SchemeSlot::Tx1 => "Text/Background - Dark 1", + SchemeSlot::Lt1 | SchemeSlot::Bg1 => "Text/Background - Light 1", + SchemeSlot::Dk2 | SchemeSlot::Tx2 => "Text/Background - Dark 2", + SchemeSlot::Lt2 | SchemeSlot::Bg2 => "Text/Background - Light 2", + SchemeSlot::Accent1 => "Accent 1", + SchemeSlot::Accent2 => "Accent 2", + SchemeSlot::Accent3 => "Accent 3", + SchemeSlot::Accent4 => "Accent 4", + SchemeSlot::Accent5 => "Accent 5", + SchemeSlot::Accent6 => "Accent 6", + SchemeSlot::Hlink => "Hyperlink", + SchemeSlot::FolHlink => "Followed Hyperlink", + SchemeSlot::PhClr => "Placeholder colour", + } + } +} + +/// A colour scheme: the twelve theme colours. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ColorScheme { + pub name: String, + pub colors: [Rgba; 12], +} + +impl ColorScheme { + pub fn get(&self, slot: SchemeSlot) -> Rgba { + let i = match slot { + SchemeSlot::Dk1 | SchemeSlot::Tx1 => 0, + SchemeSlot::Lt1 | SchemeSlot::Bg1 => 1, + SchemeSlot::Dk2 | SchemeSlot::Tx2 => 2, + SchemeSlot::Lt2 | SchemeSlot::Bg2 => 3, + SchemeSlot::Accent1 => 4, + SchemeSlot::Accent2 => 5, + SchemeSlot::Accent3 => 6, + SchemeSlot::Accent4 => 7, + SchemeSlot::Accent5 => 8, + SchemeSlot::Accent6 => 9, + SchemeSlot::Hlink => 10, + SchemeSlot::FolHlink => 11, + SchemeSlot::PhClr => 4, + }; + self.colors.get(i).copied().unwrap_or(Rgba::BLACK) + } + pub fn set(&mut self, slot: SchemeSlot, c: Rgba) { + if let Some(i) = SchemeSlot::THEME.iter().position(|s| *s == slot) + && let Some(v) = self.colors.get_mut(i) + { + *v = c; + } + } +} + +/// One colour transform, in file units (percentages ×1000, angles ×60000), applied in order. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "op", content = "val")] +pub enum ColorTransform { + Tint(i32), + Shade(i32), + LumMod(i32), + LumOff(i32), + SatMod(i32), + SatOff(i32), + HueMod(i32), + HueOff(i32), + Alpha(i32), + AlphaMod(i32), + AlphaOff(i32), + Comp, + Inv, + Gray, + RedMod(i32), + GreenMod(i32), + BlueMod(i32), +} + +impl ColorTransform { + pub fn xml_name(&self) -> &'static str { + match self { + ColorTransform::Tint(_) => "tint", + ColorTransform::Shade(_) => "shade", + ColorTransform::LumMod(_) => "lumMod", + ColorTransform::LumOff(_) => "lumOff", + ColorTransform::SatMod(_) => "satMod", + ColorTransform::SatOff(_) => "satOff", + ColorTransform::HueMod(_) => "hueMod", + ColorTransform::HueOff(_) => "hueOff", + ColorTransform::Alpha(_) => "alpha", + ColorTransform::AlphaMod(_) => "alphaMod", + ColorTransform::AlphaOff(_) => "alphaOff", + ColorTransform::Comp => "comp", + ColorTransform::Inv => "inv", + ColorTransform::Gray => "gray", + ColorTransform::RedMod(_) => "redMod", + ColorTransform::GreenMod(_) => "greenMod", + ColorTransform::BlueMod(_) => "blueMod", + } + } + pub fn value(&self) -> Option { + match *self { + ColorTransform::Tint(v) + | ColorTransform::Shade(v) + | ColorTransform::LumMod(v) + | ColorTransform::LumOff(v) + | ColorTransform::SatMod(v) + | ColorTransform::SatOff(v) + | ColorTransform::HueMod(v) + | ColorTransform::HueOff(v) + | ColorTransform::Alpha(v) + | ColorTransform::AlphaMod(v) + | ColorTransform::AlphaOff(v) + | ColorTransform::RedMod(v) + | ColorTransform::GreenMod(v) + | ColorTransform::BlueMod(v) => Some(v), + _ => None, + } + } + pub fn from_xml(name: &str, val: Option) -> Option { + let v = val.unwrap_or(0); + Some(match name { + "tint" => ColorTransform::Tint(v), + "shade" => ColorTransform::Shade(v), + "lumMod" => ColorTransform::LumMod(v), + "lumOff" => ColorTransform::LumOff(v), + "satMod" => ColorTransform::SatMod(v), + "satOff" => ColorTransform::SatOff(v), + "hueMod" => ColorTransform::HueMod(v), + "hueOff" => ColorTransform::HueOff(v), + "alpha" => ColorTransform::Alpha(v), + "alphaMod" => ColorTransform::AlphaMod(v), + "alphaOff" => ColorTransform::AlphaOff(v), + "comp" => ColorTransform::Comp, + "inv" => ColorTransform::Inv, + "gray" => ColorTransform::Gray, + "redMod" => ColorTransform::RedMod(v), + "greenMod" => ColorTransform::GreenMod(v), + "blueMod" => ColorTransform::BlueMod(v), + _ => return None, + }) + } +} + +/// Apply transforms to a base colour. +pub fn apply(base: Rgba, transforms: &[ColorTransform]) -> Rgba { + let mut c = base; + for t in transforms { + c = apply_one(c, *t); + } + c +} + +fn pct(v: i32) -> f64 { + v as f64 / 100_000.0 +} + +fn apply_one(c: Rgba, t: ColorTransform) -> Rgba { + match t { + // tint: towards white by (1 - value); shade: towards black by (1 - value). Office applies + // these in linear light, which keeps mid-tones from washing out. + ColorTransform::Tint(v) => { + let k = pct(v).clamp(0.0, 1.0); + map_linear(c, |x| x * k + (1.0 - k)) + } + ColorTransform::Shade(v) => { + let k = pct(v).clamp(0.0, 1.0); + map_linear(c, |x| x * k) + } + ColorTransform::LumMod(v) => { + let (h, s, l) = c.to_hsl(); + Rgba::from_hsl(h, s, l * pct(v), c.a) + } + ColorTransform::LumOff(v) => { + let (h, s, l) = c.to_hsl(); + Rgba::from_hsl(h, s, l + pct(v), c.a) + } + ColorTransform::SatMod(v) => { + let (h, s, l) = c.to_hsl(); + Rgba::from_hsl(h, s * pct(v), l, c.a) + } + ColorTransform::SatOff(v) => { + let (h, s, l) = c.to_hsl(); + Rgba::from_hsl(h, s + pct(v), l, c.a) + } + ColorTransform::HueMod(v) => { + let (h, s, l) = c.to_hsl(); + Rgba::from_hsl(h * pct(v), s, l, c.a) + } + ColorTransform::HueOff(v) => { + let (h, s, l) = c.to_hsl(); + Rgba::from_hsl(h + v as f64 / 60_000.0, s, l, c.a) + } + ColorTransform::Alpha(v) => c.with_alpha((pct(v).clamp(0.0, 1.0) * 255.0).round() as u8), + ColorTransform::AlphaMod(v) => c.with_alpha((c.a as f64 * pct(v)).round().clamp(0.0, 255.0) as u8), + ColorTransform::AlphaOff(v) => c.with_alpha((c.a as f64 + pct(v) * 255.0).round().clamp(0.0, 255.0) as u8), + ColorTransform::Comp => { + let (h, s, l) = c.to_hsl(); + Rgba::from_hsl(h + 180.0, s, l, c.a) + } + ColorTransform::Inv => Rgba { r: 255 - c.r, g: 255 - c.g, b: 255 - c.b, a: c.a }, + ColorTransform::Gray => { + let y = (0.299 * c.r as f64 + 0.587 * c.g as f64 + 0.114 * c.b as f64).round().clamp(0.0, 255.0) as u8; + Rgba { r: y, g: y, b: y, a: c.a } + } + ColorTransform::RedMod(v) => Rgba { r: (c.r as f64 * pct(v)).round().clamp(0.0, 255.0) as u8, ..c }, + ColorTransform::GreenMod(v) => Rgba { g: (c.g as f64 * pct(v)).round().clamp(0.0, 255.0) as u8, ..c }, + ColorTransform::BlueMod(v) => Rgba { b: (c.b as f64 * pct(v)).round().clamp(0.0, 255.0) as u8, ..c }, + } +} + +fn to_lin(v: u8) -> f64 { + let v = v as f64 / 255.0; + if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } +} +fn from_lin(v: f64) -> f64 { + let v = v.clamp(0.0, 1.0); + if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } +} +fn map_linear(c: Rgba, f: impl Fn(f64) -> f64) -> Rgba { + Rgba::from_f64(from_lin(f(to_lin(c.r))), from_lin(f(to_lin(c.g))), from_lin(f(to_lin(c.b))), c.a as f64 / 255.0) +} + +/// The colour-picker grid shown under each theme colour: lighter 80/60/40%, darker 25/50% +/// (for dark colours: lighter 90/75/50/25/10%). +pub fn theme_variants(c: Rgba) -> [(Vec, &'static str); 5] { + let (_, _, l) = c.to_hsl(); + if l < 0.2 { + [ + (vec![ColorTransform::LumMod(10000), ColorTransform::LumOff(90000)], "Lighter 90%"), + (vec![ColorTransform::LumMod(25000), ColorTransform::LumOff(75000)], "Lighter 75%"), + (vec![ColorTransform::LumMod(50000), ColorTransform::LumOff(50000)], "Lighter 50%"), + (vec![ColorTransform::LumMod(75000), ColorTransform::LumOff(25000)], "Lighter 25%"), + (vec![ColorTransform::LumMod(90000), ColorTransform::LumOff(10000)], "Lighter 10%"), + ] + } else if l > 0.8 { + [ + (vec![ColorTransform::LumMod(95000)], "Darker 5%"), + (vec![ColorTransform::LumMod(85000)], "Darker 15%"), + (vec![ColorTransform::LumMod(75000)], "Darker 25%"), + (vec![ColorTransform::LumMod(65000)], "Darker 35%"), + (vec![ColorTransform::LumMod(50000)], "Darker 50%"), + ] + } else { + [ + (vec![ColorTransform::LumMod(20000), ColorTransform::LumOff(80000)], "Lighter 80%"), + (vec![ColorTransform::LumMod(40000), ColorTransform::LumOff(60000)], "Lighter 60%"), + (vec![ColorTransform::LumMod(60000), ColorTransform::LumOff(40000)], "Lighter 40%"), + (vec![ColorTransform::LumMod(75000)], "Darker 25%"), + (vec![ColorTransform::LumMod(50000)], "Darker 50%"), + ] + } +} + +/// Standard colours row of the colour picker. +pub const STANDARD_COLORS: [(Rgba, &str); 10] = [ + (Rgba::rgb(0xC0, 0x00, 0x00), "Dark Red"), + (Rgba::rgb(0xFF, 0x00, 0x00), "Red"), + (Rgba::rgb(0xFF, 0xC0, 0x00), "Orange"), + (Rgba::rgb(0xFF, 0xFF, 0x00), "Yellow"), + (Rgba::rgb(0x92, 0xD0, 0x50), "Light Green"), + (Rgba::rgb(0x00, 0xB0, 0x50), "Green"), + (Rgba::rgb(0x00, 0xB0, 0xF0), "Light Blue"), + (Rgba::rgb(0x00, 0x70, 0xC0), "Blue"), + (Rgba::rgb(0x00, 0x20, 0x60), "Dark Blue"), + (Rgba::rgb(0x70, 0x30, 0xA0), "Purple"), +]; + +/// A few CSS-like preset colour names (`prstClr`). +pub fn preset(name: &str) -> Option { + let c = match name { + "black" => (0, 0, 0), + "white" => (255, 255, 255), + "red" => (255, 0, 0), + "green" => (0, 128, 0), + "lime" => (0, 255, 0), + "blue" => (0, 0, 255), + "yellow" => (255, 255, 0), + "cyan" | "aqua" => (0, 255, 255), + "magenta" | "fuchsia" => (255, 0, 255), + "gray" | "grey" => (128, 128, 128), + "silver" => (192, 192, 192), + "maroon" => (128, 0, 0), + "navy" => (0, 0, 128), + "olive" => (128, 128, 0), + "purple" => (128, 0, 128), + "teal" => (0, 128, 128), + "orange" => (255, 165, 0), + "pink" => (255, 192, 203), + "brown" => (165, 42, 42), + "gold" => (255, 215, 0), + "ltGray" | "lightGray" => (211, 211, 211), + "dkGray" | "darkGray" => (169, 169, 169), + _ => return None, + }; + Some(Rgba::rgb(c.0, c.1, c.2)) +} + +/// Every preset colour name a file may write (`ST_PresetColorVal`, with the later editions' aliases). +pub const PRESET_NAMES: [&str; 190] = [ + "aliceBlue", + "antiqueWhite", + "aqua", + "aquamarine", + "azure", + "beige", + "bisque", + "black", + "blanchedAlmond", + "blue", + "blueViolet", + "brown", + "burlyWood", + "cadetBlue", + "chartreuse", + "chocolate", + "coral", + "cornflowerBlue", + "cornsilk", + "crimson", + "cyan", + "darkBlue", + "darkCyan", + "darkGoldenrod", + "darkGray", + "darkGrey", + "darkGreen", + "darkKhaki", + "darkMagenta", + "darkOliveGreen", + "darkOrange", + "darkOrchid", + "darkRed", + "darkSalmon", + "darkSeaGreen", + "darkSlateBlue", + "darkSlateGray", + "darkSlateGrey", + "darkTurquoise", + "darkViolet", + "dkBlue", + "dkCyan", + "dkGoldenrod", + "dkGray", + "dkGrey", + "dkGreen", + "dkKhaki", + "dkMagenta", + "dkOliveGreen", + "dkOrange", + "dkOrchid", + "dkRed", + "dkSalmon", + "dkSeaGreen", + "dkSlateBlue", + "dkSlateGray", + "dkSlateGrey", + "dkTurquoise", + "dkViolet", + "deepPink", + "deepSkyBlue", + "dimGray", + "dimGrey", + "dodgerBlue", + "firebrick", + "floralWhite", + "forestGreen", + "fuchsia", + "gainsboro", + "ghostWhite", + "gold", + "goldenrod", + "gray", + "grey", + "green", + "greenYellow", + "honeydew", + "hotPink", + "indianRed", + "indigo", + "ivory", + "khaki", + "lavender", + "lavenderBlush", + "lawnGreen", + "lemonChiffon", + "lightBlue", + "lightCoral", + "lightCyan", + "lightGoldenrodYellow", + "lightGray", + "lightGrey", + "lightGreen", + "lightPink", + "lightSalmon", + "lightSeaGreen", + "lightSkyBlue", + "lightSlateGray", + "lightSlateGrey", + "lightSteelBlue", + "lightYellow", + "ltBlue", + "ltCoral", + "ltCyan", + "ltGoldenrodYellow", + "ltGray", + "ltGrey", + "ltGreen", + "ltPink", + "ltSalmon", + "ltSeaGreen", + "ltSkyBlue", + "ltSlateGray", + "ltSlateGrey", + "ltSteelBlue", + "ltYellow", + "lime", + "limeGreen", + "linen", + "magenta", + "maroon", + "medAquamarine", + "medBlue", + "medOrchid", + "medPurple", + "medSeaGreen", + "medSlateBlue", + "medSpringGreen", + "medTurquoise", + "medVioletRed", + "mediumAquamarine", + "mediumBlue", + "mediumOrchid", + "mediumPurple", + "mediumSeaGreen", + "mediumSlateBlue", + "mediumSpringGreen", + "mediumTurquoise", + "mediumVioletRed", + "midnightBlue", + "mintCream", + "mistyRose", + "moccasin", + "navajoWhite", + "navy", + "oldLace", + "olive", + "oliveDrab", + "orange", + "orangeRed", + "orchid", + "paleGoldenrod", + "paleGreen", + "paleTurquoise", + "paleVioletRed", + "papayaWhip", + "peachPuff", + "peru", + "pink", + "plum", + "powderBlue", + "purple", + "red", + "rosyBrown", + "royalBlue", + "saddleBrown", + "salmon", + "sandyBrown", + "seaGreen", + "seaShell", + "sienna", + "silver", + "skyBlue", + "slateBlue", + "slateGray", + "slateGrey", + "snow", + "springGreen", + "steelBlue", + "tan", + "teal", + "thistle", + "tomato", + "turquoise", + "violet", + "wheat", + "white", + "whiteSmoke", + "yellow", + "yellowGreen", +]; + +/// Every system colour name a file may write (`ST_SystemColorVal`). +pub const SYSTEM_NAMES: [&str; 30] = [ + "scrollBar", + "background", + "activeCaption", + "inactiveCaption", + "menu", + "window", + "windowFrame", + "menuText", + "windowText", + "captionText", + "activeBorder", + "inactiveBorder", + "appWorkspace", + "highlight", + "highlightText", + "btnFace", + "btnShadow", + "grayText", + "btnText", + "inactiveCaptionText", + "btnHighlight", + "3dDkShadow", + "3dLight", + "infoText", + "infoBk", + "hotLight", + "gradientActiveCaption", + "gradientInactiveCaption", + "menuHighlight", + "menuBar", +]; + +/// Whether `name` is a preset colour name a file may write. +pub fn is_preset_name(name: &str) -> bool { + PRESET_NAMES.contains(&name) +} + +/// Whether `name` is a system colour name a file may write. +pub fn is_system_name(name: &str) -> bool { + SYSTEM_NAMES.contains(&name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_hex_rejects_multiple_hashes() { + assert_eq!(Rgba::from_hex("##FF0000"), None); + assert_eq!(Rgba::from_hex("####FF0000"), None); + assert_eq!(Rgba::from_hex("#FF0000"), Some(Rgba::rgb(255, 0, 0))); + assert_eq!(Rgba::from_hex("FF0000"), Some(Rgba::rgb(255, 0, 0))); + assert_eq!(Rgba::from_hex("#FF000080"), Some(Rgba::rgba(255, 0, 0, 128))); + } + + #[test] + fn from_hex_rejects_signs() { + assert_eq!(Rgba::from_hex("+12345"), None); + assert_eq!(Rgba::from_hex("#+1+2+3"), None); + assert_eq!(Rgba::from_hex("+1+2+3+4"), None); + assert_eq!(Rgba::from_hex("+12"), None); + } + + #[test] + fn hex_roundtrip() { + assert_eq!(Rgba::from_hex("#156082"), Some(Rgba::rgb(0x15, 0x60, 0x82))); + assert_eq!(Rgba::rgb(0x15, 0x60, 0x82).hex(), "156082"); + assert_eq!(Rgba::from_hex("fff"), Some(Rgba::WHITE)); + assert_eq!(Rgba::from_hex("zz0000"), None); + assert_eq!(Rgba::from_hex("ééé"), None); + assert_eq!(Rgba::from_hex(""), None); + } + + #[test] + fn hsl_roundtrip() { + for c in [Rgba::rgb(10, 200, 30), Rgba::rgb(255, 0, 0), Rgba::rgb(128, 128, 128), Rgba::rgb(21, 96, 130)] { + let (h, s, l) = c.to_hsl(); + let d = Rgba::from_hsl(h, s, l, 255); + assert!( + (d.r as i32 - c.r as i32).abs() <= 1 && (d.g as i32 - c.g as i32).abs() <= 1 && (d.b as i32 - c.b as i32).abs() <= 1, + "{c:?} {d:?}" + ); + } + } + + #[test] + fn transforms() { + let c = Rgba::rgb(0x40, 0x80, 0xC0); + assert_eq!(apply(c, &[ColorTransform::Alpha(50000)]).a, 128); + let lighter = apply(c, &[ColorTransform::LumMod(20000), ColorTransform::LumOff(80000)]); + assert!(lighter.luminance() > c.luminance()); + let darker = apply(c, &[ColorTransform::LumMod(50000)]); + assert!(darker.luminance() < c.luminance()); + assert_eq!(apply(Rgba::WHITE, &[ColorTransform::Shade(0)]), Rgba::BLACK); + assert_eq!(apply(Rgba::BLACK, &[ColorTransform::Tint(0)]), Rgba::WHITE); + assert_eq!(apply(c, &[ColorTransform::Inv]), Rgba::rgb(0xBF, 0x7F, 0x3F)); + // hostile values never panic + let _ = apply(c, &[ColorTransform::LumMod(i32::MAX), ColorTransform::HueOff(i32::MIN), ColorTransform::AlphaMod(i32::MIN)]); + } + + #[test] + fn contrast_ratio() { + assert!((Rgba::BLACK.contrast(Rgba::WHITE) - 21.0).abs() < 0.01); + assert!((Rgba::WHITE.contrast(Rgba::WHITE) - 1.0).abs() < 1e-9); + } + + #[test] + fn scheme_slots() { + let mut s = ColorScheme { name: "t".into(), colors: [Rgba::BLACK; 12] }; + s.set(SchemeSlot::Accent3, Rgba::WHITE); + assert_eq!(s.get(SchemeSlot::Accent3), Rgba::WHITE); + assert_eq!(s.get(SchemeSlot::Tx1), Rgba::BLACK); + for slot in SchemeSlot::THEME { + assert_eq!(SchemeSlot::from_xml(slot.xml_name()), Some(slot)); + } + } +} diff --git a/crates/drawingml/src/custgeom.rs b/crates/drawingml/src/custgeom.rs new file mode 100644 index 000000000..c4ac5d331 --- /dev/null +++ b/crates/drawingml/src/custgeom.rs @@ -0,0 +1,654 @@ +//! Custom geometry (`a:custGeom`): guide formula evaluation and conversion of `a:pathLst` to +//! an `M L C Q Z` path syntax ([`CustomPath`]), and back. +//! +//! Elements are matched by local name, so the reader takes a tree parsed with any +//! [`craft_xml::Names`] mode. The writer uses the `a:` prefix. +//! +//! Formulas follow the ECMA-376 shape guide semantics (§20.1.9.11): angles are in 1/60 000 degree, +//! and every operand is a guide name, a built-in variable or an integer literal. + +use std::collections::HashMap; +use std::f64::consts::PI; +use std::fmt::Write as _; + +use craft_xml::{El, Local, Writer}; +use serde::{Deserialize, Serialize}; + +use crate::preset::FillMode; + +/// One sub-path of a custom geometry: SVG-like commands in path units. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CustomPath { + pub w: f64, + pub h: f64, + /// `M x y`, `L x y`, `C x1 y1 x2 y2 x y`, `Q x1 y1 x y`, `Z`. + pub d: String, + pub fill: FillMode, + pub stroke: bool, +} + +impl Default for CustomPath { + fn default() -> Self { + CustomPath { w: 0.0, h: 0.0, d: String::new(), fill: FillMode::Norm, stroke: true } + } +} + +const DEG: f64 = 60_000.0; + +/// Distinct path coordinate spaces whose guides are evaluated for one custom geometry; paths in +/// further spaces are left out. +const MAX_SPACES: usize = 16; + +/// Guide-name bytes all cached spaces may hold between them; the first space counts towards it but is always kept. +/// Every space copies the names, which the XML limits bound only once. +const MAX_SPACE_NAME_BYTES: usize = 4 << 20; + +/// Guide variables for one shape. +pub struct Guides { + vars: HashMap, +} + +fn to_rad(a: f64) -> f64 { + a / DEG * PI / 180.0 +} + +fn to_ang(r: f64) -> f64 { + r * 180.0 / PI * DEG +} + +fn finite(v: f64) -> f64 { + if v.is_finite() { v.clamp(-1.0e15, 1.0e15) } else { 0.0 } +} + +impl Guides { + /// Built-in variables for a shape `w` × `h` (in the shape's coordinate units). + pub fn new(w: f64, h: f64) -> Self { + let (w, h) = (finite(w), finite(h)); + let ss = w.min(h); + let ls = w.max(h); + let mut v = HashMap::new(); + let mut set = |k: &str, x: f64| { + v.insert(k.to_string(), x); + }; + set("w", w); + set("h", h); + set("l", 0.0); + set("t", 0.0); + set("r", w); + set("b", h); + set("hc", w / 2.0); + set("vc", h / 2.0); + set("ss", ss); + set("ls", ls); + for d in [2.0, 3.0, 4.0, 5.0, 6.0, 8.0, 10.0, 12.0, 16.0, 32.0] { + set(&format!("wd{d}"), w / d); + set(&format!("hd{d}"), h / d); + set(&format!("ssd{d}"), ss / d); + } + set("cd2", 10_800_000.0); + set("cd4", 5_400_000.0); + set("cd8", 2_700_000.0); + set("3cd4", 16_200_000.0); + set("3cd8", 8_100_000.0); + set("5cd8", 13_500_000.0); + set("7cd8", 18_900_000.0); + Guides { vars: v } + } + /// Bytes of the guide names held. + fn name_bytes(&self) -> usize { + self.vars.keys().map(String::len).sum() + } + pub fn get(&self, name: &str) -> f64 { + let name = name.trim(); + if let Some(v) = self.vars.get(name) { + return *v; + } + name.parse::().ok().map(finite).unwrap_or(0.0) + } + pub fn set(&mut self, name: &str, v: f64) { + if self.vars.len() < 10_000 { + self.vars.insert(name.to_string(), finite(v)); + } + } + /// Evaluate one formula (`*/ w 1 2`). + pub fn eval(&self, fmla: &str) -> f64 { + let mut it = fmla.split_whitespace(); + let op = it.next().unwrap_or(""); + let args: Vec = it.take(3).map(|a| self.get(a)).collect(); + let a = |i: usize| args.get(i).copied().unwrap_or(0.0); + let div = |n: f64, d: f64| if d == 0.0 { 0.0 } else { n / d }; + let r = match op { + "*/" => div(a(0) * a(1), a(2)), + "+-" => a(0) + a(1) - a(2), + "+/" => div(a(0) + a(1), a(2)), + "?:" => { + if a(0) > 0.0 { + a(1) + } else { + a(2) + } + } + "abs" => a(0).abs(), + "at2" => to_ang(a(1).atan2(a(0))), + "cat2" => a(0) * a(2).atan2(a(1)).cos(), + "sat2" => a(0) * a(2).atan2(a(1)).sin(), + "cos" => a(0) * to_rad(a(1)).cos(), + "sin" => a(0) * to_rad(a(1)).sin(), + "tan" => a(0) * to_rad(a(1)).tan(), + "max" => a(0).max(a(1)), + "min" => a(0).min(a(1)), + "mod" => (a(0) * a(0) + a(1) * a(1) + a(2) * a(2)).sqrt(), + "pin" => { + if a(1) < a(0) { + a(0) + } else if a(1) > a(2) { + a(2) + } else { + a(1) + } + } + "sqrt" => a(0).max(0.0).sqrt(), + "val" => a(0), + _ => 0.0, + }; + finite(r) + } + /// Evaluate a list of `a:gd` elements in order. + pub fn eval_list(&mut self, list: Option<&El>) { + let Some(list) = list else { return }; + for gd in list.children(Local("gd")).take(5000) { + let (Some(name), Some(f)) = (gd.attr("name"), gd.attr("fmla")) else { continue }; + let v = self.eval(f); + self.set(name, v); + } + } +} + +fn num(s: &mut String, v: f64) { + let v = finite(v); + let r = (v * 1000.0).round() / 1000.0; + if r == r.trunc() { + let _ = write!(s, "{}", r as i64); + } else { + let _ = write!(s, "{r}"); + } +} + +fn pt(g: &Guides, e: &El) -> (f64, f64) { + (g.get(e.attr("x").unwrap_or("0")), g.get(e.attr("y").unwrap_or("0"))) +} + +/// Convert `a:custGeom` to custom paths. `w`/`h` is the shape size in EMU (used for guides and for +/// paths that don't give their own coordinate space). +pub fn read_cust_geom(cg: &El, w_emu: f64, h_emu: f64) -> Vec { + let mut out = vec![]; + let Some(pl) = cg.child(Local("pathLst")) else { return out }; + let mut spaces: HashMap<(u64, u64), Guides> = HashMap::new(); + let mut name_bytes = 0usize; + let mut budget_full = false; + // The cap on cached spaces saves guide evaluation: a geometry without guides has none to save. + let guided = ["avLst", "gdLst"].iter().any(|n| cg.child(Local(n)).is_some_and(|l| l.children(Local("gd")).next().is_some())); + for p in pl.children(Local("path")).take(1000) { + let pw = p.attr_f64("w").unwrap_or(0.0); + let ph = p.attr_f64("h").unwrap_or(0.0); + // Guides are evaluated in the path's own space when it has one. + let (gw, gh) = if pw > 0.0 || ph > 0.0 { (pw, ph) } else { (w_emu, h_emu) }; + // The guide lists are evaluated once per coordinate space, not once per path. + let key = (gw.to_bits(), gh.to_bits()); + if !spaces.contains_key(&key) { + if guided && (budget_full || spaces.len() >= MAX_SPACES) { + continue; + } + let mut g = Guides::new(gw, gh); + g.eval_list(cg.child(Local("avLst"))); + g.eval_list(cg.child(Local("gdLst"))); + // The first space is always kept; later ones only while the cache stays within budget. + let bytes = g.name_bytes(); + if guided && !spaces.is_empty() && name_bytes.saturating_add(bytes) > MAX_SPACE_NAME_BYTES { + // Every space holds the same names, so no later space fits either. + budget_full = true; + continue; + } + name_bytes = name_bytes.saturating_add(bytes); + spaces.insert(key, g); + } + let Some(g) = spaces.get(&key) else { continue }; + let mut d = String::new(); + let mut cur = (0.0, 0.0); + let mut start = (0.0, 0.0); + for c in p.elements().take(100_000) { + match c.local() { + "moveTo" => { + if let Some(q) = c.child(Local("pt")) { + cur = pt(g, q); + start = cur; + d.push_str("M "); + num(&mut d, cur.0); + d.push(' '); + num(&mut d, cur.1); + d.push(' '); + } + } + "lnTo" => { + if let Some(q) = c.child(Local("pt")) { + cur = pt(g, q); + d.push_str("L "); + num(&mut d, cur.0); + d.push(' '); + num(&mut d, cur.1); + d.push(' '); + } + } + "cubicBezTo" => { + let pts: Vec<(f64, f64)> = c.children(Local("pt")).map(|q| pt(g, q)).collect(); + if let [a, b, e] = pts.as_slice() { + d.push_str("C "); + for (x, y) in [a, b, e] { + num(&mut d, *x); + d.push(' '); + num(&mut d, *y); + d.push(' '); + } + cur = *e; + } + } + "quadBezTo" => { + let pts: Vec<(f64, f64)> = c.children(Local("pt")).map(|q| pt(g, q)).collect(); + if let [a, e] = pts.as_slice() { + d.push_str("Q "); + for (x, y) in [a, e] { + num(&mut d, *x); + d.push(' '); + num(&mut d, *y); + d.push(' '); + } + cur = *e; + } + } + "arcTo" => { + let wr = g.get(c.attr("wR").unwrap_or("0")); + let hr = g.get(c.attr("hR").unwrap_or("0")); + let st = g.get(c.attr("stAng").unwrap_or("0")); + let sw = g.get(c.attr("swAng").unwrap_or("0")); + cur = arc_to(&mut d, cur, wr, hr, st, sw); + } + "close" => { + d.push_str("Z "); + cur = start; + } + _ => {} + } + } + let fill = match p.attr("fill").unwrap_or("norm") { + "none" => FillMode::None, + "lighten" => FillMode::Lighten, + "lightenLess" => FillMode::LightenLess, + "darken" => FillMode::Darken, + "darkenLess" => FillMode::DarkenLess, + _ => FillMode::Norm, + }; + let (ow, oh) = if pw > 0.0 || ph > 0.0 { (pw, ph) } else { (w_emu.max(1.0), h_emu.max(1.0)) }; + out.push(CustomPath { w: ow, h: oh, d: d.trim_end().to_string(), fill, stroke: p.attr_bool("stroke").unwrap_or(true) }); + } + out +} + +/// Ellipse parametric angle for a visual angle `a` (radians). +fn param(a: f64, wr: f64, hr: f64) -> f64 { + (wr * a.sin()).atan2(hr * a.cos()) +} + +/// Append an elliptical arc as cubic Béziers; returns the end point. +fn arc_to(d: &mut String, cur: (f64, f64), wr: f64, hr: f64, st: f64, sw: f64) -> (f64, f64) { + if wr.abs() < 1e-9 || hr.abs() < 1e-9 || sw == 0.0 { + return cur; + } + let st_r = to_rad(st); + let sw_r = to_rad(sw).clamp(-4.0 * PI, 4.0 * PI); + let t0 = param(st_r, wr, hr); + let mut t1 = param(st_r + sw_r, wr, hr); + // Keep the sweep direction and size of the visual angle. + let full = sw_r.abs() >= 2.0 * PI - 1e-9; + if full { + t1 = t0 + sw_r.signum() * 2.0 * PI; + } else { + while sw_r > 0.0 && t1 < t0 { + t1 += 2.0 * PI; + } + while sw_r < 0.0 && t1 > t0 { + t1 -= 2.0 * PI; + } + } + let cx = cur.0 - wr * t0.cos(); + let cy = cur.1 - hr * t0.sin(); + let total = t1 - t0; + let n = ((total.abs() / (PI / 2.0)).ceil() as usize).clamp(1, 16); + let step = total / n as f64; + let k = 4.0 / 3.0 * (step / 4.0).tan(); + let mut a = t0; + let mut end = cur; + for _ in 0..n { + let b = a + step; + let (ca, sa, cb, sb) = (a.cos(), a.sin(), b.cos(), b.sin()); + let p1 = (cx + wr * (ca - k * sa), cy + hr * (sa + k * ca)); + let p2 = (cx + wr * (cb + k * sb), cy + hr * (sb - k * cb)); + end = (cx + wr * cb, cy + hr * sb); + d.push_str("C "); + for (x, y) in [p1, p2, end] { + num(d, x); + d.push(' '); + num(d, y); + d.push(' '); + } + a = b; + } + end +} + +/// Parse the [`CustomPath::d`] syntax into commands. +pub fn parse_path(d: &str) -> Vec<(char, Vec)> { + let mut out = vec![]; + let mut cmd: Option = None; + let mut nums: Vec = vec![]; + let flush = |cmd: Option, nums: &mut Vec, out: &mut Vec<(char, Vec)>| { + if let Some(c) = cmd { + let n = match c { + 'M' | 'L' => 2, + 'C' => 6, + 'Q' => 4, + _ => 0, + }; + if n == 0 { + out.push((c, vec![])); + } else { + for (i, chunk) in nums.chunks(n).enumerate() { + if chunk.len() == n && out.len() < 1_000_000 { + // Repeated coordinates after M continue as L. + let c2 = if c == 'M' && i > 0 { 'L' } else { c }; + out.push((c2, chunk.to_vec())); + } + } + } + } + nums.clear(); + }; + let mut token = String::new(); + let push_tok = |token: &mut String, nums: &mut Vec| { + if !token.is_empty() { + if let Ok(v) = token.parse::() + && v.is_finite() + { + nums.push(v); + } + token.clear(); + } + }; + let cs: Vec = d.chars().collect(); + for (i, &ch) in cs.iter().enumerate() { + match ch { + // An 'E' inside a number followed by a digit (or a sign and a digit) is an exponent, + // not the end command. + 'E' if !token.is_empty() && { + let n1 = cs.get(i + 1).copied().unwrap_or(' '); + let n2 = cs.get(i + 2).copied().unwrap_or(' '); + n1.is_ascii_digit() || ((n1 == '-' || n1 == '+') && n2.is_ascii_digit()) + } => + { + token.push(ch) + } + 'M' | 'L' | 'C' | 'Q' | 'Z' | 'm' | 'l' | 'c' | 'q' | 'z' | 'E' => { + push_tok(&mut token, &mut nums); + flush(cmd, &mut nums, &mut out); + cmd = Some(ch.to_ascii_uppercase()); + } + ' ' | ',' | '\t' | '\n' => push_tok(&mut token, &mut nums), + '-' if !token.is_empty() && !token.ends_with(['e', 'E']) => { + push_tok(&mut token, &mut nums); + token.push(ch); + } + c => token.push(c), + } + } + push_tok(&mut token, &mut nums); + flush(cmd, &mut nums, &mut out); + out +} + +/// Write custom paths as `a:custGeom`. Coordinates are integers in the file, so small path spaces +/// are scaled up. +pub fn write_cust_geom(w: &mut Writer, paths: &[CustomPath]) { + w.open("a:custGeom", &[]); + for list in ["a:avLst", "a:gdLst", "a:ahLst", "a:cxnLst"] { + w.empty(list, &[]); + } + w.empty("a:rect", &[("l", "l"), ("t", "t"), ("r", "r"), ("b", "b")]); + w.open("a:pathLst", &[]); + for p in paths { + let (pw, ph) = (finite(p.w).abs(), finite(p.h).abs()); + let m = pw.max(ph); + let k = if m > 0.0 && m < 20_000.0 { 100_000.0 / m } else { 1.0 }; + let r = |v: f64| (finite(v) * k).round().clamp(-2.0e9, 2.0e9) as i64; + let fill = match p.fill { + FillMode::Norm => None, + FillMode::None => Some("none"), + FillMode::Lighten => Some("lighten"), + FillMode::LightenLess => Some("lightenLess"), + FillMode::Darken => Some("darken"), + FillMode::DarkenLess => Some("darkenLess"), + }; + w.tag("a:path").attr("w", r(pw).max(0)).attr("h", r(ph).max(0)).attr_opt("fill", fill).attr_opt("stroke", (!p.stroke).then_some("0")).open(); + for (c, v) in parse_path(&p.d) { + let pts: Vec<(i64, i64)> = v + .chunks(2) + .filter_map(|c| match c { + [x, y] => Some((r(*x), r(*y))), + _ => None, + }) + .collect(); + let tag = match c { + 'M' => "a:moveTo", + 'L' => "a:lnTo", + 'C' => "a:cubicBezTo", + 'Q' => "a:quadBezTo", + 'Z' => { + w.empty("a:close", &[]); + continue; + } + _ => continue, + }; + w.open(tag, &[]); + for (x, y) in pts { + w.tag("a:pt").attr("x", x).attr("y", y).empty(); + } + w.close(); + } + w.close(); + } + w.close(); + w.close(); +} + +#[cfg(test)] +mod tests { + use super::*; + use craft_xml::parse; + + #[test] + fn formulas() { + let mut g = Guides::new(1000.0, 500.0); + assert_eq!(g.eval("*/ w 1 2"), 500.0); + assert_eq!(g.eval("+- w h 100"), 1400.0); + assert_eq!(g.eval("+/ w h 3"), 500.0); + assert_eq!(g.eval("?: -1 5 7"), 7.0); + assert_eq!(g.eval("?: 1 5 7"), 5.0); + assert_eq!(g.eval("abs -42"), 42.0); + assert_eq!(g.eval("max 3 9"), 9.0); + assert_eq!(g.eval("min 3 9"), 3.0); + assert_eq!(g.eval("mod 3 4 0"), 5.0); + assert_eq!(g.eval("pin 0 150 100"), 100.0); + assert_eq!(g.eval("pin 0 -5 100"), 0.0); + assert_eq!(g.eval("sqrt 16"), 4.0); + assert_eq!(g.eval("val ss"), 500.0); + assert!((g.eval("at2 1 1") - 2_700_000.0).abs() < 1e-6); + assert!((g.eval("cos 100 5400000")).abs() < 1e-9); + assert!((g.eval("sin 100 5400000") - 100.0).abs() < 1e-9); + assert!((g.eval("tan 100 2700000") - 100.0).abs() < 1e-6); + assert!((g.eval("cat2 10 1 0") - 10.0).abs() < 1e-9); + assert!((g.eval("sat2 10 0 1") - 10.0).abs() < 1e-9); + assert_eq!(g.eval("*/ 1 1 0"), 0.0, "division by zero is 0"); + assert_eq!(g.eval("bogus 1 2"), 0.0); + assert_eq!(g.eval(""), 0.0); + g.set("x1", 7.0); + assert_eq!(g.eval("+- x1 0 2"), 5.0); + assert_eq!(g.get("hc"), 500.0); + assert_eq!(g.get("cd4"), 5_400_000.0); + } + + #[test] + fn custgeom_with_guides_and_arc() { + let x = br#" + + + "#; + let d = parse(x).unwrap(); + let paths = read_cust_geom(&d, 1000.0, 1000.0); + assert_eq!(paths.len(), 2); + assert!(paths[0].d.starts_with("M 0 0 L 100 100 C"), "{}", paths[0].d); + assert!(paths[0].d.ends_with('Z')); + // The quarter arc from (100,100) with centre (50,100) ends at (50,150). + assert!(paths[0].d.contains("50 150"), "{}", paths[0].d); + assert_eq!(paths[1].fill, FillMode::None); + assert!(!paths[1].stroke); + assert_eq!(paths[1].d, "M 1 1 Q 2 2 3 1"); + } + + #[test] + fn paths_share_guides_and_distinct_spaces_are_capped() { + let path = |w: usize| format!(r#""#); + let paths: String = (1..=40).map(|i| path(10 * i)).collect(); + let x = format!( + r#"{paths}{}"#, + path(10) + ); + let d = parse(x.as_bytes()).unwrap(); + let out = read_cust_geom(&d, 1.0, 1.0); + assert_eq!(out.len(), MAX_SPACES + 1, "16 spaces plus the repeat of the first"); + assert!(out[0].d.starts_with("M 5 1"), "{}", out[0].d); + assert!(out[MAX_SPACES].d.starts_with("M 5 1"), "{}", out[MAX_SPACES].d); + } + + #[test] + fn guide_name_bytes_across_spaces_are_budgeted() { + let name = "n".repeat(3 << 20); + let path = |w: usize| format!(r#""#); + let x = format!( + r#"{}{}{}"#, + path(10), + path(20), + path(10) + ); + let d = parse(x.as_bytes()).unwrap(); + let out = read_cust_geom(&d, 1.0, 1.0); + assert_eq!(out.len(), 2, "the second 3 MiB space is over budget and left out; the first space's repeat stays"); + } + + #[test] + fn guide_free_geometries_are_not_capped_by_space() { + let paths: String = (1..=40).map(|i| format!(r#""#, 10 * i)).collect(); + let x = format!(r#"{paths}"#); + let d = parse(x.as_bytes()).unwrap(); + assert_eq!(read_cust_geom(&d, 1.0, 1.0).len(), 40); + } + + #[test] + fn write_then_read_path() { + let p = CustomPath { w: 100.0, h: 50.0, d: "M 0 0 L 100 0 C 100 10 90 50 50 50 Q 0 50 0 0 Z".into(), fill: FillMode::Norm, stroke: true }; + let mut w = Writer::new(); + write_cust_geom(&mut w, std::slice::from_ref(&p)); + let d = parse(w.as_str().as_bytes()).unwrap(); + let back = read_cust_geom(&d, 1.0, 1.0); + assert_eq!(back.len(), 1); + // Scaled by 1000 (100 → 100000). + assert_eq!(back[0].w, 100_000.0); + assert_eq!(back[0].d, "M 0 0 L 100000 0 C 100000 10000 90000 50000 50000 50000 Q 0 50000 0 0 Z"); + } + + #[test] + fn parse_path_uppercase_exponent() { + assert_eq!(parse_path("M 1E-5 2 L 3 4"), vec![('M', vec![1e-5, 2.0]), ('L', vec![3.0, 4.0])]); + assert_eq!(parse_path("M 1.5E3 2"), vec![('M', vec![1500.0, 2.0])]); + assert_eq!(parse_path("M 1e-5 2"), vec![('M', vec![1e-5, 2.0])]); + // An 'E' right after a number with no exponent digits is the end command. + assert_eq!(parse_path("M 0 0E"), vec![('M', vec![0.0, 0.0]), ('E', vec![])]); + } + + #[test] + fn parse_path_tokens() { + let v = parse_path("M0,0 L10-5 Z"); + assert_eq!(v.len(), 3); + assert_eq!(v[1], ('L', vec![10.0, -5.0])); + assert!(parse_path("garbage ### C 1").is_empty() || parse_path("garbage").is_empty()); + } + + fn geom(inner: &str) -> El { + parse(format!(r#"{inner}"#).as_bytes()).unwrap() + } + + #[test] + fn paths_per_geometry_are_capped() { + let paths = r#""#.repeat(1001); + assert_eq!(read_cust_geom(&geom(&format!("{paths}")), 1.0, 1.0).len(), 1000); + } + + #[test] + fn commands_per_path_are_capped() { + let cmds = "".repeat(100_001); + let out = read_cust_geom(&geom(&format!(r#"{cmds}"#)), 1.0, 1.0); + assert_eq!(out[0].d.matches('Z').count(), 100_000); + } + + #[test] + fn guides_per_list_and_per_shape_are_capped() { + let gds: String = (0..5001).map(|i| format!(r#""#)).collect(); + let list = parse(format!("{gds}").as_bytes()).unwrap(); + let mut g = Guides::new(1.0, 1.0); + g.eval_list(Some(&list)); + assert_eq!((g.get("g4999"), g.get("g5000")), (7.0, 0.0), "the 5001st guide of a list is not read"); + + let mut g = Guides::new(1.0, 1.0); + let built_in = g.vars.len(); + for i in 0..10_001 { + g.set(&format!("v{i}"), 1.0); + } + assert_eq!(g.vars.len(), 10_000); + assert_eq!((g.get(&format!("v{}", 9_999 - built_in)), g.get(&format!("v{}", 10_000 - built_in))), (1.0, 0.0)); + } + + #[test] + fn values_are_finite_and_clamped() { + let g = Guides::new(f64::NAN, f64::INFINITY); + assert_eq!((g.get("w"), g.get("h")), (0.0, 0.0)); + assert_eq!(g.get("1e300"), 1.0e15); + assert_eq!(g.eval("*/ 1e15 1e15 1"), 1.0e15); + assert_eq!(g.eval("tan 1 5400000").abs(), 1.0e15, "tan 90° is clamped, not infinite"); + } + + #[test] + fn an_arc_of_many_turns_draws_one_turn() { + let out = read_cust_geom( + &geom( + r#""#, + ), + 1.0, + 1.0, + ); + assert_eq!(out[0].d.matches('C').count(), 4); + } + + #[test] + fn parsed_coordinate_commands_are_capped() { + let d = format!("M{}", " 1 2".repeat(1_000_001)); + assert_eq!(parse_path(&d).len(), 1_000_000); + } +} diff --git a/crates/drawingml/src/lib.rs b/crates/drawingml/src/lib.rs new file mode 100644 index 000000000..0f847c990 --- /dev/null +++ b/crates/drawingml/src/lib.rs @@ -0,0 +1,164 @@ +//! DrawingML (ECMA-376 part 1, chapter 20): the parts of it that PresentationML, +//! WordprocessingML and SpreadsheetML drawings share. +//! +//! - Units and placement: EMU conversion ([`emu_to_pt`], [`pt_to_emu`]) and `a:xfrm` ([`Xfrm`], +//! [`group_child_affine`]). Lengths in this crate are points (1/72 inch) as `f64`; files store +//! EMU (12 700 per point), and the conversion is exact for integer EMU values. +//! - [`color`]: sRGB colours, theme colour slots, the colour transforms (`a:lumMod`, `a:tint`, +//! `a:alpha` …) and the preset and system colour names. +//! - [`preset`]: outlines of the preset shapes (`a:prstGeom`), by their file names. +//! - [`custgeom`]: custom geometry (`a:custGeom`), with the shape guide formulas. +//! - [`warp`]: preset text warps (`a:prstTxWarp`). +//! +//! Nothing here opens a package: readers take a [`craft_xml::El`] and writers a +//! [`craft_xml::Writer`], so the same code serves a slide, a document or a worksheet drawing. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +pub mod color; +pub mod custgeom; +pub mod preset; +pub mod warp; + +use kurbo::{Affine, Point, Rect, Vec2}; +use serde::{Deserialize, Serialize}; + +/// EMU per point. +pub const EMU_PER_PT: f64 = 12_700.0; +/// EMU per inch. +pub const EMU_PER_IN: f64 = 914_400.0; +/// EMU per centimetre. +pub const EMU_PER_CM: f64 = 360_000.0; + +pub fn emu_to_pt(emu: i64) -> f64 { + emu as f64 / EMU_PER_PT +} + +/// Points to EMU, rounded. Non-finite input becomes 0; huge values saturate. +pub fn pt_to_emu(pt: f64) -> i64 { + if !pt.is_finite() { + return 0; + } + let v = (pt * EMU_PER_PT).round(); + if v >= i64::MAX as f64 { + i64::MAX + } else if v <= i64::MIN as f64 { + i64::MIN + } else { + v as i64 + } +} + +/// A shape's placement on its page (or in its group's child space): offset, extent, rotation +/// (degrees clockwise) and flips. Rotation and flips are about the centre. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Xfrm { + pub x: f64, + pub y: f64, + pub w: f64, + pub h: f64, + #[serde(skip_serializing_if = "is_zero")] + pub rot: f64, + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub flip_h: bool, + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub flip_v: bool, +} + +fn is_zero(v: &f64) -> bool { + *v == 0.0 +} + +impl Xfrm { + pub fn new(x: f64, y: f64, w: f64, h: f64) -> Self { + Xfrm { x, y, w, h, ..Default::default() } + } + pub fn rect(&self) -> Rect { + Rect::new(self.x, self.y, self.x + self.w, self.y + self.h) + } + pub fn center(&self) -> Point { + Point::new(self.x + self.w / 2.0, self.y + self.h / 2.0) + } + /// Local shape space (0..w, 0..h) → parent space, applying flips and rotation about the centre. + pub fn affine(&self) -> Affine { + let c = Vec2::new(self.w / 2.0, self.h / 2.0); + let sx = if self.flip_h { -1.0 } else { 1.0 }; + let sy = if self.flip_v { -1.0 } else { 1.0 }; + Affine::translate(Vec2::new(self.x, self.y) + c) + * Affine::rotate(self.rot.to_radians()) + * Affine::scale_non_uniform(sx, sy) + * Affine::translate(-c) + } + /// Axis-aligned bounds of the rotated box in parent space. + pub fn bounds(&self) -> Rect { + let a = self.affine(); + let pts = [Point::new(0.0, 0.0), Point::new(self.w, 0.0), Point::new(self.w, self.h), Point::new(0.0, self.h)].map(|p| a * p); + let mut r = Rect::from_points(pts[0], pts[1]); + r = r.union_pt(pts[2]).union_pt(pts[3]); + r + } + /// True when no dimension is NaN/inf. + pub fn is_finite(&self) -> bool { + [self.x, self.y, self.w, self.h, self.rot].iter().all(|v| v.is_finite()) + } + /// Rotation normalised to 0..360. + pub fn rot_norm(&self) -> f64 { + let r = self.rot.rem_euclid(360.0); + if r >= 360.0 { 0.0 } else { r } + } +} + +/// Maps a group's child coordinate space (`ch_off`, `ch_ext`) onto the group's own box. +pub fn group_child_affine(group: &Xfrm, ch: Rect) -> Affine { + let sx = if ch.width().abs() > 1e-9 { group.w / ch.width() } else { 1.0 }; + let sy = if ch.height().abs() > 1e-9 { group.h / ch.height() } else { 1.0 }; + group.affine() * Affine::scale_non_uniform(sx, sy) * Affine::translate(-ch.origin().to_vec2()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn emu_roundtrip_is_exact() { + for emu in [0i64, 1, 12_699, 12_700, 914_400, 12_192_000, 6_858_000, -5, 123_456_789] { + assert_eq!(pt_to_emu(emu_to_pt(emu)), emu); + } + assert_eq!(pt_to_emu(f64::NAN), 0); + assert_eq!(pt_to_emu(f64::INFINITY), 0); + assert_eq!(pt_to_emu(1e300), i64::MAX); + assert_eq!(pt_to_emu(-1e300), i64::MIN); + } + + #[test] + fn xfrm_affine_rotation_about_center() { + let x = Xfrm { x: 10.0, y: 20.0, w: 100.0, h: 50.0, rot: 90.0, ..Default::default() }; + let c = x.affine() * Point::new(50.0, 25.0); + assert!((c.x - 60.0).abs() < 1e-9 && (c.y - 45.0).abs() < 1e-9); + let b = x.bounds(); + assert!((b.width() - 50.0).abs() < 1e-9 && (b.height() - 100.0).abs() < 1e-9); + } + + #[test] + fn flips_mirror_local_space() { + let x = Xfrm { x: 0.0, y: 0.0, w: 100.0, h: 50.0, flip_h: true, ..Default::default() }; + let p = x.affine() * Point::new(0.0, 0.0); + assert!((p.x - 100.0).abs() < 1e-9 && p.y.abs() < 1e-9); + } + + #[test] + fn group_child_mapping() { + let g = Xfrm::new(100.0, 100.0, 200.0, 100.0); + let a = group_child_affine(&g, Rect::new(0.0, 0.0, 100.0, 50.0)); + let p = a * Point::new(100.0, 50.0); + assert!((p.x - 300.0).abs() < 1e-9 && (p.y - 200.0).abs() < 1e-9); + } + + #[test] + fn rot_norm_stays_below_360() { + for rot in [-1e-15, -1e-13, -f64::EPSILON, -0.0, 360.0, 720.0, -360.0] { + let n = Xfrm { rot, ..Default::default() }.rot_norm(); + assert!((0.0..360.0).contains(&n), "rot {rot} -> {n}"); + } + } +} diff --git a/crates/drawingml/src/preset.rs b/crates/drawingml/src/preset.rs new file mode 100644 index 000000000..3e3b5af5a --- /dev/null +++ b/crates/drawingml/src/preset.rs @@ -0,0 +1,24 @@ +//! Preset shape geometry. +//! +//! Each preset is identified by its Office Open XML name (`rect`, `roundRect`, `rightArrow`…) so +//! files round-trip, but every outline here is our own construction in Rust: we re-derive the shapes +//! from what they look like and from the meaning of their adjust values, not from any reference +//! geometry table. Adjust values use the file convention: ratios in 1/100 000 of the shorter side (or +//! of the width/height), angles in 1/60 000 degree. + +use kurbo::{Arc, BezPath, Point, Rect, Vec2}; +use serde::{Deserialize, Serialize}; + +mod build; +mod builder; +mod catalog; +mod shapes; + +pub use build::build; +use builder::{Corner, Dir, Pb, corner_rect, fill, fill_eo, fill_only, mid_sites, orient, shade, simple, stroke_only, vis}; +pub use catalog::{CATALOG, Category, FillMode, Geometry, Handle, PresetInfo, SubPath, info, is_line_like, mitred_by_default}; + +mod extra; + +#[cfg(test)] +mod tests; diff --git a/crates/drawingml/src/preset/build.rs b/crates/drawingml/src/preset/build.rs new file mode 100644 index 000000000..95b833efd --- /dev/null +++ b/crates/drawingml/src/preset/build.rs @@ -0,0 +1,1204 @@ +use super::builder::{ + Corner, Dir, Pb, arrow, corner_rect, double_arrow, ellipse_sites, fill, fill_eo, fill_only, len_thick, mid_sites, orient, polygon_regular, shade, + simple, spec_pts, star, star_text_rect, stroke_only, tip_handle, vis, +}; +use super::catalog::{FillMode, Geometry, Handle, info}; +use super::shapes::{action_button, cloud, math, ribbon, scroll, wedge_rect}; +use super::{Arc, BezPath, Point, Rect, Vec2, extra}; + +/// Size and adjust values shared by every preset-shape builder. +struct Ctx<'a> { + name: &'a str, + w: f64, + h: f64, + adj: &'a [f64], + defaults: &'a [f64], +} + +impl Ctx<'_> { + /// Adjust value `i`: the caller's value, else the preset default, else 0 (non-finite values become 0). + fn a(&self, i: usize) -> f64 { + let v = self.adj.get(i).copied().or_else(|| self.defaults.get(i).copied()).unwrap_or(0.0); + if v.is_finite() { v } else { 0.0 } + } +} + +/// Builds the geometry for preset `name` at size `w`×`h` with adjust values `adj` (missing +/// values use the preset's defaults). Unknown names return `None`; the caller falls back to `rect`. +pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option { + if !(w.is_finite() && h.is_finite()) { + return None; + } + let defaults = info(name).map(|i| i.defaults).unwrap_or(&[]); + let c = Ctx { name, w: w.max(0.0), h: h.max(0.0), adj, defaults }; + rect_and_triangle_shapes(&c) + .or_else(|| polygon_and_symbol_shapes(&c)) + .or_else(|| bracket_and_arrow_shapes(&c)) + .or_else(|| flowchart_and_star_shapes(&c)) + .or_else(|| callout_wave_and_connector_shapes(&c)) + .or_else(|| extra::build(name, c.w, c.h, &|i| c.a(i))) +} + +fn rect_and_triangle_shapes(c: &Ctx) -> Option { + let name = c.name; + let w = c.w; + let h = c.h; + let ss = c.w.min(c.h); + let full = Rect::new(0.0, 0.0, c.w, c.h); + let a = |i: usize| c.a(i); + let mut b = Pb::new(); + let g = match c.name { + "rect" | "textBox" | "flowChartProcess" => simple(corner_rect(w, h, [Corner::Square; 4]), full, w, h), + "actionButtonBlank" + | "actionButtonBackPrevious" + | "actionButtonForwardNext" + | "actionButtonBeginning" + | "actionButtonEnd" + | "actionButtonHome" + | "actionButtonInformation" + | "actionButtonReturn" + | "actionButtonMovie" + | "actionButtonDocument" + | "actionButtonSound" + | "actionButtonHelp" => action_button(name, w, h), + "roundRect" => { + let v = a(0).clamp(0.0, 50000.0); + let r = ss * v / 100000.0; + let mut g = simple(corner_rect(w, h, [Corner::Round(r); 4]), full.inset(-r * 0.29289), w, h); + g.handles.push(Handle::new(0, Point::ZERO, Vec2::new(ss / 100000.0, 0.0), v, 0.0, 50000.0)); + g + } + "flowChartAlternateProcess" => simple(corner_rect(w, h, [Corner::Round(ss / 6.0); 4]), full.inset(-ss / 6.0 * 0.29289), w, h), + "snip1Rect" => { + let v = a(0).clamp(0.0, 50000.0); + let d = ss * v / 100000.0; + let mut g = simple( + corner_rect(w, h, [Corner::Square, Corner::Snip(d), Corner::Square, Corner::Square]), + Rect::new(0.0, d / 2.0, w - d / 2.0, h), + w, + h, + ); + g.handles.push(Handle::new(0, Point::new(w, 0.0), Vec2::new(-ss / 100000.0, 0.0), v, 0.0, 50000.0)); + g + } + "snip2SameRect" => { + let (d1, d2) = (ss * a(0).clamp(0.0, 50000.0) / 1e5, ss * a(1).clamp(0.0, 50000.0) / 1e5); + simple(corner_rect(w, h, [Corner::Snip(d1), Corner::Snip(d1), Corner::Snip(d2), Corner::Snip(d2)]), full.inset(-d1.max(d2) / 2.0), w, h) + } + "snip2DiagRect" => { + let (d1, d2) = (ss * a(0).clamp(0.0, 50000.0) / 1e5, ss * a(1).clamp(0.0, 50000.0) / 1e5); + simple(corner_rect(w, h, [Corner::Snip(d1), Corner::Snip(d2), Corner::Snip(d1), Corner::Snip(d2)]), full.inset(-d1.max(d2) / 2.0), w, h) + } + "snipRoundRect" => { + let (r, d) = (ss * a(0).clamp(0.0, 50000.0) / 1e5, ss * a(1).clamp(0.0, 50000.0) / 1e5); + simple(corner_rect(w, h, [Corner::Round(r), Corner::Snip(d), Corner::Square, Corner::Square]), full.inset(-r.max(d) / 2.0), w, h) + } + "round1Rect" => { + let r = ss * a(0).clamp(0.0, 50000.0) / 1e5; + simple(corner_rect(w, h, [Corner::Square, Corner::Round(r), Corner::Square, Corner::Square]), full.inset(-r * 0.29289), w, h) + } + "round2SameRect" => { + let (r1, r2) = (ss * a(0).clamp(0.0, 50000.0) / 1e5, ss * a(1).clamp(0.0, 50000.0) / 1e5); + simple( + corner_rect(w, h, [Corner::Round(r1), Corner::Round(r1), Corner::Round(r2), Corner::Round(r2)]), + full.inset(-r1.max(r2) * 0.29289), + w, + h, + ) + } + "round2DiagRect" => { + let (r1, r2) = (ss * a(0).clamp(0.0, 50000.0) / 1e5, ss * a(1).clamp(0.0, 50000.0) / 1e5); + simple( + corner_rect(w, h, [Corner::Round(r1), Corner::Round(r2), Corner::Round(r1), Corner::Round(r2)]), + full.inset(-r1.max(r2) * 0.29289), + w, + h, + ) + } + "ellipse" | "flowChartConnector" => { + let mut g = + simple(b.ellipse(w / 2.0, h / 2.0, w / 2.0, h / 2.0).take(), Rect::new(w * 0.14645, h * 0.14645, w * 0.85355, h * 0.85355), w, h); + g.sites = ellipse_sites(w, h); + g + } + "triangle" | "flowChartExtract" => { + let v = if name == "triangle" { a(0).clamp(0.0, 100000.0) } else { 50000.0 }; + let ax = w * v / 100000.0; + let mut g = simple(b.poly(&[(0.0, h), (ax, 0.0), (w, h)]).take(), Rect::new(ax / 2.0, h / 2.0, (ax + w) / 2.0, h), w, h); + g.sites = vec![ + Point::new(ax, 0.0), + Point::new(ax / 2.0, h / 2.0), + Point::new(0.0, h), + Point::new(w / 2.0, h), + Point::new(w, h), + Point::new((ax + w) / 2.0, h / 2.0), + ]; + if name == "triangle" { + g.handles.push(Handle::new(0, Point::ZERO, Vec2::new(w / 100000.0, 0.0), v, 0.0, 100000.0)); + } + g + } + "flowChartMerge" => simple(b.poly(&[(0.0, 0.0), (w, 0.0), (w / 2.0, h)]).take(), Rect::new(w / 4.0, 0.0, w * 0.75, h / 2.0), w, h), + "rtTriangle" => { + simple(b.poly(&[(0.0, 0.0), (0.0, h), (w, h)]).take(), Rect::new(w / 12.0, h * 7.0 / 12.0, w * 7.0 / 12.0, h * 11.0 / 12.0), w, h) + } + "parallelogram" | "flowChartInputOutput" => { + let v = if name == "parallelogram" { a(0).clamp(0.0, 100000.0 * w / ss.max(1e-9)) } else { 20000.0 * w / ss.max(1e-9) }; + let off = ss * v / 100000.0; + let mut g = simple( + b.poly(&[(0.0, h), (off, 0.0), (w, 0.0), (w - off, h)]).take(), + Rect::new(off / 2.0 + off * 0.2, h * 0.08, w - off / 2.0 - off * 0.2, h * 0.92), + w, + h, + ); + if name == "parallelogram" { + g.handles.push(Handle::new(0, Point::ZERO, Vec2::new(ss / 100000.0, 0.0), v, 0.0, 100000.0 * w / ss.max(1e-9))); + } + g + } + "trapezoid" => { + let v = a(0).clamp(0.0, 50000.0 * w / ss.max(1e-9)); + let off = ss * v / 100000.0; + let mut g = + simple(b.poly(&[(0.0, h), (off, 0.0), (w - off, 0.0), (w, h)]).take(), Rect::new(off * 0.66, h * 0.2, w - off * 0.66, h), w, h); + g.handles.push(Handle::new(0, Point::ZERO, Vec2::new(ss / 100000.0, 0.0), v, 0.0, 50000.0 * w / ss.max(1e-9))); + g + } + "flowChartManualOperation" => { + simple(b.poly(&[(0.0, 0.0), (w, 0.0), (w * 0.8, h), (w * 0.2, h)]).take(), Rect::new(w * 0.2, 0.0, w * 0.8, h), w, h) + } + "diamond" | "flowChartDecision" => simple( + b.poly(&[(w / 2.0, 0.0), (w, h / 2.0), (w / 2.0, h), (0.0, h / 2.0)]).take(), + Rect::new(w / 4.0, h / 4.0, w * 0.75, h * 0.75), + w, + h, + ), + "flowChartSort" => { + let d = b.poly(&[(w / 2.0, 0.0), (w, h / 2.0), (w / 2.0, h), (0.0, h / 2.0)]).take(); + let line = Pb::new().m(0.0, h / 2.0).l(w, h / 2.0).take(); + Geometry { + paths: vec![fill(d), stroke_only(line)], + text_rect: Rect::new(w / 4.0, h / 4.0, w * 0.75, h * 0.75), + handles: vec![], + sites: mid_sites(w, h), + } + } + _ => return None, + }; + Some(g) +} + +fn polygon_and_symbol_shapes(c: &Ctx) -> Option { + let name = c.name; + let w = c.w; + let h = c.h; + let ss = c.w.min(c.h); + let full = Rect::new(0.0, 0.0, c.w, c.h); + let a = |i: usize| c.a(i); + let mut b = Pb::new(); + let g = match c.name { + "pentagon" => simple(b.poly(&polygon_regular(5, w, h, -90.0)).take(), Rect::new(w * 0.2, h * 0.27, w * 0.8, h * 0.9), w, h), + "heptagon" => simple(b.poly(&polygon_regular(7, w, h, -90.0)).take(), Rect::new(w * 0.16, h * 0.2, w * 0.84, h * 0.86), w, h), + "decagon" => simple(b.poly(&polygon_regular(10, w, h, 0.0)).take(), Rect::new(w * 0.1, h * 0.17, w * 0.9, h * 0.83), w, h), + "dodecagon" => simple(b.poly(&polygon_regular(12, w, h, 15.0)).take(), Rect::new(w * 0.13, h * 0.13, w * 0.87, h * 0.87), w, h), + "hexagon" | "flowChartPreparation" => { + let v = if name == "hexagon" { a(0).clamp(0.0, 50000.0 * w / ss.max(1e-9)) } else { 20000.0 * w / ss.max(1e-9) }; + let off = ss * v / 100000.0; + let mut g = simple( + b.poly(&[(0.0, h / 2.0), (off, 0.0), (w - off, 0.0), (w, h / 2.0), (w - off, h), (off, h)]).take(), + Rect::new(off * 0.6, h * 0.14, w - off * 0.6, h * 0.86), + w, + h, + ); + if name == "hexagon" { + g.handles.push(Handle::new(0, Point::ZERO, Vec2::new(ss / 100000.0, 0.0), v, 0.0, 50000.0 * w / ss.max(1e-9))); + } + g + } + "octagon" => { + let v = a(0).clamp(0.0, 50000.0); + let d = ss * v / 100000.0; + let mut g = simple( + b.poly(&[(d, 0.0), (w - d, 0.0), (w, d), (w, h - d), (w - d, h), (d, h), (0.0, h - d), (0.0, d)]).take(), + full.inset(-d / 2.0), + w, + h, + ); + g.handles.push(Handle::new(0, Point::ZERO, Vec2::new(ss / 100000.0, 0.0), v, 0.0, 50000.0)); + g + } + "plus" => { + let v = a(0).clamp(0.0, 50000.0); + let d = ss * v / 100000.0; + let mut g = simple( + b.poly(&[ + (d, 0.0), + (w - d, 0.0), + (w - d, d), + (w, d), + (w, h - d), + (w - d, h - d), + (w - d, h), + (d, h), + (d, h - d), + (0.0, h - d), + (0.0, d), + (d, d), + ]) + .take(), + Rect::new(0.0, d, w, h - d), + w, + h, + ); + g.handles.push(Handle::new(0, Point::ZERO, Vec2::new(ss / 100000.0, 0.0), v, 0.0, 50000.0)); + g + } + "frame" => { + let v = a(0).clamp(0.0, 50000.0); + let t = ss * v / 100000.0; + b.poly(&[(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)]); + b.poly(&[(t, t), (w - t, t), (w - t, h - t), (t, h - t)]); + let mut g = Geometry { paths: vec![fill_eo(b.take())], text_rect: full.inset(-t), handles: vec![], sites: mid_sites(w, h) }; + g.handles.push(Handle::new(0, Point::ZERO, Vec2::new(ss / 100000.0, 0.0), v, 0.0, 50000.0)); + g + } + "halfFrame" => { + let (t1, t2) = (ss * a(0).clamp(0.0, 50000.0) / 1e5, ss * a(1).clamp(0.0, 50000.0) / 1e5); + simple(b.poly(&[(0.0, 0.0), (w, 0.0), (w - t1, t1), (t2, t1), (t2, h - t2), (0.0, h)]).take(), full, w, h) + } + "corner" => { + let (t1, t2) = (ss * a(0).clamp(0.0, 100000.0) / 1e5, ss * a(1).clamp(0.0, 100000.0) / 1e5); + simple(b.poly(&[(0.0, 0.0), (t2, 0.0), (t2, h - t1), (w, h - t1), (w, h), (0.0, h)]).take(), Rect::new(0.0, h - t1, w, h), w, h) + } + "diagStripe" => { + let k = a(0).clamp(0.0, 100000.0) / 1e5; + simple(b.poly(&[(0.0, h * k), (w * k, 0.0), (w, 0.0), (0.0, h)]).take(), Rect::new(0.0, 0.0, w / 2.0, h / 2.0), w, h) + } + "plaque" => { + let v = a(0).clamp(0.0, 50000.0); + let r = ss * v / 100000.0; + b.m(0.0, r) + .arc(r, r, 90.0, -90.0) + .l(w - r, 0.0) + .arc(r, r, 180.0, -90.0) + .l(w, h - r) + .arc(r, r, 270.0, -90.0) + .l(r, h) + .arc(r, r, 0.0, -90.0) + .z(); + let mut g = simple(b.take(), full.inset(-r * 0.7), w, h); + g.handles.push(Handle::new(0, Point::ZERO, Vec2::new(ss / 100000.0, 0.0), v, 0.0, 50000.0)); + g + } + "can" | "flowChartMagneticDisk" => { + // The disk's ends are a third of its height (ECMA-376: ellipses of radius h/6). + let v = if name == "can" { a(0).clamp(0.0, 50000.0 * h / ss.max(1e-9)) } else { 100000.0 / 3.0 * h / ss.max(1e-9) }; + let eh = ss * v / 100000.0; + let r = eh / 2.0; + let body = Pb::new().m(0.0, r).l(0.0, h - r).arc(w / 2.0, r, 180.0, -180.0).l(w, r).arc(w / 2.0, r, 0.0, -180.0).z().take(); + let top = Pb::new().ellipse(w / 2.0, r, w / 2.0, r).take(); + let mut g = Geometry { + paths: vec![fill(body), shade(top, if name == "can" { FillMode::Lighten } else { FillMode::Norm })], + text_rect: Rect::new(0.0, eh, w, h - r), + handles: vec![], + sites: mid_sites(w, h), + }; + if name == "can" { + g.handles.push(Handle::new(0, Point::new(w / 2.0, 0.0), Vec2::new(0.0, ss / 100000.0), v, 0.0, 50000.0 * h / ss.max(1e-9))); + } + g + } + "flowChartMagneticDrum" => { + // ECMA-376: end ellipses of radius w/6 across. + let r = w / 6.0; + let body = Pb::new().m(r, 0.0).l(w - r, 0.0).arc(r, h / 2.0, 270.0, 180.0).l(r, h).arc(r, h / 2.0, 90.0, 180.0).z().take(); + let side = Pb::new().m(w - r, 0.0).arc(r, h / 2.0, 270.0, -180.0).take(); + Geometry { + paths: vec![fill(body), stroke_only(side)], + text_rect: Rect::new(r, 0.0, w - 2.0 * r, h), + handles: vec![], + sites: mid_sites(w, h), + } + } + "cube" => { + let v = a(0).clamp(0.0, 100000.0); + let d = ss * v / 100000.0; + let front = Pb::new().poly(&[(0.0, d), (w - d, d), (w - d, h), (0.0, h)]).take(); + let top = Pb::new().poly(&[(0.0, d), (d, 0.0), (w, 0.0), (w - d, d)]).take(); + let side = Pb::new().poly(&[(w - d, d), (w, 0.0), (w, h - d), (w - d, h)]).take(); + let mut g = Geometry { + paths: vec![fill(front), shade(top, FillMode::LightenLess), shade(side, FillMode::DarkenLess)], + text_rect: Rect::new(0.0, d, w - d, h), + handles: vec![], + sites: mid_sites(w, h), + }; + g.handles.push(Handle::new(0, Point::new(0.0, 0.0), Vec2::new(0.0, ss / 100000.0), v, 0.0, 100000.0)); + g + } + "bevel" => { + let v = a(0).clamp(0.0, 50000.0); + let d = ss * v / 100000.0; + let inner = Pb::new().poly(&[(d, d), (w - d, d), (w - d, h - d), (d, h - d)]).take(); + let top = Pb::new().poly(&[(0.0, 0.0), (w, 0.0), (w - d, d), (d, d)]).take(); + let left = Pb::new().poly(&[(0.0, 0.0), (d, d), (d, h - d), (0.0, h)]).take(); + let right = Pb::new().poly(&[(w, 0.0), (w, h), (w - d, h - d), (w - d, d)]).take(); + let bottom = Pb::new().poly(&[(0.0, h), (d, h - d), (w - d, h - d), (w, h)]).take(); + let mut g = Geometry { + paths: vec![ + fill(inner), + shade(top, FillMode::LightenLess), + shade(left, FillMode::Lighten), + shade(right, FillMode::DarkenLess), + shade(bottom, FillMode::Darken), + ], + text_rect: full.inset(-d), + handles: vec![], + sites: mid_sites(w, h), + }; + g.handles.push(Handle::new(0, Point::ZERO, Vec2::new(ss / 100000.0, 0.0), v, 0.0, 50000.0)); + g + } + "donut" => { + let v = a(0).clamp(0.0, 50000.0); + let t = ss * v / 100000.0; + b.ellipse(w / 2.0, h / 2.0, w / 2.0, h / 2.0); + b.ellipse(w / 2.0, h / 2.0, (w / 2.0 - t).max(0.0), (h / 2.0 - t).max(0.0)); + let mut g = Geometry { + paths: vec![fill_eo(b.take())], + text_rect: Rect::new(w * 0.14645, h * 0.14645, w * 0.85355, h * 0.85355), + handles: vec![], + sites: ellipse_sites(w, h), + }; + g.handles.push(Handle::new(0, Point::new(0.0, h / 2.0), Vec2::new(ss / 100000.0, 0.0), v, 0.0, 50000.0)); + g + } + "noSmoking" => { + let v = a(0).clamp(0.0, 50000.0); + let t = ss * v / 100000.0; + let (rx, ry) = ((w / 2.0 - t).max(0.0), (h / 2.0 - t).max(0.0)); + b.ellipse(w / 2.0, h / 2.0, w / 2.0, h / 2.0); + b.ellipse(w / 2.0, h / 2.0, rx, ry); + let ring = b.take(); + let th = (h).atan2(w); + let dir = Vec2::new(th.cos(), th.sin()); + let r = if rx > 0.0 && ry > 0.0 { rx * ry / ((ry * th.cos()).powi(2) + (rx * th.sin()).powi(2)).sqrt() } else { 0.0 }; + let perp = Vec2::new(-dir.y, dir.x) * (t / 2.0); + let c = Point::new(w / 2.0, h / 2.0); + let p = [c - dir * r - perp, c + dir * r - perp, c + dir * r + perp, c - dir * r + perp]; + let bar = Pb::new().poly(&p.map(|q| (q.x, q.y))).take(); + Geometry { + paths: vec![fill_eo(ring), fill(bar)], + text_rect: Rect::new(w * 0.14645, h * 0.14645, w * 0.85355, h * 0.85355), + handles: vec![Handle::new(0, Point::new(0.0, h / 2.0), Vec2::new(ss / 100000.0, 0.0), v, 0.0, 50000.0)], + sites: ellipse_sites(w, h), + } + } + "blockArc" => { + let st = a(0) / 60000.0; + let en = a(1) / 60000.0; + let t = ss * a(2).clamp(0.0, 50000.0) / 100000.0; + let mut sw = en - st; + if sw <= 0.0 { + sw += 360.0; + } + let (rx, ry) = (w / 2.0, h / 2.0); + let (irx, iry) = ((rx - t).max(0.0), (ry - t).max(0.0)); + let s0 = vis(st, rx, ry); + let e0 = vis(st + sw, rx, ry); + let mut sweep = e0 - s0; + if sweep <= 0.0 { + sweep += 360.0; + } + let si = vis(st + sw, irx, iry); + let ei = vis(st, irx, iry); + let mut isweep = ei - si; + if isweep >= 0.0 { + isweep -= 360.0; + } + let p0 = Point::new(w / 2.0 + rx * s0.to_radians().cos(), h / 2.0 + ry * s0.to_radians().sin()); + b.m(p0.x, p0.y).arc(rx, ry, s0, sweep); + let pi = Point::new(w / 2.0 + irx * si.to_radians().cos(), h / 2.0 + iry * si.to_radians().sin()); + b.l(pi.x, pi.y).arc(irx, iry, si, isweep).z(); + let mut g = simple(b.take(), full, w, h); + let c = Point::new(rx, ry); + g.handles.push(Handle::angle(0, c, rx - t / 2.0, ry - t / 2.0, a(0))); + // Thickness: slides inward from the outer edge along the end angle's ray. + let pe = Point::new(rx + rx * e0.to_radians().cos(), ry + ry * e0.to_radians().sin()); + let u = (c - pe).normalize(); + let u = if u.x.is_finite() && u.y.is_finite() { u } else { Vec2::ZERO }; + g.handles.push(Handle::new(2, pe, u * (ss / 100000.0), a(2).clamp(0.0, 50000.0), 0.0, 50000.0)); + g + } + "pie" | "chord" | "arc" => { + let (rx, ry) = (w / 2.0, h / 2.0); + let s = vis(a(0) / 60000.0, rx, ry); + let e = vis(a(1) / 60000.0, rx, ry); + let mut sw = e - s; + if sw <= 0.0 { + sw += 360.0; + } + let p0 = Point::new(rx + rx * s.to_radians().cos(), ry + ry * s.to_radians().sin()); + b.m(p0.x, p0.y).arc(rx, ry, s, sw); + let text = Rect::new(w * 0.14645, h * 0.14645, w * 0.85355, h * 0.85355); + let mut g = match name { + "pie" => { + b.l(rx, ry).z(); + simple(b.take(), text, w, h) + } + "chord" => { + b.z(); + simple(b.take(), text, w, h) + } + _ => { + let open = b.take(); + let mut f = BezPath::from_vec(open.elements().to_vec()); + f.line_to(Point::new(rx, ry)); + f.close_path(); + Geometry { paths: vec![fill_only(f), stroke_only(open)], text_rect: text, handles: vec![], sites: mid_sites(w, h) } + } + }; + let c = Point::new(rx, ry); + g.handles.push(Handle::angle(0, c, rx, ry, a(0))); + g.handles.push(Handle::angle(1, c, rx, ry, a(1))); + g + } + "teardrop" => { + let k = a(0).clamp(0.0, 200000.0) / 100000.0; + let tip = (w / 2.0 + w / 2.0 * k, h / 2.0 - h / 2.0 * k); + b.m(0.0, h / 2.0).arc(w / 2.0, h / 2.0, 180.0, 90.0); + b.q(w / 2.0 + w / 4.0 * k, 0.0, tip.0, tip.1).q(w, h / 2.0 - h / 4.0 * k, w, h / 2.0); + b.arc(w / 2.0, h / 2.0, 0.0, 180.0).z(); + simple(b.take(), Rect::new(w * 0.14645, h * 0.14645, w * 0.85355, h * 0.85355), w, h) + } + "foldedCorner" => { + let v = a(0).clamp(0.0, 50000.0); + let d = ss * v / 100000.0; + let body = Pb::new().poly(&[(0.0, 0.0), (w, 0.0), (w, h - d), (w - d, h), (0.0, h)]).take(); + let fold = Pb::new().m(w - d, h).l(w - d + d * 0.2, h - d + d * 0.2).l(w, h - d).z().take(); + Geometry { + paths: vec![fill(body), shade(fold, FillMode::DarkenLess)], + text_rect: Rect::new(0.0, 0.0, w, h - d), + handles: vec![], + sites: mid_sites(w, h), + } + } + "smileyFace" => { + let k = a(0).clamp(-4653.0, 4653.0) / 100000.0; + let face = Pb::new().ellipse(w / 2.0, h / 2.0, w / 2.0, h / 2.0).take(); + let mut eyes = Pb::new(); + eyes.ellipse(w * 0.35, h * 0.37, w * 0.05, h * 0.05); + eyes.ellipse(w * 0.65, h * 0.37, w * 0.05, h * 0.05); + let ys = h * (0.72 - k); + let smile = Pb::new().m(w * 0.22, ys).q(w / 2.0, ys + h * k * 4.0 + h * 0.0, w * 0.78, ys).take(); + Geometry { + paths: vec![fill(face), shade(eyes.take(), FillMode::DarkenLess), stroke_only(smile)], + text_rect: Rect::new(w * 0.14645, h * 0.14645, w * 0.85355, h * 0.85355), + handles: vec![], + sites: ellipse_sites(w, h), + } + } + "heart" => { + // ECMA-376 heart: two cubic curves from the notch at the top to the point. + let (dx1, dx2) = (w * 49.0 / 48.0, w * 10.0 / 48.0); + let y1 = -h / 3.0; + b.m(w / 2.0, h / 4.0) + .c(w / 2.0 + dx2, y1, w / 2.0 + dx1, h / 4.0, w / 2.0, h) + .c(w / 2.0 - dx1, h / 4.0, w / 2.0 - dx2, y1, w / 2.0, h / 4.0) + .z(); + simple(b.take(), Rect::new(w * 0.2, h * 0.25, w * 0.8, h * 0.7), w, h) + } + "lightningBolt" => { + // ECMA-376 lightningBolt, in 21600ths of the box. + let pts = spec_pts( + &[ + (8472, 0), + (12860, 6080), + (11050, 6797), + (16577, 12007), + (14767, 12877), + (21600, 21600), + (10012, 14915), + (12222, 13987), + (5022, 9705), + (7602, 8382), + (0, 3890), + ], + w, + h, + ); + simple(b.poly(&pts).take(), Rect::new(w * 8757.0 / 21600.0, h * 7637.0 / 21600.0, w * 13917.0 / 21600.0, h * 12963.0 / 21600.0), w, h) + } + "sun" => { + // ECMA-376 sun: eight triangular rays (their bases at fixed fractions of the core's + // margin) around a circle of radius 50% - adj. + let a = a(0).clamp(12500.0, 46875.0); + let g0 = 50000.0 - a; + let (g1, g2) = (g0 * 30274.0 / 32768.0, g0 * 12540.0 / 32768.0); + let (g10, g11) = ((50000.0 - g1) * 0.75, (50000.0 - g2) * 0.75); + let (g12, g13, g14) = (g10 + 3662.0, g11 + 3662.0, g11 + 12500.0); + let (g15, g16, g17, g18) = (100000.0 - g10, 100000.0 - g12, 100000.0 - g13, 100000.0 - g14); + let x = |g: f64| w * g / 100000.0; + let y = |g: f64| h * g / 100000.0; + let (ox1, oy1, ox2, oy2) = (w * 18436.0 / 21600.0, h * 3163.0 / 21600.0, w * 3163.0 / 21600.0, h * 18436.0 / 21600.0); + let (hc, vc) = (w / 2.0, h / 2.0); + b.poly(&[(w, vc), (x(g15), y(g18)), (x(g15), y(g14))]); + b.poly(&[(ox1, oy1), (x(g16), y(g13)), (x(g17), y(g12))]); + b.poly(&[(hc, 0.0), (x(g18), y(g10)), (x(g14), y(g10))]); + b.poly(&[(ox2, oy1), (x(g13), y(g12)), (x(g12), y(g13))]); + b.poly(&[(0.0, vc), (x(g10), y(g14)), (x(g10), y(g18))]); + b.poly(&[(ox2, oy2), (x(g12), y(g17)), (x(g13), y(g16))]); + b.poly(&[(hc, h), (x(g14), y(g15)), (x(g18), y(g15))]); + b.poly(&[(ox1, oy2), (x(g17), y(g16)), (x(g16), y(g17))]); + b.ellipse(hc, vc, x(g0), y(g0)); + let g7 = g0 * 23170.0 / 32768.0; + let mut g = simple(b.take(), Rect::new(x(50000.0 - g7), y(50000.0 - g7), x(50000.0 + g7), y(50000.0 + g7)), w, h); + g.handles.push(Handle::new(0, Point::new(0.0, vc), Vec2::new(w / 100000.0, 0.0), a, 12500.0, 46875.0)); + g + } + "moon" => { + // ECMA-376 moon: the outer edge is the left half of an ellipse w × h/2 centred on the + // right edge; the inner edge an arc through the corners and (adj · w, middle) of a + // larger ellipse (rx = g18, ry = dy1) whose centre lies right of that point. + let k = a(0).clamp(0.0, 87500.0) / 100000.0; + let ssf = ss.max(1e-9); + let g0 = ss * k; + let g1 = (ss - g0).max(1e-9); + let g2 = g0 * g0 / g1; + let g3 = ss * ss / g1; + let g5 = 2.0 * g3 - g2; + let g6 = g5 - g0; + let dy1 = (g5 / 2.0 - g0) * (h / 2.0) / ssf; + let (g0w, g6w) = (g0 * w / ssf, g6 * w / ssf); + let rx = (g6w - g0w) / 2.0; + let cx = g0w + rx; + b.m(w, 0.0).arc(w, h / 2.0, 270.0, -180.0); + if rx > 1e-9 && dy1 > 1e-9 { + // From the bottom-right corner back up to the top-right through (g0w, h/2). + let t0 = ((h / 2.0) / dy1).clamp(-1.0, 1.0).atan2((w - cx) / rx); + let t1 = (-(h / 2.0) / dy1).clamp(-1.0, 1.0).atan2((w - cx) / rx) + std::f64::consts::TAU; + let arc = Arc::new(Point::new(cx, h / 2.0), Vec2::new(rx, dy1), t0, t1 - t0, 0.0); + arc.to_cubic_beziers(0.05, |p1, p2, p| { + b.p.curve_to(p1, p2, p); + }); + } + b.z(); + let g12 = g0 * 9598.0 / 32768.0; + let g13 = ss - g12; + let dy4 = (ss * ss - g13 * g13).max(0.0).sqrt() * (h / 2.0) / ssf; + simple(b.take(), Rect::new(g12 * w / ssf, h / 2.0 - dy4, g0w, h / 2.0 + dy4), w, h) + } + _ => return None, + }; + Some(g) +} + +fn bracket_and_arrow_shapes(c: &Ctx) -> Option { + let name = c.name; + let w = c.w; + let h = c.h; + let ss = c.w.min(c.h); + let full = Rect::new(0.0, 0.0, c.w, c.h); + let a = |i: usize| c.a(i); + let mut b = Pb::new(); + let g = match c.name { + "cloud" | "cloudCallout" => cloud(name, w, h, a(0), a(1)), + "bracketPair" => { + let r = ss * a(0).clamp(0.0, 50000.0) / 1e5; + let mut l = Pb::new(); + l.m(r, h).arc(r, r, 90.0, 90.0).l(0.0, r).arc(r, r, 180.0, 90.0); + l.m(w - r, 0.0).arc(r, r, 270.0, 90.0).l(w, h - r).arc(r, r, 0.0, 90.0); + let f = corner_rect(w, h, [Corner::Round(r); 4]); + Geometry { + paths: vec![fill_only(f), stroke_only(l.take())], + text_rect: full.inset(-r * 0.29289), + handles: vec![], + sites: mid_sites(w, h), + } + } + "bracePair" => { + let r = ss * a(0).clamp(0.0, 25000.0) / 1e5; + let mut l = Pb::new(); + l.m(2.0 * r, h).arc(r, r, 90.0, 90.0).l(r, h / 2.0 + r).arc(r, r, 0.0, -90.0).arc(r, r, 90.0, -90.0).l(r, r).arc(r, r, 180.0, 90.0); + l.m(w - 2.0 * r, 0.0) + .arc(r, r, 270.0, 90.0) + .l(w - r, h / 2.0 - r) + .arc(r, r, 180.0, -90.0) + .arc(r, r, 270.0, -90.0) + .l(w - r, h - r) + .arc(r, r, 0.0, 90.0); + let f = corner_rect(w, h, [Corner::Round(r); 4]); + Geometry { paths: vec![fill_only(f), stroke_only(l.take())], text_rect: full.inset(-r * 1.2), handles: vec![], sites: mid_sites(w, h) } + } + "leftBracket" | "rightBracket" => { + let r = (ss * a(0).clamp(0.0, 50000.0 * h / ss.max(1e-9)) / 1e5).min(h / 2.0); + let mut l = Pb::new(); + l.m(w, h).arc(w, r, 90.0, 90.0).l(0.0, r).arc(w, r, 180.0, 90.0); + let mut path = l.take(); + if name == "rightBracket" { + path.apply_affine(kurbo::Affine::new([-1.0, 0.0, 0.0, 1.0, w, 0.0])); + } + // Filled (when the shape has a fill) out to the straight side, as PowerPoint does. + let mut area = path.clone(); + area.close_path(); + Geometry { paths: vec![fill_only(area), stroke_only(path)], text_rect: full, handles: vec![], sites: mid_sites(w, h) } + } + "leftBrace" | "rightBrace" => { + let r = (ss * a(0).clamp(0.0, 25000.0 * h / ss.max(1e-9)) / 1e5).min(h / 4.0); + let my = h * a(1).clamp(0.0, 100000.0) / 1e5; + let hw = w / 2.0; + let mut l = Pb::new(); + l.m(w, h).arc(hw, r, 90.0, 90.0).l(hw, my + r).arc(hw, r, 0.0, -90.0).arc(hw, r, 90.0, -90.0).l(hw, r).arc(hw, r, 180.0, 90.0); + let mut path = l.take(); + if name == "rightBrace" { + path.apply_affine(kurbo::Affine::new([-1.0, 0.0, 0.0, 1.0, w, 0.0])); + } + // Filled (when the shape has a fill) out to the straight side, as PowerPoint does. + let mut area = path.clone(); + area.close_path(); + Geometry { paths: vec![fill_only(area), stroke_only(path)], text_rect: full, handles: vec![], sites: mid_sites(w, h) } + } + "rightArrow" => arrow(Dir::Right, w, h, a(0), a(1)), + "leftArrow" => arrow(Dir::Left, w, h, a(0), a(1)), + "upArrow" => arrow(Dir::Up, w, h, a(0), a(1)), + "downArrow" => arrow(Dir::Down, w, h, a(0), a(1)), + "leftRightArrow" => double_arrow(false, w, h, a(0), a(1)), + "upDownArrow" => double_arrow(true, w, h, a(0), a(1)), + "notchedRightArrow" | "stripedRightArrow" => { + let mut g = arrow(Dir::Right, w, h, a(0), a(1)); + let k = a(0).clamp(0.0, 100000.0) / 1e5; + let (y1, y2) = (h / 2.0 - h * k / 2.0, h / 2.0 + h * k / 2.0); + let dx = (ss * a(1).max(0.0) / 1e5).min(w); + if name == "notchedRightArrow" { + // x1 = dy1 · dx2 / hd2 in presetShapeDefinitions, with dy1 = h·k/2: the notch's + // edges run parallel to the head's + let n = dx * k; + let pts = [(0.0, y1), (w - dx, y1), (w - dx, 0.0), (w, h / 2.0), (w - dx, h), (w - dx, y2), (0.0, y2), (n, h / 2.0)]; + g.paths = vec![fill(Pb::new().poly(&pts).take())]; + } else { + let s = ss / 32.0; + let start = 5.0 * s; + let pts = [(start, y1), (w - dx, y1), (w - dx, 0.0), (w, h / 2.0), (w - dx, h), (w - dx, y2), (start, y2)]; + let mut stripes = Pb::new(); + stripes.poly(&[(0.0, y1), (s, y1), (s, y2), (0.0, y2)]); + stripes.poly(&[(2.0 * s, y1), (4.0 * s, y1), (4.0 * s, y2), (2.0 * s, y2)]); + g.paths = vec![fill(stripes.take()), fill(Pb::new().poly(&pts).take())]; + } + g + } + "homePlate" | "chevron" => { + let v = a(0).clamp(0.0, 100000.0 * w / ss.max(1e-9)); + let d = (ss * v / 1e5).min(w); + let pts: Vec<(f64, f64)> = if name == "homePlate" { + vec![(0.0, 0.0), (w - d, 0.0), (w, h / 2.0), (w - d, h), (0.0, h)] + } else { + vec![(0.0, 0.0), (w - d, 0.0), (w, h / 2.0), (w - d, h), (0.0, h), (d, h / 2.0)] + }; + // Text rectangles as presetShapeDefinitions give them: homePlate's runs to halfway + // along the point; chevron's lies between the notch and the point's base (x1..x2) + // and covers the whole width once those meet or cross (`?: dx x1 l`). + let text = if name == "homePlate" { + Rect::new(0.0, 0.0, w - d / 2.0, h) + } else if w - d > d { + Rect::new(d, 0.0, w - d, h) + } else { + Rect::new(0.0, 0.0, w, h) + }; + let mut g = simple(b.poly(&pts).take(), text, w, h); + g.handles.push(Handle::new(0, Point::new(w, 0.0), Vec2::new(-ss / 1e5, 0.0), v, 0.0, 100000.0 * w / ss.max(1e-9))); + g + } + "quadArrow" => { + let (t, hw, hl) = (ss * a(0).clamp(0.0, 50000.0) / 1e5, ss * a(1).clamp(0.0, 50000.0) / 1e5, ss * a(2).clamp(0.0, 50000.0) / 1e5); + let (cx, cy) = (w / 2.0, h / 2.0); + let (t2, hw2) = (t / 2.0, hw); + let pts = [ + (0.0, cy), + (hl, cy - hw2), + (hl, cy - t2), + (cx - t2, cy - t2), + (cx - t2, hl), + (cx - hw2, hl), + (cx, 0.0), + (cx + hw2, hl), + (cx + t2, hl), + (cx + t2, cy - t2), + (w - hl, cy - t2), + (w - hl, cy - hw2), + (w, cy), + (w - hl, cy + hw2), + (w - hl, cy + t2), + (cx + t2, cy + t2), + (cx + t2, h - hl), + (cx + hw2, h - hl), + (cx, h), + (cx - hw2, h - hl), + (cx - t2, h - hl), + (cx - t2, cy + t2), + (hl, cy + t2), + (hl, cy + hw2), + ]; + simple(b.poly(&pts).take(), Rect::new(cx - t2, cy - t2, cx + t2, cy + t2), w, h) + } + "leftRightUpArrow" => { + let (t, hw, hl) = (ss * a(0).clamp(0.0, 50000.0) / 1e5, ss * a(1).clamp(0.0, 50000.0) / 1e5, ss * a(2).clamp(0.0, 50000.0) / 1e5); + let cx = w / 2.0; + let by = h - hw; // horizontal shaft centre + let pts = [ + (0.0, by), + (hl, by - hw), + (hl, by - t / 2.0), + (cx - t / 2.0, by - t / 2.0), + (cx - t / 2.0, hl), + (cx - hw, hl), + (cx, 0.0), + (cx + hw, hl), + (cx + t / 2.0, hl), + (cx + t / 2.0, by - t / 2.0), + (w - hl, by - t / 2.0), + (w - hl, by - hw), + (w, by), + (w - hl, h), + (w - hl, by + t / 2.0), + (hl, by + t / 2.0), + (hl, h), + ]; + simple(b.poly(&pts).take(), Rect::new(hl, by - t / 2.0, w - hl, by + t / 2.0), w, h) + } + "leftUpArrow" | "bentUpArrow" => { + let (t, hw, hl) = (ss * a(0).clamp(0.0, 50000.0) / 1e5, ss * a(1).clamp(0.0, 50000.0) / 1e5, ss * a(2).clamp(0.0, 50000.0) / 1e5); + let pts: Vec<(f64, f64)> = if name == "leftUpArrow" { + let cx = w - hw; + let cy = h - hw; + vec![ + (0.0, cy), + (hl, cy - hw), + (hl, cy - t / 2.0), + (cx - t / 2.0, cy - t / 2.0), + (cx - t / 2.0, hl), + (cx - hw, hl), + (cx, 0.0), + (w, hl), + (cx + t / 2.0, hl), + (cx + t / 2.0, cy + t / 2.0), + (hl, cy + t / 2.0), + (hl, h), + ] + } else { + let cx = w - hw; + vec![ + (0.0, h - t), + (cx - t / 2.0, h - t), + (cx - t / 2.0, hl), + (cx - hw, hl), + (cx, 0.0), + (w, hl), + (cx + t / 2.0, hl), + (cx + t / 2.0, h), + (0.0, h), + ] + }; + simple(b.poly(&pts).take(), full, w, h) + } + "bentArrow" => { + let (t, hw, hl, r) = ( + ss * a(0).clamp(0.0, 50000.0) / 1e5, + ss * a(1).clamp(0.0, 50000.0) / 1e5, + ss * a(2).clamp(0.0, 50000.0) / 1e5, + ss * a(3).clamp(0.0, 100000.0) / 1e5, + ); + let cy = hw; // head centre line + let ro = r.max(t); + let ri = (ro - t).max(0.0); + b.m(0.0, h) + .l(0.0, cy - t / 2.0 + ro) + .arc(ro, ro, 180.0, 90.0) + .l(w - hl, cy - t / 2.0) + .l(w - hl, 0.0) + .l(w, cy) + .l(w - hl, 2.0 * hw) + .l(w - hl, cy + t / 2.0); + b.l(t + ri, cy + t / 2.0); + if ri > 0.0 { + b.arc(ri, ri, 270.0, -90.0); + } + b.l(t, h).z(); + simple(b.take(), full, w, h) + } + "uturnArrow" => { + let (t, hw, hl, r) = ( + ss * a(0).clamp(0.0, 25000.0) / 1e5, + ss * a(1).clamp(0.0, 25000.0) / 1e5, + ss * a(2).clamp(0.0, 25000.0) / 1e5, + ss * a(3).clamp(0.0, 100000.0) / 1e5, + ); + let hy = (h * a(4).clamp(0.0, 100000.0) / 1e5).max(hl + t); + let ax = w - hw; // arrow shaft centre x + let ro = r.max(t).min((ax + t / 2.0) / 2.0); + let ri = (ro - t).max(0.0); + b.m(0.0, h).l(0.0, ro).arc(ro, ro, 180.0, 90.0).l(ax + t / 2.0 - ro, 0.0).arc(ro, ro, 270.0, 90.0); + b.l(ax + t / 2.0, hy - hl).l(w, hy - hl).l(ax, hy).l(ax - hw, hy - hl).l(ax - t / 2.0, hy - hl).l(ax - t / 2.0, ro); + if ri > 0.0 { + b.arc(ri, ri, 0.0, -90.0); + } + b.l(t + ri, t); + if ri > 0.0 { + b.arc(ri, ri, 270.0, -90.0); + } + b.l(t, h).z(); + simple(b.take(), full, w, h) + } + "rightArrowCallout" | "leftArrowCallout" | "upArrowCallout" | "downArrowCallout" => { + let d = match name { + "rightArrowCallout" => Dir::Right, + "leftArrowCallout" => Dir::Left, + "upArrowCallout" => Dir::Up, + _ => Dir::Down, + }; + let (l, t) = len_thick(d, w, h); + let ss2 = l.min(t); + let shaft = ss2 * a(0).clamp(0.0, 100000.0) / 1e5; + let head = ss2 * a(1).clamp(0.0, 100000.0) / 1e5; + let hl = ss2 * a(2).clamp(0.0, 100000.0) / 1e5; + let boxl = (l * a(3).clamp(0.0, 100000.0) / 1e5).min(l - hl); + let c = t / 2.0; + let pts = [ + (0.0, 0.0), + (boxl, 0.0), + (boxl, c - shaft / 2.0), + (l - hl, c - shaft / 2.0), + (l - hl, c - head), + (l, c), + (l - hl, c + head), + (l - hl, c + shaft / 2.0), + (boxl, c + shaft / 2.0), + (boxl, t), + (0.0, t), + ]; + let pts = orient(d, w, h, &pts); + let tr = orient(d, w, h, &[(0.0, 0.0), (boxl, t)]); + simple(b.poly(&pts).take(), Rect::from_points(Point::new(tr[0].0, tr[0].1), Point::new(tr[1].0, tr[1].1)).abs(), w, h) + } + "mathPlus" | "mathMinus" | "mathEqual" | "mathDivide" | "mathMultiply" | "mathNotEqual" => math(name, w, h, a(0), a(1), a(2)), + _ => return None, + }; + Some(g) +} + +fn flowchart_and_star_shapes(c: &Ctx) -> Option { + let name = c.name; + let w = c.w; + let h = c.h; + let ss = c.w.min(c.h); + let full = Rect::new(0.0, 0.0, c.w, c.h); + let a = |i: usize| c.a(i); + let mut b = Pb::new(); + let g = match c.name { + "flowChartPredefinedProcess" => { + let r = Pb::new().poly(&[(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)]).take(); + let mut l = Pb::new(); + l.m(w / 8.0, 0.0).l(w / 8.0, h).m(w * 7.0 / 8.0, 0.0).l(w * 7.0 / 8.0, h); + Geometry { + paths: vec![fill(r), stroke_only(l.take())], + text_rect: Rect::new(w / 8.0, 0.0, w * 7.0 / 8.0, h), + handles: vec![], + sites: mid_sites(w, h), + } + } + "flowChartInternalStorage" => { + let r = Pb::new().poly(&[(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)]).take(); + let mut l = Pb::new(); + l.m(w / 8.0, 0.0).l(w / 8.0, h).m(0.0, h / 8.0).l(w, h / 8.0); + Geometry { + paths: vec![fill(r), stroke_only(l.take())], + text_rect: Rect::new(w / 8.0, h / 8.0, w, h), + handles: vec![], + sites: mid_sites(w, h), + } + } + "flowChartDocument" => { + let u = |x: f64, y: f64| (w * x / 21600.0, h * y / 21600.0); + let (p1, p2, p3, p4) = (u(21600.0, 17322.0), u(10800.0, 17322.0), u(10800.0, 23922.0), u(0.0, 20172.0)); + b.m(0.0, 0.0).l(w, 0.0).l(p1.0, p1.1).c(p2.0, p2.1, p3.0, p3.1, p4.0, p4.1).z(); + simple(b.take(), Rect::new(0.0, 0.0, w, h * 17322.0 / 21600.0), w, h) + } + "flowChartMultidocument" => { + // ECMA-376 multidocument (21600ths): two back pages peeking out above and right of + // the front one; the back pages' edges are outlined only where they show. + let x = |v: f64| w * v / 21600.0; + let y = |v: f64| h * v / 21600.0; + let front = |b: &mut Pb| { + b.m(0.0, y(20782.0)) + .c(x(9298.0), y(23542.0), x(9298.0), y(18022.0), x(18595.0), y(18022.0)) + .l(x(18595.0), y(3675.0)) + .l(0.0, y(3675.0)) + .z(); + }; + let mid = |b: &mut Pb| { + b.m(x(1532.0), y(3675.0)).l(x(1532.0), y(1815.0)).l(x(20000.0), y(1815.0)).l(x(20000.0), y(16252.0)); + b.c(x(19298.0), y(16252.0), x(18595.0), y(16352.0), x(18595.0), y(16352.0)); + }; + let back = |b: &mut Pb| { + b.m(x(2972.0), y(1815.0)).l(x(2972.0), 0.0).l(w, 0.0).l(w, y(14392.0)); + b.c(x(20800.0), y(14392.0), x(20000.0), y(14467.0), x(20000.0), y(14467.0)); + }; + let mut area = Pb::new(); + front(&mut area); + mid(&mut area); + area.l(x(18595.0), y(3675.0)).z(); + back(&mut area); + area.l(x(20000.0), y(1815.0)).z(); + let mut outline = Pb::new(); + outline.m(0.0, y(3675.0)).l(x(18595.0), y(3675.0)).l(x(18595.0), y(18022.0)); + outline.c(x(9298.0), y(18022.0), x(9298.0), y(23542.0), 0.0, y(20782.0)).z(); + mid(&mut outline); + back(&mut outline); + Geometry { + paths: vec![fill_only(area.take()), stroke_only(outline.take())], + text_rect: Rect::new(0.0, y(3675.0), x(18595.0), y(17322.0)), + handles: vec![], + sites: mid_sites(w, h), + } + } + "flowChartTerminator" => { + let rx = (w * 0.16).min(w / 2.0); + b.m(rx, 0.0).l(w - rx, 0.0).arc(rx, h / 2.0, 270.0, 180.0).l(rx, h).arc(rx, h / 2.0, 90.0, 180.0).z(); + simple(b.take(), Rect::new(rx * 0.3, h * 0.15, w - rx * 0.3, h * 0.85), w, h) + } + "flowChartManualInput" => simple(b.poly(&[(0.0, h / 5.0), (w, 0.0), (w, h), (0.0, h)]).take(), Rect::new(0.0, h / 5.0, w, h), w, h), + "flowChartOffpageConnector" => { + simple(b.poly(&[(0.0, 0.0), (w, 0.0), (w, h * 0.8), (w / 2.0, h), (0.0, h * 0.8)]).take(), Rect::new(0.0, 0.0, w, h * 0.8), w, h) + } + "flowChartPunchedCard" => { + simple(b.poly(&[(w * 0.2, 0.0), (w, 0.0), (w, h), (0.0, h), (0.0, h * 0.2)]).take(), Rect::new(0.0, h * 0.2, w, h), w, h) + } + "flowChartPunchedTape" => { + // ECMA-376: each long edge is two half-ellipses a quarter of the width wide and a + // tenth of the height deep, one bulging in and one out. + let (rx, ry) = (w / 4.0, h / 10.0); + b.m(0.0, ry).arc(rx, ry, 180.0, -180.0).arc(rx, ry, 180.0, 180.0).l(w, h - ry).arc(rx, ry, 0.0, -180.0).arc(rx, ry, 0.0, 180.0).z(); + simple(b.take(), Rect::new(0.0, h / 5.0, w, h * 0.8), w, h) + } + "flowChartSummingJunction" | "flowChartOr" => { + let e = Pb::new().ellipse(w / 2.0, h / 2.0, w / 2.0, h / 2.0).take(); + let mut l = Pb::new(); + if name == "flowChartOr" { + l.m(w / 2.0, 0.0).l(w / 2.0, h).m(0.0, h / 2.0).l(w, h / 2.0); + } else { + let (dx, dy) = (w / 2.0 * std::f64::consts::FRAC_1_SQRT_2, h / 2.0 * std::f64::consts::FRAC_1_SQRT_2); + l.m(w / 2.0 - dx, h / 2.0 - dy).l(w / 2.0 + dx, h / 2.0 + dy).m(w / 2.0 + dx, h / 2.0 - dy).l(w / 2.0 - dx, h / 2.0 + dy); + } + Geometry { + paths: vec![fill(e), stroke_only(l.take())], + text_rect: Rect::new(w * 0.14645, h * 0.14645, w * 0.85355, h * 0.85355), + handles: vec![], + sites: ellipse_sites(w, h), + } + } + "flowChartCollate" => simple(b.poly(&[(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)]).take(), Rect::new(w / 4.0, h / 4.0, w * 0.75, h * 0.75), w, h), + "flowChartOnlineStorage" => { + let rx = w / 6.0; + b.m(rx, 0.0).l(w, 0.0).arc(rx, h / 2.0, 270.0, -180.0).l(rx, h).arc(rx, h / 2.0, 90.0, 180.0).z(); + simple(b.take(), Rect::new(rx, 0.0, w - rx, h), w, h) + } + "flowChartDelay" => { + b.m(0.0, 0.0).l(w / 2.0, 0.0).arc(w / 2.0, h / 2.0, 270.0, 180.0).l(0.0, h).z(); + simple(b.take(), Rect::new(0.0, h * 0.15, w * 0.85, h * 0.85), w, h) + } + "flowChartMagneticTape" => { + b.m(w / 2.0, h).arc(w / 2.0, h / 2.0, 90.0, 90.0).arc(w / 2.0, h / 2.0, 180.0, 90.0).arc(w / 2.0, h / 2.0, 270.0, 90.0).arc( + w / 2.0, + h / 2.0, + 0.0, + 45.0, + ); + let p = b.cur; + b.l(w, p.y).l(w, h).z(); + simple(b.take(), Rect::new(w * 0.14645, h * 0.14645, w * 0.85355, h * 0.85355), w, h) + } + "flowChartDisplay" => { + let rx = w / 6.0; + b.m(0.0, h / 2.0).l(rx, 0.0).l(w - rx, 0.0).arc(rx, h / 2.0, 270.0, 180.0).l(rx, h).z(); + simple(b.take(), Rect::new(rx, 0.0, w - rx, h), w, h) + } + "star4" | "star5" | "star6" | "star7" | "star8" | "star10" | "star12" | "star16" | "star24" | "star32" => { + let n: usize = name.trim_start_matches("star").parse().unwrap_or(5); + let v = a(0).clamp(0.0, 50000.0); + let inner = v / 50000.0; + let text = star_text_rect(n, w, h, inner).unwrap_or_else(|| full.inset(-ss * (0.5 - inner * 0.5) * 0.5)); + let mut g = simple(b.poly(&star(n, w, h, inner)).take(), text, w, h); + g.handles.push(Handle::new(0, Point::new(w / 2.0, h / 2.0), Vec2::new(0.0, -h / 2.0 / 50000.0), v, 0.0, 50000.0)); + g + } + "irregularSeal1" => { + // ECMA-376 irregularSeal1 ("Explosion: 8 Points"), in 21600ths of the box. + let pts = spec_pts( + &[ + (10800, 5800), + (14522, 0), + (14155, 5325), + (18380, 4457), + (16702, 7315), + (21097, 8137), + (17607, 10475), + (21600, 13290), + (16837, 12942), + (18145, 18095), + (14020, 14457), + (13247, 19737), + (10532, 14935), + (8485, 21600), + (7715, 15627), + (4762, 17617), + (5667, 13937), + (135, 14587), + (3722, 11775), + (0, 8615), + (4627, 7617), + (370, 2295), + (7312, 6320), + (8352, 2295), + ], + w, + h, + ); + simple(b.poly(&pts).take(), Rect::new(w * 4627.0 / 21600.0, h * 6320.0 / 21600.0, w * 16702.0 / 21600.0, h * 13937.0 / 21600.0), w, h) + } + "irregularSeal2" => { + // ECMA-376 irregularSeal2 ("Explosion: 14 Points"), in 21600ths of the box. + let pts = spec_pts( + &[ + (11462, 4342), + (14790, 0), + (14525, 5777), + (18007, 3172), + (16380, 6532), + (21600, 6645), + (16985, 9402), + (18270, 11290), + (16380, 12310), + (18877, 15632), + (14640, 14350), + (14942, 17370), + (12180, 15935), + (11612, 18842), + (9872, 17370), + (8700, 19712), + (7527, 18125), + (4917, 21600), + (4805, 18240), + (1285, 17825), + (3330, 15370), + (0, 12877), + (3935, 11592), + (1172, 8270), + (5372, 7817), + (4502, 3625), + (8550, 6382), + (9722, 1887), + ], + w, + h, + ); + simple(b.poly(&pts).take(), Rect::new(w * 5372.0 / 21600.0, h * 6382.0 / 21600.0, w * 14640.0 / 21600.0, h * 15935.0 / 21600.0), w, h) + } + _ => return None, + }; + Some(g) +} + +fn callout_wave_and_connector_shapes(c: &Ctx) -> Option { + let name = c.name; + let w = c.w; + let h = c.h; + let ss = c.w.min(c.h); + let full = Rect::new(0.0, 0.0, c.w, c.h); + let a = |i: usize| c.a(i); + let mut b = Pb::new(); + let g = match c.name { + "ribbon" | "ribbon2" => ribbon(name == "ribbon2", w, h, a(0), a(1)), + "verticalScroll" | "horizontalScroll" => scroll(name == "verticalScroll", w, h, a(0)), + "wave" | "doubleWave" => { + // ECMA-376 wave / doubleWave: the top and bottom edges are cubic waves (one or two + // periods) of amplitude adj1, shifted sideways by adj2. + let a1 = a(0).clamp(0.0, if name == "wave" { 20000.0 } else { 12500.0 }); + let a2 = a(1).clamp(-10000.0, 10000.0); + let y1 = h * a1 / 100000.0; + let dy2 = y1 * 10.0 / 3.0; + let (y2, y3, y4) = (y1 - dy2, y1 + dy2, h - y1); + let (y5, y6) = (y4 - dy2, y4 + dy2); + let of2 = w * a2 / 50000.0; + let dx2 = of2.min(0.0); + let dxr = of2.max(0.0); + let (x2, xr, xl, x10) = (-dx2, w - dxr, dxr, w + dx2); + let text_y = h * a1 / 50000.0; + if name == "wave" { + let dx3 = (dx2 + xr) / 3.0; + let (x3, x7) = (x2 + dx3, xl + dx3); + let (x4, x8) = ((x3 + xr) / 2.0, (x7 + x10) / 2.0); + b.m(x2, y1).c(x3, y2, x4, y3, xr, y1).l(x10, y4).c(x8, y6, x7, y5, xl, y4).z(); + } else { + let (dx3, dx4) = ((dx2 + xr) / 6.0, (dx2 + xr) / 3.0); + let (x3, x4, x5) = (x2 + dx3, x2 + dx4, (x2 + xr) / 2.0); + let x6 = x5 + dx3; + let x7 = (x6 + xr) / 2.0; + let (x10b, x11, x12) = (xl + dx3, xl + dx4, (xl + x10) / 2.0); + let x13 = x12 + dx3; + let x14 = (x13 + x10) / 2.0; + b.m(x2, y1).c(x3, y2, x4, y3, x5, y1).c(x6, y2, x7, y3, xr, y1).l(x10, y4); + b.c(x14, y6, x13, y5, x12, y4).c(x11, y6, x10b, y5, xl, y4).z(); + } + simple(b.take(), Rect::new(x2.max(xl), text_y, xr.min(x10), h - text_y), w, h) + } + "wedgeRectCallout" | "wedgeRoundRectCallout" => { + let r = if name == "wedgeRoundRectCallout" { ss * a(2).clamp(0.0, 50000.0) / 1e5 } else { 0.0 }; + wedge_rect(w, h, a(0), a(1), r) + } + "wedgeEllipseCallout" => { + let tip = Point::new(w / 2.0 + w * a(0) / 1e5, h / 2.0 + h * a(1) / 1e5); + let ang = (tip.y - h / 2.0).atan2(tip.x - w / 2.0).to_degrees(); + let s = vis(ang + 10.0, w / 2.0, h / 2.0); + let p0 = Point::new(w / 2.0 + w / 2.0 * s.to_radians().cos(), h / 2.0 + h / 2.0 * s.to_radians().sin()); + let e = vis(ang - 10.0, w / 2.0, h / 2.0); + let mut sw = e - s; + if sw <= 0.0 { + sw += 360.0; + } + b.m(p0.x, p0.y).arc(w / 2.0, h / 2.0, s, sw).l(tip.x, tip.y).z(); + let mut g = simple(b.take(), Rect::new(w * 0.14645, h * 0.14645, w * 0.85355, h * 0.85355), w, h); + g.handles.push(Handle::new(0, Point::new(w / 2.0, h / 2.0), Vec2::new(w / 1e5, 0.0), a(0), -1e6, 1e6)); + g.handles.push(Handle::new(1, Point::new(w / 2.0, h / 2.0), Vec2::new(0.0, h / 1e5), a(1), -1e6, 1e6)); + g.handles.retain(|_| false); + g.handles.push(tip_handle(w, h, tip)); + g + } + "borderCallout1" => { + let r = Pb::new().poly(&[(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)]).take(); + let p1 = (w * a(1) / 1e5, h * a(0) / 1e5); + let p2 = (w * a(3) / 1e5, h * a(2) / 1e5); + let l = Pb::new().m(p1.0, p1.1).l(p2.0, p2.1).take(); + Geometry { paths: vec![fill(r), stroke_only(l)], text_rect: full, handles: vec![], sites: mid_sites(w, h) } + } + "line" | "straightConnector1" => { + let l = Pb::new().m(0.0, 0.0).l(w, h).take(); + Geometry { paths: vec![stroke_only(l)], text_rect: full, handles: vec![], sites: vec![Point::ZERO, Point::new(w, h)] } + } + "bentConnector2" => { + let l = Pb::new().m(0.0, 0.0).l(w, 0.0).l(w, h).take(); + Geometry { paths: vec![stroke_only(l)], text_rect: full, handles: vec![], sites: vec![Point::ZERO, Point::new(w, h)] } + } + "bentConnector3" | "bentConnector4" | "bentConnector5" => { + let x = w * a(0) / 1e5; + let l = Pb::new().m(0.0, 0.0).l(x, 0.0).l(x, h).l(w, h).take(); + let mut g = Geometry { paths: vec![stroke_only(l)], text_rect: full, handles: vec![], sites: vec![Point::ZERO, Point::new(w, h)] }; + g.handles.push(Handle::new(0, Point::new(0.0, h / 2.0), Vec2::new(w / 1e5, 0.0), a(0), -1e6, 1e6)); + g + } + "curvedConnector2" | "curvedConnector3" | "curvedConnector4" | "curvedConnector5" => { + let x = w * if name == "curvedConnector2" { 100000.0 } else { a(0) } / 1e5; + let l = Pb::new().m(0.0, 0.0).c(x / 2.0, 0.0, x, h / 4.0, x, h / 2.0).c(x, h * 0.75, (x + w) / 2.0, h, w, h).take(); + Geometry { paths: vec![stroke_only(l)], text_rect: full, handles: vec![], sites: vec![Point::ZERO, Point::new(w, h)] } + } + _ => return None, + }; + Some(g) +} diff --git a/crates/drawingml/src/preset/builder.rs b/crates/drawingml/src/preset/builder.rs new file mode 100644 index 000000000..701d15242 --- /dev/null +++ b/crates/drawingml/src/preset/builder.rs @@ -0,0 +1,344 @@ +use super::catalog::{FillMode, Geometry, Handle, SubPath}; +use super::{Arc, BezPath, Point, Rect, Vec2}; + +// ---------- path builder ---------- + +pub(super) struct Pb { + pub(super) p: BezPath, + pub(super) cur: Point, +} + +impl Pb { + pub(super) fn new() -> Self { + Pb { p: BezPath::new(), cur: Point::ZERO } + } + pub(super) fn m(&mut self, x: f64, y: f64) -> &mut Self { + self.cur = Point::new(x, y); + self.p.move_to(self.cur); + self + } + pub(super) fn l(&mut self, x: f64, y: f64) -> &mut Self { + self.cur = Point::new(x, y); + self.p.line_to(self.cur); + self + } + pub(super) fn c(&mut self, x1: f64, y1: f64, x2: f64, y2: f64, x: f64, y: f64) -> &mut Self { + self.cur = Point::new(x, y); + self.p.curve_to(Point::new(x1, y1), Point::new(x2, y2), self.cur); + self + } + pub(super) fn q(&mut self, x1: f64, y1: f64, x: f64, y: f64) -> &mut Self { + self.cur = Point::new(x, y); + self.p.quad_to(Point::new(x1, y1), self.cur); + self + } + /// Elliptic arc continuing from the current point, which lies on the ellipse at parametric + /// angle `st` (degrees); sweeps `sw` degrees (positive = clockwise on screen). + pub(super) fn arc(&mut self, wr: f64, hr: f64, st: f64, sw: f64) -> &mut Self { + let (s, e) = (st.to_radians(), (st + sw).to_radians()); + let c = Point::new(self.cur.x - wr * s.cos(), self.cur.y - hr * s.sin()); + let a = Arc::new(c, Vec2::new(wr.abs(), hr.abs()), s, e - s, 0.0); + a.to_cubic_beziers(0.05, |p1, p2, p| { + self.p.curve_to(p1, p2, p); + }); + self.cur = Point::new(c.x + wr * e.cos(), c.y + hr * e.sin()); + self + } + /// A DrawingML `arcTo`: angles are where the ray from the ellipse's centre at that angle + /// meets it (not the ellipse's parameter), so elliptical arcs start and end where the + /// standard's shapes expect. + pub(super) fn arc_spec(&mut self, wr: f64, hr: f64, st: f64, sw: f64) -> &mut Self { + let par = |a: f64| { + let a = a.to_radians(); + (wr * a.sin()).atan2(hr * a.cos()) + }; + let t0 = par(st); + let mut d = par(st + sw) - t0; + let tau = std::f64::consts::TAU; + if sw.abs() >= 360.0 { + d = tau.copysign(sw); + } else { + while sw > 0.0 && d < 0.0 { + d += tau; + } + while sw < 0.0 && d > 0.0 { + d -= tau; + } + } + self.arc(wr, hr, t0.to_degrees(), d.to_degrees()) + } + pub(super) fn z(&mut self) -> &mut Self { + self.p.close_path(); + self + } + pub(super) fn poly(&mut self, pts: &[(f64, f64)]) -> &mut Self { + for (i, (x, y)) in pts.iter().enumerate() { + if i == 0 { + self.m(*x, *y); + } else { + self.l(*x, *y); + } + } + self.z() + } + pub(super) fn ellipse(&mut self, cx: f64, cy: f64, rx: f64, ry: f64) -> &mut Self { + self.m(cx + rx, cy); + self.arc(rx, ry, 0.0, 360.0); + self.z() + } + pub(super) fn take(&mut self) -> BezPath { + std::mem::take(&mut self.p) + } +} + +pub(super) fn fill(p: BezPath) -> SubPath { + SubPath { path: p, fill: FillMode::Norm, stroke: true, even_odd: false } +} + +pub(super) fn fill_eo(p: BezPath) -> SubPath { + SubPath { path: p, fill: FillMode::Norm, stroke: true, even_odd: true } +} + +pub(super) fn shade(p: BezPath, m: FillMode) -> SubPath { + SubPath { path: p, fill: m, stroke: true, even_odd: false } +} + +pub(super) fn stroke_only(p: BezPath) -> SubPath { + SubPath { path: p, fill: FillMode::None, stroke: true, even_odd: false } +} + +pub(super) fn fill_only(p: BezPath) -> SubPath { + SubPath { path: p, fill: FillMode::Norm, stroke: false, even_odd: false } +} + +/// Parametric angle on an rx×ry ellipse for a visual angle `deg` (file angles are visual). +pub(super) fn vis(deg: f64, rx: f64, ry: f64) -> f64 { + let t = deg.to_radians(); + (rx * t.sin()).atan2(ry * t.cos()).to_degrees() +} + +pub(super) fn polygon_regular(n: usize, w: f64, h: f64, phase: f64) -> Vec<(f64, f64)> { + let pts: Vec<(f64, f64)> = (0..n) + .map(|i| { + let a = (phase + 360.0 * i as f64 / n as f64).to_radians(); + (a.cos(), a.sin()) + }) + .collect(); + normalize(&pts, w, h) +} + +/// Scale `pts` so their bounding box fills 0..w × 0..h. +fn normalize(pts: &[(f64, f64)], w: f64, h: f64) -> Vec<(f64, f64)> { + let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for (x, y) in pts { + x0 = x0.min(*x); + y0 = y0.min(*y); + x1 = x1.max(*x); + y1 = y1.max(*y); + } + let sx = if x1 - x0 > 1e-12 { w / (x1 - x0) } else { 0.0 }; + let sy = if y1 - y0 > 1e-12 { h / (y1 - y0) } else { 0.0 }; + pts.iter().map(|(x, y)| ((x - x0) * sx, (y - y0) * sy)).collect() +} + +/// An n-pointed star as ECMA-376 draws it: points on an ellipse around (hc, vc · vf) with radii +/// half the box stretched by the preset's `hf` / `vf` (so a five-pointed star fills its box and +/// looks regular in a square), the inner corners `inner` of the way out. +pub(super) fn star(n: usize, w: f64, h: f64, inner: f64) -> Vec<(f64, f64)> { + let (hf, vf) = match n { + 5 => (1.05146, 1.10557), + 6 => (1.15470, 1.0), + 7 => (1.02572, 1.05210), + 10 => (1.05146, 1.0), + _ => (1.0, 1.0), + }; + let (rx, ry, cy) = (w / 2.0 * hf, h / 2.0 * vf, h / 2.0 * vf); + let mut pts = Vec::with_capacity(n * 2); + for i in 0..n * 2 { + let a = (-90.0 + 180.0 * i as f64 / n as f64).to_radians(); + let r = if i % 2 == 0 { 1.0 } else { inner }; + pts.push((w / 2.0 + r * rx * a.cos(), cy + r * ry * a.sin())); + } + pts +} + +/// The text rectangle ECMA-376's presetShapeDefinitions give a star (`inner` is the adjust value +/// over 50000), where known: star4's is the square inside its inner points, star5's spans the +/// inner pentagon from its upper side points to its bottom inner point. +pub(super) fn star_text_rect(n: usize, w: f64, h: f64, inner: f64) -> Option { + let (hc, vc) = (w / 2.0, h / 2.0); + match n { + 4 => { + let (dx, dy) = (hc * inner * 45f64.to_radians().cos(), vc * inner * 45f64.to_radians().sin()); + Some(Rect::new(hc - dx, vc - dy, hc + dx, vc + dy)) + } + 5 => { + let (swd2, shd2, svc) = (hc * 1.05146, vc * 1.10557, vc * 1.10557); + let (iwd2, ihd2) = (swd2 * inner, shd2 * inner); + let sdx1 = iwd2 * 18f64.to_radians().cos(); + let sdy1 = ihd2 * 54f64.to_radians().sin(); + Some(Rect::new(hc - sdx1, svc - sdy1, hc + sdx1, svc + ihd2)) + } + _ => None, + } +} + +#[derive(Clone, Copy)] +pub(super) enum Corner { + Square, + Round(f64), + Snip(f64), +} + +/// A rectangle with each corner (TL, TR, BR, BL) square, rounded or snipped. +pub(super) fn corner_rect(w: f64, h: f64, c: [Corner; 4]) -> BezPath { + let mut b = Pb::new(); + let size = |k: Corner| match k { + Corner::Square => 0.0, + Corner::Round(r) | Corner::Snip(r) => r.max(0.0), + }; + let s0 = size(c[0]); + b.m(s0, 0.0); + // top edge → TR + let s1 = size(c[1]); + b.l(w - s1, 0.0); + match c[1] { + Corner::Round(r) if r > 0.0 => { + b.arc(r, r, 270.0, 90.0); + } + Corner::Snip(_) => { + b.l(w, s1); + } + _ => {} + } + let s2 = size(c[2]); + b.l(w, h - s2); + match c[2] { + Corner::Round(r) if r > 0.0 => { + b.arc(r, r, 0.0, 90.0); + } + Corner::Snip(_) => { + b.l(w - s2, h); + } + _ => {} + } + let s3 = size(c[3]); + b.l(s3, h); + match c[3] { + Corner::Round(r) if r > 0.0 => { + b.arc(r, r, 90.0, 90.0); + } + Corner::Snip(_) => { + b.l(0.0, h - s3); + } + _ => {} + } + b.l(0.0, s0); + match c[0] { + Corner::Round(r) if r > 0.0 => { + b.arc(r, r, 180.0, 90.0); + } + Corner::Snip(_) => { + b.l(s0, 0.0); + } + _ => {} + } + b.z(); + b.take() +} + +pub(super) fn mid_sites(w: f64, h: f64) -> Vec { + vec![Point::new(w / 2.0, 0.0), Point::new(0.0, h / 2.0), Point::new(w / 2.0, h), Point::new(w, h / 2.0)] +} + +/// Orientation for single-axis arrows built along +x. +#[derive(Clone, Copy)] +pub(super) enum Dir { + Right, + Left, + Up, + Down, +} + +/// Map canonical (u along the arrow's length L, v across thickness T) to shape space. +pub(super) fn orient(d: Dir, w: f64, h: f64, pts: &[(f64, f64)]) -> Vec<(f64, f64)> { + pts.iter() + .map(|&(u, v)| match d { + Dir::Right => (u, v), + Dir::Left => (w - u, v), + Dir::Up => (v, h - u), + Dir::Down => (v, u), + }) + .collect() +} + +pub(super) fn len_thick(d: Dir, w: f64, h: f64) -> (f64, f64) { + match d { + Dir::Right | Dir::Left => (w, h), + Dir::Up | Dir::Down => (h, w), + } +} + +pub(super) fn arrow(d: Dir, w: f64, h: f64, a1: f64, a2: f64) -> Geometry { + let (l, t) = len_thick(d, w, h); + let ss = l.min(t); + let a1 = a1.clamp(0.0, 100000.0) / 100000.0; + let dx = (ss * a2.clamp(0.0, 100000.0 * l / ss.max(1e-9)) / 100000.0).min(l); + let y1 = t / 2.0 - t * a1 / 2.0; + let y2 = t / 2.0 + t * a1 / 2.0; + let pts = [(0.0, y1), (l - dx, y1), (l - dx, 0.0), (l, t / 2.0), (l - dx, t), (l - dx, y2), (0.0, y2)]; + let pts = orient(d, w, h, &pts); + let tr = orient(d, w, h, &[(0.0, y1), (l - dx + dx * (y1 / (t / 2.0).max(1e-9)), y2)]); + let mut text = Rect::from_points(tr[0], tr[1]); + text = text.abs(); + let mut g = Geometry { paths: vec![fill(Pb::new().poly(&pts).take())], text_rect: text, handles: vec![], sites: mid_sites(w, h) }; + // handle 0: shaft thickness on the head base; handle 1: head length. + let o = orient(d, w, h, &[(l - dx, t / 2.0), (l, t / 2.0)]); + let (o0, o1) = (Point::new(o[0].0, o[0].1), Point::new(o[1].0, o[1].1)); + let across = match d { + Dir::Right | Dir::Left => Vec2::new(0.0, -t / 2.0 / 100000.0), + Dir::Up | Dir::Down => Vec2::new(-t / 2.0 / 100000.0, 0.0), + }; + let along = (o0 - o1) * (ss / 100000.0 / dx.max(1e-9)); + g.handles.push(Handle::new(0, o0, across, a1 * 100000.0, 0.0, 100000.0)); + if dx > 1e-9 { + g.handles.push(Handle::new(1, o1, along, a2, 0.0, 100000.0 * l / ss.max(1e-9))); + } + g +} + +pub(super) fn double_arrow(vertical: bool, w: f64, h: f64, a1: f64, a2: f64) -> Geometry { + let (l, t) = if vertical { (h, w) } else { (w, h) }; + let ss = l.min(t); + let a1 = a1.clamp(0.0, 100000.0) / 100000.0; + let dx = (ss * a2.max(0.0) / 100000.0).min(l / 2.0); + let y1 = t / 2.0 - t * a1 / 2.0; + let y2 = t / 2.0 + t * a1 / 2.0; + let pts = [(0.0, t / 2.0), (dx, 0.0), (dx, y1), (l - dx, y1), (l - dx, 0.0), (l, t / 2.0), (l - dx, t), (l - dx, y2), (dx, y2), (dx, t)]; + let pts = orient(if vertical { Dir::Down } else { Dir::Right }, w, h, &pts); + let r = if vertical { Rect::new(y1, dx, y2, h - dx) } else { Rect::new(dx, y1, w - dx, y2) }; + Geometry { paths: vec![fill(Pb::new().poly(&pts).take())], text_rect: r, handles: vec![], sites: mid_sites(w, h) } +} + +/// Points given in 21600ths of the box (as ECMA-376 defines many fixed shapes), scaled to it. +pub(super) fn spec_pts(pts: &[(i32, i32)], w: f64, h: f64) -> Vec<(f64, f64)> { + pts.iter().map(|(x, y)| (w * *x as f64 / 21600.0, h * *y as f64 / 21600.0)).collect() +} + +pub(super) fn simple(path: BezPath, text: Rect, w: f64, h: f64) -> Geometry { + Geometry { paths: vec![fill(path)], text_rect: text, handles: vec![], sites: mid_sites(w, h) } +} + +pub(super) fn ellipse_sites(w: f64, h: f64) -> Vec { + (0..8) + .map(|i| { + let a = (-90.0 - 45.0 * i as f64).to_radians(); + Point::new(w / 2.0 + w / 2.0 * a.cos(), h / 2.0 + h / 2.0 * a.sin()) + }) + .collect() +} + +pub(super) fn tip_handle(w: f64, h: f64, tip: Point) -> Handle { + // A free 2-D handle is represented as index 0 with zero direction; callers move both values. + Handle { index: 0, pos: tip, origin: Point::new(w / 2.0, h / 2.0), dir: Vec2::new(w / 1e5, h / 1e5), min: -1e6, max: 1e6, angle: false } +} diff --git a/crates/drawingml/src/preset/catalog.rs b/crates/drawingml/src/preset/catalog.rs new file mode 100644 index 000000000..ca090f94d --- /dev/null +++ b/crates/drawingml/src/preset/catalog.rs @@ -0,0 +1,391 @@ +use super::builder::vis; +use super::{BezPath, Deserialize, Point, Rect, Serialize, Vec2}; + +/// How a sub-path is filled. `Lighten`/`Darken` variants shade the shape's fill (3-D looking faces). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FillMode { + Norm, + None, + Lighten, + LightenLess, + Darken, + DarkenLess, +} + +#[derive(Clone, Debug)] +pub struct SubPath { + pub path: BezPath, + pub fill: FillMode, + pub stroke: bool, + /// Even-odd fill (holes in donuts and frames). + pub even_odd: bool, +} + +/// A draggable adjust handle (the yellow dot). `pos = origin + dir * value`; dragging projects +/// the pointer onto `dir`. An `angle` handle instead measures the pointer's angle about `origin` +/// (1/60 000 degree, y down, 0° at +x). +#[derive(Clone, Debug, PartialEq)] +pub struct Handle { + pub index: usize, + pub pos: Point, + pub origin: Point, + pub dir: Vec2, + pub min: f64, + pub max: f64, + pub angle: bool, +} + +impl Handle { + pub(super) fn new(index: usize, origin: Point, dir: Vec2, value: f64, min: f64, max: f64) -> Self { + Handle { index, pos: origin + dir * value, origin, dir, min, max, angle: false } + } + /// An angle handle on the `rx`×`ry` ellipse around `center` at visual angle `value`. + pub(super) fn angle(index: usize, center: Point, rx: f64, ry: f64, value: f64) -> Self { + let t = vis(value / 60000.0, rx, ry).to_radians(); + let pos = Point::new(center.x + rx * t.cos(), center.y + ry * t.sin()); + Handle { index, pos, origin: center, dir: Vec2::ZERO, min: 0.0, max: 21600000.0, angle: true } + } + /// The adjust value for a pointer at `p` (shape-local space). + pub fn value_at(&self, p: Point) -> f64 { + if self.angle { + let d = p - self.origin; + if !(d.x.is_finite() && d.y.is_finite()) || d.hypot() < 1e-9 { + return self.min; + } + let v = (d.y.atan2(d.x).to_degrees().rem_euclid(360.0) * 60000.0).round(); + return if v >= self.max { self.min } else { v }; + } + let l2 = self.dir.hypot2(); + if l2 < 1e-18 { + return self.min; + } + let v = (p - self.origin).dot(self.dir) / l2; + if !v.is_finite() { + return self.min; + } + v.clamp(self.min.min(self.max), self.max.max(self.min)).round() + } +} + +#[derive(Clone, Debug)] +pub struct Geometry { + pub paths: Vec, + /// Where text goes, in shape-local space. + pub text_rect: Rect, + pub handles: Vec, + /// Connector glue points, shape-local. + pub sites: Vec, +} + +impl Geometry { + /// The union outline used for hit testing and selection (all filled or stroked sub-paths). + pub fn outline(&self) -> BezPath { + let mut p = BezPath::new(); + for s in &self.paths { + p.extend(s.path.iter()); + } + p + } + /// Whether nothing is filled (lines, connectors, arcs, brackets). + pub fn is_open(&self) -> bool { + self.paths.iter().all(|s| s.fill == FillMode::None) + } +} + +/// Gallery category, as in the Shapes menu. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Category { + Lines, + Rectangles, + BasicShapes, + BlockArrows, + EquationShapes, + Flowchart, + StarsAndBanners, + Callouts, + ActionButtons, +} + +impl Category { + pub const ALL: [Category; 9] = [ + Category::Lines, + Category::Rectangles, + Category::BasicShapes, + Category::BlockArrows, + Category::EquationShapes, + Category::Flowchart, + Category::StarsAndBanners, + Category::Callouts, + Category::ActionButtons, + ]; + pub fn label(self) -> &'static str { + match self { + Category::Lines => "Lines", + Category::Rectangles => "Rectangles", + Category::BasicShapes => "Basic Shapes", + Category::BlockArrows => "Block Arrows", + Category::EquationShapes => "Equation Shapes", + Category::Flowchart => "Flowchart", + Category::StarsAndBanners => "Stars and Banners", + Category::Callouts => "Callouts", + Category::ActionButtons => "Action Buttons", + } + } +} + +pub struct PresetInfo { + pub name: &'static str, + pub label: &'static str, + pub category: Category, + /// Default adjust values. + pub defaults: &'static [f64], +} + +macro_rules! p { + ($n:literal, $l:literal, $c:ident, [$($d:expr),*]) => { + PresetInfo { name: $n, label: $l, category: Category::$c, defaults: &[$($d as f64),*] } + }; +} + +/// Every preset we generate, in gallery order. +pub static CATALOG: &[PresetInfo] = &[ + p!("line", "Line", Lines, []), + p!("straightConnector1", "Arrow", Lines, []), + p!("bentConnector3", "Connector: Elbow", Lines, [50000]), + p!("curvedConnector3", "Connector: Curved", Lines, [50000]), + p!("bentConnector2", "Connector: Elbow (one bend)", Lines, []), + p!("lineInv", "Line: Rising", Lines, []), + p!("rect", "Rectangle", Rectangles, []), + p!("roundRect", "Rectangle: Rounded Corners", Rectangles, [16667]), + p!("snip1Rect", "Rectangle: Single Corner Snipped", Rectangles, [16667]), + p!("snip2SameRect", "Rectangle: Top Corners Snipped", Rectangles, [16667, 0]), + p!("snip2DiagRect", "Rectangle: Diagonal Corners Snipped", Rectangles, [0, 16667]), + p!("snipRoundRect", "Rectangle: Top Corners One Rounded and One Snipped", Rectangles, [16667, 16667]), + p!("round1Rect", "Rectangle: Single Corner Rounded", Rectangles, [16667]), + p!("round2SameRect", "Rectangle: Top Corners Rounded", Rectangles, [16667, 0]), + p!("round2DiagRect", "Rectangle: Diagonal Corners Rounded", Rectangles, [16667, 0]), + p!("textBox", "Text Box", BasicShapes, []), + p!("ellipse", "Oval", BasicShapes, []), + p!("triangle", "Isosceles Triangle", BasicShapes, [50000]), + p!("rtTriangle", "Right Triangle", BasicShapes, []), + p!("parallelogram", "Parallelogram", BasicShapes, [25000]), + p!("trapezoid", "Trapezoid", BasicShapes, [25000]), + p!("nonIsoscelesTrapezoid", "Trapezoid: Uneven Sides", BasicShapes, [25000, 25000]), + p!("diamond", "Diamond", BasicShapes, []), + p!("pentagon", "Regular Pentagon", BasicShapes, []), + p!("hexagon", "Hexagon", BasicShapes, [25000]), + p!("heptagon", "Heptagon", BasicShapes, []), + p!("octagon", "Octagon", BasicShapes, [29289]), + p!("decagon", "Decagon", BasicShapes, []), + p!("dodecagon", "Dodecagon", BasicShapes, []), + p!("pie", "Partial Circle", BasicShapes, [0, 16200000]), + p!("pieWedge", "Quarter Wedge", BasicShapes, []), + p!("chord", "Chord", BasicShapes, [2700000, 16200000]), + p!("teardrop", "Teardrop", BasicShapes, [100000]), + p!("frame", "Frame", BasicShapes, [12500]), + p!("cornerTabs", "Corner Tabs: Triangles", BasicShapes, []), + p!("squareTabs", "Corner Tabs: Squares", BasicShapes, []), + p!("plaqueTabs", "Corner Tabs: Quarter Circles", BasicShapes, []), + p!("halfFrame", "Half Frame", BasicShapes, [33333, 33333]), + p!("corner", "L-Shape", BasicShapes, [50000, 50000]), + p!("diagStripe", "Diagonal Stripe", BasicShapes, [50000]), + p!("plus", "Cross", BasicShapes, [25000]), + p!("plaque", "Plaque", BasicShapes, [16667]), + p!("can", "Cylinder", BasicShapes, [25000]), + p!("cube", "Cube", BasicShapes, [25000]), + p!("bevel", "Bevel", BasicShapes, [12500]), + p!("donut", "Donut", BasicShapes, [25000]), + p!("noSmoking", "\"Not Allowed\" Symbol", BasicShapes, [18750]), + p!("blockArc", "Block Arc", BasicShapes, [10800000, 0, 25000]), + p!("foldedCorner", "Folded Corner", BasicShapes, [16667]), + p!("smileyFace", "Smiley Face", BasicShapes, [4653]), + p!("heart", "Heart", BasicShapes, []), + p!("lightningBolt", "Lightning Bolt", BasicShapes, []), + p!("sun", "Sun", BasicShapes, [25000]), + p!("moon", "Moon", BasicShapes, [50000]), + p!("cloud", "Cloud", BasicShapes, []), + p!("gear6", "Gear: 6 Teeth", BasicShapes, [15000, 3526]), + p!("gear9", "Gear: 9 Teeth", BasicShapes, [10000, 1763]), + p!("funnel", "Funnel", BasicShapes, []), + p!("arc", "Arc", BasicShapes, [16200000, 0]), + p!("bracketPair", "Double Bracket", BasicShapes, [16667]), + p!("bracePair", "Double Brace", BasicShapes, [8333]), + p!("leftBracket", "Left Bracket", BasicShapes, [8333]), + p!("rightBracket", "Right Bracket", BasicShapes, [8333]), + p!("leftBrace", "Left Brace", BasicShapes, [8333, 50000]), + p!("rightBrace", "Right Brace", BasicShapes, [8333, 50000]), + p!("chartX", "Chart Marker: Cross Lines", BasicShapes, []), + p!("chartStar", "Chart Marker: Star Lines", BasicShapes, []), + p!("chartPlus", "Chart Marker: Plus Lines", BasicShapes, []), + p!("rightArrow", "Arrow: Right", BlockArrows, [50000, 50000]), + p!("leftArrow", "Arrow: Left", BlockArrows, [50000, 50000]), + p!("upArrow", "Arrow: Up", BlockArrows, [50000, 50000]), + p!("downArrow", "Arrow: Down", BlockArrows, [50000, 50000]), + p!("leftRightArrow", "Arrow: Left-Right", BlockArrows, [50000, 50000]), + p!("upDownArrow", "Arrow: Up-Down", BlockArrows, [50000, 50000]), + p!("quadArrow", "Arrow: Quad", BlockArrows, [22500, 22500, 22500]), + p!("leftRightUpArrow", "Arrow: Left-Right-Up", BlockArrows, [25000, 25000, 25000]), + p!("bentArrow", "Arrow: Bent", BlockArrows, [25000, 25000, 25000, 43750]), + p!("uturnArrow", "Arrow: U-Turn", BlockArrows, [25000, 25000, 25000, 43750, 75000]), + p!("curvedRightArrow", "Arrow: Curved Right", BlockArrows, [25000, 50000, 25000]), + p!("curvedLeftArrow", "Arrow: Curved Left", BlockArrows, [25000, 50000, 25000]), + p!("curvedUpArrow", "Arrow: Curved Up", BlockArrows, [25000, 50000, 25000]), + p!("curvedDownArrow", "Arrow: Curved Down", BlockArrows, [25000, 50000, 25000]), + p!("leftUpArrow", "Arrow: Left-Up", BlockArrows, [25000, 25000, 25000]), + p!("bentUpArrow", "Arrow: Bent-Up", BlockArrows, [25000, 25000, 25000]), + p!("stripedRightArrow", "Arrow: Striped Right", BlockArrows, [50000, 50000]), + p!("notchedRightArrow", "Arrow: Notched Right", BlockArrows, [50000, 50000]), + p!("homePlate", "Arrow: Pentagon", BlockArrows, [50000]), + p!("chevron", "Arrow: Chevron", BlockArrows, [50000]), + p!("circularArrow", "Arrow: Circular", BlockArrows, [12500, 1142319, 20457681, 10800000, 12500]), + p!("leftCircularArrow", "Arrow: Circular Counter-clockwise", BlockArrows, [12500, -1142319, 1142319, 10800000, 12500]), + p!("leftRightCircularArrow", "Arrow: Circular Two-headed", BlockArrows, [12500, 1142319, 20457681, 11942319, 12500]), + p!("swooshArrow", "Arrow: Swoosh", BlockArrows, [25000, 16667]), + p!("rightArrowCallout", "Callout: Right Arrow", BlockArrows, [25000, 25000, 25000, 64977]), + p!("downArrowCallout", "Callout: Down Arrow", BlockArrows, [25000, 25000, 25000, 64977]), + p!("leftArrowCallout", "Callout: Left Arrow", BlockArrows, [25000, 25000, 25000, 64977]), + p!("upArrowCallout", "Callout: Up Arrow", BlockArrows, [25000, 25000, 25000, 64977]), + p!("leftRightArrowCallout", "Callout: Left-Right Arrow", BlockArrows, [25000, 25000, 25000, 48123]), + p!("upDownArrowCallout", "Callout: Up-Down Arrow", BlockArrows, [25000, 25000, 25000, 48123]), + p!("quadArrowCallout", "Callout: Quad Arrow", BlockArrows, [18515, 18515, 18515, 48123]), + p!("mathPlus", "Plus Sign", EquationShapes, [23520]), + p!("mathMinus", "Minus Sign", EquationShapes, [23520]), + p!("mathMultiply", "Multiplication Sign", EquationShapes, [23520]), + p!("mathDivide", "Division Sign", EquationShapes, [23520, 5880, 11760]), + p!("mathEqual", "Equals", EquationShapes, [23520, 11760]), + p!("mathNotEqual", "Not Equal", EquationShapes, [23520, 6600000, 11760]), + p!("flowChartProcess", "Flowchart: Process", Flowchart, []), + p!("flowChartAlternateProcess", "Flowchart: Alternate Process", Flowchart, []), + p!("flowChartDecision", "Flowchart: Decision", Flowchart, []), + p!("flowChartInputOutput", "Flowchart: Data", Flowchart, []), + p!("flowChartPredefinedProcess", "Flowchart: Predefined Process", Flowchart, []), + p!("flowChartInternalStorage", "Flowchart: Internal Storage", Flowchart, []), + p!("flowChartDocument", "Flowchart: Document", Flowchart, []), + p!("flowChartMultidocument", "Flowchart: Multidocument", Flowchart, []), + p!("flowChartTerminator", "Flowchart: Terminator", Flowchart, []), + p!("flowChartPreparation", "Flowchart: Preparation", Flowchart, []), + p!("flowChartManualInput", "Flowchart: Manual Input", Flowchart, []), + p!("flowChartManualOperation", "Flowchart: Manual Operation", Flowchart, []), + p!("flowChartConnector", "Flowchart: Connector", Flowchart, []), + p!("flowChartOffpageConnector", "Flowchart: Off-page Connector", Flowchart, []), + p!("flowChartPunchedCard", "Flowchart: Card", Flowchart, []), + p!("flowChartPunchedTape", "Flowchart: Punched Tape", Flowchart, []), + p!("flowChartSummingJunction", "Flowchart: Summing Junction", Flowchart, []), + p!("flowChartOr", "Flowchart: Or", Flowchart, []), + p!("flowChartCollate", "Flowchart: Collate", Flowchart, []), + p!("flowChartSort", "Flowchart: Sort", Flowchart, []), + p!("flowChartExtract", "Flowchart: Extract", Flowchart, []), + p!("flowChartMerge", "Flowchart: Merge", Flowchart, []), + p!("flowChartOnlineStorage", "Flowchart: Stored Data", Flowchart, []), + p!("flowChartDelay", "Flowchart: Delay", Flowchart, []), + p!("flowChartMagneticTape", "Flowchart: Sequential Access Storage", Flowchart, []), + p!("flowChartMagneticDisk", "Flowchart: Magnetic Disk", Flowchart, []), + p!("flowChartMagneticDrum", "Flowchart: Direct Access Storage", Flowchart, []), + p!("flowChartDisplay", "Flowchart: Display", Flowchart, []), + p!("flowChartOfflineStorage", "Flowchart: Offline Storage", Flowchart, []), + p!("irregularSeal1", "Explosion: 8 Points", StarsAndBanners, []), + p!("irregularSeal2", "Explosion: 14 Points", StarsAndBanners, []), + p!("star4", "Star: 4 Points", StarsAndBanners, [12500]), + p!("star5", "Star: 5 Points", StarsAndBanners, [19098]), + p!("star6", "Star: 6 Points", StarsAndBanners, [28868]), + p!("star7", "Star: 7 Points", StarsAndBanners, [34601]), + p!("star8", "Star: 8 Points", StarsAndBanners, [37500]), + p!("star10", "Star: 10 Points", StarsAndBanners, [42533]), + p!("star12", "Star: 12 Points", StarsAndBanners, [37500]), + p!("star16", "Star: 16 Points", StarsAndBanners, [37500]), + p!("star24", "Star: 24 Points", StarsAndBanners, [37500]), + p!("star32", "Star: 32 Points", StarsAndBanners, [37500]), + p!("ribbon2", "Ribbon: Tilted Up", StarsAndBanners, [16667, 50000]), + p!("ribbon", "Ribbon: Tilted Down", StarsAndBanners, [16667, 50000]), + p!("ellipseRibbon2", "Ribbon: Curved and Tilted Up", StarsAndBanners, [25000, 50000, 12500]), + p!("ellipseRibbon", "Ribbon: Curved and Tilted Down", StarsAndBanners, [25000, 50000, 12500]), + p!("leftRightRibbon", "Ribbon: Left-Right Fold", StarsAndBanners, [50000, 50000, 16667]), + p!("verticalScroll", "Scroll: Vertical", StarsAndBanners, [12500]), + p!("horizontalScroll", "Scroll: Horizontal", StarsAndBanners, [12500]), + p!("wave", "Wave", StarsAndBanners, [12500, 0]), + p!("doubleWave", "Double Wave", StarsAndBanners, [6250, 0]), + p!("wedgeRectCallout", "Speech Bubble: Rectangle", Callouts, [-20833, 62500]), + p!("wedgeRoundRectCallout", "Speech Bubble: Rectangle with Corners Rounded", Callouts, [-20833, 62500, 16667]), + p!("wedgeEllipseCallout", "Speech Bubble: Oval", Callouts, [-20833, 62500]), + p!("cloudCallout", "Thought Bubble: Cloud", Callouts, [-20833, 62500]), + p!("borderCallout1", "Callout: Line", Callouts, [18750, -8333, 112500, -38333]), + p!("borderCallout2", "Callout: Bent Line", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]), + p!("borderCallout3", "Callout: Double Bent Line", Callouts, [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333]), + p!("accentCallout1", "Callout: Line with Accent Bar", Callouts, [18750, -8333, 112500, -38333]), + p!("accentCallout2", "Callout: Bent Line with Accent Bar", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]), + p!("accentCallout3", "Callout: Double Bent Line with Accent Bar", Callouts, [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333]), + p!("callout1", "Callout: Line with No Border", Callouts, [18750, -8333, 112500, -38333]), + p!("callout2", "Callout: Bent Line with No Border", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]), + p!("callout3", "Callout: Double Bent Line with No Border", Callouts, [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333]), + p!("accentBorderCallout1", "Callout: Line with Border and Accent Bar", Callouts, [18750, -8333, 112500, -38333]), + p!("accentBorderCallout2", "Callout: Bent Line with Border and Accent Bar", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]), + p!( + "accentBorderCallout3", + "Callout: Double Bent Line with Border and Accent Bar", + Callouts, + [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333] + ), + p!("actionButtonBackPrevious", "Action Button: Go Back or Previous", ActionButtons, []), + p!("actionButtonForwardNext", "Action Button: Go Forward or Next", ActionButtons, []), + p!("actionButtonBeginning", "Action Button: Go to Beginning", ActionButtons, []), + p!("actionButtonEnd", "Action Button: Go to End", ActionButtons, []), + p!("actionButtonHome", "Action Button: Go Home", ActionButtons, []), + p!("actionButtonInformation", "Action Button: Get Information", ActionButtons, []), + p!("actionButtonReturn", "Action Button: Go Back", ActionButtons, []), + p!("actionButtonMovie", "Action Button: Video", ActionButtons, []), + p!("actionButtonDocument", "Action Button: Document", ActionButtons, []), + p!("actionButtonSound", "Action Button: Sound", ActionButtons, []), + p!("actionButtonHelp", "Action Button: Help", ActionButtons, []), + p!("actionButtonBlank", "Action Button: Custom", ActionButtons, []), +]; + +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") + || name.starts_with("curvedConnector") +} diff --git a/crates/drawingml/src/preset/extra.rs b/crates/drawingml/src/preset/extra.rs new file mode 100644 index 000000000..8c61e5f11 --- /dev/null +++ b/crates/drawingml/src/preset/extra.rs @@ -0,0 +1,799 @@ +//! More preset outlines: line callouts, arrow callouts, curved and circular arrows, curved +//! ribbons, gears, tabs and chart markers. Like the rest of `preset`, every outline is our own +//! construction, fitted to how the shapes look and to what their adjust values mean. + +use super::{ + Corner, Dir, FillMode, Geometry, Handle, Pb, Point, Rect, Vec2, corner_rect, fill, fill_eo, fill_only, mid_sites, orient, shade, simple, + stroke_only, vis, +}; + +/// Builds the presets this module knows; `None` for any other name. +pub(super) fn build(name: &str, w: f64, h: f64, a: &dyn Fn(usize) -> f64) -> Option { + let ss = w.min(h); + let full = Rect::new(0.0, 0.0, w, h); + let mut b = Pb::new(); + let g = match name { + "lineInv" => { + let l = b.m(0.0, h).l(w, 0.0).take(); + Geometry { paths: vec![stroke_only(l)], text_rect: full, handles: vec![], sites: vec![Point::new(0.0, h), Point::new(w, 0.0)] } + } + "nonIsoscelesTrapezoid" => { + let max = 50000.0 * w / ss.max(1e-9); + let (v1, v2) = (a(0).clamp(0.0, max), a(1).clamp(0.0, max)); + let (d1, d2) = (ss * v1 / 1e5, ss * v2 / 1e5); + let mut g = simple(b.poly(&[(0.0, h), (d1, 0.0), (w - d2, 0.0), (w, h)]).take(), Rect::new(d1 * 0.66, h * 0.2, w - d2 * 0.66, h), w, h); + g.handles.push(Handle::new(0, Point::ZERO, Vec2::new(ss / 1e5, 0.0), v1, 0.0, max)); + g.handles.push(Handle::new(1, Point::new(w, 0.0), Vec2::new(-ss / 1e5, 0.0), v2, 0.0, max)); + g + } + "pieWedge" => { + b.m(0.0, h).arc(w, h, 180.0, 90.0).l(w, h).z(); + simple(b.take(), Rect::new(w * 0.29289, h * 0.29289, w, h), w, h) + } + "flowChartOfflineStorage" => { + let tri = b.poly(&[(0.0, 0.0), (w, 0.0), (w / 2.0, h)]).take(); + let bar = Pb::new().m(w * 0.4, h * 0.8).l(w * 0.6, h * 0.8).take(); + Geometry { + paths: vec![fill(tri), stroke_only(bar)], + text_rect: Rect::new(w / 4.0, 0.0, w * 0.75, h * 0.8), + handles: vec![], + sites: vec![Point::new(w / 2.0, 0.0), Point::new(w / 4.0, h / 2.0), Point::new(w / 2.0, h), Point::new(w * 0.75, h / 2.0)], + } + } + "chartX" | "chartStar" | "chartPlus" => { + let body = corner_rect(w, h, [Corner::Square; 4]); + let mut l = Pb::new(); + if name != "chartPlus" { + l.m(0.0, 0.0).l(w, h).m(0.0, h).l(w, 0.0); + } + if name != "chartX" { + l.m(w / 2.0, 0.0).l(w / 2.0, h); + } + if name == "chartPlus" { + l.m(0.0, h / 2.0).l(w, h / 2.0); + } + Geometry { paths: vec![fill_only(body), stroke_only(l.take())], text_rect: full, handles: vec![], sites: mid_sites(w, h) } + } + "cornerTabs" | "squareTabs" | "plaqueTabs" => { + // Each tab's size is a twentieth of the diagonal. + let d = (w.hypot(h) / 20.0).min(w / 2.0).min(h / 2.0); + match name { + "cornerTabs" => { + b.poly(&[(0.0, 0.0), (d, 0.0), (0.0, d)]); + b.poly(&[(w - d, 0.0), (w, 0.0), (w, d)]); + b.poly(&[(w, h - d), (w, h), (w - d, h)]); + b.poly(&[(0.0, h - d), (d, h), (0.0, h)]); + } + "squareTabs" => { + b.poly(&[(0.0, 0.0), (d, 0.0), (d, d), (0.0, d)]); + b.poly(&[(w - d, 0.0), (w, 0.0), (w, d), (w - d, d)]); + b.poly(&[(w - d, h - d), (w, h - d), (w, h), (w - d, h)]); + b.poly(&[(0.0, h - d), (d, h - d), (d, h), (0.0, h)]); + } + _ => { + if d > 0.0 { + b.m(0.0, 0.0).l(d, 0.0).arc(d, d, 0.0, 90.0).z(); + b.m(w, 0.0).l(w, d).arc(d, d, 90.0, 90.0).z(); + b.m(w, h).l(w - d, h).arc(d, d, 180.0, 90.0).z(); + b.m(0.0, h).l(0.0, h - d).arc(d, d, 270.0, 90.0).z(); + } else { + b.m(0.0, 0.0).z(); + } + } + } + Geometry { paths: vec![fill(b.take())], text_rect: full.inset(-d), handles: vec![], sites: mid_sites(w, h) } + } + "callout1" + | "callout2" + | "callout3" + | "accentCallout1" + | "accentCallout2" + | "accentCallout3" + | "borderCallout2" + | "borderCallout3" + | "accentBorderCallout1" + | "accentBorderCallout2" + | "accentBorderCallout3" => { + let n = match name.chars().last() { + Some('2') => 2, + Some('3') => 3, + _ => 1, + }; + line_callout(w, h, a, n + 1, name.starts_with("accent"), name.contains("order")) + } + "leftRightArrowCallout" | "upDownArrowCallout" => { + let vertical = name == "upDownArrowCallout"; + let (l, t) = if vertical { (h, w) } else { (w, h) }; + let ss = l.min(t); + let head = (ss * a(1).max(0.0) / 1e5).min(t / 2.0); + let shaft = (ss * a(0).max(0.0) / 1e5).min(head * 2.0); + let hl = (ss * a(2).max(0.0) / 1e5).min(l / 2.0); + let boxl = (l * a(3).max(0.0) / 1e5).min(l - 2.0 * hl); + let (c, b0, b1) = (t / 2.0, (l - boxl) / 2.0, (l + boxl) / 2.0); + let pts = [ + (0.0, c), + (hl, c - head), + (hl, c - shaft / 2.0), + (b0, c - shaft / 2.0), + (b0, 0.0), + (b1, 0.0), + (b1, c - shaft / 2.0), + (l - hl, c - shaft / 2.0), + (l - hl, c - head), + (l, c), + (l - hl, c + head), + (l - hl, c + shaft / 2.0), + (b1, c + shaft / 2.0), + (b1, t), + (b0, t), + (b0, c + shaft / 2.0), + (hl, c + shaft / 2.0), + (hl, c + head), + ]; + let d = if vertical { Dir::Down } else { Dir::Right }; + let pts = orient(d, w, h, &pts); + let tr = orient(d, w, h, &[(b0, 0.0), (b1, t)]); + simple(b.poly(&pts).take(), Rect::from_points(Point::new(tr[0].0, tr[0].1), Point::new(tr[1].0, tr[1].1)).abs(), w, h) + } + "quadArrowCallout" => { + let head = (ss * a(1).max(0.0) / 1e5).min(ss / 2.0); + let shaft = (ss * a(0).max(0.0) / 1e5).min(head * 2.0); + let hl = (ss * a(2).max(0.0) / 1e5).min(ss / 2.0); + let k = a(3).clamp(0.0, 100000.0) / 1e5; + let (bw, bh) = ((w * k).min(w - 2.0 * hl).max(shaft), (h * k).min(h - 2.0 * hl).max(shaft)); + let (cx, cy) = (w / 2.0, h / 2.0); + let (bx0, bx1, by0, by1) = (cx - bw / 2.0, cx + bw / 2.0, cy - bh / 2.0, cy + bh / 2.0); + let (s2, hd) = (shaft / 2.0, head); + let pts = [ + (0.0, cy), + (hl, cy - hd), + (hl, cy - s2), + (bx0, cy - s2), + (bx0, by0), + (cx - s2, by0), + (cx - s2, hl), + (cx - hd, hl), + (cx, 0.0), + (cx + hd, hl), + (cx + s2, hl), + (cx + s2, by0), + (bx1, by0), + (bx1, cy - s2), + (w - hl, cy - s2), + (w - hl, cy - hd), + (w, cy), + (w - hl, cy + hd), + (w - hl, cy + s2), + (bx1, cy + s2), + (bx1, by1), + (cx + s2, by1), + (cx + s2, h - hl), + (cx + hd, h - hl), + (cx, h), + (cx - hd, h - hl), + (cx - s2, h - hl), + (cx - s2, by1), + (bx0, by1), + (bx0, cy + s2), + (hl, cy + s2), + (hl, cy + hd), + ]; + simple(b.poly(&pts).take(), Rect::new(bx0, by0, bx1, by1), w, h) + } + "curvedRightArrow" | "curvedLeftArrow" | "curvedUpArrow" | "curvedDownArrow" => curved_arrow(name, w, h, a(0), a(1), a(2)), + "swooshArrow" => swoosh(w, h, a(0), a(1)), + "ellipseRibbon" | "ellipseRibbon2" => ellipse_ribbon(name == "ellipseRibbon2", w, h, a(0), a(1), a(2)), + "leftRightRibbon" => left_right_ribbon(w, h, a(0), a(1), a(2)), + "gear6" | "gear9" => gear(if name == "gear6" { 6 } else { 9 }, w, h, a(0), a(1)), + "funnel" => funnel(w, h), + "circularArrow" | "leftCircularArrow" | "leftRightCircularArrow" => circular_arrow(name, w, h, [a(0), a(1), a(2), a(3), a(4)]), + _ => return None, + }; + Some(g) +} + +/// An ellipse-centred frame for the circular arrows: the band's middle runs on the ellipse +/// `(rx, ry)`, its edges `th/2` either side. +struct Ring { + c: Point, + rx: f64, + ry: f64, + th: f64, + hw: f64, +} + +impl Ring { + /// The point at visual angle `deg` on the ellipse grown by `d`. + fn at(&self, deg: f64, d: f64) -> Point { + let (ax, ay) = ((self.rx + d).max(0.0), (self.ry + d).max(0.0)); + let t = vis(deg, ax, ay).to_radians(); + Point::new(self.c.x + ax * t.cos(), self.c.y + ay * t.sin()) + } + /// Parametric angle (degrees) of `p` on the ellipse grown by `d`. + fn param(&self, p: Point, d: f64) -> f64 { + let (ax, ay) = ((self.rx + d).max(1e-9), (self.ry + d).max(1e-9)); + ((p.y - self.c.y) / ay).atan2((p.x - self.c.x) / ax).to_degrees() + } + /// Where the line `b + s·u` meets the ellipse grown by `d`, taking the crossing nearest `b` + /// on the side `sign` (+1 outwards, −1 inwards). + fn cross(&self, b: Point, u: Vec2, d: f64, sign: f64) -> Point { + let (ax, ay) = ((self.rx + d).max(1e-9), (self.ry + d).max(1e-9)); + let (px, py) = ((b.x - self.c.x) / ax, (b.y - self.c.y) / ay); + let (ux, uy) = (u.x / ax, u.y / ay); + let qa = ux * ux + uy * uy; + let qb = 2.0 * (px * ux + py * uy); + let qc = px * px + py * py - 1.0; + let disc = qb * qb - 4.0 * qa * qc; + if qa < 1e-18 || disc < 0.0 { + // The line misses (a long head on a thin ring): the edge then carries on from the + // base centre itself, as the reference does. + return b; + } + let r = disc.sqrt(); + let (s0, s1) = ((-qb - r) / (2.0 * qa), (-qb + r) / (2.0 * qa)); + let s = if sign > 0.0 { + if s0 >= 0.0 { s0 } else { s1 } + } else if s1 <= 0.0 { + s1 + } else { + s0 + }; + b + u * s + } + /// An arrow head whose base centre sits on the middle ellipse at `base` degrees and whose tip + /// sits there at `base + sweep`. The base runs along the line from the centre to the tip. + /// Returns (band outer edge, outer wing, tip, inner wing, band inner edge). + fn head(&self, base: f64, sweep: f64) -> [Point; 5] { + let b = self.at(base, 0.0); + let tip = self.at(base + sweep, 0.0); + let d = tip - self.c; + let u = if d.hypot() > 1e-12 { d / d.hypot() } else { Vec2::new(1.0, 0.0) }; + let half = self.th / 2.0; + [self.cross(b, u, half, 1.0), b + u * self.hw, tip, b - u * self.hw, self.cross(b, u, -half, -1.0)] + } +} + +/// Sweep (degrees) from `a` to `b` turning one way: positive when `dir > 0`. +fn turn(a: f64, b: f64, dir: f64) -> f64 { + if dir > 0.0 { (b - a).rem_euclid(360.0) } else { -(a - b).rem_euclid(360.0) } +} + +/// The circular arrows: a band along an ellipse with one head (`circularArrow` clockwise, +/// `leftCircularArrow` anticlockwise) or a head at each end (`leftRightCircularArrow`). +/// Adjusts: band thickness, head sweep (degrees), end angle, start angle, head half-width. +fn circular_arrow(name: &str, w: f64, h: f64, a: [f64; 5]) -> Geometry { + let ss = w.min(h); + if ss <= 0.0 { + return simple(corner_rect(w, h, [Corner::Square; 4]), Rect::new(0.0, 0.0, w, h), w, h); + } + let a5 = a[4].clamp(0.0, 25000.0); + let hw = ss * a5 / 1e5; + let th = ss * a[0].clamp(0.0, 2.0 * a5) / 1e5; + let ring = Ring { c: Point::new(w / 2.0, h / 2.0), rx: (w / 2.0 - hw).max(th / 2.0), ry: (h / 2.0 - hw).max(th / 2.0), th, hw }; + let deg = |v: f64| (v / 60000.0) % 360.0; + let (sweep, end, start) = (deg(a[1]), deg(a[2]), deg(a[3])); + let half = th / 2.0; + let mut b = Pb::new(); + // Arcs of the ellipse grown by `d`, drawn on from the current point (which is on it except in + // the degenerate case above, where the arc shifts with it like the reference's). + let edge_arc = |b: &mut Pb, from: Point, to: Point, d: f64, dir: f64| { + let (ax, ay) = ((ring.rx + d).max(0.0), (ring.ry + d).max(0.0)); + let (s, e) = (ring.param(from, d), ring.param(to, d)); + b.arc(ax, ay, s, turn(s, e, dir)); + }; + let outer_arc = |b: &mut Pb, from: Point, to: Point, dir: f64| edge_arc(b, from, to, half, dir); + let inner_arc = |b: &mut Pb, from: Point, to: Point, dir: f64| edge_arc(b, from, to, -half, dir); + if name == "leftRightCircularArrow" { + let hs = ring.head(start, -sweep); + let he = ring.head(end, sweep); + b.m(hs[0].x, hs[0].y); + outer_arc(&mut b, hs[0], he[0], 1.0); + b.l(he[1].x, he[1].y).l(he[2].x, he[2].y).l(he[3].x, he[3].y).l(he[4].x, he[4].y); + inner_arc(&mut b, he[4], hs[4], -1.0); + b.l(hs[3].x, hs[3].y).l(hs[2].x, hs[2].y).l(hs[1].x, hs[1].y).z(); + } else { + let dir = if name == "circularArrow" { 1.0 } else { -1.0 }; + let he = ring.head(end, sweep); + let (o0, i0) = (ring.at(start, half), ring.at(start, -half)); + b.m(o0.x, o0.y); + outer_arc(&mut b, o0, he[0], dir); + b.l(he[1].x, he[1].y).l(he[2].x, he[2].y).l(he[3].x, he[3].y).l(he[4].x, he[4].y); + inner_arc(&mut b, he[4], i0, -dir); + b.z(); + } + simple(b.take(), Rect::new(0.0, 0.0, w, h), w, h) +} + +/// A funnel: an elliptical rim (with an open mouth) across the top quarter, and straight sides +/// down to a small rounded spout. The side lines leave the rim and meet the spout at fixed +/// angles, fitted to the reference look. +fn funnel(w: f64, h: f64) -> Geometry { + const RIM: f64 = 173.0; // where the left side leaves the rim, degrees on the rim ellipse + const SPOUT: f64 = 163.4; // where it meets the spout, degrees on the spout ellipse + let ss = w.min(h); + let (cx, rim_y) = (w / 2.0, h / 4.0); + let (sx, sy) = (w / 8.0, h / 15.0); + let spout_y = h - sy; + let at = |rx: f64, ry: f64, cy: f64, deg: f64| { + let t = deg.to_radians(); + Point::new(cx + rx * t.cos(), cy + ry * t.sin()) + }; + let p0 = at(cx, rim_y, rim_y, RIM); + let p1 = at(sx, sy, spout_y, 180.0 - SPOUT); + let mut b = Pb::new(); + b.m(p0.x, p0.y).arc(cx, rim_y, RIM, 540.0 - 2.0 * RIM); + b.l(p1.x, p1.y).arc(sx, sy, 180.0 - SPOUT, 2.0 * SPOUT - 180.0).z(); + let d = ss / 20.0; + b.ellipse(cx, rim_y, (cx - d).max(0.0), (rim_y - d).max(0.0)); + Geometry { paths: vec![fill_eo(b.take())], text_rect: Rect::new(w / 4.0, h / 2.0, w * 0.75, h - sy), handles: vec![], sites: mid_sites(w, h) } +} + +/// A gear: an elliptical body with `n` teeth, the first pointing straight up. Each tooth leaves +/// the body at two points an equal angle either side of its centre, and its tip corners sit a +/// tooth height further out along the body's normal and a flank lean further in. +fn gear(n: usize, w: f64, h: f64, a0: f64, a1: f64) -> Geometry { + let ss = w.min(h); + let th = ss * a0.clamp(0.0, 20000.0) / 1e5; // tooth height + let lean_max = if n == 6 { 5380.0 } else { 2730.0 }; + let l = ss * a1.clamp(0.0, a0.clamp(0.0, 20000.0).min(lean_max)) / 1e5; // flank lean + let (cx, cy) = (w / 2.0, h / 2.0); + let (rx, ry) = ((cx - th).max(0.0), (cy - th).max(0.0)); + let r = (ss / 2.0 - th).max(1e-9); + let half = ((th + l) / 2.0 / r).atan(); + let step = std::f64::consts::TAU / n as f64; + let on_body = |t: f64| Point::new(cx + rx * t.cos(), cy + ry * t.sin()); + let mut b = Pb::new(); + for k in 0..n { + let t = -std::f64::consts::FRAC_PI_2 + step * k as f64; + // The tooth stands along the body's normal at its centre. + let nrm = Vec2::new(t.cos() * ry, t.sin() * rx); + let u = if nrm.hypot() > 1e-12 { nrm / nrm.hypot() } else { Vec2::new(t.cos(), t.sin()) }; + let v = Vec2::new(-u.y, u.x); + let (r0, r1) = (on_body(t - half), on_body(t + half)); + let (c0, c1) = (r0 + u * th + v * l, r1 + u * th - v * l); + if k == 0 { + b.m(r0.x, r0.y); + } else { + b.arc(rx, ry, (t - step + half).to_degrees(), (step - 2.0 * half).to_degrees()); + } + b.l(c0.x, c0.y).l(c1.x, c1.y).l(r1.x, r1.y); + } + let t_last = -std::f64::consts::FRAC_PI_2 + step * (n - 1) as f64; + b.arc(rx, ry, (t_last + half).to_degrees(), (step - 2.0 * half).to_degrees()).z(); + let k = std::f64::consts::FRAC_1_SQRT_2; + let text = Rect::new(cx - rx * k, cy - ry * k, cx + rx * k, cy + ry * k); + simple(b.take(), text, w, h) +} + +/// Two arrows on a band that steps down in the middle: the left arrow sits high, the right one low, +/// and the band rolls over between them in a shaded half-ellipse curl. +fn left_right_ribbon(w: f64, h: f64, a0: f64, a1: f64, a2: f64) -> Geometry { + let ss = w.min(h); + let sh = h * a2.clamp(0.0, 33333.0) / 1e5; // step between the two halves + let bt = (h * a0.max(0.0) / 1e5).min(h - sh); // band thickness + let cx = w / 2.0; + let (rx, ry) = (w / 32.0, (sh / 4.0).min(w / 24.0)); + let hb = (ss * a1.max(0.0) / 1e5).min(cx - rx); // head length + let yc = (h - sh) / 2.0; + let (lt, lb) = (yc - bt / 2.0, yc + bt / 2.0); + let (rt, rb) = (lt + sh, lb + sh); + // The left half: filled as a closed outline, stroked only where it can show. + let mut edge = Pb::new(); + edge.m(cx - rx, lb).l(hb, lb).l(hb, h - sh).l(0.0, yc).l(hb, 0.0).l(hb, lt).l(cx, lt); + edge.arc(rx, ry, 270.0, 180.0).arc(rx, ry, 270.0, -90.0); + let edge = edge.take(); + let mut left = Pb::new(); + left.m(0.0, yc).l(hb, 0.0).l(hb, lt).l(cx, lt).arc(rx, ry, 270.0, 180.0).arc(rx, ry, 270.0, -180.0); + left.l(cx, lb.max(lt + 4.0 * ry)).l(hb, lb).l(hb, h - sh).z(); + let left = left.take(); + let mut curl = Pb::new(); + curl.m(cx + rx, lt + ry).l(cx + rx, rt).l(cx, lt + 4.0 * ry).arc(rx, ry, 90.0, 180.0).arc(rx, ry, 90.0, -90.0).z(); + let mut right = Pb::new(); + right.m(w, yc + sh).l(w - hb, h).l(w - hb, rb).l(cx, rb).arc(rx, ry, 90.0, 90.0).l(cx - rx, rt - ry).arc(rx, ry, 180.0, -90.0); + right.l(w - hb, rt).l(w - hb, sh).z(); + Geometry { + paths: vec![fill_only(left), stroke_only(edge), shade(curl.take(), FillMode::DarkenLess), fill(right.take())], + text_rect: Rect::new(hb.min(cx - rx), rt, (w - hb).max(cx + rx), lb.max(rt)), + handles: vec![], + sites: vec![Point::new(0.0, yc), Point::new(cx, rt), Point::new(w, yc + sh), Point::new(cx, lb)], + } +} + +/// Adds a piece of the parabola `y = y0 + d·(1 − ((x − w/2)/(w/2))²)` from the current point (at +/// `xa`) to `xb`, as one quadratic curve. +fn sag(b: &mut Pb, w: f64, d: f64, y0: f64, xa: f64, xb: f64) { + let hw = (w / 2.0).max(1e-9); + let f = |x: f64| y0 + d * (1.0 - ((x - hw) / hw).powi(2)); + let slope = -2.0 * d * (xa - hw) / (hw * hw); + b.q((xa + xb) / 2.0, f(xa) + slope * (xb - xa) / 2.0, xb, f(xb)); +} + +/// A ribbon whose edges sag along one parabola: a front band in the middle and two notched ends +/// behind it, joined by shaded folds. Built sagging downwards; `up` mirrors it vertically. +fn ellipse_ribbon(up: bool, w: f64, h: f64, a0: f64, a1: f64, a2: f64) -> Geometry { + let th = h * a0.clamp(0.0, 100000.0) / 1e5; // band drop at the centre + // Sag of every edge: never more than the drop, never so little that the band would rise + // above the ends by more than half their height. + let d = (h * a2.max(0.0) / 1e5).max(th - (h - th) / 2.0).clamp(0.0, th); + let bw = w * a1.clamp(25000.0, 75000.0) / 1e5; + let (x0, x1) = ((w - bw) / 2.0, (w + bw) / 2.0); + let ear = w / 8.0; + let (xf, xg) = (x0 + ear, x1 - ear); + let e = h - th; // band height + let off = th - d; // band top below the ends' top + let t = |x: f64| { + let hw = (w / 2.0).max(1e-9); + d * (1.0 - ((x - hw) / hw).powi(2)) + }; + let mut ends = Pb::new(); + ends.m(0.0, 0.0); + sag(&mut ends, w, d, 0.0, 0.0, xf); + ends.l(xf, t(xf) + off).l(x0, t(x0) + off).l(x0, t(x0) + e); + sag(&mut ends, w, d, e, x0, 0.0); + ends.l(ear, t(ear) + e / 2.0).z(); + ends.m(w, 0.0).l(w - ear, t(w - ear) + e / 2.0).l(w, e); + sag(&mut ends, w, d, e, w, x1); + ends.l(x1, t(x1) + off).l(xg, t(xg) + off).l(xg, t(xg)); + sag(&mut ends, w, d, 0.0, xg, w); + ends.z(); + let mut folds = Pb::new(); + folds.poly(&[(x0, t(x0) + off), (xf, t(xf)), (xf, t(xf) + off)]); + folds.poly(&[(x1, t(x1) + off), (xg, t(xg)), (xg, t(xg) + off)]); + let mut band = Pb::new(); + band.m(x0, t(x0) + off); + sag(&mut band, w, d, off, x0, x1); + band.l(x1, t(x1) + off + e); + sag(&mut band, w, d, off + e, x1, x0); + band.z(); + let mut paths = vec![fill(ends.take()), shade(folds.take(), FillMode::DarkenLess), fill(band.take())]; + let mut text = Rect::new(x0, th, x1, t(x0) + off + e); + if up { + let m = kurbo::Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, h]); + for p in &mut paths { + p.path.apply_affine(m); + } + text = m.transform_rect_bbox(text); + } + Geometry { paths, text_rect: text, handles: vec![], sites: mid_sites(w, h) } +} + +/// A sweeping arrow rising from the bottom-left corner. The band's two edges are quadratic curves +/// from the corner; the head's base leans slightly forward and reaches one eighth of the shorter +/// side above and below the band. The tilt and the tip's lift were fitted to the reference look. +fn swoosh(w: f64, h: f64, a0: f64, a1: f64) -> Geometry { + const TILT: f64 = 0.1126; // tan of the head base's lean from vertical + const LIFT: f64 = 0.051; // tip height above the band's centre, per unit of height + let ss = w.min(h); + let e = ss / 8.0; + let th = (h * a0.max(0.0) / 1e5).min((h - 2.0 * e).max(0.0)); + // The head is at least long enough to keep its leaning base inside the frame. + let hl = (ss * a1.max(0.0) / 1e5).max((e + th) * TILT).min((w - e * TILT).max(0.0)); + let qx = w - hl; + let mut b = Pb::new(); + b.m(0.0, h) + .q(w / 6.0, h / 3.0, qx, e) + .l(qx - e * TILT, 0.0) + .l(w, (e + th / 2.0 - LIFT * h).clamp(0.0, 2.0 * e + th)) + .l((qx + (e + th) * TILT).min(w), 2.0 * e + th) + .l((qx + th * TILT).min(w), e + th) + .q(w / 4.0, (th + h / 12.0 + e).min(h), 0.0, h) + .z(); + simple(b.take(), Rect::new(0.0, 0.0, w, h), w, h) +} + +/// A band that sweeps around half an ellipse and ends in an arrow head. Built pointing right +/// (`curvedRightArrow`) in a `cw`×`ch` frame, then turned for the other directions. +/// +/// Both halves of the band hang off one ellipse touching the far side: its centre sits on the +/// head side's edge, its horizontal radius is the full length, and the band has a constant +/// vertical thickness. The back half (shaded) starts flat against the head side; the front half +/// runs to the head, centred on the band where it ends. +fn curved_arrow(name: &str, w: f64, h: f64, a0: f64, a1: f64, a2: f64) -> Geometry { + let along_x = matches!(name, "curvedRightArrow" | "curvedLeftArrow"); + let (cw, ch) = if along_x { (w, h) } else { (h, w) }; + let ss = cw.min(ch); + let head = (ss * a1.max(0.0) / 1e5).min(ch / 2.0); + let th = (ss * a0.max(0.0) / 1e5).min(head); + let hl = (ss * a2.max(0.0) / 1e5).min(cw); + let hh = head / 2.0; + // The band's centre line, carried on to the head side, ends at the tip, which sits half a + // head width above the bottom. + let r = ((ch - hh - th / 2.0) / 2.0).max(0.0); + // Parametric angle where the front band reaches the head base. + let te = if cw > 0.0 { (-(hl / cw)).clamp(-1.0, 1.0).acos().to_degrees() } else { 90.0 }; + let mut back = Pb::new(); + back.m(cw, 0.0).arc(cw, r, 270.0, -90.0).l(0.0, r + th).arc(cw, r, 180.0, 90.0).z(); + let mut front = Pb::new(); + front.m(0.0, r).arc(cw, r, 180.0, te - 180.0); + let yb = front.cur.y; + let yc = yb + th / 2.0; + front.l(cw - hl, yc - hh).l(cw, 2.0 * r + th / 2.0).l(cw - hl, yc + hh).l(cw - hl, yb + th).arc(cw, r, te, 180.0 - te).z(); + let m = match name { + "curvedLeftArrow" => kurbo::Affine::new([-1.0, 0.0, 0.0, 1.0, w, 0.0]), + "curvedUpArrow" => kurbo::Affine::new([0.0, -1.0, 1.0, 0.0, 0.0, h]), + "curvedDownArrow" => kurbo::Affine::new([0.0, 1.0, 1.0, 0.0, 0.0, 0.0]), + _ => kurbo::Affine::IDENTITY, + }; + let (mut back, mut front) = (back.take(), front.take()); + back.apply_affine(m); + front.apply_affine(m); + Geometry { + paths: vec![shade(back, FillMode::DarkenLess), fill(front)], + text_rect: Rect::new(0.0, 0.0, w, h), + handles: vec![], + sites: mid_sites(w, h), + } +} + +/// A rectangle with a callout leader through `pts` points (`(y, x)` adjust pairs in 1/100 000 of +/// the height and width), optionally with an accent bar at the first point's x and a border. +fn line_callout(w: f64, h: f64, a: &dyn Fn(usize) -> f64, pts: usize, accent: bool, border: bool) -> Geometry { + let body = corner_rect(w, h, [Corner::Square; 4]); + // Leaders may reach well outside the box, but not without bound. + let pt = |i: usize| (w * a(2 * i + 1).clamp(-1e7, 1e7) / 1e5, h * a(2 * i).clamp(-1e7, 1e7) / 1e5); + let mut l = Pb::new(); + let (x0, y0) = pt(0); + l.m(x0, y0); + for i in 1..pts { + let (x, y) = pt(i); + l.l(x, y); + } + let mut paths = vec![if border { fill(body) } else { fill_only(body) }]; + if accent { + paths.push(stroke_only(Pb::new().m(x0, 0.0).l(x0, h).take())); + } + paths.push(stroke_only(l.take())); + Geometry { paths, text_rect: Rect::new(0.0, 0.0, w, h), handles: vec![], sites: mid_sites(w, h) } +} + +#[cfg(test)] +mod tests { + use super::super::{CATALOG, FillMode, build, info}; + use kurbo::{BezPath, PathEl, Point, Shape}; + + const NEW: [&str; 38] = [ + "lineInv", + "nonIsoscelesTrapezoid", + "pieWedge", + "leftRightArrowCallout", + "upDownArrowCallout", + "quadArrowCallout", + "circularArrow", + "leftCircularArrow", + "leftRightCircularArrow", + "curvedRightArrow", + "curvedLeftArrow", + "curvedUpArrow", + "curvedDownArrow", + "swooshArrow", + "callout1", + "callout2", + "callout3", + "accentCallout1", + "accentCallout2", + "accentCallout3", + "borderCallout2", + "borderCallout3", + "accentBorderCallout1", + "accentBorderCallout2", + "accentBorderCallout3", + "ellipseRibbon", + "ellipseRibbon2", + "leftRightRibbon", + "flowChartOfflineStorage", + "gear6", + "gear9", + "funnel", + "cornerTabs", + "squareTabs", + "plaqueTabs", + "chartX", + "chartStar", + "chartPlus", + ]; + + fn outline(name: &str, w: f64, h: f64, adj: &[f64]) -> BezPath { + build(name, w, h, adj).map(|g| g.outline()).unwrap_or_default() + } + + /// Distance from `p` to the nearest point of the (flattened) outline. + fn dist(path: &BezPath, p: Point) -> f64 { + let mut best = f64::MAX; + let mut prev = Point::ZERO; + let mut start = Point::ZERO; + kurbo::flatten(path.iter(), 0.01, |el| { + let (a, b) = match el { + PathEl::MoveTo(q) => { + prev = q; + start = q; + return; + } + PathEl::LineTo(q) => (prev, q), + PathEl::ClosePath => (prev, start), + _ => return, + }; + let d = b - a; + let t = if d.hypot2() > 0.0 { ((p - a).dot(d) / d.hypot2()).clamp(0.0, 1.0) } else { 0.0 }; + best = best.min((a + d * t - p).hypot()); + prev = b; + }); + best + } + + /// Asserts every point lies within 1pt of the outline. + fn on(name: &str, w: f64, h: f64, adj: &[f64], pts: &[(f64, f64)]) { + let path = outline(name, w, h, adj); + for &(x, y) in pts { + let d = dist(&path, Point::new(x, y)); + assert!(d < 1.0, "{name} {w}x{h} {adj:?}: ({x}, {y}) is {d:.2} from the outline"); + } + } + + #[test] + fn new_presets_are_in_the_catalog() { + for n in NEW { + let i = info(n).unwrap_or_else(|| panic!("{n} missing from the catalog")); + assert!(!i.label.is_empty()); + } + assert!(CATALOG.len() >= 183); + } + + #[test] + fn new_presets_build_within_bounds_at_many_sizes_and_adjusts() { + let sizes = [(480.0, 300.0), (300.0, 300.0), (200.0, 460.0), (100.0, 460.0), (1.0, 1.0), (0.0, 0.0), (0.0, 50.0), (60.0, 0.0)]; + let adjusts: [&[f64]; 8] = [ + &[], + &[0.0; 8], + &[100000.0; 8], + &[-50000.0; 8], + &[1e12, -1e12, 1e12, -1e12, 1e12, -1e12, 1e12, -1e12], + &[f64::MAX, f64::MIN, f64::MAX, f64::MIN, f64::MAX, f64::MIN, f64::MAX, f64::MIN], + &[f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::NAN, 5.0, 6.0, 7.0, 8.0], + &[30000.0, 60000.0, 20000.0, 9000000.0, 20000.0, 40000.0, 120000.0, -30000.0], + ]; + for n in NEW { + // Line callouts' leaders may reach anywhere, by design. + let callout = n.contains("allout") && !n.contains("Arrow"); + for (w, h) in sizes { + for adj in adjusts { + let g = build(n, w, h, adj).unwrap_or_else(|| panic!("{n} did not build")); + assert!(!g.paths.is_empty(), "{n} has no paths"); + for s in &g.paths { + let bb = s.path.bounding_box(); + assert!(bb.x0.is_finite() && bb.y0.is_finite() && bb.x1.is_finite() && bb.y1.is_finite(), "{n} {w}x{h} {adj:?}: {bb:?}"); + if !callout { + // Circular arrows with a head longer than their ring can hold carry the + // inner edge on from the head, as the reference does, so may spill. + let spill = if n.contains("ircular") { 0.5 } else { 0.01 }; + let tol = 0.5 + w.max(h) * spill; + assert!( + bb.x0 >= -tol && bb.y0 >= -tol && bb.x1 <= w + tol && bb.y1 <= h + tol, + "{n} {w}x{h} {adj:?} out of bounds: {bb:?}" + ); + } + } + let t = g.text_rect; + assert!(t.x0.is_finite() && t.y0.is_finite() && t.x1.is_finite() && t.y1.is_finite(), "{n} text rect {t:?}"); + } + } + let g = build(n, 480.0, 300.0, &[]).unwrap_or_else(|| panic!("{n}")); + assert!(g.outline().elements().len() >= 2, "{n} default outline is empty"); + } + } + + // The spot checks below are outline points measured on reference renders. + + #[test] + fn simple_new_presets_match_reference_points() { + on("lineInv", 480.0, 300.0, &[], &[(0.0, 300.0), (480.0, 0.0)]); + on("nonIsoscelesTrapezoid", 480.0, 300.0, &[], &[(75.0, 0.0), (405.0, 0.0), (0.0, 300.0)]); + on("nonIsoscelesTrapezoid", 200.0, 460.0, &[80000.0, 20000.0], &[(100.0, 0.0), (160.0, 0.0)]); + on("pieWedge", 480.0, 300.0, &[], &[(64.3, 150.0), (0.0, 300.0), (480.0, 0.0)]); + on("flowChartOfflineStorage", 480.0, 300.0, &[], &[(240.0, 300.0), (192.0, 240.0), (288.0, 240.0)]); + on("squareTabs", 480.0, 300.0, &[], &[(28.3, 28.3), (451.7, 271.7)]); + on("cornerTabs", 480.0, 300.0, &[], &[(23.3, 5.0)]); + on("plaqueTabs", 480.0, 300.0, &[], &[(27.85, 5.0)]); + on("chartStar", 480.0, 300.0, &[], &[(240.0, 0.0), (240.0, 300.0), (48.0, 30.0)]); + on("chartPlus", 480.0, 300.0, &[], &[(0.0, 150.0), (240.0, 0.0)]); + // Chart markers and plain callouts leave the box unstroked. + for n in ["chartX", "callout1", "accentCallout2"] { + let g = build(n, 100.0, 100.0, &[]).unwrap_or_else(|| panic!("{n}")); + assert!(!g.paths[0].stroke, "{n}"); + } + } + + #[test] + fn line_callouts_follow_their_adjusts() { + on("callout3", 480.0, 300.0, &[], &[(-40.0, 56.25), (-80.0, 56.25), (-80.0, 300.0), (-40.0, 338.9)]); + let adj = [50000.0, 110000.0, 20000.0, 120000.0, -20000.0, 80000.0, 30000.0, 40000.0]; + let g = build("accentBorderCallout3", 480.0, 300.0, &adj).unwrap_or_else(|| panic!("accentBorderCallout3")); + assert_eq!(g.paths.len(), 3); + assert!(g.paths[0].stroke); + let accent = g.paths[1].path.bounding_box(); + assert!((accent.x0 - 528.0).abs() < 1e-6 && accent.y0 == 0.0 && accent.y1 == 300.0, "{accent:?}"); + on("accentBorderCallout3", 480.0, 300.0, &adj, &[(576.0, 60.0), (384.0, -60.0), (192.0, 90.0)]); + } + + #[test] + fn arrow_callouts_match_reference_points() { + on("leftRightArrowCallout", 480.0, 300.0, &[], &[(0.0, 150.0), (75.0, 75.0), (124.5, 0.0), (355.5, 300.0), (480.0, 150.0)]); + on("leftRightArrowCallout", 200.0, 460.0, &[], &[(50.0, 180.0), (52.0, 0.0), (148.0, 460.0)]); + on("upDownArrowCallout", 480.0, 300.0, &[], &[(240.0, 0.0), (0.0, 77.8), (480.0, 222.2), (240.0, 300.0)]); + on("quadArrowCallout", 480.0, 300.0, &[], &[(0.0, 150.0), (124.5, 77.8), (240.0, 0.0), (480.0, 150.0), (240.0, 300.0)]); + } + + #[test] + fn curved_arrows_match_reference_points() { + on( + "curvedRightArrow", + 480.0, + 300.0, + &[], + &[(480.0, 0.0), (480.0, 75.0), (480.0, 225.0), (405.0, 149.0), (405.0, 299.0), (100.0, 151.5), (300.0, 6.7)], + ); + on("curvedRightArrow", 200.0, 460.0, &[30000.0, 60000.0, 40000.0], &[(200.0, 400.0), (120.0, 324.5), (120.0, 443.5), (200.0, 60.0)]); + // The band never outgrows the head; the head never outgrows half the frame. + on("curvedRightArrow", 200.0, 460.0, &[60000.0, 30000.0, 80000.0], &[(200.0, 60.0)]); + on("curvedLeftArrow", 480.0, 300.0, &[], &[(0.0, 225.0), (0.0, 0.0)]); + on("curvedUpArrow", 480.0, 300.0, &[], &[(405.0, 0.0), (0.0, 0.0), (75.0, 0.0)]); + on("curvedDownArrow", 480.0, 300.0, &[], &[(405.0, 300.0), (0.0, 300.0)]); + let g = build("curvedRightArrow", 480.0, 300.0, &[]).unwrap_or_else(|| panic!("curvedRightArrow")); + assert_eq!(g.paths[0].fill, FillMode::DarkenLess); + } + + #[test] + fn swoosh_matches_reference_points() { + on("swooshArrow", 480.0, 300.0, &[], &[(0.0, 300.0), (425.8, 0.0), (480.0, 59.7), (442.8, 150.0), (202.0, 105.5), (202.0, 159.0)]); + on("swooshArrow", 480.0, 300.0, &[40000.0, 16667.0], &[(480.0, 82.2), (447.8, 195.0)]); + on("swooshArrow", 200.0, 460.0, &[], &[(163.9, 0.0), (200.0, 59.4)]); + } + + #[test] + fn ribbons_match_reference_points() { + on( + "ellipseRibbon", + 480.0, + 300.0, + &[], + &[(0.0, 0.0), (120.0, 28.1), (60.0, 128.9), (240.0, 300.0), (120.0, 66.1), (180.0, 72.2), (180.0, 35.2)], + ); + on("ellipseRibbon", 480.0, 300.0, &[30000.0, 60000.0, 20000.0], &[(120.0, 45.0), (240.0, 300.0), (96.0, 68.4)]); + on("ellipseRibbon", 480.0, 300.0, &[80000.0, 20000.0, 60000.0], &[(240.0, 240.0), (1.0, 61.75)]); + on("ellipseRibbon2", 480.0, 300.0, &[], &[(0.0, 300.0), (240.0, 0.0)]); + on( + "leftRightRibbon", + 480.0, + 300.0, + &[], + &[(0.0, 125.0), (150.0, 0.0), (150.0, 50.0), (255.0, 62.5), (225.0, 87.5), (480.0, 175.0), (330.0, 300.0)], + ); + on("leftRightRibbon", 480.0, 300.0, &[90000.0, 10000.0, 60000.0], &[(0.0, 100.0), (480.0, 200.0), (240.0, 300.0)]); + } + + #[test] + fn gears_and_funnel_match_reference_points() { + // Square gear6: body radius 105, top tooth tip 3.5 inside the frame, tip half-width ~16.3. + on("gear6", 300.0, 300.0, &[], &[(150.0, 3.5), (133.7, 3.5), (166.3, 3.5), (45.0, 150.0)]); + on("gear9", 300.0, 300.0, &[], &[(150.0, 1.0), (30.0, 150.0)]); + on("gear6", 480.0, 300.0, &[], &[(240.0, 3.5), (201.0, 3.5)]); + on("funnel", 480.0, 300.0, &[], &[(240.0, 0.0), (0.0, 75.0), (240.0, 15.0), (15.0, 75.0), (240.0, 300.0), (60.5, 150.0)]); + let g = build("funnel", 480.0, 300.0, &[]).unwrap_or_else(|| panic!("funnel")); + assert!(g.paths[0].even_odd); + } + + #[test] + fn circular_arrows_match_reference_points() { + on("circularArrow", 300.0, 300.0, &[], &[(19.0, 150.0), (56.5, 150.0), (150.0, 19.0), (262.4, 150.0), (218.6, 113.4), (293.6, 113.4)]); + on( + "circularArrow", + 300.0, + 300.0, + &[20000.0, 1800000.0, 18000000.0, 5400000.0, 20000.0], + &[(150.0, 270.0), (150.0, 210.0), (247.0, 42.1), (143.0, 102.1), (227.9, 105.0)], + ); + on("leftCircularArrow", 300.0, 300.0, &[], &[(37.6, 150.0), (262.4, 150.0), (150.0, 281.0)]); + on("leftRightCircularArrow", 300.0, 300.0, &[], &[(262.4, 150.0), (37.6, 150.0), (150.0, 19.0)]); + // The band never grows past twice the head's half-width. + on("circularArrow", 300.0, 300.0, &[25000.0, 1142319.0, 20457681.0, 10800000.0, 5000.0], &[(0.0, 150.0), (30.0, 150.0)]); + } +} diff --git a/crates/drawingml/src/preset/shapes.rs b/crates/drawingml/src/preset/shapes.rs new file mode 100644 index 000000000..156475435 --- /dev/null +++ b/crates/drawingml/src/preset/shapes.rs @@ -0,0 +1,375 @@ +use super::builder::{Corner, Pb, corner_rect, fill, mid_sites, shade, simple, stroke_only, tip_handle}; +use super::catalog::{FillMode, Geometry}; +use super::{Point, Rect}; + +pub(super) fn wedge_rect(w: f64, h: f64, a0: f64, a1: f64, r: f64) -> Geometry { + let tip = Point::new(w / 2.0 + w * a0 / 1e5, h / 2.0 + h * a1 / 1e5); + let inside = tip.x > 0.0 && tip.x < w && tip.y > 0.0 && tip.y < h; + let (dx, dy) = ((tip.x - w / 2.0) / w.max(1e-9), (tip.y - h / 2.0) / h.max(1e-9)); + let mut b = Pb::new(); + let r = r.min(w / 2.0).min(h / 2.0); + // Walk clockwise from the top-left; insert the wedge on the edge facing the tip. + let horiz = dx.abs() > dy.abs(); + // The wedge's base: 2..5 twelfths of the edge on the tip's side of the centre when it is + // before it, 7..10 twelfths when after (presetShapeDefinitions' xg1/xg2, yg1/yg2). + let (bx0, bx1) = if tip.x < w / 2.0 { (w * 2.0 / 12.0, w * 5.0 / 12.0) } else { (w * 7.0 / 12.0, w * 10.0 / 12.0) }; + let (by0, by1) = if tip.y < h / 2.0 { (h * 2.0 / 12.0, h * 5.0 / 12.0) } else { (h * 7.0 / 12.0, h * 10.0 / 12.0) }; + b.m(r, 0.0); + if !inside && !horiz && dy < 0.0 { + b.l(bx0, 0.0).l(tip.x, tip.y).l(bx1, 0.0); + } + b.l(w - r, 0.0); + if r > 0.0 { + b.arc(r, r, 270.0, 90.0); + } + if !inside && horiz && dx > 0.0 { + b.l(w, by0).l(tip.x, tip.y).l(w, by1); + } + b.l(w, h - r); + if r > 0.0 { + b.arc(r, r, 0.0, 90.0); + } + if !inside && !horiz && dy > 0.0 { + b.l(bx1, h).l(tip.x, tip.y).l(bx0, h); + } + b.l(r, h); + if r > 0.0 { + b.arc(r, r, 90.0, 90.0); + } + if !inside && horiz && dx < 0.0 { + b.l(0.0, by1).l(tip.x, tip.y).l(0.0, by0); + } + b.l(0.0, r); + if r > 0.0 { + b.arc(r, r, 180.0, 90.0); + } + b.z(); + let mut g = simple(b.take(), Rect::new(0.0, 0.0, w, h).inset(-r * 0.29289), w, h); + g.handles.push(tip_handle(w, h, tip)); + g +} + +/// ECMA-376 cloud outline arcs (radii in 43200ths of the box, angles in 60000ths of a degree, +/// as DrawingML `arcTo` takes them), from the start point (3900, 14370). +const CLOUD_ARCS: [(f64, f64, f64, f64); 11] = [ + (6753.0, 9190.0, -11429249.0, 7426832.0), + (5333.0, 7267.0, -8646143.0, 5396714.0), + (4365.0, 5945.0, -8748475.0, 5983381.0), + (4857.0, 6595.0, -7859164.0, 7034504.0), + (5333.0, 7273.0, -4722533.0, 6541615.0), + (6775.0, 9220.0, -2776035.0, 7816140.0), + (5785.0, 7867.0, 37501.0, 6842000.0), + (6752.0, 9215.0, 1347096.0, 6910353.0), + (7720.0, 10543.0, 3974558.0, 4542661.0), + (4360.0, 5918.0, -16496525.0, 8804134.0), + (4345.0, 5945.0, -14809710.0, 9151131.0), +]; + +/// The cloud's creases: short arcs inside the outline, each from its own start point. +const CLOUD_CREASES: [(f64, f64, f64, f64, f64, f64); 11] = [ + (4693.0, 26177.0, 4345.0, 5945.0, 5204520.0, 1585770.0), + (6928.0, 34899.0, 4360.0, 5918.0, 4416628.0, 686848.0), + (16478.0, 39090.0, 6752.0, 9215.0, 8257449.0, 844866.0), + (28827.0, 34751.0, 6752.0, 9215.0, 387196.0, 959901.0), + (34129.0, 22954.0, 5785.0, 7867.0, -4217541.0, 4255042.0), + (41798.0, 15354.0, 5333.0, 7273.0, 1819082.0, 1665090.0), + (38324.0, 5426.0, 4857.0, 6595.0, -824660.0, 891534.0), + (29078.0, 3952.0, 4857.0, 6595.0, -8950887.0, 1091722.0), + (22141.0, 4720.0, 4365.0, 5945.0, -9809656.0, 1061181.0), + (14000.0, 5192.0, 6753.0, 9190.0, -4002417.0, 739161.0), + (4127.0, 15789.0, 6753.0, 9190.0, 9459261.0, 711490.0), +]; + +pub(super) fn cloud(name: &str, w: f64, h: f64, a0: f64, a1: f64) -> Geometry { + // Drawn in the path's own 43200-unit square, then stretched to the box (as DrawingML lays out + // a path with its own w and h), so the arcs keep their shape's proportions at any aspect. + let to_box = kurbo::Affine::scale_non_uniform(w / 43200.0, h / 43200.0); + let deg = |v: f64| v / 60000.0; + let mut b = Pb::new(); + b.m(3900.0, 14370.0); + for (wr, hr, st, sw) in CLOUD_ARCS { + b.arc_spec(wr, hr, deg(st), deg(sw)); + } + b.z(); + let mut creases = Pb::new(); + for (x, y, wr, hr, st, sw) in CLOUD_CREASES { + creases.m(x, y).arc_spec(wr, hr, deg(st), deg(sw)); + } + let mut outline = b.take(); + outline.apply_affine(to_box); + let mut paths = vec![fill(outline)]; + if name == "cloud" { + let mut c = creases.take(); + c.apply_affine(to_box); + paths.push(stroke_only(c)); + } + let text_rect = Rect::new(w * 2977.0 / 21600.0, h * 3262.0 / 21600.0, w * 17087.0 / 21600.0, h * 17337.0 / 21600.0); + let mut handles = vec![]; + if name == "cloudCallout" { + let tip = Point::new(w / 2.0 + w * a0 / 1e5, h / 2.0 + h * a1 / 1e5); + let c = Point::new(w / 2.0, h / 2.0); + let mut bubbles = Pb::new(); + for (k, r) in [(0.55, 0.06), (0.75, 0.045), (0.95, 0.03)] { + let p = c + (tip - c) * k; + if !(p.x > w * 0.1 && p.x < w * 0.9 && p.y > h * 0.1 && p.y < h * 0.9) || k > 0.9 { + bubbles.ellipse(p.x, p.y, w * r, h * r); + } + } + paths.push(fill(bubbles.take())); + handles.push(tip_handle(w, h, tip)); + } + Geometry { paths, text_rect, handles, sites: mid_sites(w, h) } +} + +/// The equation shapes, by ECMA-376's formulas (checked against PowerPoint's outlines at several +/// sizes and adjust values). Their bars span 73.49% of the width; the plus and multiply bars are +/// `adj1` of the shorter side thick, the others' `adj1` of the height. +pub(super) fn math(name: &str, w: f64, h: f64, a0: f64, a1: f64, a2: f64) -> Geometry { + let pin = |lo: f64, v: f64, hi: f64| v.max(lo).min(hi.max(lo)); + let ss = w.min(h); + let (hc, vc) = (w / 2.0, h / 2.0); + let dx1 = w * 73490.0 / 200000.0; + let (x1, x2) = (hc - dx1, hc + dx1); + let mut b = Pb::new(); + let text = match name { + "mathPlus" => { + let a = pin(0.0, a0, 73490.0); + let dy1 = h * 73490.0 / 200000.0; + let dx2 = ss * a / 200000.0; + let (y1, y2, y3, y4) = (vc - dy1, vc - dx2, vc + dx2, vc + dy1); + let (xa, xb) = (hc - dx2, hc + dx2); + b.poly(&[(x1, y2), (xa, y2), (xa, y1), (xb, y1), (xb, y2), (x2, y2), (x2, y3), (xb, y3), (xb, y4), (xa, y4), (xa, y3), (x1, y3)]); + Rect::new(x1, y2, x2, y3) + } + "mathMinus" => { + let dy1 = h * pin(0.0, a0, 100000.0) / 200000.0; + b.poly(&[(x1, vc - dy1), (x2, vc - dy1), (x2, vc + dy1), (x1, vc + dy1)]); + Rect::new(x1, vc - dy1, x2, vc + dy1) + } + "mathEqual" => { + let a = pin(0.0, a0, 36745.0); + let g = pin(0.0, a1, 100000.0 - 2.0 * a); + let (dy1, dy2) = (h * a / 100000.0, h * g / 200000.0); + let (y2, y3) = (vc - dy2, vc + dy2); + let (y1, y4) = (y2 - dy1, y3 + dy1); + b.poly(&[(x1, y1), (x2, y1), (x2, y2), (x1, y2)]); + b.poly(&[(x1, y3), (x2, y3), (x2, y4), (x1, y4)]); + Rect::new(x1, y1, x2, y4) + } + "mathNotEqual" => { + let a = pin(0.0, a0, 50000.0); + let cr_ang = pin(4200000.0, a1, 6600000.0); + let g = pin(0.0, a2, 100000.0 - 2.0 * a); + let (dy1, dy2) = (h * a / 100000.0, h * g / 200000.0); + let hd2 = (h / 2.0).max(1e-9); + let (y2, y3) = (vc - dy2, vc + dy2); + let (y1, y4) = (y2 - dy1, y3 + dy1); + let cadj2 = cr_ang - 5400000.0; + let xadj2 = hd2 * (cadj2 / 60000.0).to_radians().tan(); + let len = xadj2.hypot(hd2); + let bhw = len * dy1 / hd2; + let x7 = hc + xadj2 - bhw / 2.0; + let at = |y: f64| x7 - xadj2 * y / hd2; + let (x6, x5, x4, x3) = (at(y1), at(y2), at(y3), at(y4)); + let rx7 = x7 + bhw; + let dx7 = dy1 * hd2 / len; + let dy3 = dy1 * xadj2 / len; + let (rx, lx, ry, ly) = if cadj2 > 0.0 { (x7 + dx7, x7, dy3, 0.0) } else { (rx7, rx7 - dx7, 0.0, -dy3) }; + b.poly(&[ + (x1, y1), + (x6, y1), + (lx, ly), + (rx, ry), + (x6 + bhw, y1), + (x2, y1), + (x2, y2), + (x5 + bhw, y2), + (x4 + bhw, y3), + (x2, y3), + (x2, y4), + (x3 + bhw, y4), + (w - lx, h - ly), + (w - rx, h - ry), + (x3, y4), + (x1, y4), + (x1, y3), + (x4, y3), + (x5, y2), + (x1, y2), + ]); + Rect::new(x1, y1, x2, y4) + } + "mathDivide" => { + let a = pin(1000.0, a0, 36745.0); + let max3 = ((73490.0 - a) / 4.0).min(36745.0 * w / h.max(1e-9)); + let r3 = pin(1000.0, a2, max3); + let g = pin(0.0, a1, 73490.0 - 4.0 * r3 - a); + let dy1 = h * a / 200000.0; + let yg = h * g / 100000.0; + let rad = h * r3 / 100000.0; + let (y3, y4) = (vc - dy1, vc + dy1); + let y2 = y3 - (yg + rad); + let y1 = y2 - rad; + b.ellipse(hc, y1 + rad, rad, rad); + b.ellipse(hc, h - y1 - rad, rad, rad); + b.poly(&[(x1, y3), (x2, y3), (x2, y4), (x1, y4)]); + Rect::new(x1, y3, x2, y4) + } + _ => { + // mathMultiply: an X whose arms run along the box's diagonals. + let th = ss * pin(0.0, a0, 51965.0) / 100000.0; + let ang = h.atan2(w); + let (sa, ca, ta) = (ang.sin(), ang.cos(), ang.tan().max(1e-9)); + let lm = w.hypot(h) * (1.0 - 51965.0 / 100000.0); + let (xm, ym) = (ca * lm / 2.0, sa * lm / 2.0); + let (dxam, dyam) = (sa * th / 2.0, ca * th / 2.0); + let (xa, ya, xb, yb) = (xm - dxam, ym + dyam, xm + dxam, ym - dyam); + let yc = (hc - xb) * ta + yb; + let (xd, xe) = (w - xb, w - xa); + let xfe = (vc - ya) / ta; + let (xf, xl) = (xe - xfe, xa + xfe); + let (yg, yh, yi) = (h - ya, h - yb, h - yc); + b.poly(&[(xa, ya), (xb, yb), (hc, yc), (xd, yb), (xe, ya), (xf, vc), (xe, yg), (xd, yh), (hc, yi), (xb, yh), (xa, yg), (xl, vc)]); + Rect::new(xa, yb, xe, yh) + } + }; + Geometry { paths: vec![fill(b.take())], text_rect: text, handles: vec![], sites: mid_sites(w, h) } +} + +pub(super) fn ribbon(up: bool, w: f64, h: f64, a0: f64, a1: f64) -> Geometry { + let fold = h * a0.clamp(0.0, 33333.0) / 1e5; + let cw = w * a1.clamp(25000.0, 75000.0) / 1e5; // centre band width + let x0 = (w - cw) / 2.0; + let x1 = w - x0; + let ear = w / 8.0; + let (top, bot) = (0.0, h); + let mut b = Pb::new(); + let mut shade_p = Pb::new(); + if up { + // centre band sits high; ends hang low. + b.poly(&[(x0, top), (x1, top), (x1, bot - fold), (x0, bot - fold)]); + let mut ends = Pb::new(); + ends.poly(&[(0.0, fold), (x0 + ear * 0.5, fold), (x0 + ear * 0.5, bot), (0.0, bot), (ear, (fold + bot) / 2.0)]); + ends.poly(&[(w, fold), (x1 - ear * 0.5, fold), (x1 - ear * 0.5, bot), (w, bot), (w - ear, (fold + bot) / 2.0)]); + shade_p.poly(&[(x0, bot - fold), (x0 + ear * 0.5, bot - fold), (x0 + ear * 0.5, bot)]); + shade_p.poly(&[(x1, bot - fold), (x1 - ear * 0.5, bot - fold), (x1 - ear * 0.5, bot)]); + let text = Rect::new(x0, top, x1, bot - fold); + return Geometry { + paths: vec![fill(ends.take()), shade(shade_p.take(), FillMode::DarkenLess), fill(b.take())], + text_rect: text, + handles: vec![], + sites: mid_sites(w, h), + }; + } + b.poly(&[(x0, top + fold), (x1, top + fold), (x1, bot), (x0, bot)]); + let mut ends = Pb::new(); + ends.poly(&[(0.0, top), (x0 + ear * 0.5, top), (x0 + ear * 0.5, bot - fold), (0.0, bot - fold), (ear, (bot - fold) / 2.0)]); + ends.poly(&[(w, top), (x1 - ear * 0.5, top), (x1 - ear * 0.5, bot - fold), (w, bot - fold), (w - ear, (bot - fold) / 2.0)]); + shade_p.poly(&[(x0, top + fold), (x0 + ear * 0.5, top + fold), (x0 + ear * 0.5, top)]); + shade_p.poly(&[(x1, top + fold), (x1 - ear * 0.5, top + fold), (x1 - ear * 0.5, top)]); + let text = Rect::new(x0, top + fold, x1, bot); + Geometry { + paths: vec![fill(ends.take()), shade(shade_p.take(), FillMode::DarkenLess), fill(b.take())], + text_rect: text, + handles: vec![], + sites: mid_sites(w, h), + } +} + +pub(super) fn scroll(vertical: bool, w: f64, h: f64, a0: f64) -> Geometry { + let ss = w.min(h); + let r = ss * a0.clamp(0.0, 25000.0) / 1e5 / 2.0; + let mut b = Pb::new(); + let mut curl = Pb::new(); + if vertical { + b.poly(&[(r, r * 2.0), (w - r, r * 2.0), (w - r, h - r), (r, h - r)]); + b.ellipse(w / 2.0, r, w / 2.0 - r * 0.0, r); + curl.ellipse(r, h - r, r, r); + curl.ellipse(w - r * 2.0, r, r * 0.5, r * 0.5); + let text = Rect::new(r, r * 2.0, w - r, h - r); + return Geometry { + paths: vec![fill(b.take()), shade(curl.take(), FillMode::DarkenLess)], + text_rect: text, + handles: vec![], + sites: mid_sites(w, h), + }; + } + b.poly(&[(r * 2.0, r), (w - r, r), (w - r, h - r), (r * 2.0, h - r)]); + b.ellipse(r, h / 2.0, r, h / 2.0); + curl.ellipse(w - r, r, r, r); + curl.ellipse(r, h - r * 2.0, r * 0.5, r * 0.5); + let text = Rect::new(r * 2.0, r, w - r, h - r); + Geometry { paths: vec![fill(b.take()), shade(curl.take(), FillMode::DarkenLess)], text_rect: text, handles: vec![], sites: mid_sites(w, h) } +} + +pub(super) fn action_button(name: &str, w: f64, h: f64) -> Geometry { + let body = corner_rect(w, h, [Corner::Square; 4]); + let ss = w.min(h); + let (cx, cy) = (w / 2.0, h / 2.0); + let s = ss * 0.3; + let mut g = Pb::new(); + match name { + "actionButtonBackPrevious" => { + g.poly(&[(cx + s, cy - s), (cx + s, cy + s), (cx - s, cy)]); + } + "actionButtonForwardNext" => { + g.poly(&[(cx - s, cy - s), (cx - s, cy + s), (cx + s, cy)]); + } + "actionButtonBeginning" => { + g.poly(&[(cx + s, cy - s), (cx + s, cy + s), (cx - s * 0.6, cy)]); + g.poly(&[(cx - s, cy - s), (cx - s * 0.7, cy - s), (cx - s * 0.7, cy + s), (cx - s, cy + s)]); + } + "actionButtonEnd" => { + g.poly(&[(cx - s, cy - s), (cx - s, cy + s), (cx + s * 0.6, cy)]); + g.poly(&[(cx + s * 0.7, cy - s), (cx + s, cy - s), (cx + s, cy + s), (cx + s * 0.7, cy + s)]); + } + "actionButtonHome" => { + g.poly(&[ + (cx, cy - s), + (cx + s, cy), + (cx + s * 0.7, cy), + (cx + s * 0.7, cy + s), + (cx - s * 0.7, cy + s), + (cx - s * 0.7, cy), + (cx - s, cy), + ]); + } + "actionButtonInformation" | "actionButtonHelp" => { + g.ellipse(cx, cy, s, s); + } + "actionButtonReturn" => { + g.m(cx + s, cy - s * 0.6).l(cx + s, cy + s * 0.2).q(cx + s, cy + s * 0.8, cx + s * 0.4, cy + s * 0.8).l(cx - s * 0.5, cy + s * 0.8); + g.l(cx - s * 0.5, cy + s * 0.4) + .l(cx - s, cy + s * 0.9) + .l(cx - s * 0.5, cy + s * 1.4) + .l(cx - s * 0.5, cy + s) + .l(cx + s * 0.6, cy + s) + .l(cx + s * 0.6, cy - s * 0.6) + .z(); + } + "actionButtonMovie" => { + g.poly(&[(cx - s, cy - s * 0.6), (cx + s * 0.4, cy - s * 0.6), (cx + s * 0.4, cy + s * 0.6), (cx - s, cy + s * 0.6)]); + g.poly(&[(cx + s * 0.4, cy), (cx + s, cy - s * 0.5), (cx + s, cy + s * 0.5)]); + } + "actionButtonDocument" => { + g.poly(&[(cx - s * 0.7, cy - s), (cx + s * 0.3, cy - s), (cx + s * 0.7, cy - s * 0.6), (cx + s * 0.7, cy + s), (cx - s * 0.7, cy + s)]); + } + "actionButtonSound" => { + g.poly(&[ + (cx - s, cy - s * 0.35), + (cx - s * 0.5, cy - s * 0.35), + (cx, cy - s), + (cx, cy + s), + (cx - s * 0.5, cy + s * 0.35), + (cx - s, cy + s * 0.35), + ]); + } + _ => {} + } + Geometry { + paths: vec![fill(body), shade(g.take(), FillMode::DarkenLess)], + text_rect: Rect::new(0.0, 0.0, w, h), + handles: vec![], + sites: mid_sites(w, h), + } +} diff --git a/crates/drawingml/src/preset/tests.rs b/crates/drawingml/src/preset/tests.rs new file mode 100644 index 000000000..dc18dcdb5 --- /dev/null +++ b/crates/drawingml/src/preset/tests.rs @@ -0,0 +1,420 @@ +use super::*; +use kurbo::Shape; + +#[test] +fn every_catalog_preset_builds_within_bounds() { + for p in CATALOG { + for (w, h) in [(200.0, 100.0), (100.0, 200.0), (50.0, 50.0), (0.0, 0.0), (1.0, 300.0)] { + let g = build(p.name, w, h, &[]).unwrap_or_else(|| panic!("{} did not build", p.name)); + assert!(!g.paths.is_empty(), "{} has no paths", p.name); + for s in &g.paths { + let bb = s.path.bounding_box(); + if w > 0.0 && h > 0.0 && !p.name.contains("Connector") && !p.name.contains("allout") { + let tol = w.max(h) * 0.35 + 1.0; + assert!(bb.x0 >= -tol && bb.y0 >= -tol && bb.x1 <= w + tol && bb.y1 <= h + tol, "{} {w}x{h} out of bounds: {bb:?}", p.name); + } + assert!(bb.x0.is_finite() && bb.y1.is_finite(), "{} produced non-finite geometry", p.name); + } + assert!(g.text_rect.x0.is_finite() && g.text_rect.y1.is_finite(), "{}", p.name); + } + } +} + +#[test] +fn hostile_adjust_values_never_panic() { + for p in CATALOG { + for v in [f64::NAN, f64::INFINITY, -1e12, 1e12, -1.0, 0.0] { + let adj = vec![v; 6]; + let g = build(p.name, 120.0, 80.0, &adj); + assert!(g.is_some(), "{}", p.name); + } + } + assert!(build("rect", f64::NAN, 10.0, &[]).is_none()); + assert!(build("noSuchShape", 10.0, 10.0, &[]).is_none()); +} + +#[test] +fn catalog_names_are_unique() { + let mut names: Vec<&str> = CATALOG.iter().map(|p| p.name).collect(); + names.sort_unstable(); + let n = names.len(); + names.dedup(); + assert_eq!(n, names.len()); + assert!(n >= 140, "catalog has {n} presets"); +} + +#[test] +fn round_rect_handle_maps_back() { + let g = build("roundRect", 200.0, 100.0, &[16667.0]).unwrap(); + let h = g.handles.first().unwrap(); + assert!((h.value_at(h.pos) - 16667.0).abs() <= 1.0); + assert_eq!(h.value_at(Point::new(-50.0, 0.0)), 0.0); + assert_eq!(h.value_at(Point::new(500.0, 0.0)), 50000.0); +} + +#[test] +fn partial_circle_angle_handles_map_back() { + // Start 0° sits at the right of the ellipse, end 270° at its top. + for name in ["pie", "chord", "arc"] { + let g = build(name, 200.0, 100.0, &[0.0, 16200000.0]).unwrap(); + assert_eq!(g.handles.iter().map(|h| h.index).collect::>(), vec![0, 1], "{name}"); + let (s, e) = (&g.handles[0], &g.handles[1]); + assert!((s.pos - Point::new(200.0, 50.0)).hypot() < 1e-6, "{name} {:?}", s.pos); + assert!((e.pos - Point::new(100.0, 0.0)).hypot() < 1e-6, "{name} {:?}", e.pos); + assert_eq!(s.value_at(s.pos), 0.0, "{name}"); + assert_eq!(e.value_at(e.pos), 16200000.0, "{name}"); + // Dragging to the bottom of the ellipse gives 90°. + assert_eq!(s.value_at(Point::new(100.0, 100.0)), 5400000.0, "{name}"); + // Off-axis angles on a non-square ellipse: the visual 45° ray meets the 200×100 + // ellipse at offset (√2000, √2000) from the centre. + let k = 2000f64.sqrt(); + let g = build(name, 200.0, 100.0, &[2700000.0, 8100000.0]).unwrap(); + let (s, e) = (&g.handles[0], &g.handles[1]); + assert!((s.pos - Point::new(100.0 + k, 50.0 + k)).hypot() < 1e-6, "{name} {:?}", s.pos); + assert!((e.pos - Point::new(100.0 - k, 50.0 + k)).hypot() < 1e-6, "{name} {:?}", e.pos); + for (deg, h) in [(45.0, s), (135.0, e)] { + let on = ((h.pos.x - 100.0) / 100.0).powi(2) + ((h.pos.y - 50.0) / 50.0).powi(2); + assert!((on - 1.0).abs() < 1e-9, "{name} {deg}° off ellipse: {:?}", h.pos); + assert!((h.value_at(h.pos) - deg * 60000.0).abs() <= 1.0, "{name} {deg}°: {}", h.value_at(h.pos)); + } + } + for deg in [45.0, 135.0, 200.0, 315.0] { + let g = build("blockArc", 200.0, 100.0, &[deg * 60000.0, 0.0, 25000.0]).unwrap(); + let start = g.handles.iter().find(|h| h.index == 0).unwrap(); + // Thickness 25 → the handle runs on the 87.5×37.5 mid-ring ellipse. + let on = ((start.pos.x - 100.0) / 87.5).powi(2) + ((start.pos.y - 50.0) / 37.5).powi(2); + assert!((on - 1.0).abs() < 1e-9, "blockArc {deg}° off ring: {:?}", start.pos); + let d = start.pos - Point::new(100.0, 50.0); + assert!((d.y.atan2(d.x).to_degrees().rem_euclid(360.0) - deg).abs() < 1e-6, "blockArc {deg}°: {:?}", start.pos); + assert!((start.value_at(start.pos) - deg * 60000.0).abs() <= 1.0, "blockArc {deg}°"); + } + let g = build("blockArc", 200.0, 100.0, &[10800000.0, 0.0, 25000.0]).unwrap(); + let start = g.handles.iter().find(|h| h.index == 0).unwrap(); + assert_eq!(start.value_at(start.pos), 10800000.0); + let thick = g.handles.iter().find(|h| h.index == 2).unwrap(); + assert!((thick.value_at(thick.pos) - 25000.0).abs() <= 1.0); +} + +#[test] +fn presets_with_adjust_values_expose_handles() { + // Presets whose adjust values have no handle of their own yet. + const NO_HANDLES: &[&str] = &[ + "curvedConnector3", + "snip2SameRect", + "snip2DiagRect", + "snipRoundRect", + "round1Rect", + "round2SameRect", + "round2DiagRect", + "teardrop", + "halfFrame", + "corner", + "diagStripe", + "foldedCorner", + "smileyFace", + "moon", + "gear6", + "gear9", + "bracketPair", + "bracePair", + "leftBracket", + "rightBracket", + "leftBrace", + "rightBrace", + "leftRightArrow", + "upDownArrow", + "quadArrow", + "leftRightUpArrow", + "bentArrow", + "uturnArrow", + "curvedRightArrow", + "curvedLeftArrow", + "curvedUpArrow", + "curvedDownArrow", + "leftUpArrow", + "bentUpArrow", + "circularArrow", + "leftCircularArrow", + "leftRightCircularArrow", + "swooshArrow", + "rightArrowCallout", + "downArrowCallout", + "leftArrowCallout", + "upArrowCallout", + "leftRightArrowCallout", + "upDownArrowCallout", + "quadArrowCallout", + "mathPlus", + "mathMinus", + "mathMultiply", + "mathDivide", + "mathEqual", + "mathNotEqual", + "ribbon2", + "ribbon", + "ellipseRibbon2", + "ellipseRibbon", + "leftRightRibbon", + "verticalScroll", + "horizontalScroll", + "wave", + "doubleWave", + "borderCallout1", + "borderCallout2", + "borderCallout3", + "accentCallout1", + "accentCallout2", + "accentCallout3", + "callout1", + "callout2", + "callout3", + "accentBorderCallout1", + "accentBorderCallout2", + "accentBorderCallout3", + ]; + for p in CATALOG { + if p.defaults.is_empty() || NO_HANDLES.contains(&p.name) { + continue; + } + let g = build(p.name, 200.0, 100.0, &[]).unwrap(); + assert!(!g.handles.is_empty(), "{} has adjust values but no handles", p.name); + } +} + +#[test] +fn rect_area_and_ellipse_area() { + let g = build("rect", 100.0, 50.0, &[]).unwrap(); + assert!((g.paths[0].path.area().abs() - 5000.0).abs() < 1e-6); + let e = build("ellipse", 100.0, 50.0, &[]).unwrap(); + let expect = std::f64::consts::PI * 50.0 * 25.0; + assert!((e.paths[0].path.area().abs() - expect).abs() / expect < 0.01); +} + +#[test] +fn lines_are_open() { + assert!(build("line", 10.0, 10.0, &[]).unwrap().is_open()); + assert!(!build("rect", 10.0, 10.0, &[]).unwrap().is_open()); +} + +fn on_path(g: &Geometry, p: Point, tol: f64) -> bool { + use kurbo::ParamCurveNearest; + g.paths.iter().any(|sp| sp.path.segments().any(|seg| seg.nearest(p, 1e-6).distance_sq <= tol * tol)) +} + +#[test] +fn fixed_shapes_follow_the_standards_coordinates() { + // Points of ECMA-376's definitions (in 21600ths), checked on a 216 × 108 box. + let (w, h) = (216.0, 108.0); + let at = |x: f64, y: f64| Point::new(w * x / 21600.0, h * y / 21600.0); + let g = build("lightningBolt", w, h, &[]).unwrap(); + for (x, y) in [(8472.0, 0.0), (16577.0, 12007.0), (21600.0, 21600.0), (0.0, 3890.0)] { + assert!(on_path(&g, at(x, y), 1e-6), "lightningBolt ({x}, {y})"); + } + let g = build("irregularSeal1", w, h, &[]).unwrap(); + assert!(on_path(&g, at(14522.0, 0.0), 1e-6) && on_path(&g, at(8485.0, 21600.0), 1e-6)); + let g = build("irregularSeal2", w, h, &[]).unwrap(); + assert!(on_path(&g, at(14790.0, 0.0), 1e-6) && on_path(&g, at(4917.0, 21600.0), 1e-6)); + // The heart's point is the middle of the bottom edge, its notch a quarter down. + let g = build("heart", w, h, &[]).unwrap(); + assert!(on_path(&g, Point::new(w / 2.0, h), 1e-6) && on_path(&g, Point::new(w / 2.0, h / 4.0), 1e-6)); + let g = build("flowChartDocument", w, h, &[]).unwrap(); + assert!(on_path(&g, at(21600.0, 17322.0), 1e-6) && on_path(&g, at(0.0, 20172.0), 1e-6)); + // Punched tape: the top edge dips to a fifth of the height a quarter of the way along. + let g = build("flowChartPunchedTape", w, h, &[]).unwrap(); + assert!(on_path(&g, Point::new(w / 4.0, h / 5.0), 1e-3) && on_path(&g, Point::new(w * 0.75, 0.0), 1e-3)); + let g = build("flowChartMultidocument", w, h, &[]).unwrap(); + assert!(on_path(&g, at(2972.0, 0.0), 1e-6) && on_path(&g, at(18595.0, 18022.0), 1e-6)); +} + +#[test] +fn notched_arrow_notch_parallels_the_head() { + // presetShapeDefinitions: the notch's point is at x1 = dy1 · dx2 / hd2, so its edges run + // parallel to the head's (25 on a 200 × 100 arrow at the default adjusts). + for (w, h, adj, x1) in [(200.0, 100.0, [50000.0, 50000.0], 25.0), (300.0, 120.0, [80000.0, 30000.0], 28.8)] { + let g = build("notchedRightArrow", w, h, &adj).unwrap(); + let notch = g.paths[0].path.elements().iter().find_map(|e| match e { + kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) if (p.y - h / 2.0).abs() < 1e-9 && p.x < w / 2.0 => Some(*p), + _ => None, + }); + assert!(notch.is_some_and(|p| (p.x - x1).abs() < 1e-6), "{w}x{h} {adj:?}: notch at {notch:?}, want x = {x1}"); + } +} + +#[test] +fn equation_shapes_match_powerpoints_outlines() { + // Corners read off PowerPoint's own outlines of the presets at their default adjusts. + let g = build("mathMultiply", 216.0, 216.0, &[]).unwrap(); + for (x, y) in [(0.32333, 0.15702), (0.5, 0.33369), (0.84298, 0.32333), (0.66631, 0.5), (0.33369, 0.5)] { + assert!(on_path(&g, Point::new(216.0 * x, 216.0 * y), 0.01), "multiply ({x}, {y})"); + } + let g = build("mathEqual", 300.0, 120.0, &[]).unwrap(); + for (x, y) in [(0.13255, 0.5588), (0.86745, 0.794), (0.13255, 0.206), (0.86745, 0.4412)] { + assert!(on_path(&g, Point::new(300.0 * x, 120.0 * y), 0.01), "equal ({x}, {y})"); + } + let g = build("mathPlus", 216.0, 216.0, &[]).unwrap(); + assert!(on_path(&g, Point::new(216.0 * 0.3824, 216.0 * 0.13255), 0.01) && on_path(&g, Point::new(216.0 * 0.86745, 216.0 * 0.6176), 0.01)); + // A tall "not equal": its slash, leaning at the default 110° from top to bottom, reaches + // past the box's sides exactly as far as PowerPoint's (-22.242 to 122.242). + let g = build("mathNotEqual", 100.0, 260.0, &[]).unwrap(); + use kurbo::Shape as _; + let bb = g.paths[0].path.bounding_box(); + assert!((bb.x0 + 22.242).abs() < 0.01 && (bb.x1 - 122.242).abs() < 0.01 && bb.y0.abs() < 1e-6 && (bb.y1 - 260.0).abs() < 1e-6, "{bb:?}"); +} + +#[test] +fn sun_and_waves_match_powerpoints_outlines() { + // Points read off PowerPoint's outlines of the presets (216 pt square, default adjusts). + let k = 216.0; + let g = build("sun", k, k, &[]).unwrap(); + for (x, y) in [(0.85352, 0.14644), (0.76161, 0.33987), (0.66013, 0.23839), (0.42825, 0.79823), (0.20177, 0.57175), (0.75, 0.5), (0.5, 0.25)] { + assert!(on_path(&g, Point::new(k * x, k * y), 0.02), "sun ({x}, {y})"); + } + let g = build("wave", k, k, &[]).unwrap(); + for (x, y) in [(0.21875, 0.00484), (1.0, 0.125), (1.0, 0.875), (0.0, 0.875)] { + assert!(on_path(&g, Point::new(k * x, k * y), 0.05), "wave ({x}, {y})"); + } + // Shifted: a wave of 300 × 120 with adj (20000, 8000) starts 0.16 of the width in. + let g = build("wave", 300.0, 120.0, &[20000.0, 8000.0]).unwrap(); + for (x, y) in [(0.18375, 0.00774), (0.84, 0.2), (1.0, 0.8), (0.16, 0.8)] { + assert!(on_path(&g, Point::new(300.0 * x, 120.0 * y), 0.05), "shifted wave ({x}, {y})"); + } +} + +#[test] +fn star_text_rectangles_follow_the_standard() { + // star5 at its default 19098 in a 144 pt square: the inner pentagon, about 0.38 of the + // width (PowerPoint wraps "Shape 3" at 18 pt there), from y ≈ 55 to ≈ 110. + let g = build("star5", 144.0, 144.0, &[]).unwrap(); + let t = g.text_rect; + assert!((t.width() - 55.0).abs() < 1.0, "{t:?}"); + assert!((t.y0 - 55.0).abs() < 1.0 && (t.y1 - 110.0).abs() < 1.0, "{t:?}"); + // star4 at 12500: the square through its inner points' 45° corners. + let g = build("star4", 144.0, 144.0, &[]).unwrap(); + assert!((g.text_rect.width() - 72.0 * 0.25 * 2f64.sqrt()).abs() < 0.5, "{:?}", g.text_rect); +} + +#[test] +fn stars_are_stretched_like_the_standards() { + // star5 in a 216 pt square: top point at the top middle, side points at 18 degrees on an + // ellipse 5.146% wider and 10.557% taller, centred 10.557% below the middle; its bottom + // points reach the box's bottom edge. + let k = 216.0; + let g = build("star5", k, k, &[]).unwrap(); + let (rx, ry, cy) = (k / 2.0 * 1.05146, k / 2.0 * 1.10557, k / 2.0 * 1.10557); + let a = 18f64.to_radians(); + assert!(on_path(&g, Point::new(k / 2.0, 0.0), 1e-6)); + assert!(on_path(&g, Point::new(k / 2.0 + rx * a.cos(), cy - ry * a.sin()), 1e-6)); + use kurbo::Shape as _; + let bb = g.paths[0].path.bounding_box(); + assert!((bb.x0).abs() < 0.5 && (bb.x1 - k).abs() < 0.5 && (bb.y1 - k).abs() < 0.5, "{bb:?}"); + // star6 is 15.47% wider than its box's half-width allows (its points touch the sides). + let g = build("star6", k, k, &[]).unwrap(); + let bb = g.paths[0].path.bounding_box(); + assert!((bb.x0).abs() < 0.5 && (bb.x1 - k).abs() < 0.5, "{bb:?}"); +} + +#[test] +fn moon_disk_and_drum_match_powerpoints_outlines() { + let k = 216.0; + // Moon, default: the inner edge runs through (0.5, 0.5) and (0.625, 0.2266) of the box. + let g = build("moon", k, k, &[]).unwrap(); + for (x, y) in [(0.5, 0.5), (0.626, 0.2266), (0.0, 0.5), (1.0, 0.0)] { + assert!(on_path(&g, Point::new(k * x, k * y), 0.5), "moon ({x}, {y})"); + } + // 300 × 120 at 30%: its inner edge reaches 30% of the width in. + let g = build("moon", 300.0, 120.0, &[30000.0]).unwrap(); + assert!(on_path(&g, Point::new(90.0, 60.0), 0.5)); + // Magnetic disk ends are ellipses h/6 high, drum ends w/6 across. + let g = build("flowChartMagneticDisk", k, k, &[]).unwrap(); + assert!(on_path(&g, Point::new(k, k / 6.0), 0.01) && on_path(&g, Point::new(k / 2.0, k / 3.0), 0.5)); + let g = build("flowChartMagneticDrum", k, k, &[]).unwrap(); + assert!(on_path(&g, Point::new(k / 6.0, 0.0), 0.01) && on_path(&g, Point::new(0.0, k / 2.0), 0.01)); +} + +#[test] +fn the_cloud_fills_its_box_at_any_aspect_and_has_creases() { + use kurbo::Shape as _; + for (w, h) in [(200.0, 200.0), (300.0, 100.0), (60.0, 180.0)] { + let g = build("cloud", w, h, &[]).unwrap(); + let bb = g.paths[0].path.bounding_box(); + assert!(bb.x0.abs() < w * 0.02 && (bb.x1 - w).abs() < w * 0.02, "{w}×{h}: {bb:?}"); + assert!(bb.y0.abs() < h * 0.02 && (bb.y1 - h).abs() < h * 0.02, "{w}×{h}: {bb:?}"); + assert_eq!(g.paths.len(), 2, "outline and creases"); + assert!(g.paths[1].stroke && g.paths[1].fill == FillMode::None); + } +} + +#[test] +fn spec_arcs_take_angles_as_directions_from_the_centre() { + // A DrawingML arc of a 2:1 ellipse from 45° to 90°: 45° is the direction (1, 1) from the + // centre, which on that ellipse is the point (r/√5)·(2, 1)… scaled so it lies on it. + let mut b = Pb::new(); + b.m(0.0, 0.0).arc_spec(200.0, 100.0, 45.0, 45.0); + let p = b.take(); + let end = p.elements().last().and_then(|e| match e { + kurbo::PathEl::CurveTo(_, _, q) => Some(*q), + _ => None, + }); + // Centre: the start sits at direction 45°, i.e. at (k, k) from it with (k/200)² + (k/100)² = 1. + let k = 1.0 / (1.0f64 / 40000.0 + 1.0 / 10000.0).sqrt(); + let c = Point::new(-k, -k); + let q = end.unwrap(); + assert!((q.x - c.x).abs() < 1e-6 && (q.y - (c.y + 100.0)).abs() < 1e-6, "{q:?} vs centre {c:?}"); +} + +#[test] +fn brackets_and_braces_fill_out_to_their_straight_side() { + // PowerPoint fills a bracket or brace (when it has a fill) between its curve and the + // straight side it opens to; only the curve is outlined. + for name in ["leftBracket", "rightBracket", "leftBrace", "rightBrace"] { + let g = build(name, 40.0, 100.0, &[]).unwrap(); + let filled: Vec<_> = g.paths.iter().filter(|p| p.fill != FillMode::None).collect(); + assert_eq!(filled.len(), 1, "{name}"); + assert!(!filled[0].stroke, "{name}: the filled area is not outlined"); + let area = filled[0].path.area().abs(); + assert!(area > 40.0 * 100.0 * 0.4 && area < 40.0 * 100.0, "{name}: {area}"); + assert!(g.paths.iter().any(|p| p.stroke && p.fill == FillMode::None), "{name}: the curve is outlined"); + } +} + +/// The end points of a path's move/line elements, in order. +fn vertices(p: &BezPath) -> Vec { + p.elements() + .iter() + .filter_map(|e| match e { + kurbo::PathEl::MoveTo(q) | kurbo::PathEl::LineTo(q) => Some(*q), + _ => None, + }) + .collect() +} + +#[test] +fn wedge_callout_mirrors_with_its_tip() { + // A tip below and right of the centre draws the mirror image of the same tip on the left; + // the same for a tip right of the centre, above or below it. presetShapeDefinitions puts + // the wedge's base at 2..5 twelfths of the edge on the near side of the centre and at + // 7..10 twelfths on the far side. + let (w, h) = (240.0, 120.0); + // Sorted and deduplicated: the outline closes on the corner it starts from, which the + // mirror moves to the opposite corner. + let sorted = |mut v: Vec| { + v.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y))); + v.dedup_by(|a, b| (*a - *b).hypot() < 1e-9); + v + }; + for (right, left, flip_x) in [([10000.0, 80000.0], [-10000.0, 80000.0], true), ([80000.0, 10000.0], [80000.0, -10000.0], false)] { + let a = vertices(&build("wedgeRectCallout", w, h, &right).unwrap().paths[0].path); + let b = vertices(&build("wedgeRectCallout", w, h, &left).unwrap().paths[0].path); + let b: Vec = b.iter().map(|p| if flip_x { Point::new(w - p.x, p.y) } else { Point::new(p.x, h - p.y) }).collect(); + let (a, b) = (sorted(a), sorted(b)); + assert_eq!(a.len(), b.len(), "{right:?}: {a:?} vs mirrored {left:?}: {b:?}"); + for (p, q) in a.iter().zip(&b) { + assert!((*p - *q).hypot() < 1e-6, "{right:?}: {a:?}\n is not the mirror of {left:?}: {b:?}"); + } + } + // The bottom wedge of a tip right of the centre leaves the edge at 7/12 and 10/12 of the width. + let a = vertices(&build("wedgeRectCallout", w, h, &[10000.0, 80000.0]).unwrap().paths[0].path); + for x in [w * 7.0 / 12.0, w * 10.0 / 12.0] { + assert!(a.iter().any(|p| (p.x - x).abs() < 1e-6 && (p.y - h).abs() < 1e-6), "no wedge corner at ({x}, {h}): {a:?}"); + } +} diff --git a/crates/drawingml/src/warp.rs b/crates/drawingml/src/warp.rs new file mode 100644 index 000000000..acb453add --- /dev/null +++ b/crates/drawingml/src/warp.rs @@ -0,0 +1,1023 @@ +//! WordArt text warps (DrawingML `a:bodyPr/a:prstTxWarp`). +//! +//! A warp preset is a list of bands laid over the shape's text rectangle (insets are ignored). +//! Text lines are shared out over the bands in order (the first ⌈n/k⌉ lines to the first band +//! and so on). A band either +//! - stretches its lines between a top and a bottom guide ([`Band::Fill`]): the ink box of the +//! band's lines fills the band vertically (a band whose ink is shorter than the tallest band's +//! keeps the same scale, centred), and horizontally the text is scaled so that the +//! widest band is filled along the mean length of its guides (one scale for the whole text), or +//! - sets its lines at their natural size along one guide ([`Band::Follow`], arches and circles): +//! glyphs keep their size, are placed by arc length (aligned as in the paragraph, the whole +//! guide being the line's width: insets don't count) and stand on +//! the guide's left normal; lines further from the guide stack outwards. Lines that are longer +//! than the guide are shrunk to fit. +//! +//! Positions along a guide are by arc length; a fill band maps the same fraction of its top and +//! bottom guide's length and interpolates between them. The guide curves were derived from +//! observation: synthetic decks of `I` bars were drawn with PowerPoint and every curve fitted to +//! the measured bar ends (most fit within a point on a 420 × 200 pt box). +use kurbo::{Arc, BezPath, PathEl, Point, Rect, Vec2}; + +/// Flattening tolerance for guides, in points. +const TOL: f64 = 0.02; + +/// A guide curve flattened to a polyline with cumulative arc lengths. +#[derive(Clone, Debug, Default)] +pub struct Guide { + pts: Vec, + cum: Vec, + closed: bool, +} + +impl Guide { + fn from_points(raw: impl IntoIterator, closed: bool) -> Guide { + let mut pts: Vec = Vec::new(); + let mut cum: Vec = Vec::new(); + for p in raw { + if !(p.x.is_finite() && p.y.is_finite()) { + continue; + } + match pts.last() { + Some(q) => { + let d = (p - *q).hypot(); + if d > 1e-9 { + cum.push(cum.last().copied().unwrap_or(0.0) + d); + pts.push(p); + } + } + None => { + pts.push(p); + cum.push(0.0); + } + } + if pts.len() > 100_000 { + break; + } + } + Guide { pts, cum, closed } + } + + /// A guide through `path` (any curves), flattened. + pub fn from_path(path: &BezPath, closed: bool) -> Guide { + let mut pts = Vec::new(); + kurbo::flatten(path.iter(), TOL, |el| match el { + PathEl::MoveTo(p) | PathEl::LineTo(p) => pts.push(p), + _ => {} + }); + Guide::from_points(pts, closed) + } + + /// Total arc length. + pub fn length(&self) -> f64 { + self.cum.last().copied().unwrap_or(0.0) + } + + /// Whether the guide is a closed loop (positions past its end wrap around). + pub fn is_closed(&self) -> bool { + self.closed + } + + /// Point and unit tangent at arc length `s`. Open guides continue along their end tangents + /// past either end; closed guides wrap. + pub fn at(&self, s: f64) -> (Point, Vec2) { + let len = self.length(); + let (Some(&first), Some(&last)) = (self.pts.first(), self.pts.last()) else { + return (Point::ZERO, Vec2::new(1.0, 0.0)); + }; + if self.pts.len() < 2 || len <= 1e-9 { + return (first, Vec2::new(1.0, 0.0)); + } + let s = if s.is_finite() { s } else { 0.0 }; + let s = if self.closed { s.rem_euclid(len) } else { s }; + let seg = |i: usize| -> (Point, Point, f64) { + let a = self.pts.get(i).copied().unwrap_or(first); + let b = self.pts.get(i + 1).copied().unwrap_or(last); + (a, b, self.cum.get(i).copied().unwrap_or(0.0)) + }; + let n = self.pts.len(); + let i = if s <= 0.0 { + 0 + } else if s >= len { + n - 2 + } else { + self.cum.partition_point(|c| *c <= s).saturating_sub(1).min(n - 2) + }; + let (a, b, c0) = seg(i); + let d = b - a; + let l = d.hypot(); + let t = if l > 1e-12 { d / l } else { Vec2::new(1.0, 0.0) }; + (a + t * (s - c0), t) + } + + /// The parallel curve `d` points away on the left of the direction of travel (above a + /// guide running left to right). + pub fn offset(&self, d: f64) -> Guide { + let n = self.pts.len(); + if n < 2 || !d.is_finite() || d == 0.0 { + return self.clone(); + } + let unit = |i: usize| -> Vec2 { + let (a, b) = (self.pts.get(i).copied().unwrap_or(Point::ZERO), self.pts.get(i + 1).copied().unwrap_or(Point::ZERO)); + let v = b - a; + let l = v.hypot(); + if l > 1e-12 { v / l } else { Vec2::ZERO } + }; + let pts = (0..n).map(|i| { + let before = if i > 0 { + unit(i - 1) + } else if self.closed { + unit(n - 2) + } else { + Vec2::ZERO + }; + let after = if i + 1 < n { + unit(i) + } else if self.closed { + unit(0) + } else { + Vec2::ZERO + }; + let t = before + after; + let l = t.hypot(); + let t = if l > 1e-12 { t / l } else { Vec2::new(1.0, 0.0) }; + self.pts.get(i).copied().unwrap_or(Point::ZERO) + Vec2::new(t.y, -t.x) * d + }); + Guide::from_points(pts.collect::>(), self.closed) + } + + /// The point at fraction `t` of the length. + pub fn at_frac(&self, t: f64) -> Point { + self.at(t * self.length()).0 + } +} + +/// Which line of a follow band sits on its guide. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LineAnchor { + /// The last line; earlier lines stack outwards (arch up, circle). + Last, + /// The first line; later lines stack below it (arch down). + First, + /// The middle of the band's lines. + Middle, +} + +#[derive(Clone, Debug)] +pub enum Band { + /// Text stretched between two guides. + Fill { top: Guide, bottom: Guide }, + /// Text at its natural size along a guide. + Follow { path: Guide, anchor: LineAnchor }, +} + +impl Band { + /// The length text is measured against: the mean of a fill band's guides. + pub fn length(&self) -> f64 { + match self { + Band::Fill { top, bottom } => (top.length() + bottom.length()) / 2.0, + Band::Follow { path, .. } => path.length(), + } + } +} + +/// A resolved warp: its bands in shape-local points. +#[derive(Clone, Debug)] +pub struct Warp { + pub bands: Vec, +} + +/// Presets whose text follows a guide at its natural size (no wrapping). +pub fn is_follow(name: &str) -> bool { + matches!(name, "textArchUp" | "textArchDown" | "textCircle" | "textButton") +} + +/// Default adjust values of a warp preset (`adj`, or `adj1`, `adj2`); empty for unknown presets. +pub fn adjust_defaults(name: &str) -> &'static [f64] { + match name { + "textPlain" => &[50_000.0], + "textStop" => &[25_000.0], + "textTriangle" | "textTriangleInverted" => &[50_000.0], + "textChevron" => &[25_000.0], + "textChevronInverted" => &[75_000.0], + "textRingInside" | "textRingOutside" => &[60_000.0], + "textArchUp" | "textCircle" | "textButton" => &[10_800_000.0], + "textArchDown" => &[0.0], + "textArchUpPour" | "textCirclePour" | "textButtonPour" => &[10_800_000.0, 50_000.0], + "textArchDownPour" => &[0.0, 25_000.0], + "textCurveUp" | "textCurveDown" => &[45_977.0], + "textCanUp" => &[85_000.0], + "textCanDown" => &[14_286.0], + "textWave1" | "textWave2" => &[12_500.0, 0.0], + "textDoubleWave1" | "textWave4" => &[6_250.0, 0.0], + "textInflate" | "textDeflate" => &[18_750.0], + "textInflateBottom" => &[60_000.0], + "textDeflateBottom" | "textDeflateTop" => &[50_000.0], + "textInflateTop" => &[40_000.0], + "textDeflateInflate" => &[35_000.0], + "textDeflateInflateDeflate" => &[25_000.0], + "textFadeRight" | "textFadeLeft" | "textFadeUp" | "textFadeDown" => &[33_333.0], + "textSlantUp" => &[55_556.0], + "textSlantDown" => &[44_444.0], + "textCascadeUp" | "textCascadeDown" => &[44_444.0], + _ => &[], + } +} + +/// Number of adjust values a warp preset takes. +pub fn adjust_count(name: &str) -> usize { + adjust_defaults(name).len() +} + +/// Whether `name` is a warp preset this module draws. +pub fn is_supported(name: &str) -> bool { + !adjust_defaults(name).is_empty() +} + +/// One DrawingML text-warp preset, in gallery order. +pub struct Preset { + /// `prstTxWarp` value, e.g. `textArchUp`. + pub id: &'static str, + /// Name shown on the Transform gallery tile. + pub label: &'static str, +} + +/// The 40 presets this module draws, Follow Path first, then Warp. +pub const CATALOG: &[Preset] = &[ + Preset { id: "textArchUp", label: "Arch Up" }, + Preset { id: "textArchDown", label: "Arch Down" }, + Preset { id: "textCircle", label: "Circle" }, + Preset { id: "textButton", label: "Button" }, + Preset { id: "textPlain", label: "Plain" }, + Preset { id: "textStop", label: "Stop" }, + Preset { id: "textTriangle", label: "Triangle" }, + Preset { id: "textTriangleInverted", label: "Inverted Triangle" }, + Preset { id: "textChevron", label: "Chevron" }, + Preset { id: "textChevronInverted", label: "Inverted Chevron" }, + Preset { id: "textRingInside", label: "Ring Inside" }, + Preset { id: "textRingOutside", label: "Ring Outside" }, + Preset { id: "textArchUpPour", label: "Arch Up Pour" }, + Preset { id: "textArchDownPour", label: "Arch Down Pour" }, + Preset { id: "textCirclePour", label: "Circle Pour" }, + Preset { id: "textButtonPour", label: "Button Pour" }, + Preset { id: "textCurveUp", label: "Curve Up" }, + Preset { id: "textCurveDown", label: "Curve Down" }, + Preset { id: "textCanUp", label: "Can Up" }, + Preset { id: "textCanDown", label: "Can Down" }, + Preset { id: "textWave1", label: "Wave 1" }, + Preset { id: "textWave2", label: "Wave 2" }, + Preset { id: "textDoubleWave1", label: "Double Wave 1" }, + Preset { id: "textWave4", label: "Wave 4" }, + Preset { id: "textInflate", label: "Inflate" }, + Preset { id: "textDeflate", label: "Deflate" }, + Preset { id: "textInflateBottom", label: "Inflate Bottom" }, + Preset { id: "textDeflateBottom", label: "Deflate Bottom" }, + Preset { id: "textInflateTop", label: "Inflate Top" }, + Preset { id: "textDeflateTop", label: "Deflate Top" }, + Preset { id: "textDeflateInflate", label: "Deflate Inflate" }, + Preset { id: "textDeflateInflateDeflate", label: "Deflate Inflate Deflate" }, + Preset { id: "textFadeRight", label: "Fade Right" }, + Preset { id: "textFadeLeft", label: "Fade Left" }, + Preset { id: "textFadeUp", label: "Fade Up" }, + Preset { id: "textFadeDown", label: "Fade Down" }, + Preset { id: "textSlantUp", label: "Slant Up" }, + Preset { id: "textSlantDown", label: "Slant Down" }, + Preset { id: "textCascadeUp", label: "Cascade Up" }, + Preset { id: "textCascadeDown", label: "Cascade Down" }, +]; + +/// A yellow adjust handle for one warp guide value. `pos` is in the same space as `rect` +/// (shape-local points). Dragging projects the pointer onto the handle's axis, or around the +/// text rectangle's centre for an angle. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WarpHandle { + pub index: usize, + pub pos: Point, + kind: HandleKind, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum HandleKind { + /// `pos = origin + dir * raw`. `value_at` projects onto that line and clamps. + Line { origin: Point, dir: Vec2, min: f64, max: f64 }, + /// Angle about `center` (y down, 0° at +x). `invert` is arch-down, whose guide angle is `180° − adj`. + Angle { center: Point, invert: bool }, +} + +impl WarpHandle { + /// The raw guide value (`adj` / `adjN`) for a pointer at `p`. + pub fn value_at(&self, p: Point) -> f64 { + match self.kind { + HandleKind::Line { origin, dir, min, max } => { + let l2 = dir.hypot2(); + if l2 < 1e-18 || !(p.x.is_finite() && p.y.is_finite()) { + return min; + } + let v = (p - origin).dot(dir) / l2; + if !v.is_finite() { + return min; + } + let (lo, hi) = (min.min(max), max.max(min)); + v.clamp(lo, hi).round() + } + HandleKind::Angle { center, invert } => { + let d = p - center; + if !(d.x.is_finite() && d.y.is_finite()) || d.hypot() < 1e-9 { + return 0.0; + } + let mut deg = d.y.atan2(d.x).to_degrees(); + if invert { + deg = 180.0 - deg; + } + (deg.rem_euclid(360.0) * 60_000.0).round() + } + } + } +} + +fn stored_raw(adj: &[f64], defaults: &[f64], i: usize) -> f64 { + adj.get(i).copied().filter(|v| v.is_finite()).or_else(|| defaults.get(i).copied()).unwrap_or(0.0) +} + +fn unit(rect: Rect, u: f64, v: f64) -> Point { + Point::new(rect.x0 + u * rect.width(), rect.y0 + v * rect.height()) +} + +/// A fraction handle: unit position `(x, sign * frac)`, raw = frac × 100 000, clamped to `lo..=hi`. +fn frac_handle(rect: Rect, index: usize, x: f64, frac: f64, lo: f64, hi: f64, sign: f64) -> WarpHandle { + let frac = frac.clamp(lo, hi); + WarpHandle { + index, + pos: unit(rect, x, sign * frac), + kind: HandleKind::Line { + origin: unit(rect, x, 0.0), + dir: Vec2::new(0.0, sign * rect.height() / 100_000.0), + min: lo * 100_000.0, + max: hi * 100_000.0, + }, + } +} + +/// A fraction handle that slides horizontally: unit `(frac, y)`. +fn frac_x_handle(rect: Rect, index: usize, y: f64, frac: f64, lo: f64, hi: f64) -> WarpHandle { + let frac = frac.clamp(lo, hi); + WarpHandle { + index, + pos: unit(rect, frac, y), + kind: HandleKind::Line { + origin: unit(rect, 0.0, y), + dir: Vec2::new(rect.width() / 100_000.0, 0.0), + min: lo * 100_000.0, + max: hi * 100_000.0, + }, + } +} + +/// Angle handle, inset from the ellipse so it doesn't sit on a resize handle. +fn angle_handle(rect: Rect, index: usize, deg: f64, invert: bool) -> WarpHandle { + let geom = if invert { 180.0 - deg } else { deg }; + let rad = geom.to_radians(); + let c = rect.center(); + WarpHandle { + index, + pos: Point::new(c.x + rect.width() * 0.36 * rad.cos(), c.y + rect.height() * 0.36 * rad.sin()), + kind: HandleKind::Angle { center: c, invert }, + } +} + +/// Inner-ellipse radius (pour presets): the handle sits on the downward point of that ellipse. +fn radius_handle(rect: Rect, r: f64) -> WarpHandle { + let r = r.clamp(0.0, 0.99); + WarpHandle { + index: 1, + pos: unit(rect, 0.5, 0.5 + 0.5 * r), + kind: HandleKind::Line { origin: unit(rect, 0.5, 0.5), dir: Vec2::new(0.0, 0.5 * rect.height() / 100_000.0), min: 0.0, max: 99_000.0 }, + } +} + +/// Wave phase: slides sideways around the middle of the lower half. +fn shift_handle(rect: Rect, s: f64) -> WarpHandle { + let s = s.clamp(-0.1, 0.1); + WarpHandle { + index: 1, + pos: unit(rect, 0.5 + s, 0.78), + kind: HandleKind::Line { origin: unit(rect, 0.5, 0.78), dir: Vec2::new(rect.width() / 100_000.0, 0.0), min: -10_000.0, max: 10_000.0 }, + } +} + +/// Yellow handles for warp `name` over text rectangle `rect`. Empty when `name` isn't a drawn +/// preset or `rect` isn't a usable box. Missing adjust values take the preset defaults. +pub fn handles(name: &str, adj: &[f64], rect: Rect) -> Vec { + if ![rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) { + return Vec::new(); + } + let rect = Rect::new(rect.x0.min(rect.x1), rect.y0.min(rect.y1), rect.x0.max(rect.x1), rect.y0.max(rect.y1)); + if rect.width() < 1e-3 || rect.height() < 1e-3 { + return Vec::new(); + } + let defaults = adjust_defaults(name); + if defaults.is_empty() { + return Vec::new(); + } + let raw = |i| stored_raw(adj, defaults, i); + let frac = |i| raw(i) / 100_000.0; + let deg = |i| (raw(i) / 60_000.0).rem_euclid(360.0); + let y = |out: &mut Vec, x, lo, hi, sign| out.push(frac_handle(rect, 0, x, frac(0), lo, hi, sign)); + let xh = |out: &mut Vec, yy, lo, hi| out.push(frac_x_handle(rect, 0, yy, frac(0), lo, hi)); + let mut out = Vec::with_capacity(defaults.len()); + match name { + "textPlain" => xh(&mut out, 0.28, 0.3, 0.7), + "textStop" => y(&mut out, 0.5, 0.14286, 0.5, 1.0), + "textTriangle" | "textTriangleInverted" => y(&mut out, 0.18, 0.0, 1.0, 1.0), + "textChevron" => y(&mut out, 0.18, 0.0, 0.5, 1.0), + "textChevronInverted" => y(&mut out, 0.18, 0.5, 1.0, 1.0), + "textFadeRight" => y(&mut out, 0.82, 0.0, 0.49999, 1.0), + "textFadeLeft" => y(&mut out, 0.18, 0.0, 0.49999, 1.0), + "textFadeUp" => xh(&mut out, 0.22, 0.0, 0.49999), + "textFadeDown" => xh(&mut out, 0.78, 0.0, 0.49999), + "textSlantUp" => y(&mut out, 0.18, 0.44444, 1.0, 1.0), + "textSlantDown" => y(&mut out, 0.18, 0.0, 0.55556, 1.0), + "textCascadeUp" => y(&mut out, 0.82, 0.0, 1.0, 1.0), + "textCascadeDown" => y(&mut out, 0.18, 0.0, 1.0, 1.0), + "textInflate" => y(&mut out, 0.62, 0.0, 0.2, -1.0), + "textDeflate" => y(&mut out, 0.62, 0.0, 0.375, 1.0), + "textInflateBottom" => y(&mut out, 0.62, 0.6, 1.0, 1.0), + "textDeflateBottom" => y(&mut out, 0.62, 0.0625, 1.0, 1.0), + "textInflateTop" => y(&mut out, 0.62, 0.0, 0.5, 1.0), + "textDeflateTop" => y(&mut out, 0.62, 0.0, 0.9375, 1.0), + "textDeflateInflate" => y(&mut out, 0.5, 0.05, 0.95, 1.0), + "textDeflateInflateDeflate" => y(&mut out, 0.5, 0.01, 1.0 / 3.0, 1.0), + "textCurveUp" | "textCurveDown" => y(&mut out, 0.18, 0.0, 0.56818, 1.0), + "textCanUp" => y(&mut out, 0.62, 0.5, 1.0, 1.0), + "textCanDown" => y(&mut out, 0.62, 0.0, 0.5, 1.0), + "textRingInside" | "textRingOutside" => y(&mut out, 0.5, 0.5, 0.99, 1.0), + "textArchUp" | "textCircle" | "textButton" => out.push(angle_handle(rect, 0, deg(0), false)), + "textArchDown" => out.push(angle_handle(rect, 0, deg(0), true)), + "textArchUpPour" | "textCirclePour" | "textButtonPour" => { + out.push(angle_handle(rect, 0, deg(0), false)); + out.push(radius_handle(rect, frac(1))); + } + "textArchDownPour" => { + out.push(angle_handle(rect, 0, deg(0), true)); + out.push(radius_handle(rect, frac(1))); + } + "textWave1" | "textWave2" => { + y(&mut out, 0.32, 0.0, 0.2, 1.0); + out.push(shift_handle(rect, frac(1))); + } + "textDoubleWave1" | "textWave4" => { + y(&mut out, 0.32, 0.0, 0.125, 1.0); + out.push(shift_handle(rect, frac(1))); + } + _ => return Vec::new(), + } + out +} + +/// Segments of a guide in unit coordinates (x, y in 0..1 over the text rectangle). +#[derive(Clone, Copy, Debug)] +enum Seg { + M(f64, f64), + L(f64, f64), + Q(f64, f64, f64, f64), + C(f64, f64, f64, f64, f64, f64), + /// Elliptical arc: centre, radii, start and sweep in degrees (y down: positive = clockwise). + A(f64, f64, f64, f64, f64, f64), +} + +enum Spec { + Fill(Vec, Vec), + Follow(Vec, LineAnchor), +} + +struct Adj<'a> { + raw: &'a [f64], + defaults: &'static [f64], +} + +impl Adj<'_> { + fn raw(&self, i: usize) -> f64 { + self.raw.get(i).copied().filter(|v| v.is_finite()).or_else(|| self.defaults.get(i).copied()).unwrap_or(0.0) + } + /// Adjust `i` as a fraction (1/100 000), pinned to `lo..=hi` (fractions). + fn frac(&self, i: usize, lo: f64, hi: f64) -> f64 { + (self.raw(i) / 100_000.0).clamp(lo, hi) + } + /// Adjust `i` as an angle in degrees (1/60 000), in 0..360. + fn angle(&self, i: usize) -> f64 { + (self.raw(i) / 60_000.0).rem_euclid(360.0) + } +} + +fn line(pts: &[(f64, f64)]) -> Vec { + pts.iter().enumerate().map(|(i, &(x, y))| if i == 0 { Seg::M(x, y) } else { Seg::L(x, y) }).collect() +} + +/// Quadratic from (0, y0) through control (0.5, c) to (1, y0). +fn quad(y0: f64, c: f64) -> Vec { + vec![Seg::M(0.0, y0), Seg::Q(0.5, c, 1.0, y0)] +} + +/// A sweep in (0, 360]: zero means a full turn. +fn turn(deg: f64) -> f64 { + let d = deg.rem_euclid(360.0); + if d < 1e-9 { 360.0 } else { d } +} + +/// Arc of the ellipse inscribed in the rectangle, scaled by `s` about its centre. +fn ellipse(s: f64, start: f64, sweep: f64) -> Vec { + vec![Seg::A(0.5, 0.5, 0.5 * s, 0.5 * s, start, sweep)] +} + +/// The arch-up arc for adjust angle `a`: symmetric about the top (270°), clockwise. +fn arch_up(a: f64) -> (f64, f64) { + (a, turn(2.0 * (270.0 - a))) +} + +/// The arch-down arc for adjust angle `a`: symmetric about the bottom (90°), counter-clockwise. +fn arch_down(a: f64) -> (f64, f64) { + (180.0 - a, -turn(180.0 - 2.0 * a)) +} + +/// The circle arc for adjust angle `a`: from `a` clockwise, symmetric about 0°. +fn circle(a: f64) -> (f64, f64) { + (a, turn(2.0 * (360.0 - a))) +} + +/// One cubic wave period from x0 to x1 at height y: up first when `k` > 0 (y decreases). +fn wave(out: &mut Vec, x0: f64, x1: f64, y: f64, k: f64) { + let l = x1 - x0; + out.push(Seg::C(x0 + l / 3.0, y - k, x0 + 2.0 * l / 3.0, y + k, x1, y)); +} + +/// Wave guides: `periods` cubic waves over the width, shifted apart by `shift` (fraction). +fn waves(a: f64, shift: f64, periods: usize, up_first: bool) -> Spec { + let d = 2.0 * shift; + let (top_x, bot_x) = if d >= 0.0 { ((0.0, 1.0 - d), (d, 1.0)) } else { ((-d, 1.0), (0.0, 1.0 + d)) }; + let k = if up_first { a * 10.0 / 3.0 } else { -a * 10.0 / 3.0 }; + let guide = |(x0, x1): (f64, f64), y: f64| { + let mut v = vec![Seg::M(x0, y)]; + let n = periods.max(1) as f64; + for i in 0..periods.max(1) { + let f = i as f64; + wave(&mut v, x0 + (x1 - x0) * f / n, x0 + (x1 - x0) * (f + 1.0) / n, y, k); + } + v + }; + Spec::Fill(guide(top_x, a), guide(bot_x, 1.0 - a)) +} + +fn spec(name: &str, a: &Adj) -> Option> { + use Seg::*; + let fill = |t: Vec, b: Vec| vec![Spec::Fill(t, b)]; + let top = || line(&[(0.0, 0.0), (1.0, 0.0)]); + let bottom = || line(&[(0.0, 1.0), (1.0, 1.0)]); + Some(match name { + "textPlain" => { + let d = 2.0 * (a.frac(0, 0.3, 0.7) - 0.5); + if d < 0.0 { + fill(line(&[(-d, 0.0), (1.0, 0.0)]), line(&[(0.0, 1.0), (1.0 + d, 1.0)])) + } else { + fill(line(&[(0.0, 0.0), (1.0 - d, 0.0)]), line(&[(d, 1.0), (1.0, 1.0)])) + } + } + "textStop" => { + let y = a.frac(0, 0.14286, 0.5); + let t = 1.0 / 3.0; + fill(line(&[(0.0, y), (t, 0.0), (1.0 - t, 0.0), (1.0, y)]), line(&[(0.0, 1.0 - y), (t, 1.0), (1.0 - t, 1.0), (1.0, 1.0 - y)])) + } + "textTriangle" => { + let y = a.frac(0, 0.0, 1.0); + fill(line(&[(0.0, y), (0.5, 0.0), (1.0, y)]), bottom()) + } + "textTriangleInverted" => { + let y = a.frac(0, 0.0, 1.0); + fill(top(), line(&[(0.0, y), (0.5, 1.0), (1.0, y)])) + } + "textChevron" => { + let y = a.frac(0, 0.0, 0.5); + fill(line(&[(0.0, y), (0.5, 0.0), (1.0, y)]), line(&[(0.0, 1.0), (0.5, 1.0 - y), (1.0, 1.0)])) + } + "textChevronInverted" => { + let y = a.frac(0, 0.5, 1.0); + fill(line(&[(0.0, 0.0), (0.5, 1.0 - y), (1.0, 0.0)]), line(&[(0.0, y), (0.5, 1.0), (1.0, y)])) + } + "textFadeRight" => { + let y = a.frac(0, 0.0, 0.49999); + fill(line(&[(0.0, 0.0), (1.0, y)]), line(&[(0.0, 1.0), (1.0, 1.0 - y)])) + } + "textFadeLeft" => { + let y = a.frac(0, 0.0, 0.49999); + fill(line(&[(0.0, y), (1.0, 0.0)]), line(&[(0.0, 1.0 - y), (1.0, 1.0)])) + } + "textFadeUp" => { + let x = a.frac(0, 0.0, 0.49999); + fill(line(&[(x, 0.0), (1.0 - x, 0.0)]), bottom()) + } + "textFadeDown" => { + let x = a.frac(0, 0.0, 0.49999); + fill(top(), line(&[(x, 1.0), (1.0 - x, 1.0)])) + } + "textSlantUp" => { + let y = a.frac(0, 0.44444, 1.0); + fill(line(&[(0.0, y), (1.0, 0.0)]), line(&[(0.0, 1.0), (1.0, 1.0 - y)])) + } + "textSlantDown" => { + let y = a.frac(0, 0.0, 0.55556); + fill(line(&[(0.0, 0.0), (1.0, 1.0 - y)]), line(&[(0.0, y), (1.0, 1.0)])) + } + "textCascadeUp" => { + let y = a.frac(0, 0.0, 1.0); + fill(line(&[(0.0, (1.0 - y) / 4.0), (1.0, 0.0)]), line(&[(0.0, 1.0), (1.0, y)])) + } + "textCascadeDown" => { + let y = a.frac(0, 0.0, 1.0); + fill(line(&[(0.0, 0.0), (1.0, (1.0 - y) / 4.0)]), line(&[(0.0, y), (1.0, 1.0)])) + } + "textInflate" => { + let y = a.frac(0, 0.0, 0.2); + fill(quad(y, -y), quad(1.0 - y, 1.0 + y)) + } + "textDeflate" => { + let y = a.frac(0, 0.0, 0.375); + fill(quad(0.0, 2.0 * y), quad(1.0, 1.0 - 2.0 * y)) + } + "textInflateBottom" => { + let y = a.frac(0, 0.6, 1.0); + fill(top(), quad(y, 1.0)) + } + "textDeflateBottom" => { + let y = a.frac(0, 0.0625, 1.0); + fill(top(), quad(1.0, 2.0 * y - 1.0)) + } + "textInflateTop" => { + let y = a.frac(0, 0.0, 0.5); + fill(quad(y, 0.0), bottom()) + } + "textDeflateTop" => { + let y = a.frac(0, 0.0, 0.9375); + fill(quad(0.0, 2.0 * y), bottom()) + } + "textDeflateInflate" => { + // The bands are split by a curve from mid-height up to `y`, with a gap of a tenth of + // a band's height. + let y = a.frac(0, 0.05, 0.95); + let g = 0.05; + vec![Spec::Fill(top(), quad(0.5 - g, 2.0 * y - 0.5 - g)), Spec::Fill(quad(0.5 + g, 2.0 * y - 0.5 + g), bottom())] + } + "textDeflateInflateDeflate" => { + let y = a.frac(0, 0.01, 1.0 / 3.0); + let g = 1.0 / 30.0; + let (t1, t2) = (1.0 / 3.0, 2.0 / 3.0); + let (c1, c2) = (2.0 * y - t1, 2.0 * (1.0 - y) - t2); + vec![ + Spec::Fill(top(), quad(t1 - g, c1 - g)), + Spec::Fill(quad(t1 + g, c1 + g), quad(t2 - g, c2 - g)), + Spec::Fill(quad(t2 + g, c2 + g), bottom()), + ] + } + "textCurveUp" | "textCurveDown" => { + let y = a.frac(0, 0.0, 0.56818); + let b = vec![M(0.0, 1.0 - 3.0 * y / 8.0), C(1.0 / 3.0, 1.0 + y / 8.0, 2.0 / 3.0, 1.0 + y / 8.0, 1.0, 1.0 - 3.0 * y / 8.0)]; + let t = if name == "textCurveUp" { + vec![M(0.0, y), C(1.0 / 3.0, 1.25 * y, 2.0 / 3.0, 0.75 * y, 1.0, 0.0)] + } else { + vec![M(0.0, 0.0), C(1.0 / 3.0, 0.75 * y, 2.0 / 3.0, 1.25 * y, 1.0, y)] + }; + fill(t, b) + } + "textCanUp" => { + let r = 1.0 - a.frac(0, 0.5, 1.0); + fill(vec![A(0.5, r, 0.5, r, 180.0, 180.0)], vec![A(0.5, 1.0, 0.5, r, 180.0, 180.0)]) + } + "textCanDown" => { + let r = a.frac(0, 0.0, 0.5); + fill(vec![A(0.5, 0.0, 0.5, r, 180.0, -180.0)], vec![A(0.5, 1.0 - r, 0.5, r, 180.0, -180.0)]) + } + "textRingInside" | "textRingOutside" => { + let h = a.frac(0, 0.5, 0.99); + let sweep = if name == "textRingInside" { 360.0 } else { -360.0 }; + fill(vec![A(0.5, h / 2.0, 0.5, h / 2.0, 180.0, sweep)], vec![A(0.5, 1.0 - h / 2.0, 0.5, h / 2.0, 180.0, sweep)]) + } + "textWave1" => vec![waves(a.frac(0, 0.0, 0.2), a.frac(1, -0.1, 0.1), 1, true)], + "textWave2" => vec![waves(a.frac(0, 0.0, 0.2), a.frac(1, -0.1, 0.1), 1, false)], + "textDoubleWave1" => vec![waves(a.frac(0, 0.0, 0.125), a.frac(1, -0.1, 0.1), 2, true)], + "textWave4" => vec![waves(a.frac(0, 0.0, 0.125), a.frac(1, -0.1, 0.1), 2, false)], + "textArchUp" => { + let (s, w) = arch_up(a.angle(0)); + vec![Spec::Follow(ellipse(1.0, s, w), LineAnchor::Last)] + } + "textArchDown" => { + let (s, w) = arch_down(a.angle(0)); + vec![Spec::Follow(ellipse(1.0, s, w), LineAnchor::First)] + } + "textCircle" => { + let (s, w) = circle(a.angle(0)); + vec![Spec::Follow(ellipse(1.0, s, w), LineAnchor::Last)] + } + "textButton" => { + let t = a.angle(0); + let (s, w) = arch_up(t); + let half = 0.5 * t.to_radians().cos().abs(); + vec![ + Spec::Follow(ellipse(1.0, s, w), LineAnchor::Last), + Spec::Follow(line(&[(0.5 - half, 0.5), (0.5 + half, 0.5)]), LineAnchor::Middle), + Spec::Follow(ellipse(1.0, 360.0 - s, -w), LineAnchor::First), + ] + } + "textArchUpPour" => { + let (s, w) = arch_up(a.angle(0)); + let r = a.frac(1, 0.0, 0.99); + fill(ellipse(1.0, s, w), ellipse(r, s, w)) + } + "textArchDownPour" => { + let (s, w) = arch_down(a.angle(0)); + let r = a.frac(1, 0.0, 0.99); + fill(ellipse(r, s, w), ellipse(1.0, s, w)) + } + "textCirclePour" => { + let (s, w) = circle(a.angle(0)); + let r = a.frac(1, 0.0, 0.99); + fill(ellipse(1.0, s, w), ellipse(r, s, w)) + } + "textButtonPour" => { + let (s, w) = arch_up(a.angle(0)); + let r = a.frac(1, 0.0, 0.99); + // The middle band runs between chords of the inner ellipse halfway to its top and + // bottom. + let (dy, half) = (0.25 * r, 0.25 * r * 3f64.sqrt()); + vec![ + Spec::Fill(ellipse(1.0, s, w), ellipse(r, s, w)), + Spec::Fill(line(&[(0.5 - half, 0.5 - dy), (0.5 + half, 0.5 - dy)]), line(&[(0.5 - half, 0.5 + dy), (0.5 + half, 0.5 + dy)])), + Spec::Fill(ellipse(r, 360.0 - s, -w), ellipse(1.0, 360.0 - s, -w)), + ] + } + _ => return None, + }) +} + +/// Build a guide from unit-space segments mapped onto `r`. +fn guide(segs: &[Seg], r: Rect) -> Guide { + let mut p = BezPath::new(); + let mut started = false; + let mut closed = false; + for s in segs { + match *s { + Seg::M(x, y) => { + p.move_to((x, y)); + started = true; + } + Seg::L(x, y) => { + if started { + p.line_to((x, y)) + } else { + p.move_to((x, y)) + } + started = true; + } + Seg::Q(cx, cy, x, y) => { + if started { + p.quad_to((cx, cy), (x, y)); + } + } + Seg::C(x1, y1, x2, y2, x, y) => { + if started { + p.curve_to((x1, y1), (x2, y2), (x, y)); + } + } + Seg::A(cx, cy, rx, ry, start, sweep) => { + let arc = Arc::new((cx, cy), (rx.max(0.0), ry.max(0.0)), start.to_radians(), sweep.to_radians(), 0.0); + let s0 = Point::new(cx + rx * start.to_radians().cos(), cy + ry * start.to_radians().sin()); + if started { + p.line_to(s0); + } else { + p.move_to(s0); + } + started = true; + for el in arc.append_iter(1e-5) { + p.push(el); + } + closed = sweep.abs() >= 359.999; + } + } + } + let m = kurbo::Affine::new([r.width(), 0.0, 0.0, r.height(), r.x0, r.y0]); + p.apply_affine(m); + Guide::from_path(&p, closed) +} + +/// The warp `name` with adjust values `adj` (raw guide values; missing ones take the defaults) +/// over text rectangle `rect`. `None` for unknown presets, `textNoShape` or a non-finite rect. +pub fn warp(name: &str, adj: &[f64], rect: Rect) -> Option { + if ![rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) { + return None; + } + let r = Rect::new(rect.x0.min(rect.x1), rect.y0.min(rect.y1), rect.x0.max(rect.x1), rect.y0.max(rect.y1)); + let a = Adj { raw: adj, defaults: adjust_defaults(name) }; + let specs = spec(name, &a)?; + let bands = specs + .into_iter() + .map(|s| match s { + Spec::Fill(t, b) => Band::Fill { top: guide(&t, r), bottom: guide(&b, r) }, + Spec::Follow(p, anchor) => Band::Follow { path: guide(&p, r), anchor }, + }) + .collect(); + Some(Warp { bands }) +} + +/// The band line `line` of `lines` goes to when there are `bands` bands: the first ⌈n/k⌉ lines +/// share the first band, and so on. +pub fn band_of_line(line: usize, lines: usize, bands: usize) -> usize { + if bands <= 1 || lines == 0 { + return 0; + } + // Band b holds lines ceil(b·n/k) .. ceil((b+1)·n/k). + (0..bands).rev().find(|&b| (b * lines).div_ceil(bands) <= line).unwrap_or(0) +} + +/// One laid-out line as the warp sees it (layout coordinates, points). +#[derive(Clone, Copy, Debug)] +pub struct WarpLine { + /// Ink box of the line's glyphs; `None` for a line without ink. + pub ink: Option, + /// Advance extent of the line (left, right). + pub extent: (f64, f64), + /// Reference height for follow bands: the y that sits on the guide. + pub ref_y: f64, +} + +#[derive(Clone, Copy, Debug)] +enum LineMap { + Fill { + band: usize, + cx: f64, + k: f64, + len: f64, + y0: f64, + y1: f64, + }, + /// `guide`: the line's own parallel guide in [`Mapper::offsets`], if it is off the band's. + Follow { + band: usize, + guide: Option, + e0: f64, + s0: f64, + k: f64, + ref_y: f64, + }, +} + +/// Maps points of laid-out lines onto a warp. +#[derive(Clone, Debug)] +pub struct Mapper<'a> { + warp: &'a Warp, + lines: Vec, + offsets: Vec, + zoom: f64, +} + +impl Warp { + /// Plan the mapping of `lines`, laid out in the inner text box `inner` (the line's place in + /// it gives its alignment along follow guides; the insets themselves don't shorten a guide). + pub fn mapper(&self, lines: &[WarpLine], inner: Rect) -> Mapper<'_> { + let nb = self.bands.len().max(1); + let n = lines.len(); + let band_of: Vec = (0..n).map(|i| band_of_line(i, n, nb).min(nb - 1)).collect(); + // Ink of the whole text, for the shared horizontal centre and scale of fill bands. + let ink_all = lines.iter().filter_map(|l| l.ink).reduce(|a, b| a.union(b)); + let cx = ink_all.map(|r| r.center().x).unwrap_or(0.0); + let mut k_fill = f64::INFINITY; + let mut zoom: f64 = 1.0; + let mut band_y: Vec> = vec![None; nb]; + for (i, l) in lines.iter().enumerate() { + let Some(r) = l.ink else { continue }; + let b = band_of.get(i).copied().unwrap_or(0); + if let Some(slot) = band_y.get_mut(b) { + *slot = Some(match *slot { + Some((y0, y1)) => (y0.min(r.y0), y1.max(r.y1)), + None => (r.y0, r.y1), + }); + } + } + // Every fill band maps a box as tall as the tallest band's ink, centred on its own ink, so + // a band with fewer lines keeps them at the same size, centred between its guides. + let tallest = band_y.iter().flatten().map(|(y0, y1)| y1 - y0).fold(0.0, f64::max); + let band_y: Vec> = + band_y.iter().map(|r| r.map(|(y0, y1)| ((y0 + y1 - tallest) / 2.0, (y0 + y1 + tallest) / 2.0))).collect(); + for (b, band) in self.bands.iter().enumerate() { + if let Band::Fill { top, bottom } = band { + let half = lines + .iter() + .enumerate() + .filter(|(i, _)| band_of.get(*i) == Some(&b)) + .filter_map(|(_, l)| l.ink) + .map(|r| (r.x0 - cx).abs().max((r.x1 - cx).abs())) + .fold(0.0, f64::max); + if half > 1e-9 { + k_fill = k_fill.min(band.length() / (2.0 * half)); + } + if let Some(Some((y0, y1))) = band_y.get(b) { + let thick = [0.0, 0.25, 0.5, 0.75, 1.0].iter().map(|&u| (top.at_frac(u) - bottom.at_frac(u)).hypot()).fold(0.0, f64::max); + if y1 - y0 > 1e-9 { + zoom = zoom.max(thick / (y1 - y0)); + } + } + } + } + if !k_fill.is_finite() { + k_fill = 1.0; + } + zoom = zoom.max(k_fill); + // Per band, measured once: its widest line and its first and last lines' baselines. + let mut stats: Vec> = vec![None; nb]; + for (l, &b) in lines.iter().zip(&band_of) { + let w = l.extent.1 - l.extent.0; + if let Some(slot) = stats.get_mut(b) { + match slot { + Some((widest, _, last)) => { + *widest = widest.max(w); + *last = l.ref_y; + } + None => *slot = Some((0.0f64.max(w), l.ref_y, l.ref_y)), + } + } + } + let mut maps = Vec::with_capacity(n); + let mut offsets = Vec::new(); + for (i, l) in lines.iter().enumerate() { + let b = band_of.get(i).copied().unwrap_or(0); + let band = stats.get(b).copied().flatten(); + match self.bands.get(b) { + Some(Band::Fill { .. }) | None => { + let (y0, y1) = band_y.get(b).copied().flatten().unwrap_or((0.0, 1.0)); + let len = self.bands.get(b).map(Band::length).unwrap_or(0.0); + maps.push(LineMap::Fill { band: b, cx, k: k_fill, len, y0, y1 }); + } + Some(Band::Follow { path, anchor }) => { + let len = path.length(); + let widest = band.map_or(0.0, |s| s.0); + let k = if widest > len && widest > 1e-9 { len / widest } else { 1.0 }; + let ref_y = match anchor { + LineAnchor::Last => band.map(|s| s.2), + LineAnchor::First => band.map(|s| s.1), + LineAnchor::Middle => band.map(|s| (s.1 + s.2) / 2.0), + } + .unwrap_or(l.ref_y); + // Lines off the guide run on a parallel curve at their natural length. + let d = (ref_y - l.ref_y) * k; + let (guide, len) = if d.abs() > 1e-6 && d.is_finite() && offsets.len() < 4096 { + let g = path.offset(d); + let len = g.length(); + offsets.push(g); + (Some(offsets.len() - 1), len) + } else { + (None, len) + }; + let (e0, e1) = l.extent; + let w = (e1 - e0).max(0.0) * k; + let (gl, gr) = ((e0 - inner.x0).max(0.0), (inner.x1 - e1).max(0.0)); + let f = if gl + gr > 1e-9 { gl / (gl + gr) } else { 0.5 }; + let s0 = f * (len - w); + maps.push(LineMap::Follow { band: b, guide, e0, s0, k, ref_y: l.ref_y }); + } + } + } + Mapper { warp: self, lines: maps, offsets, zoom: if zoom.is_finite() { zoom.clamp(1.0, 1.0e4) } else { 1.0 } } + } +} + +impl Mapper<'_> { + /// How much the warp magnifies text at most (for flattening tolerances), ≥ 1. + pub fn zoom(&self) -> f64 { + self.zoom + } + + /// Map point `p` of line `line` (layout coordinates) onto the warp. + pub fn map(&self, line: usize, p: Point) -> Point { + let out = match self.lines.get(line) { + Some(&LineMap::Fill { band, cx, k, len, y0, y1 }) => { + let Some(Band::Fill { top, bottom }) = self.warp.bands.get(band) else { return p }; + let u = if len > 1e-9 { 0.5 + (p.x - cx) * k / len } else { 0.5 }; + let v = if y1 - y0 > 1e-9 { (p.y - y0) / (y1 - y0) } else { 0.5 }; + let (t, b) = (top.at_frac(u), bottom.at_frac(u)); + t.lerp(b, v) + } + Some(&LineMap::Follow { band, guide, e0, s0, k, ref_y }) => { + let path = match (guide.and_then(|g| self.offsets.get(g)), self.warp.bands.get(band)) { + (Some(g), _) => g, + (None, Some(Band::Follow { path, .. })) => path, + _ => return p, + }; + let s = s0 + (p.x - e0) * k; + let d = (ref_y - p.y) * k; + let (q, t) = path.at(s); + q + Vec2::new(t.y, -t.x) * d + } + None => p, + }; + if out.x.is_finite() && out.y.is_finite() { out } else { p } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/drawingml/src/warp/tests.rs b/crates/drawingml/src/warp/tests.rs new file mode 100644 index 000000000..c4aa1bbd0 --- /dev/null +++ b/crates/drawingml/src/warp/tests.rs @@ -0,0 +1,338 @@ +use super::{ + Band, BezPath, CATALOG, Guide, Point, Rect, WarpLine, adjust_count, adjust_defaults, band_of_line, handles, is_follow, is_supported, warp, +}; + +const W: f64 = 420.0; +const H: f64 = 200.0; + +fn rect() -> Rect { + Rect::new(0.0, 0.0, W, H) +} + +/// One line whose ink box is `ink`. +fn one_line(ink: Rect) -> Vec { + vec![WarpLine { ink: Some(ink), extent: (ink.x0, ink.x1), ref_y: ink.y1 }] +} + +/// Where the unit-square point (u, v) of a single line's ink box lands, in unit coordinates. +fn land(name: &str, adj: &[f64], u: f64, v: f64) -> (f64, f64) { + let w = warp(name, adj, rect()).unwrap(); + let ink = Rect::new(100.0, 50.0, 300.0, 90.0); + let lines = one_line(ink); + let m = w.mapper(&lines, rect().inset(-5.0)); + let p = m.map(0, Point::new(ink.x0 + u * ink.width(), ink.y0 + v * ink.height())); + (p.x / W, p.y / H) +} + +fn close(a: (f64, f64), b: (f64, f64), tol: f64) -> bool { + (a.0 - b.0).abs() <= tol && (a.1 - b.1).abs() <= tol +} + +#[track_caller] +fn corners(name: &str, adj: &[f64], tl: (f64, f64), tr: (f64, f64), bl: (f64, f64), br: (f64, f64)) { + for ((u, v), want) in [((0.0, 0.0), tl), ((1.0, 0.0), tr), ((0.0, 1.0), bl), ((1.0, 1.0), br)] { + let got = land(name, adj, u, v); + assert!(close(got, want, 0.004), "{name} corner ({u},{v}): got {got:?}, want {want:?}"); + } +} + +#[test] +fn catalog_is_the_forty_drawn_presets() { + assert_eq!(CATALOG.len(), 40); + let mut seen = std::collections::BTreeSet::new(); + for p in CATALOG { + assert!(seen.insert(p.id), "duplicate {}", p.id); + assert!(!p.label.is_empty(), "{}", p.id); + assert!(is_supported(p.id), "{}", p.id); + assert_eq!(is_follow(p.id), matches!(p.id, "textArchUp" | "textArchDown" | "textCircle" | "textButton"), "{}", p.id); + } +} + +#[test] +fn every_preset_builds_finite_guides() { + for p in CATALOG { + let name = p.id; + assert!(is_supported(name), "{name}"); + let w = warp(name, &[], rect()).unwrap_or_else(|| panic!("{name}")); + assert!(!w.bands.is_empty(), "{name}"); + for b in &w.bands { + let l = b.length(); + assert!(l.is_finite() && l > 10.0, "{name}: band length {l}"); + } + assert_eq!(adjust_count(name), adjust_defaults(name).len()); + } + assert!(warp("textNoShape", &[], rect()).is_none()); + assert!(warp("bogus", &[], rect()).is_none()); + assert!(!is_supported("textNoShape")); +} + +#[test] +fn straight_presets_put_the_ink_corners_on_their_guides() { + corners("textPlain", &[], (0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)); + // A smaller adjust shears the text: the top edge starts 40 % in, the bottom edge ends 60 % in. + corners("textPlain", &[30_000.0], (0.4, 0.0), (1.0, 0.0), (0.0, 1.0), (0.6, 1.0)); + corners("textStop", &[], (0.0, 0.25), (1.0, 0.25), (0.0, 0.75), (1.0, 0.75)); + corners("textTriangle", &[], (0.0, 0.5), (1.0, 0.5), (0.0, 1.0), (1.0, 1.0)); + corners("textTriangleInverted", &[25_000.0], (0.0, 0.0), (1.0, 0.0), (0.0, 0.25), (1.0, 0.25)); + corners("textChevron", &[], (0.0, 0.25), (1.0, 0.25), (0.0, 1.0), (1.0, 1.0)); + corners("textChevronInverted", &[], (0.0, 0.0), (1.0, 0.0), (0.0, 0.75), (1.0, 0.75)); + corners("textFadeRight", &[], (0.0, 0.0), (1.0, 0.33333), (0.0, 1.0), (1.0, 0.66667)); + corners("textFadeLeft", &[], (0.0, 0.33333), (1.0, 0.0), (0.0, 0.66667), (1.0, 1.0)); + corners("textFadeUp", &[], (0.33333, 0.0), (0.66667, 0.0), (0.0, 1.0), (1.0, 1.0)); + corners("textFadeDown", &[], (0.0, 0.0), (1.0, 0.0), (0.33333, 1.0), (0.66667, 1.0)); + corners("textSlantUp", &[], (0.0, 0.55556), (1.0, 0.0), (0.0, 1.0), (1.0, 0.44444)); + corners("textSlantDown", &[], (0.0, 0.0), (1.0, 0.55556), (0.0, 0.44444), (1.0, 1.0)); + corners("textCascadeUp", &[], (0.0, 0.13889), (1.0, 0.0), (0.0, 1.0), (1.0, 0.44444)); + corners("textCascadeDown", &[], (0.0, 0.0), (1.0, 0.13889), (0.0, 0.44444), (1.0, 1.0)); +} + +#[test] +fn curved_presets_bend_where_powerpoint_bends_them() { + corners("textInflate", &[], (0.0, 0.1875), (1.0, 0.1875), (0.0, 0.8125), (1.0, 0.8125)); + assert!(close(land("textInflate", &[], 0.5, 0.0), (0.5, 0.0), 0.004)); + assert!(close(land("textInflate", &[], 0.5, 1.0), (0.5, 1.0), 0.004)); + corners("textDeflate", &[], (0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)); + assert!(close(land("textDeflate", &[], 0.5, 0.0), (0.5, 0.1875), 0.004)); + corners("textInflateBottom", &[], (0.0, 0.0), (1.0, 0.0), (0.0, 0.6), (1.0, 0.6)); + assert!(close(land("textInflateBottom", &[], 0.5, 1.0), (0.5, 0.8), 0.004)); + assert!(close(land("textDeflateBottom", &[], 0.5, 1.0), (0.5, 0.5), 0.004)); + assert!(close(land("textInflateTop", &[], 0.5, 0.0), (0.5, 0.2), 0.004)); + assert!(close(land("textDeflateTop", &[], 0.5, 0.0), (0.5, 0.5), 0.004)); + // Cans: half ellipses. + corners("textCanUp", &[], (0.0, 0.15), (1.0, 0.15), (0.0, 1.0), (1.0, 1.0)); + assert!(close(land("textCanUp", &[], 0.5, 0.0), (0.5, 0.0), 0.004)); + assert!(close(land("textCanUp", &[], 0.5, 1.0), (0.5, 0.85), 0.004)); + assert!(close(land("textCanDown", &[], 0.5, 0.0), (0.5, 0.14286), 0.004)); + // Curves and waves. + let a = 0.45977; + corners("textCurveUp", &[], (0.0, a), (1.0, 0.0), (0.0, 1.0 - 3.0 * a / 8.0), (1.0, 1.0 - 3.0 * a / 8.0)); + assert!(close(land("textCurveUp", &[], 0.5, 1.0), (0.5, 1.0), 0.004)); + corners("textCurveDown", &[], (0.0, 0.0), (1.0, a), (0.0, 1.0 - 3.0 * a / 8.0), (1.0, 1.0 - 3.0 * a / 8.0)); + corners("textWave1", &[], (0.0, 0.125), (1.0, 0.125), (0.0, 0.875), (1.0, 0.875)); + // Wave 1 rises first: its top is near the box top a quarter of the way along. + assert!(land("textWave1", &[], 0.25, 0.0).1 < 0.02); + assert!(land("textWave2", &[], 0.25, 0.0).1 > 0.23); + // A horizontal shift moves the bottom guide right. + corners("textWave1", &[12_500.0, 5_000.0], (0.0, 0.125), (0.9, 0.125), (0.1, 0.875), (1.0, 0.875)); + assert!(land("textDoubleWave1", &[], 0.125, 0.0).1 < 0.02); + assert!(land("textWave4", &[], 0.125, 0.0).1 > 0.1); +} + +#[test] +fn arc_presets_follow_ellipses() { + // Pour arches: the outer arc from the left end over the top; the inner one is scaled by adj2. + corners("textArchUpPour", &[], (0.0, 0.5), (1.0, 0.5), (0.25, 0.5), (0.75, 0.5)); + assert!(close(land("textArchUpPour", &[], 0.5, 0.0), (0.5, 0.0), 0.004)); + assert!(close(land("textArchUpPour", &[], 0.5, 1.0), (0.5, 0.25), 0.004)); + assert!(close(land("textArchUpPour", &[10_800_000.0, 30_000.0], 0.5, 1.0), (0.5, 0.35), 0.004)); + assert!(close(land("textArchDownPour", &[], 0.5, 0.0), (0.5, 0.625), 0.004)); + assert!(close(land("textArchDownPour", &[], 0.5, 1.0), (0.5, 1.0), 0.004)); + // Circle pour: once round from the left; the middle of the text is at the right. + assert!(close(land("textCirclePour", &[], 0.5, 0.0), (1.0, 0.5), 0.004)); + // Rings: the text goes round two stacked ellipses. + assert!(close(land("textRingInside", &[], 0.0, 0.0), (0.0, 0.3), 0.004)); + assert!(close(land("textRingInside", &[], 0.0, 1.0), (0.0, 0.7), 0.004)); + assert!(close(land("textRingInside", &[], 0.25, 0.0), (0.5, 0.0), 0.01)); + assert!(close(land("textRingOutside", &[], 0.25, 1.0), (0.5, 1.0), 0.01)); +} + +#[test] +fn follow_presets_keep_text_size_and_stack_lines_outwards() { + let w = warp("textArchUp", &[], rect()).unwrap(); + // A 40-wide centred line whose reference height is 80. + let line = WarpLine { ink: Some(Rect::new(190.0, 50.0, 230.0, 88.0)), extent: (190.0, 230.0), ref_y: 80.0 }; + let inner = Rect::new(7.2, 3.6, W - 7.2, H - 3.6); + let m = w.mapper(&[line], inner); + // The middle of the reference line lands on the top of the ellipse; 10 pt above it lands + // 10 pt above the top. + let p = m.map(0, Point::new(210.0, 80.0)); + assert!((p.x - 210.0).abs() < 0.05 && p.y.abs() < 0.05, "{p:?}"); + let q = m.map(0, Point::new(210.0, 70.0)); + assert!((q.x - 210.0).abs() < 0.15 && (q.y + 10.0).abs() < 0.05, "{q:?}"); + // Natural size: 40 pt of text spans about 40 pt of arc. + let (a, b) = (m.map(0, Point::new(190.0, 80.0)), m.map(0, Point::new(230.0, 80.0))); + assert!(((b - a).hypot() - 40.0).abs() < 0.5); + // Left-aligned text starts at the very start of the arc: insets don't shorten it. + let left = WarpLine { ink: Some(Rect::new(7.2, 50.0, 47.2, 88.0)), extent: (7.2, 47.2), ref_y: 80.0 }; + let m = w.mapper(&[left], inner); + let p = m.map(0, Point::new(7.2, 80.0)); + assert!(p.x.abs() < 0.01 && (p.y - 100.0).abs() < 0.01, "{p:?}"); + // Two lines on an arch up: the last sits on the arc, the first stacks outside it. + let l1 = WarpLine { ink: Some(Rect::new(190.0, 10.0, 230.0, 40.0)), extent: (190.0, 230.0), ref_y: 34.0 }; + let l2 = WarpLine { ink: Some(Rect::new(190.0, 50.0, 230.0, 80.0)), extent: (190.0, 230.0), ref_y: 74.0 }; + let m = w.mapper(&[l1, l2], inner); + assert!(m.map(1, Point::new(210.0, 74.0)).y.abs() < 0.05); + assert!((m.map(0, Point::new(210.0, 34.0)).y + 40.0).abs() < 0.05); + // Arch down: the first line sits on the bottom of the ellipse. + let w = warp("textArchDown", &[], rect()).unwrap(); + let m = w.mapper(&[l1, l2], inner); + assert!((m.map(0, Point::new(210.0, 34.0)).y - H).abs() < 0.05); + assert!((m.map(1, Point::new(210.0, 74.0)).y - H - 40.0).abs() < 0.05); + // Text longer than the arc is shrunk to fit it. + let w = warp("textArchUp", &[], rect()).unwrap(); + let long = WarpLine { ink: Some(Rect::new(-500.0, 50.0, 900.0, 88.0)), extent: (-500.0, 900.0), ref_y: 80.0 }; + let m = w.mapper(&[long], inner); + let (a, b) = (m.map(0, Point::new(-500.0, 80.0)), m.map(0, Point::new(900.0, 80.0))); + assert!(a.x < 5.0 && b.x > W - 5.0 && (a.y - 100.0).abs() < 10.0 && (b.y - 100.0).abs() < 10.0, "{a:?} {b:?}"); +} + +#[test] +fn lines_share_bands_in_order() { + assert_eq!((0..2).map(|i| band_of_line(i, 2, 2)).collect::>(), [0, 1]); + assert_eq!((0..3).map(|i| band_of_line(i, 3, 2)).collect::>(), [0, 0, 1]); + assert_eq!((0..5).map(|i| band_of_line(i, 5, 2)).collect::>(), [0, 0, 0, 1, 1]); + assert_eq!((0..5).map(|i| band_of_line(i, 5, 3)).collect::>(), [0, 0, 1, 1, 2]); + assert_eq!((0..4).map(|i| band_of_line(i, 4, 3)).collect::>(), [0, 0, 1, 2]); + assert_eq!((0..2).map(|i| band_of_line(i, 2, 3)).collect::>(), [0, 1]); + assert_eq!(band_of_line(0, 1, 3), 0); + assert_eq!(band_of_line(7, 9, 1), 0); + assert_eq!(band_of_line(0, 0, 3), 0); +} + +#[test] +fn deflate_inflate_maps_each_band_separately() { + let w = warp("textDeflateInflate", &[], rect()).unwrap(); + assert_eq!(w.bands.len(), 2); + let l1 = WarpLine { ink: Some(Rect::new(100.0, 10.0, 300.0, 40.0)), extent: (100.0, 300.0), ref_y: 34.0 }; + let l2 = WarpLine { ink: Some(Rect::new(100.0, 50.0, 300.0, 80.0)), extent: (100.0, 300.0), ref_y: 74.0 }; + let m = w.mapper(&[l1, l2], rect()); + // Band 1 ends at 45 % height on the sides, band 2 starts at 55 %. + assert!((m.map(0, Point::new(100.0, 40.0)).y / H - 0.45).abs() < 0.004); + assert!((m.map(1, Point::new(100.0, 50.0)).y / H - 0.55).abs() < 0.004); + assert!((m.map(1, Point::new(300.0, 80.0)).y / H - 1.0).abs() < 0.004); + // A band with fewer lines keeps them at the size of the fuller band, centred in its band: + // two lines (ink 10..60) in band 1, one line (ink 70..80) in band 2 of three. + let a = WarpLine { ink: Some(Rect::new(100.0, 10.0, 300.0, 30.0)), extent: (100.0, 300.0), ref_y: 28.0 }; + let b = WarpLine { ink: Some(Rect::new(100.0, 40.0, 300.0, 60.0)), extent: (100.0, 300.0), ref_y: 58.0 }; + let c = WarpLine { ink: Some(Rect::new(100.0, 70.0, 300.0, 80.0)), extent: (100.0, 300.0), ref_y: 78.0 }; + let m = w.mapper(&[a, b, c], rect()); + let (top, bot) = (m.map(2, Point::new(200.0, 70.0)).y / H, m.map(2, Point::new(200.0, 80.0)).y / H); + // Band 2 runs from 0.4 to 1 at the centre; the line takes the middle fifth of it. + assert!((top - 0.64).abs() < 0.005 && (bot - 0.76).abs() < 0.005, "{top} {bot}"); + // A shorter first line keeps its share of the width. + let short = WarpLine { ink: Some(Rect::new(150.0, 10.0, 250.0, 40.0)), extent: (150.0, 250.0), ref_y: 34.0 }; + let m = w.mapper(&[short, l2], rect()); + let p = m.map(0, Point::new(150.0, 10.0)); + assert!((p.x / W - 0.25).abs() < 0.01, "{p:?}"); +} + +#[test] +fn guides_extrapolate_and_wrap() { + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + p.line_to((10.0, 0.0)); + let g = Guide::from_path(&p, false); + assert!((g.length() - 10.0).abs() < 1e-9); + assert!((g.at(15.0).0.x - 15.0).abs() < 1e-9); + assert!((g.at(-5.0).0.x + 5.0).abs() < 1e-9); + assert!(g.at(f64::NAN).0.x.is_finite()); + let w = warp("textCircle", &[], rect()).unwrap(); + let Some(Band::Follow { path, .. }) = w.bands.first() else { panic!() }; + assert!(path.is_closed()); + let l = path.length(); + let (a, b) = (path.at(5.0).0, path.at(l + 5.0).0); + assert!((a - b).hypot() < 1e-6); + let empty = Guide::default(); + assert_eq!(empty.length(), 0.0); + assert!(empty.at(3.0).0.x.is_finite()); +} + +#[test] +fn hostile_adjusts_and_boxes_stay_finite() { + let adjs: &[&[f64]] = + &[&[], &[f64::NAN, f64::INFINITY], &[f64::NEG_INFINITY, f64::NAN], &[1e300, -1e300], &[-1e9, 1e9], &[0.0, 0.0], &[21_599_999.0, 100_000.0]]; + let rects = [ + Rect::new(0.0, 0.0, 0.0, 0.0), + Rect::new(10.0, 10.0, 10.0, 200.0), + Rect::new(0.0, 0.0, 300.0, 0.0), + Rect::new(100.0, 100.0, -50.0, -20.0), + Rect::new(0.0, 0.0, 1e7, 1e7), + ]; + let lines = [ + WarpLine { ink: Some(Rect::new(0.0, 0.0, 0.0, 0.0)), extent: (0.0, 0.0), ref_y: 0.0 }, + WarpLine { ink: None, extent: (5.0, 5.0), ref_y: 3.0 }, + WarpLine { ink: Some(Rect::new(-1e6, -5.0, 1e6, 5.0)), extent: (-1e6, 1e6), ref_y: 4.0 }, + ]; + for name in CATALOG.iter().map(|p| p.id) { + for adj in adjs { + for r in rects { + let Some(w) = warp(name, adj, r) else { panic!("{name} {adj:?} {r:?}") }; + for b in &w.bands { + assert!(b.length().is_finite()); + } + let m = w.mapper(&lines, r); + assert!(m.zoom().is_finite() && m.zoom() >= 1.0); + let probes = [ + (0, Point::ZERO), + (1, Point::new(5.0, 3.0)), + (2, Point::new(1e6, -5.0)), + (2, Point::new(f64::NAN, 1.0)), + (9, Point::new(1.0, 1.0)), + ]; + for (i, p) in probes { + let q = m.map(i, p); + assert!(q.x.is_finite() || !p.x.is_finite(), "{name} {adj:?} {r:?}: {q:?}"); + assert!(q.y.is_finite(), "{name} {adj:?} {r:?}: {q:?}"); + } + } + } + } + assert!(warp("textPlain", &[], Rect::new(f64::NAN, 0.0, 1.0, 1.0)).is_none()); +} + +#[test] +fn every_preset_has_one_handle_per_adjust() { + let r = rect(); + for name in CATALOG.iter().map(|p| p.id) { + let hs = handles(name, &[], r); + assert_eq!(hs.len(), adjust_count(name), "{name}"); + let defaults = adjust_defaults(name); + for h in &hs { + assert!(h.pos.x.is_finite() && h.pos.y.is_finite(), "{name}"); + let back = h.value_at(h.pos); + let want = defaults[h.index]; + assert!((back - want).abs() <= 1.0, "{name} handle {}: {back} vs {want}", h.index); + } + } + assert!(handles("textNoShape", &[], r).is_empty()); + assert!(handles("textArchUp", &[], Rect::new(0.0, 0.0, f64::NAN, 10.0)).is_empty()); + assert!(handles("textArchUp", &[], Rect::new(0.0, 0.0, 0.0, 0.0)).is_empty()); + // A hostile adjust still places a finite handle on the clamped default. + let hs = handles("textInflate", &[f64::NAN], r); + assert_eq!(hs.len(), 1); + assert!(hs[0].pos.x.is_finite() && hs[0].pos.y.is_finite()); +} + +#[test] +fn arch_and_inflate_handles_follow_the_pointer() { + let r = rect(); + let arch = &handles("textArchUp", &[], r)[0]; + let deg = arch.value_at(Point::new(r.center().x, r.y0)) / 60_000.0; + assert!((deg - 270.0).abs() < 0.5, "{deg}"); + let down = &handles("textArchDown", &[], r)[0]; + // The arch-down handle starts on the left; a pointer at the bottom is 90°, which is adjust 90 + // (guide angle 180 − adj). + let deg = down.value_at(Point::new(r.center().x, r.y1)) / 60_000.0; + assert!((deg - 90.0).abs() < 0.5, "{deg}"); + let inf = &handles("textInflate", &[], r)[0]; + let more = inf.value_at(Point::new(inf.pos.x, r.y0 - 80.0)); + assert!(more > adjust_defaults("textInflate")[0], "{more}"); + assert!((more - 20_000.0).abs() < 1.0, "{more}"); +} + +#[test] +fn many_lines_on_a_follow_band_are_mapped_from_one_measurement() { + // 50,000 lines on an arch: each line's scale and anchor come from the band measured once. + let w = warp("textArchUp", &[], rect()).unwrap(); + let inner = Rect::new(7.2, 3.6, W - 7.2, H - 3.6); + let lines: Vec = (0..50_000) + .map(|i| { + let y = i as f64 * 0.01; + WarpLine { ink: Some(Rect::new(190.0, y, 230.0, y + 1.0)), extent: (190.0, 230.0), ref_y: y + 1.0 } + }) + .collect(); + let m = w.mapper(&lines, inner); + // The last line sits on the arc, as with two lines. + let last = lines.len() - 1; + assert!(m.map(last, Point::new(210.0, lines[last].ref_y)).y.abs() < 0.05); +}