mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
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.
This commit is contained in:
1 parent
a266035672
commit
ccd0046430
17 files changed
+6638
No files matched your search
Generated
+9
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -55,6 +55,10 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
- [`craft-docpath`](crates/docpath/src/lib.rs): decides whether a path or `file:` URL stored in a
|
||||
document may be opened. Paths stored in documents are followed only when they are local or on the
|
||||
document's own network share; other network paths, device paths and non-file URLs are refused.
|
||||
- [`craft-drawingml`](crates/drawingml/README.md): the DrawingML that PPTX, DOCX and XLSX share:
|
||||
EMU units and `a:xfrm`, colours with theme slots and colour transforms, preset shape outlines
|
||||
(`a:prstGeom`), custom geometry with guide formulas (`a:custGeom`) and preset text warps
|
||||
(`a:prstTxWarp`). From DeckCraft. Takes `craft-xml` trees; packages stay with each format.
|
||||
- [`craft-dxf`](crates/dxf/src/lib.rs): DXF tag reader and writer, from CADCraft: ASCII and
|
||||
binary DXF as (group code, value) tags, split into sections and entities, with pre-2007 code
|
||||
pages and version detection, and written back. Bounded on hostile input. Mapping tags to a
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "craft-drawingml"
|
||||
description = "DrawingML (ECMA-376 part 1, chapter 20) pieces shared by PPTX, DOCX and XLSX: EMU units and shape transforms, colours and colour transforms, preset shape geometry, custom geometry and text warps"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-xml = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
@@ -0,0 +1,42 @@
|
||||
# craft-drawingml
|
||||
|
||||
The parts of DrawingML (ECMA-376 part 1, chapter 20) that presentations, word-processing
|
||||
documents and spreadsheets share. A `.pptx` slide, a `.docx` drawing and an `.xlsx` drawing all
|
||||
hold the same `a:` elements; this crate is the one place that knows what they mean.
|
||||
|
||||
Nothing here opens a package. Readers take a `craft_xml::El` and writers a `craft_xml::Writer`,
|
||||
so the caller decides which part the markup comes from. Lengths are points (1/72 inch) as `f64`;
|
||||
files store EMU (12 700 per point).
|
||||
|
||||
| Module | What | Markup |
|
||||
|---|---|---|
|
||||
| crate root | `emu_to_pt`, `pt_to_emu`, `Xfrm` (offset, extent, rotation, flips, as an affine), `group_child_affine` | `a:xfrm`, `a:chOff`, `a:chExt` |
|
||||
| `color` | `Rgba`, `SchemeSlot` and `ColorScheme` (the twelve theme colours and their `bg1`/`tx1` aliases), `ColorTransform` with `apply`, the preset and system colour names | `a:srgbClr`, `a:schemeClr`, `a:prstClr`, `a:sysClr`, `a:lumMod`, `a:tint`, `a:alpha` … |
|
||||
| `preset` | `build(name, w, h, adjusts)`: the outline of a preset shape as fill and stroke sub-paths, its text rectangle, adjust handles and connection sites; `CATALOG` with labels, categories and default adjusts | `a:prstGeom` |
|
||||
| `custgeom` | `read_cust_geom` / `write_cust_geom` between `a:custGeom` and `CustomPath` (an `M L C Q Z` path string per sub-path), and `Guides`, the shape guide formula evaluator | `a:custGeom`, `a:gd` |
|
||||
| `warp` | `warp(name, adjusts, rect)`: the bands and guide curves of a preset text warp, and the mapping of laid-out lines onto them | `a:prstTxWarp` |
|
||||
|
||||
Fills, lines, effects, text bodies, tables, themes and charts are not here yet.
|
||||
|
||||
## Preset shapes and text warps
|
||||
|
||||
Preset names are the file's (`roundRect`, `rightArrow`, `textArchUp`) so files round-trip. The
|
||||
outlines are constructed in Rust from what each shape looks like and from the meaning of its
|
||||
adjust values; no geometry table is copied. Adjust values use the file convention: ratios in
|
||||
1/100 000 of the shorter side (or of the width or height), angles in 1/60 000 degree.
|
||||
|
||||
```rust
|
||||
let g = craft_drawingml::preset::build("roundRect", 200.0, 100.0, &[25_000.0]).unwrap();
|
||||
assert!(!g.paths.is_empty());
|
||||
assert!(g.text_rect.width() < 200.0);
|
||||
```
|
||||
|
||||
## Hostile input
|
||||
|
||||
`read_cust_geom` reads at most 1 000 paths per geometry, 100 000 commands per path and 5 000
|
||||
guides per list, keeps at most 10 000 guide values, and evaluates the guide lists once per
|
||||
coordinate space, for at most 16 spaces and 4 MiB of guide names. Every evaluated value is finite and within
|
||||
±1e15; a division by zero is 0. An arc that sweeps a full turn or more draws one turn. `preset::build` and
|
||||
`warp::warp` take any `f64` and return `None` or a finite outline. Each limit has a test.
|
||||
|
||||
The types derive `serde` traits with the field names the apps' own document files already use.
|
||||
@@ -0,0 +1,830 @@
|
||||
//! Colour: sRGB colours, theme colour slots and DrawingML-style colour transforms
|
||||
//! (tint, shade, luminance modulation/offset, alpha…), so theme-relative colours resolve the way
|
||||
//! presentation files expect.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// An sRGB colour with straight alpha, components 0–255.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct Rgba {
|
||||
pub r: u8,
|
||||
pub g: u8,
|
||||
pub b: u8,
|
||||
pub a: u8,
|
||||
}
|
||||
|
||||
impl Default for Rgba {
|
||||
fn default() -> 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<Self> {
|
||||
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<Self> {
|
||||
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<i32> {
|
||||
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<i32>) -> Option<Self> {
|
||||
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<ColorTransform>, &'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<Rgba> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, f64>,
|
||||
}
|
||||
|
||||
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::<f64>().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<f64> = 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<CustomPath> {
|
||||
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<f64>)> {
|
||||
let mut out = vec![];
|
||||
let mut cmd: Option<char> = None;
|
||||
let mut nums: Vec<f64> = vec![];
|
||||
let flush = |cmd: Option<char>, nums: &mut Vec<f64>, out: &mut Vec<(char, Vec<f64>)>| {
|
||||
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<f64>| {
|
||||
if !token.is_empty() {
|
||||
if let Ok(v) = token.parse::<f64>()
|
||||
&& v.is_finite()
|
||||
{
|
||||
nums.push(v);
|
||||
}
|
||||
token.clear();
|
||||
}
|
||||
};
|
||||
let cs: Vec<char> = 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#"<a:custGeom xmlns:a="x"><a:avLst/><a:gdLst><a:gd name="mid" fmla="*/ w 1 2"/></a:gdLst>
|
||||
<a:pathLst><a:path w="200" h="100"><a:moveTo><a:pt x="0" y="0"/></a:moveTo><a:lnTo><a:pt x="mid" y="h"/></a:lnTo>
|
||||
<a:arcTo wR="50" hR="50" stAng="0" swAng="5400000"/><a:close/></a:path>
|
||||
<a:path w="10" h="10" fill="none" stroke="0"><a:moveTo><a:pt x="1" y="1"/></a:moveTo><a:quadBezTo><a:pt x="2" y="2"/><a:pt x="3" y="1"/></a:quadBezTo></a:path></a:pathLst></a:custGeom>"#;
|
||||
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#"<a:path w="{w}" h="10"><a:moveTo><a:pt x="mid" y="1"/></a:moveTo></a:path>"#);
|
||||
let paths: String = (1..=40).map(|i| path(10 * i)).collect();
|
||||
let x = format!(
|
||||
r#"<a:custGeom xmlns:a="x"><a:gdLst><a:gd name="mid" fmla="*/ w 1 2"/></a:gdLst><a:pathLst>{paths}{}</a:pathLst></a:custGeom>"#,
|
||||
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#"<a:path w="{w}" h="10"><a:moveTo><a:pt x="{name}" y="1"/></a:moveTo></a:path>"#);
|
||||
let x = format!(
|
||||
r#"<a:custGeom xmlns:a="x"><a:gdLst><a:gd name="{name}" fmla="*/ w 1 2"/></a:gdLst><a:pathLst>{}{}{}</a:pathLst></a:custGeom>"#,
|
||||
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#"<a:path w="{}" h="10"><a:moveTo><a:pt x="1" y="1"/></a:moveTo></a:path>"#, 10 * i)).collect();
|
||||
let x = format!(r#"<a:custGeom xmlns:a="x"><a:avLst/><a:gdLst/><a:pathLst>{paths}</a:pathLst></a:custGeom>"#);
|
||||
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#"<a:custGeom xmlns:a="x">{inner}</a:custGeom>"#).as_bytes()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_per_geometry_are_capped() {
|
||||
let paths = r#"<a:path w="1" h="1"/>"#.repeat(1001);
|
||||
assert_eq!(read_cust_geom(&geom(&format!("<a:pathLst>{paths}</a:pathLst>")), 1.0, 1.0).len(), 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_per_path_are_capped() {
|
||||
let cmds = "<a:close/>".repeat(100_001);
|
||||
let out = read_cust_geom(&geom(&format!(r#"<a:pathLst><a:path w="1" h="1">{cmds}</a:path></a:pathLst>"#)), 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#"<a:gd name="g{i}" fmla="val 7"/>"#)).collect();
|
||||
let list = parse(format!("<gdLst>{gds}</gdLst>").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#"<a:pathLst><a:path w="9" h="9"><a:moveTo><a:pt x="2" y="1"/></a:moveTo><a:arcTo wR="1" hR="1" stAng="0" swAng="2000000000"/></a:path></a:pathLst>"#,
|
||||
),
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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<Rect> {
|
||||
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<Point> {
|
||||
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<Point> {
|
||||
(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 }
|
||||
}
|
||||
@@ -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<SubPath>,
|
||||
/// Where text goes, in shape-local space.
|
||||
pub text_rect: Rect,
|
||||
pub handles: Vec<Handle>,
|
||||
/// Connector glue points, shape-local.
|
||||
pub sites: Vec<Point>,
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
@@ -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<Geometry> {
|
||||
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)]);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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<_>>(), 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<Point> {
|
||||
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<Point>| {
|
||||
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<Point> = 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:?}");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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<WarpLine> {
|
||||
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::<Vec<_>>(), [0, 1]);
|
||||
assert_eq!((0..3).map(|i| band_of_line(i, 3, 2)).collect::<Vec<_>>(), [0, 0, 1]);
|
||||
assert_eq!((0..5).map(|i| band_of_line(i, 5, 2)).collect::<Vec<_>>(), [0, 0, 0, 1, 1]);
|
||||
assert_eq!((0..5).map(|i| band_of_line(i, 5, 3)).collect::<Vec<_>>(), [0, 0, 1, 1, 2]);
|
||||
assert_eq!((0..4).map(|i| band_of_line(i, 4, 3)).collect::<Vec<_>>(), [0, 0, 1, 2]);
|
||||
assert_eq!((0..2).map(|i| band_of_line(i, 2, 3)).collect::<Vec<_>>(), [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<WarpLine> = (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);
|
||||
}
|
||||
Reference in new issue
Block a user