Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 1f2f90935b Text: no Latin ligatures, and kerning only from the run's kern size
PowerPoint keeps fi, ffi and Calibri's ti apart and kerns a run only when
a:rPr/@kern is set and the size reaches it; DeckCraft shaped with the font's
defaults (ligatures and kerning always on), so Calibri text read differently
and kern="0" text came out narrower.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 18:09:50 -04:00
26 changed files with 30 additions and 2606 deletions
-2
View File
@@ -28,8 +28,6 @@ deck and UI icons are original work of the DeckCraft contributors (drawn or gene
| `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests |
| `crates/media/tests/fixtures/make.sh` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the media fixtures |
| `crates/render/tests/fixtures/colr-squares.ttf` | DeckCraft contributors | two squares as a COLRv0/CPAL test font, built by `make_colr_font.py` (fontTools as the builder only) | CC0-1.0 | Colour glyph rendering tests |
| `crates/fonts/tests/fixtures/legacy-semibold.ttf` | DeckCraft contributors | a one-glyph Semibold face named like Segoe UI Semibold (its Windows family differs from the typographic one), built by `make_legacy_font.py` (fontTools as the builder only) | CC0-1.0 | Font lookup by Windows family name tests |
| `crates/fonts/tests/fixtures/legacy-regular.ttf` | DeckCraft contributors | the same family's one-glyph Regular face, built by `make_legacy_font.py` (fontTools as the builder only) | CC0-1.0 | Font lookup by Windows family name tests |
| `crates/render/tests/fixtures/make_colr_font.py` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the COLR test font |
## Bundled through dependencies
-1
View File
@@ -128,7 +128,6 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip |
| 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
| 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) |
| 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG |
+13 -126
View File
@@ -52,53 +52,6 @@ pub fn substitutes(family: &str) -> &'static [&'static str] {
}
}
/// Families whose open substitutes ([`substitutes`]) keep their metrics, so text drawn with
/// them breaks into the same lines.
fn has_metric_twin(family: &str) -> bool {
matches!(
family.to_ascii_lowercase().as_str(),
"calibri" | "calibri light" | "cambria" | "cambria math" | "arial" | "helvetica" | "times new roman" | "times" | "courier new" | "courier"
)
}
/// The installed family PowerPoint draws a missing `family` with instead of Calibri: Liberation
/// Sans and Serif as Arial and Times New Roman (observed in PowerPoint; Liberation Mono, Carlito,
/// Caladea, Arimo and Tinos are not mapped and draw in Calibri), and Windows' standard font
/// substitutes (Arial CE, Times New Roman CYR…).
fn office_twin(family: &str) -> Option<&'static str> {
let f = family.to_ascii_lowercase();
Some(match f.as_str() {
"liberation sans" | "helvetica" | "helv" => "Arial",
"liberation serif" | "times" | "tms rmn" => "Times New Roman",
_ => {
let base = f
.strip_suffix(" baltic")
.or(f.strip_suffix(" ce"))
.or(f.strip_suffix(" cyr"))
.or(f.strip_suffix(" greek"))
.or(f.strip_suffix(" tur"))?;
match base {
"arial" => "Arial",
"courier new" => "Courier New",
"times new roman" => "Times New Roman",
_ => return None,
}
}
})
}
/// Office's downloadable (cloud) families: PowerPoint fetches them when a document uses them, so
/// they draw as themselves there, not as Calibri. Without them DeckCraft keeps their closest open
/// substitute ([`substitutes`]; Aptos as Inter).
fn is_office_cloud_font(family: &str) -> bool {
let f = family.to_ascii_lowercase();
f.starts_with("aptos") || ["bierstadt", "grandview", "seaford", "skeena", "tenorite"].iter().any(|c| f.starts_with(c))
}
/// The family PowerPoint draws a document's Latin text in when the font it names isn't installed,
/// whatever the theme, `panose` or `pitchFamily` say.
pub const MISSING_FONT_FAMILY: &str = "Calibri";
#[derive(Clone)]
enum FontBytes {
Static(&'static [u8]),
@@ -112,10 +65,6 @@ pub struct FontFace {
pub family: String,
/// Typographic style name (e.g. "Semibold", "Italic").
pub style: String,
/// The Windows (GDI) family and style names (`name` IDs 1 and 2) when the family differs from
/// the typographic one: "Segoe UI Semibold" / "Regular" for Segoe UI Semibold. Documents name
/// fonts this way.
pub legacy: Option<(String, String)>,
/// usWeightClass-style weight (400 = regular).
pub weight: f32,
pub italic: bool,
@@ -304,11 +253,6 @@ pub struct FontDb {
/// scan, so what they find doesn't depend on what ran before them.
#[cfg(not(target_arch = "wasm32"))]
cataloged: std::sync::OnceLock<()>,
/// Families (lower case) whose cataloged files have all been loaded: a family's files load
/// together, so a lookup never settles for the one face another name loaded ("Segoe UI
/// Semibold" loads a face of the family "Segoe UI").
#[cfg(not(target_arch = "wasm32"))]
loaded_families: Mutex<std::collections::HashSet<String>>,
/// System fallback state: enabled, and characters no system font covers.
#[cfg(not(target_arch = "wasm32"))]
sys: Mutex<SysFallback>,
@@ -372,16 +316,6 @@ fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> {
Some((family, style))
}
/// A face's Windows (GDI) family and style (`name` IDs 1 and 2) when that family differs from the
/// typographic `family`.
fn legacy_names(f: &skrifa::FontRef<'_>, family: &str) -> Option<(String, String)> {
let legacy = name(f, &[StringId::FAMILY_NAME])?;
if legacy.eq_ignore_ascii_case(family) {
return None;
}
Some((legacy, name(f, &[StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into())))
}
/// (family, style) of every face in the font file at `path`, reading only its table directories
/// and `name` tables: a scan opens hundreds of font files, many of them megabytes long. A
/// variable font is cataloged under its default style; its named instances appear once it loads.
@@ -428,13 +362,8 @@ fn file_face_names(path: &std::path::Path) -> Vec<(String, String)> {
font.extend_from_slice(&u32::to_be_bytes(v));
}
font.extend_from_slice(&table);
let f = skrifa::FontRef::new(&font).ok()?;
let (family, style) = face_names(&f)?;
// Cataloged under its Windows family too, so "Segoe UI Semibold" finds the file.
let legacy = legacy_names(&f, &family);
Some(std::iter::once((family, style)).chain(legacy))
face_names(&skrifa::FontRef::new(&font).ok()?)
})
.flatten()
.collect()
}
@@ -562,12 +491,10 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
.map(|v| v >= 0.5)
.or(axis(b"slnt").map(|v| v.abs() > 0.1))
.unwrap_or(!matches!(a.style, skrifa::attribute::Style::Normal) || style_italic(&style));
let legacy = if coords.is_empty() { legacy_names(&f, &family) } else { None };
Some(FontFace {
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
family,
style,
legacy,
weight,
italic,
upem: m.units_per_em.max(1) as f64,
@@ -644,8 +571,6 @@ impl FontDb {
#[cfg(not(target_arch = "wasm32"))]
cataloged: std::sync::OnceLock::new(),
#[cfg(not(target_arch = "wasm32"))]
loaded_families: Mutex::new(std::collections::HashSet::new()),
#[cfg(not(target_arch = "wasm32"))]
sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }),
}
}
@@ -751,8 +676,6 @@ impl FontDb {
/// Returns the number of faces cataloged.
#[cfg(not(target_arch = "wasm32"))]
pub fn load_system_fonts(&self) -> usize {
// Families load again from the new catalog when next looked up.
self.loaded_families.lock().unwrap_or_else(|e| e.into_inner()).clear();
// The first scan, or a rescan once it is done (never both at once).
let mut first = None;
self.cataloged.get_or_init(|| first = Some(self.scan_font_dirs()));
@@ -833,35 +756,17 @@ impl FontDb {
.unwrap_or_else(last_resort_face)
}
/// The face a document's text in `family` is drawn with, as PowerPoint draws it: the family
/// when it is installed or bundled, or a metric-compatible stand-in (Carlito for Calibri,
/// Liberation Sans for Arial…), or the family PowerPoint maps it to (Liberation Sans as Arial,
/// see `office_twin`); any other missing family is drawn in Calibri (or Carlito).
/// DeckCraft's own default family keeps [`FontDb::face`]'s fallbacks.
pub fn document_face(&self, family: &str, style: &str) -> Arc<FontFace> {
if family.eq_ignore_ascii_case(FALLBACK_FAMILY) || has_metric_twin(family) || is_office_cloud_font(family) || self.has_family(family) {
return self.face(family, style);
}
if let Some(twin) = office_twin(family) {
return self.face(twin, style);
}
self.face(MISSING_FONT_FAMILY, style)
}
fn try_family(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
// Every installed file of the family first, so the best style is among the candidates. The
// lock is held while the files load, so another thread looking up the family waits for
// them rather than finding none and falling back to another family.
#[cfg(not(target_arch = "wasm32"))]
{
let mut loaded = self.loaded_families.lock().unwrap_or_else(|e| e.into_inner());
let key = family.to_lowercase();
if !loaded.contains(&key) {
self.load_cataloged(family);
loaded.insert(key);
}
if let Some(f) = self.find(family, style) {
return Some(f);
}
self.find(family, style)
#[cfg(not(target_arch = "wasm32"))]
if self.load_cataloged(family)
&& let Some(f) = self.find(family, style)
{
return Some(f);
}
None
}
/// Is `family` available (loaded, or installed on the system)?
@@ -877,9 +782,7 @@ impl FontDb {
}
pub(crate) fn is_loaded(&self, family: &str) -> bool {
self.read_faces()
.iter()
.any(|f| f.family.eq_ignore_ascii_case(family) || f.legacy.as_ref().is_some_and(|(lf, _)| lf.eq_ignore_ascii_case(family)))
self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family))
}
/// A variable font's axes: (tag, name, min, default, max), empty for static fonts.
@@ -934,27 +837,11 @@ impl FontDb {
return self.instance(family, style);
}
let faces = self.read_faces();
let mut cands: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect();
let ns = norm(style);
if cands.is_empty() {
// A Windows family name ("Segoe UI Semibold", "Arial Black"): its faces, matched by
// their Windows style ("Regular", "Bold"…).
cands = faces.iter().filter(|f| f.legacy.as_ref().is_some_and(|(lf, _)| lf.eq_ignore_ascii_case(family))).collect();
if let Some(f) = cands.iter().find(|f| f.legacy.as_ref().is_some_and(|(_, ls)| norm(ls) == ns)) {
return Some((*f).clone());
}
// Bold of a family that is itself a weight (Semibold, Light): the face, made bolder
// by the caller, as Windows does, rather than a face of another weight.
if let Some(f) = cands
.iter()
.find(|f| f.legacy.as_ref().is_some_and(|(_, ls)| norm(ls) == norm(if style_italic(style) { "Italic" } else { "Regular" })))
{
return Some((*f).clone());
}
}
let cands: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect();
if cands.is_empty() {
return None;
}
let ns = norm(style);
if let Some(f) = cands.iter().find(|f| norm(&f.style) == ns) {
return Some((*f).clone());
}
+1 -62
View File
@@ -14,9 +14,7 @@ pub mod script;
mod shape;
pub use fallback::{bundled_families, covers_cluster, is_emoji_cluster, ligature_carets};
pub use fontdb::{
FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, MISSING_FONT_FAMILY, base_style, bundled, substitutes, system_font_dirs,
};
pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, base_style, bundled, substitutes, system_font_dirs};
pub use harfrust::Feature;
use harfrust::Tag;
pub use kurbo::BezPath;
@@ -184,65 +182,6 @@ mod tests {
assert!(!shape(&f, "Hello", &[], |c| c).is_empty());
}
#[test]
fn windows_family_names_find_their_faces() {
// Documents name fonts by their Windows family ("Segoe UI Semibold"); the font's
// typographic family is "Segoe UI" with a "Semibold" style. The fixture is named the same way.
let check = |db: &FontDb| {
assert!(db.has_family("DeckCraft Legacy Semibold"));
for style in ["Regular", "Bold"] {
let f = db.face("DeckCraft Legacy Semibold", style);
assert_eq!((f.family.as_str(), f.style.as_str()), ("DeckCraft Legacy", "Semibold"), "{style}");
}
assert_eq!(db.document_face("DeckCraft Legacy Semibold", "Regular").family, "DeckCraft Legacy", "installed, so not Calibri");
// The typographic name still works.
assert_eq!(db.face("DeckCraft Legacy", "Semibold").style, "Semibold");
};
// Loaded directly…
let db = FontDb::with_font_dirs(Vec::new());
db.set_system_fallback(false);
assert_eq!(db.add_font(include_bytes!("../tests/fixtures/legacy-semibold.ttf").to_vec()), 1);
check(&db);
// …and found by the system font scan under its Windows name, then loaded.
#[cfg(not(target_arch = "wasm32"))]
{
let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
let db = FontDb::with_font_dirs(vec![dir.clone()]);
db.set_system_fallback(false);
check(&db);
// Looking up the Windows name loads the Semibold file only; a later lookup of the
// family's Regular must still load and pick the Regular file, not settle for Semibold.
let db = FontDb::with_font_dirs(vec![dir]);
db.set_system_fallback(false);
assert_eq!(db.face("DeckCraft Legacy Semibold", "Regular").style, "Semibold");
let regular = db.face("DeckCraft Legacy", "Regular");
assert_eq!((regular.style.as_str(), regular.weight), ("Regular", 400.0));
}
}
#[test]
fn missing_document_fonts_are_drawn_in_calibri() {
// PowerPoint draws a missing family in Calibri, whatever its panose or the theme says.
let db = FontDb::global();
for missing in ["Qqq Nonexistent", "Avenir Next Qqq", "", "Arimo Qqq"] {
let f = db.document_face(missing, "Bold");
assert_eq!(f.family, db.face(MISSING_FONT_FAMILY, "Bold").family, "{missing:?}");
}
// PowerPoint's own twins and Windows' substitutes, when the font itself is missing.
for (missing, twin) in
[("Liberation Sans", "Arial"), ("Liberation Serif", "Times New Roman"), ("Arial CE", "Arial"), ("Times New Roman CYR", "Times New Roman")]
{
if !db.has_family(missing) {
assert_eq!(db.document_face(missing, "Regular").family, db.face(twin, "Regular").family, "{missing}");
}
}
// Metric-compatible stand-ins and DeckCraft's default keep their own fallbacks.
// So do Office's cloud fonts, which PowerPoint downloads and draws as themselves.
for kept in ["Arial", "Times New Roman", "Cambria", "Courier New", FALLBACK_FAMILY, "Aptos", "Aptos Display", "Grandview"] {
assert_eq!(db.document_face(kept, "Regular").family, db.face(kept, "Regular").family, "{kept}");
}
}
#[test]
fn office_fonts_have_substitutes() {
assert!(substitutes("Calibri").contains(&"Carlito"));
Binary file not shown.
Binary file not shown.
-55
View File
@@ -1,55 +0,0 @@
"""Build the legacy-name test font: DeckCraft Legacy Semibold (contributor-original, CC0-1.0).
Usage: python make_legacy_font.py legacy-semibold.ttf legacy-regular.ttf (needs fontTools)
legacy-semibold.ttf: typographic family "DeckCraft Legacy", style "Semibold" (name IDs 16/17,
weight 600); Windows family "DeckCraft Legacy Semibold", style "Regular" (name IDs 1/2), as fonts
like Segoe UI Semibold are named. legacy-regular.ttf: the family's Regular ("DeckCraft Legacy",
"Regular", weight 400), named only by IDs 1/2 as Segoe UI's regular file is. Glyphs: .notdef and
a square for "A".
"""
import sys
from fontTools.fontBuilder import FontBuilder
from fontTools.pens.ttGlyphPen import TTGlyphPen
def square():
pen = TTGlyphPen(None)
pen.moveTo((100, 0))
pen.lineTo((100, 700))
pen.lineTo((600, 700))
pen.lineTo((600, 0))
pen.closePath()
return pen.glyph()
def build(path, names, weight):
fb = FontBuilder(1000, isTTF=True)
fb.setupGlyphOrder([".notdef", "A"])
fb.setupCharacterMap({ord("A"): "A"})
fb.setupGlyf({".notdef": TTGlyphPen(None).glyph(), "A": square()})
fb.setupHorizontalMetrics({".notdef": (500, 0), "A": (700, 100)})
fb.setupHorizontalHeader(ascent=800, descent=-200)
fb.setupNameTable(names)
fb.setupOS2(usWeightClass=weight, sTypoAscender=800, sTypoDescender=-200, usWinAscent=800, usWinDescent=200)
fb.setupPost()
fb.save(path)
build(sys.argv[1], {
"familyName": "DeckCraft Legacy Semibold",
"styleName": "Regular",
"typographicFamily": "DeckCraft Legacy",
"typographicSubfamily": "Semibold",
"uniqueFontIdentifier": "DeckCraft Legacy Semibold",
"fullName": "DeckCraft Legacy Semibold",
"psName": "DeckCraftLegacy-Semibold",
}, 600)
build(sys.argv[2], {
"familyName": "DeckCraft Legacy",
"styleName": "Regular",
"uniqueFontIdentifier": "DeckCraft Legacy Regular",
"fullName": "DeckCraft Legacy",
"psName": "DeckCraftLegacy-Regular",
}, 400)
-1
View File
@@ -8,7 +8,6 @@
pub mod morph;
pub mod preset;
pub mod warp;
pub use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as KurboShape, Size, Vec2};
use serde::{Deserialize, Serialize};
+1 -46
View File
@@ -9,8 +9,6 @@
use kurbo::{Arc, BezPath, Point, Rect, Shape as _, Vec2};
use serde::{Deserialize, Serialize};
mod extra;
/// 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")]
@@ -150,7 +148,6 @@ pub static CATALOG: &[PresetInfo] = &[
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]),
@@ -166,7 +163,6 @@ pub static CATALOG: &[PresetInfo] = &[
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]),
@@ -175,13 +171,9 @@ pub static CATALOG: &[PresetInfo] = &[
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]),
@@ -200,9 +192,6 @@ pub static CATALOG: &[PresetInfo] = &[
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]),
@@ -210,9 +199,6 @@ pub static CATALOG: &[PresetInfo] = &[
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]),
@@ -223,27 +209,16 @@ pub static CATALOG: &[PresetInfo] = &[
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]),
@@ -278,7 +253,6 @@ pub static CATALOG: &[PresetInfo] = &[
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]),
@@ -293,9 +267,6 @@ pub static CATALOG: &[PresetInfo] = &[
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]),
@@ -305,22 +276,6 @@ pub static CATALOG: &[PresetInfo] = &[
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, []),
@@ -1580,7 +1535,7 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option<Geometry> {
let l = Pb::new().m(0.0, 0.0).c(x / 2.0, 0.0, x, h / 4.0, x, h / 2.0).c(x, h * 0.75, (x + w) / 2.0, h, w, h).take();
Geometry { paths: vec![stroke_only(l)], text_rect: full, handles: vec![], sites: vec![Point::ZERO, Point::new(w, h)] }
}
_ => return extra::build(name, w, h, &a),
_ => return None,
};
Some(g)
}
-796
View File
@@ -1,796 +0,0 @@
//! 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::*;
/// 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)]);
}
}
-773
View File
@@ -1,773 +0,0 @@
//! WordArt text warps (DrawingML `a:bodyPr/a:prstTxWarp`).
//!
//! A warp preset is a list of bands laid over the shape's text rectangle (insets are ignored).
//! Text lines are shared out over the bands in order (the first ⌈n/k⌉ lines to the first band
//! and so on). A band either
//! - stretches its lines between a top and a bottom guide ([`Band::Fill`]): the ink box of the
//! band's lines fills the band vertically (a band whose ink is shorter than the tallest band's
//! keeps the same scale, centred), and horizontally the text is scaled so that the
//! widest band is filled along the mean length of its guides (one scale for the whole text), or
//! - sets its lines at their natural size along one guide ([`Band::Follow`], arches and circles):
//! glyphs keep their size, are placed by arc length (aligned as in the paragraph, the whole
//! guide being the line's width: insets don't count) and stand on
//! the guide's left normal; lines further from the guide stack outwards. Lines that are longer
//! than the guide are shrunk to fit.
//!
//! Positions along a guide are by arc length; a fill band maps the same fraction of its top and
//! bottom guide's length and interpolates between them. The guide curves were derived from
//! observation: synthetic decks of `I` bars were drawn with PowerPoint and every curve fitted to
//! the measured bar ends (most fit within a point on a 420 × 200 pt box).
use kurbo::{Arc, BezPath, PathEl, Point, Rect, Vec2};
/// Flattening tolerance for guides, in points.
const TOL: f64 = 0.02;
/// A guide curve flattened to a polyline with cumulative arc lengths.
#[derive(Clone, Debug, Default)]
pub struct Guide {
pts: Vec<Point>,
cum: Vec<f64>,
closed: bool,
}
impl Guide {
fn from_points(raw: impl IntoIterator<Item = Point>, closed: bool) -> Guide {
let mut pts: Vec<Point> = Vec::new();
let mut cum: Vec<f64> = Vec::new();
for p in raw {
if !(p.x.is_finite() && p.y.is_finite()) {
continue;
}
match pts.last() {
Some(q) => {
let d = (p - *q).hypot();
if d > 1e-9 {
cum.push(cum.last().copied().unwrap_or(0.0) + d);
pts.push(p);
}
}
None => {
pts.push(p);
cum.push(0.0);
}
}
if pts.len() > 100_000 {
break;
}
}
Guide { pts, cum, closed }
}
/// A guide through `path` (any curves), flattened.
pub fn from_path(path: &BezPath, closed: bool) -> Guide {
let mut pts = Vec::new();
kurbo::flatten(path.iter(), TOL, |el| match el {
PathEl::MoveTo(p) | PathEl::LineTo(p) => pts.push(p),
_ => {}
});
Guide::from_points(pts, closed)
}
/// Total arc length.
pub fn length(&self) -> f64 {
self.cum.last().copied().unwrap_or(0.0)
}
/// Whether the guide is a closed loop (positions past its end wrap around).
pub fn is_closed(&self) -> bool {
self.closed
}
/// Point and unit tangent at arc length `s`. Open guides continue along their end tangents
/// past either end; closed guides wrap.
pub fn at(&self, s: f64) -> (Point, Vec2) {
let len = self.length();
let (Some(&first), Some(&last)) = (self.pts.first(), self.pts.last()) else {
return (Point::ZERO, Vec2::new(1.0, 0.0));
};
if self.pts.len() < 2 || len <= 1e-9 {
return (first, Vec2::new(1.0, 0.0));
}
let s = if s.is_finite() { s } else { 0.0 };
let s = if self.closed { s.rem_euclid(len) } else { s };
let seg = |i: usize| -> (Point, Point, f64) {
let a = self.pts.get(i).copied().unwrap_or(first);
let b = self.pts.get(i + 1).copied().unwrap_or(last);
(a, b, self.cum.get(i).copied().unwrap_or(0.0))
};
let n = self.pts.len();
let i = if s <= 0.0 {
0
} else if s >= len {
n - 2
} else {
self.cum.partition_point(|c| *c <= s).saturating_sub(1).min(n - 2)
};
let (a, b, c0) = seg(i);
let d = b - a;
let l = d.hypot();
let t = if l > 1e-12 { d / l } else { Vec2::new(1.0, 0.0) };
(a + t * (s - c0), t)
}
/// The parallel curve `d` points away on the left of the direction of travel (above a
/// guide running left to right).
pub fn offset(&self, d: f64) -> Guide {
let n = self.pts.len();
if n < 2 || !d.is_finite() || d == 0.0 {
return self.clone();
}
let unit = |i: usize| -> Vec2 {
let (a, b) = (self.pts.get(i).copied().unwrap_or(Point::ZERO), self.pts.get(i + 1).copied().unwrap_or(Point::ZERO));
let v = b - a;
let l = v.hypot();
if l > 1e-12 { v / l } else { Vec2::ZERO }
};
let pts = (0..n).map(|i| {
let before = if i > 0 {
unit(i - 1)
} else if self.closed {
unit(n - 2)
} else {
Vec2::ZERO
};
let after = if i + 1 < n {
unit(i)
} else if self.closed {
unit(0)
} else {
Vec2::ZERO
};
let t = before + after;
let l = t.hypot();
let t = if l > 1e-12 { t / l } else { Vec2::new(1.0, 0.0) };
self.pts.get(i).copied().unwrap_or(Point::ZERO) + Vec2::new(t.y, -t.x) * d
});
Guide::from_points(pts.collect::<Vec<_>>(), self.closed)
}
/// The point at fraction `t` of the length.
pub fn at_frac(&self, t: f64) -> Point {
self.at(t * self.length()).0
}
}
/// Which line of a follow band sits on its guide.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LineAnchor {
/// The last line; earlier lines stack outwards (arch up, circle).
Last,
/// The first line; later lines stack below it (arch down).
First,
/// The middle of the band's lines.
Middle,
}
#[derive(Clone, Debug)]
pub enum Band {
/// Text stretched between two guides.
Fill { top: Guide, bottom: Guide },
/// Text at its natural size along a guide.
Follow { path: Guide, anchor: LineAnchor },
}
impl Band {
/// The length text is measured against: the mean of a fill band's guides.
pub fn length(&self) -> f64 {
match self {
Band::Fill { top, bottom } => (top.length() + bottom.length()) / 2.0,
Band::Follow { path, .. } => path.length(),
}
}
}
/// A resolved warp: its bands in shape-local points.
#[derive(Clone, Debug)]
pub struct Warp {
pub bands: Vec<Band>,
}
/// Presets whose text follows a guide at its natural size (no wrapping).
pub fn is_follow(name: &str) -> bool {
matches!(name, "textArchUp" | "textArchDown" | "textCircle" | "textButton")
}
/// Default adjust values of a warp preset (`adj`, or `adj1`, `adj2`); empty for unknown presets.
pub fn adjust_defaults(name: &str) -> &'static [f64] {
match name {
"textPlain" => &[50_000.0],
"textStop" => &[25_000.0],
"textTriangle" | "textTriangleInverted" => &[50_000.0],
"textChevron" => &[25_000.0],
"textChevronInverted" => &[75_000.0],
"textRingInside" | "textRingOutside" => &[60_000.0],
"textArchUp" | "textCircle" | "textButton" => &[10_800_000.0],
"textArchDown" => &[0.0],
"textArchUpPour" | "textCirclePour" | "textButtonPour" => &[10_800_000.0, 50_000.0],
"textArchDownPour" => &[0.0, 25_000.0],
"textCurveUp" | "textCurveDown" => &[45_977.0],
"textCanUp" => &[85_000.0],
"textCanDown" => &[14_286.0],
"textWave1" | "textWave2" => &[12_500.0, 0.0],
"textDoubleWave1" | "textWave4" => &[6_250.0, 0.0],
"textInflate" | "textDeflate" => &[18_750.0],
"textInflateBottom" => &[60_000.0],
"textDeflateBottom" | "textDeflateTop" => &[50_000.0],
"textInflateTop" => &[40_000.0],
"textDeflateInflate" => &[35_000.0],
"textDeflateInflateDeflate" => &[25_000.0],
"textFadeRight" | "textFadeLeft" | "textFadeUp" | "textFadeDown" => &[33_333.0],
"textSlantUp" => &[55_556.0],
"textSlantDown" => &[44_444.0],
"textCascadeUp" | "textCascadeDown" => &[44_444.0],
_ => &[],
}
}
/// Number of adjust values a warp preset takes.
pub fn adjust_count(name: &str) -> usize {
adjust_defaults(name).len()
}
/// Whether `name` is a warp preset DeckCraft draws.
pub fn is_supported(name: &str) -> bool {
!adjust_defaults(name).is_empty()
}
/// Segments of a guide in unit coordinates (x, y in 0..1 over the text rectangle).
#[derive(Clone, Copy, Debug)]
enum Seg {
M(f64, f64),
L(f64, f64),
Q(f64, f64, f64, f64),
C(f64, f64, f64, f64, f64, f64),
/// Elliptical arc: centre, radii, start and sweep in degrees (y down: positive = clockwise).
A(f64, f64, f64, f64, f64, f64),
}
enum Spec {
Fill(Vec<Seg>, Vec<Seg>),
Follow(Vec<Seg>, LineAnchor),
}
struct Adj<'a> {
raw: &'a [f64],
defaults: &'static [f64],
}
impl Adj<'_> {
fn raw(&self, i: usize) -> f64 {
self.raw.get(i).copied().filter(|v| v.is_finite()).or_else(|| self.defaults.get(i).copied()).unwrap_or(0.0)
}
/// Adjust `i` as a fraction (1/100 000), pinned to `lo..=hi` (fractions).
fn frac(&self, i: usize, lo: f64, hi: f64) -> f64 {
(self.raw(i) / 100_000.0).clamp(lo, hi)
}
/// Adjust `i` as an angle in degrees (1/60 000), in 0..360.
fn angle(&self, i: usize) -> f64 {
(self.raw(i) / 60_000.0).rem_euclid(360.0)
}
}
fn line(pts: &[(f64, f64)]) -> Vec<Seg> {
pts.iter().enumerate().map(|(i, &(x, y))| if i == 0 { Seg::M(x, y) } else { Seg::L(x, y) }).collect()
}
/// Quadratic from (0, y0) through control (0.5, c) to (1, y0).
fn quad(y0: f64, c: f64) -> Vec<Seg> {
vec![Seg::M(0.0, y0), Seg::Q(0.5, c, 1.0, y0)]
}
/// A sweep in (0, 360]: zero means a full turn.
fn turn(deg: f64) -> f64 {
let d = deg.rem_euclid(360.0);
if d < 1e-9 { 360.0 } else { d }
}
/// Arc of the ellipse inscribed in the rectangle, scaled by `s` about its centre.
fn ellipse(s: f64, start: f64, sweep: f64) -> Vec<Seg> {
vec![Seg::A(0.5, 0.5, 0.5 * s, 0.5 * s, start, sweep)]
}
/// The arch-up arc for adjust angle `a`: symmetric about the top (270°), clockwise.
fn arch_up(a: f64) -> (f64, f64) {
(a, turn(2.0 * (270.0 - a)))
}
/// The arch-down arc for adjust angle `a`: symmetric about the bottom (90°), counter-clockwise.
fn arch_down(a: f64) -> (f64, f64) {
(180.0 - a, -turn(180.0 - 2.0 * a))
}
/// The circle arc for adjust angle `a`: from `a` clockwise, symmetric about 0°.
fn circle(a: f64) -> (f64, f64) {
(a, turn(2.0 * (360.0 - a)))
}
/// One cubic wave period from x0 to x1 at height y: up first when `k` > 0 (y decreases).
fn wave(out: &mut Vec<Seg>, x0: f64, x1: f64, y: f64, k: f64) {
let l = x1 - x0;
out.push(Seg::C(x0 + l / 3.0, y - k, x0 + 2.0 * l / 3.0, y + k, x1, y));
}
/// Wave guides: `periods` cubic waves over the width, shifted apart by `shift` (fraction).
fn waves(a: f64, shift: f64, periods: usize, up_first: bool) -> Spec {
let d = 2.0 * shift;
let (top_x, bot_x) = if d >= 0.0 { ((0.0, 1.0 - d), (d, 1.0)) } else { ((-d, 1.0), (0.0, 1.0 + d)) };
let k = if up_first { a * 10.0 / 3.0 } else { -a * 10.0 / 3.0 };
let guide = |(x0, x1): (f64, f64), y: f64| {
let mut v = vec![Seg::M(x0, y)];
let n = periods.max(1) as f64;
for i in 0..periods.max(1) {
let f = i as f64;
wave(&mut v, x0 + (x1 - x0) * f / n, x0 + (x1 - x0) * (f + 1.0) / n, y, k);
}
v
};
Spec::Fill(guide(top_x, a), guide(bot_x, 1.0 - a))
}
fn spec(name: &str, a: &Adj) -> Option<Vec<Spec>> {
use Seg::*;
let fill = |t: Vec<Seg>, b: Vec<Seg>| vec![Spec::Fill(t, b)];
let top = || line(&[(0.0, 0.0), (1.0, 0.0)]);
let bottom = || line(&[(0.0, 1.0), (1.0, 1.0)]);
Some(match name {
"textPlain" => {
let d = 2.0 * (a.frac(0, 0.3, 0.7) - 0.5);
if d < 0.0 {
fill(line(&[(-d, 0.0), (1.0, 0.0)]), line(&[(0.0, 1.0), (1.0 + d, 1.0)]))
} else {
fill(line(&[(0.0, 0.0), (1.0 - d, 0.0)]), line(&[(d, 1.0), (1.0, 1.0)]))
}
}
"textStop" => {
let y = a.frac(0, 0.14286, 0.5);
let t = 1.0 / 3.0;
fill(line(&[(0.0, y), (t, 0.0), (1.0 - t, 0.0), (1.0, y)]), line(&[(0.0, 1.0 - y), (t, 1.0), (1.0 - t, 1.0), (1.0, 1.0 - y)]))
}
"textTriangle" => {
let y = a.frac(0, 0.0, 1.0);
fill(line(&[(0.0, y), (0.5, 0.0), (1.0, y)]), bottom())
}
"textTriangleInverted" => {
let y = a.frac(0, 0.0, 1.0);
fill(top(), line(&[(0.0, y), (0.5, 1.0), (1.0, y)]))
}
"textChevron" => {
let y = a.frac(0, 0.0, 0.5);
fill(line(&[(0.0, y), (0.5, 0.0), (1.0, y)]), line(&[(0.0, 1.0), (0.5, 1.0 - y), (1.0, 1.0)]))
}
"textChevronInverted" => {
let y = a.frac(0, 0.5, 1.0);
fill(line(&[(0.0, 0.0), (0.5, 1.0 - y), (1.0, 0.0)]), line(&[(0.0, y), (0.5, 1.0), (1.0, y)]))
}
"textFadeRight" => {
let y = a.frac(0, 0.0, 0.49999);
fill(line(&[(0.0, 0.0), (1.0, y)]), line(&[(0.0, 1.0), (1.0, 1.0 - y)]))
}
"textFadeLeft" => {
let y = a.frac(0, 0.0, 0.49999);
fill(line(&[(0.0, y), (1.0, 0.0)]), line(&[(0.0, 1.0 - y), (1.0, 1.0)]))
}
"textFadeUp" => {
let x = a.frac(0, 0.0, 0.49999);
fill(line(&[(x, 0.0), (1.0 - x, 0.0)]), bottom())
}
"textFadeDown" => {
let x = a.frac(0, 0.0, 0.49999);
fill(top(), line(&[(x, 1.0), (1.0 - x, 1.0)]))
}
"textSlantUp" => {
let y = a.frac(0, 0.44444, 1.0);
fill(line(&[(0.0, y), (1.0, 0.0)]), line(&[(0.0, 1.0), (1.0, 1.0 - y)]))
}
"textSlantDown" => {
let y = a.frac(0, 0.0, 0.55556);
fill(line(&[(0.0, 0.0), (1.0, 1.0 - y)]), line(&[(0.0, y), (1.0, 1.0)]))
}
"textCascadeUp" => {
let y = a.frac(0, 0.0, 1.0);
fill(line(&[(0.0, (1.0 - y) / 4.0), (1.0, 0.0)]), line(&[(0.0, 1.0), (1.0, y)]))
}
"textCascadeDown" => {
let y = a.frac(0, 0.0, 1.0);
fill(line(&[(0.0, 0.0), (1.0, (1.0 - y) / 4.0)]), line(&[(0.0, y), (1.0, 1.0)]))
}
"textInflate" => {
let y = a.frac(0, 0.0, 0.2);
fill(quad(y, -y), quad(1.0 - y, 1.0 + y))
}
"textDeflate" => {
let y = a.frac(0, 0.0, 0.375);
fill(quad(0.0, 2.0 * y), quad(1.0, 1.0 - 2.0 * y))
}
"textInflateBottom" => {
let y = a.frac(0, 0.6, 1.0);
fill(top(), quad(y, 1.0))
}
"textDeflateBottom" => {
let y = a.frac(0, 0.0625, 1.0);
fill(top(), quad(1.0, 2.0 * y - 1.0))
}
"textInflateTop" => {
let y = a.frac(0, 0.0, 0.5);
fill(quad(y, 0.0), bottom())
}
"textDeflateTop" => {
let y = a.frac(0, 0.0, 0.9375);
fill(quad(0.0, 2.0 * y), bottom())
}
"textDeflateInflate" => {
// The bands are split by a curve from mid-height up to `y`, with a gap of a tenth of
// a band's height.
let y = a.frac(0, 0.05, 0.95);
let g = 0.05;
vec![Spec::Fill(top(), quad(0.5 - g, 2.0 * y - 0.5 - g)), Spec::Fill(quad(0.5 + g, 2.0 * y - 0.5 + g), bottom())]
}
"textDeflateInflateDeflate" => {
let y = a.frac(0, 0.01, 1.0 / 3.0);
let g = 1.0 / 30.0;
let (t1, t2) = (1.0 / 3.0, 2.0 / 3.0);
let (c1, c2) = (2.0 * y - t1, 2.0 * (1.0 - y) - t2);
vec![
Spec::Fill(top(), quad(t1 - g, c1 - g)),
Spec::Fill(quad(t1 + g, c1 + g), quad(t2 - g, c2 - g)),
Spec::Fill(quad(t2 + g, c2 + g), bottom()),
]
}
"textCurveUp" | "textCurveDown" => {
let y = a.frac(0, 0.0, 0.56818);
let b = vec![M(0.0, 1.0 - 3.0 * y / 8.0), C(1.0 / 3.0, 1.0 + y / 8.0, 2.0 / 3.0, 1.0 + y / 8.0, 1.0, 1.0 - 3.0 * y / 8.0)];
let t = if name == "textCurveUp" {
vec![M(0.0, y), C(1.0 / 3.0, 1.25 * y, 2.0 / 3.0, 0.75 * y, 1.0, 0.0)]
} else {
vec![M(0.0, 0.0), C(1.0 / 3.0, 0.75 * y, 2.0 / 3.0, 1.25 * y, 1.0, y)]
};
fill(t, b)
}
"textCanUp" => {
let r = 1.0 - a.frac(0, 0.5, 1.0);
fill(vec![A(0.5, r, 0.5, r, 180.0, 180.0)], vec![A(0.5, 1.0, 0.5, r, 180.0, 180.0)])
}
"textCanDown" => {
let r = a.frac(0, 0.0, 0.5);
fill(vec![A(0.5, 0.0, 0.5, r, 180.0, -180.0)], vec![A(0.5, 1.0 - r, 0.5, r, 180.0, -180.0)])
}
"textRingInside" | "textRingOutside" => {
let h = a.frac(0, 0.5, 0.99);
let sweep = if name == "textRingInside" { 360.0 } else { -360.0 };
fill(vec![A(0.5, h / 2.0, 0.5, h / 2.0, 180.0, sweep)], vec![A(0.5, 1.0 - h / 2.0, 0.5, h / 2.0, 180.0, sweep)])
}
"textWave1" => vec![waves(a.frac(0, 0.0, 0.2), a.frac(1, -0.1, 0.1), 1, true)],
"textWave2" => vec![waves(a.frac(0, 0.0, 0.2), a.frac(1, -0.1, 0.1), 1, false)],
"textDoubleWave1" => vec![waves(a.frac(0, 0.0, 0.125), a.frac(1, -0.1, 0.1), 2, true)],
"textWave4" => vec![waves(a.frac(0, 0.0, 0.125), a.frac(1, -0.1, 0.1), 2, false)],
"textArchUp" => {
let (s, w) = arch_up(a.angle(0));
vec![Spec::Follow(ellipse(1.0, s, w), LineAnchor::Last)]
}
"textArchDown" => {
let (s, w) = arch_down(a.angle(0));
vec![Spec::Follow(ellipse(1.0, s, w), LineAnchor::First)]
}
"textCircle" => {
let (s, w) = circle(a.angle(0));
vec![Spec::Follow(ellipse(1.0, s, w), LineAnchor::Last)]
}
"textButton" => {
let t = a.angle(0);
let (s, w) = arch_up(t);
let half = 0.5 * t.to_radians().cos().abs();
vec![
Spec::Follow(ellipse(1.0, s, w), LineAnchor::Last),
Spec::Follow(line(&[(0.5 - half, 0.5), (0.5 + half, 0.5)]), LineAnchor::Middle),
Spec::Follow(ellipse(1.0, 360.0 - s, -w), LineAnchor::First),
]
}
"textArchUpPour" => {
let (s, w) = arch_up(a.angle(0));
let r = a.frac(1, 0.0, 0.99);
fill(ellipse(1.0, s, w), ellipse(r, s, w))
}
"textArchDownPour" => {
let (s, w) = arch_down(a.angle(0));
let r = a.frac(1, 0.0, 0.99);
fill(ellipse(r, s, w), ellipse(1.0, s, w))
}
"textCirclePour" => {
let (s, w) = circle(a.angle(0));
let r = a.frac(1, 0.0, 0.99);
fill(ellipse(1.0, s, w), ellipse(r, s, w))
}
"textButtonPour" => {
let (s, w) = arch_up(a.angle(0));
let r = a.frac(1, 0.0, 0.99);
// The middle band runs between chords of the inner ellipse halfway to its top and
// bottom.
let (dy, half) = (0.25 * r, 0.25 * r * 3f64.sqrt());
vec![
Spec::Fill(ellipse(1.0, s, w), ellipse(r, s, w)),
Spec::Fill(line(&[(0.5 - half, 0.5 - dy), (0.5 + half, 0.5 - dy)]), line(&[(0.5 - half, 0.5 + dy), (0.5 + half, 0.5 + dy)])),
Spec::Fill(ellipse(r, 360.0 - s, -w), ellipse(1.0, 360.0 - s, -w)),
]
}
_ => return None,
})
}
/// Build a guide from unit-space segments mapped onto `r`.
fn guide(segs: &[Seg], r: Rect) -> Guide {
let mut p = BezPath::new();
let mut started = false;
let mut closed = false;
for s in segs {
match *s {
Seg::M(x, y) => {
p.move_to((x, y));
started = true;
}
Seg::L(x, y) => {
if started {
p.line_to((x, y))
} else {
p.move_to((x, y))
}
started = true;
}
Seg::Q(cx, cy, x, y) => {
if started {
p.quad_to((cx, cy), (x, y));
}
}
Seg::C(x1, y1, x2, y2, x, y) => {
if started {
p.curve_to((x1, y1), (x2, y2), (x, y));
}
}
Seg::A(cx, cy, rx, ry, start, sweep) => {
let arc = Arc::new((cx, cy), (rx.max(0.0), ry.max(0.0)), start.to_radians(), sweep.to_radians(), 0.0);
let s0 = Point::new(cx + rx * start.to_radians().cos(), cy + ry * start.to_radians().sin());
if started {
p.line_to(s0);
} else {
p.move_to(s0);
}
started = true;
for el in arc.append_iter(1e-5) {
p.push(el);
}
closed = sweep.abs() >= 359.999;
}
}
}
let m = kurbo::Affine::new([r.width(), 0.0, 0.0, r.height(), r.x0, r.y0]);
p.apply_affine(m);
Guide::from_path(&p, closed)
}
/// The warp `name` with adjust values `adj` (raw guide values; missing ones take the defaults)
/// over text rectangle `rect`. `None` for unknown presets, `textNoShape` or a non-finite rect.
pub fn warp(name: &str, adj: &[f64], rect: Rect) -> Option<Warp> {
if ![rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) {
return None;
}
let r = Rect::new(rect.x0.min(rect.x1), rect.y0.min(rect.y1), rect.x0.max(rect.x1), rect.y0.max(rect.y1));
let a = Adj { raw: adj, defaults: adjust_defaults(name) };
let specs = spec(name, &a)?;
let bands = specs
.into_iter()
.map(|s| match s {
Spec::Fill(t, b) => Band::Fill { top: guide(&t, r), bottom: guide(&b, r) },
Spec::Follow(p, anchor) => Band::Follow { path: guide(&p, r), anchor },
})
.collect();
Some(Warp { bands })
}
/// The band line `line` of `lines` goes to when there are `bands` bands: the first ⌈n/k⌉ lines
/// share the first band, and so on.
pub fn band_of_line(line: usize, lines: usize, bands: usize) -> usize {
if bands <= 1 || lines == 0 {
return 0;
}
// Band b holds lines ceil(b·n/k) .. ceil((b+1)·n/k).
(0..bands).rev().find(|&b| (b * lines).div_ceil(bands) <= line).unwrap_or(0)
}
/// One laid-out line as the warp sees it (layout coordinates, points).
#[derive(Clone, Copy, Debug)]
pub struct WarpLine {
/// Ink box of the line's glyphs; `None` for a line without ink.
pub ink: Option<Rect>,
/// Advance extent of the line (left, right).
pub extent: (f64, f64),
/// Reference height for follow bands: the y that sits on the guide.
pub ref_y: f64,
}
#[derive(Clone, Copy, Debug)]
enum LineMap {
Fill {
band: usize,
cx: f64,
k: f64,
len: f64,
y0: f64,
y1: f64,
},
/// `guide`: the line's own parallel guide in [`Mapper::offsets`], if it is off the band's.
Follow {
band: usize,
guide: Option<usize>,
e0: f64,
s0: f64,
k: f64,
ref_y: f64,
},
}
/// Maps points of laid-out lines onto a warp.
#[derive(Clone, Debug)]
pub struct Mapper<'a> {
warp: &'a Warp,
lines: Vec<LineMap>,
offsets: Vec<Guide>,
zoom: f64,
}
impl Warp {
/// Plan the mapping of `lines`, laid out in the inner text box `inner` (the line's place in
/// it gives its alignment along follow guides; the insets themselves don't shorten a guide).
pub fn mapper(&self, lines: &[WarpLine], inner: Rect) -> Mapper<'_> {
let nb = self.bands.len().max(1);
let n = lines.len();
let band_of: Vec<usize> = (0..n).map(|i| band_of_line(i, n, nb).min(nb - 1)).collect();
// Ink of the whole text, for the shared horizontal centre and scale of fill bands.
let ink_all = lines.iter().filter_map(|l| l.ink).reduce(|a, b| a.union(b));
let cx = ink_all.map(|r| r.center().x).unwrap_or(0.0);
let mut k_fill = f64::INFINITY;
let mut zoom: f64 = 1.0;
let mut band_y: Vec<Option<(f64, f64)>> = vec![None; nb];
for (i, l) in lines.iter().enumerate() {
let Some(r) = l.ink else { continue };
let b = band_of.get(i).copied().unwrap_or(0);
if let Some(slot) = band_y.get_mut(b) {
*slot = Some(match *slot {
Some((y0, y1)) => (y0.min(r.y0), y1.max(r.y1)),
None => (r.y0, r.y1),
});
}
}
// Every fill band maps a box as tall as the tallest band's ink, centred on its own ink, so
// a band with fewer lines keeps them at the same size, centred between its guides.
let tallest = band_y.iter().flatten().map(|(y0, y1)| y1 - y0).fold(0.0, f64::max);
let band_y: Vec<Option<(f64, f64)>> =
band_y.iter().map(|r| r.map(|(y0, y1)| ((y0 + y1 - tallest) / 2.0, (y0 + y1 + tallest) / 2.0))).collect();
for (b, band) in self.bands.iter().enumerate() {
if let Band::Fill { top, bottom } = band {
let half = lines
.iter()
.enumerate()
.filter(|(i, _)| band_of.get(*i) == Some(&b))
.filter_map(|(_, l)| l.ink)
.map(|r| (r.x0 - cx).abs().max((r.x1 - cx).abs()))
.fold(0.0, f64::max);
if half > 1e-9 {
k_fill = k_fill.min(band.length() / (2.0 * half));
}
if let Some(Some((y0, y1))) = band_y.get(b) {
let thick = [0.0, 0.25, 0.5, 0.75, 1.0].iter().map(|&u| (top.at_frac(u) - bottom.at_frac(u)).hypot()).fold(0.0, f64::max);
if y1 - y0 > 1e-9 {
zoom = zoom.max(thick / (y1 - y0));
}
}
}
}
if !k_fill.is_finite() {
k_fill = 1.0;
}
zoom = zoom.max(k_fill);
let mut maps = Vec::with_capacity(n);
let mut offsets = Vec::new();
for (i, l) in lines.iter().enumerate() {
let b = band_of.get(i).copied().unwrap_or(0);
let in_band = |j: &usize| band_of.get(*j) == Some(&b);
match self.bands.get(b) {
Some(Band::Fill { .. }) | None => {
let (y0, y1) = band_y.get(b).copied().flatten().unwrap_or((0.0, 1.0));
let len = self.bands.get(b).map(Band::length).unwrap_or(0.0);
maps.push(LineMap::Fill { band: b, cx, k: k_fill, len, y0, y1 });
}
Some(Band::Follow { path, anchor }) => {
let len = path.length();
let widest = (0..n).filter(in_band).filter_map(|j| lines.get(j)).map(|l| l.extent.1 - l.extent.0).fold(0.0, f64::max);
let k = if widest > len && widest > 1e-9 { len / widest } else { 1.0 };
let members: Vec<&WarpLine> = (0..n).filter(in_band).filter_map(|j| lines.get(j)).collect();
let ref_y = match anchor {
LineAnchor::Last => members.last().map(|l| l.ref_y),
LineAnchor::First => members.first().map(|l| l.ref_y),
LineAnchor::Middle => match (members.first(), members.last()) {
(Some(a), Some(z)) => Some((a.ref_y + z.ref_y) / 2.0),
_ => None,
},
}
.unwrap_or(l.ref_y);
// Lines off the guide run on a parallel curve at their natural length.
let d = (ref_y - l.ref_y) * k;
let (guide, len) = if d.abs() > 1e-6 && d.is_finite() && offsets.len() < 4096 {
let g = path.offset(d);
let len = g.length();
offsets.push(g);
(Some(offsets.len() - 1), len)
} else {
(None, len)
};
let (e0, e1) = l.extent;
let w = (e1 - e0).max(0.0) * k;
let (gl, gr) = ((e0 - inner.x0).max(0.0), (inner.x1 - e1).max(0.0));
let f = if gl + gr > 1e-9 { gl / (gl + gr) } else { 0.5 };
let s0 = f * (len - w);
maps.push(LineMap::Follow { band: b, guide, e0, s0, k, ref_y: l.ref_y });
}
}
}
Mapper { warp: self, lines: maps, offsets, zoom: if zoom.is_finite() { zoom.clamp(1.0, 1.0e4) } else { 1.0 } }
}
}
impl Mapper<'_> {
/// How much the warp magnifies text at most (for flattening tolerances), ≥ 1.
pub fn zoom(&self) -> f64 {
self.zoom
}
/// Map point `p` of line `line` (layout coordinates) onto the warp.
pub fn map(&self, line: usize, p: Point) -> Point {
let out = match self.lines.get(line) {
Some(&LineMap::Fill { band, cx, k, len, y0, y1 }) => {
let Some(Band::Fill { top, bottom }) = self.warp.bands.get(band) else { return p };
let u = if len > 1e-9 { 0.5 + (p.x - cx) * k / len } else { 0.5 };
let v = if y1 - y0 > 1e-9 { (p.y - y0) / (y1 - y0) } else { 0.5 };
let (t, b) = (top.at_frac(u), bottom.at_frac(u));
t.lerp(b, v)
}
Some(&LineMap::Follow { band, guide, e0, s0, k, ref_y }) => {
let path = match (guide.and_then(|g| self.offsets.get(g)), self.warp.bands.get(band)) {
(Some(g), _) => g,
(None, Some(Band::Follow { path, .. })) => path,
_ => return p,
};
let s = s0 + (p.x - e0) * k;
let d = (ref_y - p.y) * k;
let (q, t) = path.at(s);
q + Vec2::new(t.y, -t.x) * d
}
None => p,
};
if out.x.is_finite() && out.y.is_finite() { out } else { p }
}
}
#[cfg(test)]
mod tests;
-309
View File
@@ -1,309 +0,0 @@
use super::*;
const ALL: &[&str] = &[
"textPlain",
"textStop",
"textTriangle",
"textTriangleInverted",
"textChevron",
"textChevronInverted",
"textRingInside",
"textRingOutside",
"textArchUp",
"textArchDown",
"textCircle",
"textButton",
"textArchUpPour",
"textArchDownPour",
"textCirclePour",
"textButtonPour",
"textCurveUp",
"textCurveDown",
"textCanUp",
"textCanDown",
"textWave1",
"textWave2",
"textDoubleWave1",
"textWave4",
"textInflate",
"textDeflate",
"textInflateBottom",
"textDeflateBottom",
"textInflateTop",
"textDeflateTop",
"textDeflateInflate",
"textDeflateInflateDeflate",
"textFadeRight",
"textFadeLeft",
"textFadeUp",
"textFadeDown",
"textSlantUp",
"textSlantDown",
"textCascadeUp",
"textCascadeDown",
];
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 every_preset_builds_finite_guides() {
for name in ALL {
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 ALL {
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());
}
-1
View File
@@ -223,7 +223,6 @@ pub fn body(ctx: &Ctx, shape: &Shape) -> BodyProps {
columns: Some(1),
col_spacing: Some(0.0),
warp: None,
warp_adj: Vec::new(),
rtl_col: Some(false),
upright: Some(false),
});
-5
View File
@@ -98,10 +98,6 @@ pub struct BodyProps {
pub col_spacing: Option<f64>,
/// WordArt transform (`prstTxWarp`), e.g. `textArchUp`.
pub warp: Option<String>,
/// The warp's adjust values (`prstTxWarp/avLst`: `adj` or `adj1`, `adj2`…, raw guide values);
/// empty = the preset's defaults.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub warp_adj: Vec<f64>,
pub rtl_col: Option<bool>,
/// Keep text upright when the shape rotates.
pub upright: Option<bool>,
@@ -123,7 +119,6 @@ impl BodyProps {
self.col_spacing = self.col_spacing.or(p.col_spacing);
if self.warp.is_none() {
self.warp.clone_from(&p.warp);
self.warp_adj.clone_from(&p.warp_adj);
}
self.rtl_col = self.rtl_col.or(p.rtl_col);
self.upright = self.upright.or(p.upright);
+1 -33
View File
@@ -321,45 +321,13 @@ pub fn body_pr(b: &El) -> BodyProps {
}
"spAutoFit" => p.autofit = Some(AutoFit::Shape),
"noAutofit" => p.autofit = Some(AutoFit::None),
"prstTxWarp" => {
p.warp = e.attr("prst").filter(|w| *w != "textNoShape" && !w.is_empty()).map(String::from);
p.warp_adj = p.warp.as_deref().map(|w| warp_adjusts(w, e)).unwrap_or_default();
}
"prstTxWarp" => p.warp = e.attr("prst").filter(|w| *w != "textNoShape").map(String::from),
_ => {}
}
}
p
}
/// `prstTxWarp/avLst` guides `adj` / `adj1`… as raw values by index (empty when none is given);
/// slots the file leaves out keep the preset's default.
fn warp_adjusts(preset: &str, e: &El) -> Vec<f64> {
let mut adj: Vec<f64> = Vec::new();
let Some(av) = e.child("avLst") else { return adj };
let defaults = deckcraft_geom::warp::adjust_defaults(preset);
for gd in av.children_named("gd").take(16) {
let n = gd.attr("name").unwrap_or("");
let idx = if n == "adj" {
0
} else if let Some(k) = n.strip_prefix("adj").and_then(|k| k.parse::<usize>().ok()).filter(|k| (1..=8).contains(k)) {
k - 1
} else {
continue;
};
let Some(v) = gd.attr("fmla").and_then(|f| f.trim().strip_prefix("val")).and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite())
else {
continue;
};
while adj.len() <= idx {
adj.push(defaults.get(adj.len()).copied().unwrap_or(0.0));
}
if let Some(slot) = adj.get_mut(idx) {
*slot = v.clamp(-1.0e9, 1.0e9);
}
}
adj
}
fn spacing(e: &El) -> Option<Spacing> {
if let Some(p) = e.child("spcPct") {
return Some(Spacing::Pct(pct(p, "val").unwrap_or(1.0).clamp(0.0, 100.0)));
+1 -13
View File
@@ -392,19 +392,7 @@ pub fn body_pr(w: &mut W, b: &BodyProps) {
w.open("a:bodyPr", a);
if let Some(p) = warp {
w.open("a:prstTxWarp", A::new().a("prst", p));
let adj: Vec<(usize, i64)> =
b.warp_adj.iter().take(8).enumerate().filter(|(_, v)| v.is_finite()).map(|(i, v)| (i, v.round().clamp(-2.0e9, 2.0e9) as i64)).collect();
if adj.is_empty() {
w.empty0("a:avLst");
} else {
let single = deckcraft_geom::warp::adjust_count(p) <= 1 && b.warp_adj.len() == 1;
w.open0("a:avLst");
for (i, v) in adj {
let n = if single { "adj".to_string() } else { format!("adj{}", i + 1) };
w.empty("a:gd", A::new().a("name", n).a("fmla", format!("val {v}")));
}
w.close("a:avLst");
}
w.empty0("a:avLst");
w.close("a:prstTxWarp");
}
match b.autofit {
-61
View File
@@ -796,64 +796,3 @@ fn a_table_fill_round_trips() {
matches!(&t.background, Some(Fill::Solid { color }) if matches!(color.base, deckcraft_model::ColorBase::Rgb { rgb } if rgb == Rgba::rgb(0, 112, 192)))
);
}
#[test]
fn wordart_warps_and_their_adjusts_round_trip() {
let mut p = Presentation::default();
let cases: [(&str, &str, Vec<f64>); 5] = [
("Arch pour", "textArchUpPour", vec![13_500_000.0, 30_000.0]),
("Wave", "textWave1", vec![18_000.0, -5_000.0]),
("Inflate", "textInflate", vec![30_000.0]),
("Arch", "textArchUp", vec![]),
("Ring", "textRingInside", vec![75_000.0]),
];
let mut shapes = vec![];
for (i, (name, warp, adj)) in cases.iter().enumerate() {
let mut s = shape(&mut p, name, 10.0, 10.0 + 50.0 * i as f64, 300.0, 40.0);
let mut body = TextBody::from_text("WordArt");
body.body.warp = Some((*warp).into());
body.body.warp_adj = adj.clone();
s.text = Some(body);
shapes.push(s);
}
Arc::make_mut(&mut p.slides[0]).shapes = shapes;
let q = round(&p);
for (name, warp, adj) in &cases {
let b = &by_name(&q.slides[0], name).text.as_ref().expect("text").body;
assert_eq!(b.warp.as_deref(), Some(*warp), "{name}");
assert_eq!(&b.warp_adj, adj, "{name}");
}
// The single-adjust preset is written with the guide name `adj`, two-adjust ones with
// `adj1`/`adj2`, and a preset with no adjusts given gets an empty list.
let bytes = deckcraft_pptx::export(&p).expect("export");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip");
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("ppt/slides/slide1.xml").expect("slide"), &mut xml).expect("read");
assert!(xml.contains(r#"<a:prstTxWarp prst="textInflate"><a:avLst><a:gd name="adj" fmla="val 30000"/></a:avLst></a:prstTxWarp>"#), "{xml}");
assert!(xml.contains(r#"<a:gd name="adj1" fmla="val 13500000"/><a:gd name="adj2" fmla="val 30000"/>"#));
assert!(xml.contains(r#"<a:prstTxWarp prst="textArchUp"><a:avLst/></a:prstTxWarp>"#));
// A file that gives only `adj2` keeps the preset's default for `adj1`; hostile values are
// read as numbers or dropped.
let edited = xml
.replace(r#"<a:gd name="adj1" fmla="val 13500000"/>"#, "")
.replace(r#"<a:gd name="adj" fmla="val 30000"/>"#, r#"<a:gd name="adj" fmla="val NaN"/>"#);
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for i in 0..zip.len() {
let mut f = zip.by_index(i).expect("entry");
let name = f.name().to_string();
let mut data = vec![];
std::io::Read::read_to_end(&mut f, &mut data).expect("read");
if name == "ppt/slides/slide1.xml" {
data = edited.clone().into_bytes();
}
out.start_file(name, zip::write::SimpleFileOptions::default()).expect("start");
std::io::Write::write_all(&mut out, &data).expect("write");
}
let bytes = out.finish().expect("finish").into_inner();
let q = deckcraft_pptx::import(&bytes).expect("import");
let pour = &by_name(&q.slides[0], "Arch pour").text.as_ref().expect("text").body;
assert_eq!(pour.warp_adj, vec![10_800_000.0, 30_000.0]);
let inflate = &by_name(&q.slides[0], "Inflate").text.as_ref().expect("text").body;
assert_eq!(inflate.warp.as_deref(), Some("textInflate"));
assert!(inflate.warp_adj.is_empty());
}
+1 -12
View File
@@ -21,7 +21,6 @@ mod pathgrad;
mod placed;
mod ramp;
mod table;
mod warp;
use std::collections::HashMap;
use std::sync::Arc;
@@ -817,10 +816,7 @@ impl Renderer {
let tr = geo.text_rect;
if !body.is_empty() {
let l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false });
match text_warp(rctx, s, tr).filter(|_| l.rotation == 0.0) {
Some(w) => warp::draw(ctx, &l, tr, m, &st.paras, &w),
None => draw_layout_with(ctx, &l, tr, m, &st.paras),
}
draw_layout_with(ctx, &l, tr, m, &st.paras);
} else if f.opts.edit && s.ph.is_some() {
let kind = s.ph_type().unwrap_or(PhType::Body);
let prompt_body = prompt_body(rctx, s, kind);
@@ -1150,13 +1146,6 @@ pub fn text_transform(m: Affine, tr: Rect, rotation: f64) -> Affine {
t
}
/// The WordArt warp of `s`'s text over its text rectangle `tr`, if it has one DeckCraft draws.
pub fn text_warp(rctx: &Ctx, s: &Shape, tr: Rect) -> Option<deckcraft_geom::warp::Warp> {
let bp = resolve::body(rctx, s);
let name = bp.warp.as_deref()?;
deckcraft_geom::warp::warp(name, &bp.warp_adj, tr)
}
/// Draw an already laid-out text block.
pub fn draw_layout(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: Rect, m: Affine) {
draw_layout_with(ctx, l, tr, m, &[]);
-81
View File
@@ -994,84 +994,3 @@ fn shadows_and_glows_spread_like_powerpoints() {
assert!((60..170).contains(&red(&img, 15, 120)), "{}", red(&img, 15, 120));
assert!(red(&img, 10, 120) > 245);
}
/// A 400 × 200 text box at (100, 100) holding `text` at 40 pt, warped by `warp`.
fn wordart(warp: Option<&str>, text: &str) -> Presentation {
let mut t = TextBody::from_text(text);
t.paragraphs[0].runs[0].props.size = Some(40.0);
t.paragraphs[0].props.align = Some(deckcraft_model::text::Align::Center);
t.body.warp = warp.map(String::from);
deck_with(vec![Shape { id: ShapeId(700), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), text: Some(t), ..Default::default() }])
}
/// Ink box (x0, y0, x1, y1) of dark pixels, and the top ink row of each column.
fn ink(img: &Image) -> ((u32, u32, u32, u32), Vec<Option<u32>>) {
let mut b = (u32::MAX, u32::MAX, 0, 0);
let mut tops = vec![None; img.width as usize];
for y in 0..img.height {
for x in 0..img.width {
if img.pixel(x, y)[0] < 128 {
b = (b.0.min(x), b.1.min(y), b.2.max(x), b.3.max(y));
if tops[x as usize].is_none() {
tops[x as usize] = Some(y);
}
}
}
}
(b, tops)
}
#[test]
fn wordart_arch_up_lifts_the_middle_of_the_line() {
let o = RenderOpts { scale: 1.0, ..Default::default() };
let flat = render_slide(&wordart(None, "ARCHED WORDART"), 0, &o);
let arched = render_slide(&wordart(Some("textArchUp"), "ARCHED WORDART"), 0, &o);
assert!(diff_px(&flat, &arched) > 1000);
let ((fx0, _, fx1, _), ftops) = ink(&flat);
let ((ax0, ay0, ax1, _), atops) = ink(&arched);
let top_near = |tops: &[Option<u32>], x: u32| (x.saturating_sub(15)..x + 15).filter_map(|i| tops.get(i as usize).copied().flatten()).min();
// Flat text: the ends and the middle stand on one line.
let (fl, fm) = (top_near(&ftops, fx0 + 20).unwrap(), top_near(&ftops, (fx0 + fx1) / 2).unwrap());
assert!(fl.abs_diff(fm) < 4, "flat {fl} {fm}");
// Arched: the line follows the top of the ellipse inscribed in the box, so the middle is
// at the box top and the ends sit clearly lower.
let (al, am, ar) = (top_near(&atops, ax0 + 20).unwrap(), top_near(&atops, (ax0 + ax1) / 2).unwrap(), top_near(&atops, ax1 - 20).unwrap());
assert!(al > am + 10 && ar > am + 10, "arched {al} {am} {ar}");
assert!((80..=110).contains(&ay0), "arch top {ay0}");
}
#[test]
fn wordart_plain_stretches_the_ink_over_the_text_rect() {
let img = render_slide(&wordart(Some("textPlain"), "PLAIN"), 0, &RenderOpts { scale: 1.0, ..Default::default() });
let ((x0, y0, x1, y1), _) = ink(&img);
for (got, want) in [(x0, 100), (y0, 100), (x1, 499), (y1, 299)] {
assert!(got.abs_diff(want) <= 2, "ink box {:?}", (x0, y0, x1, y1));
}
}
#[test]
fn unknown_or_absent_warps_draw_flat_text() {
let o = RenderOpts { scale: 0.5, ..Default::default() };
// Warm the font database first (other tests load fonts concurrently).
let _ = render_slide(&wordart(None, "FLAT"), 0, &o);
let flat = render_slide(&wordart(None, "FLAT"), 0, &o);
for w in ["textNoShape", "bogus", ""] {
assert_eq!(diff_px(&flat, &render_slide(&wordart(Some(w), "FLAT"), 0, &o)), 0, "{w}");
}
}
#[test]
fn every_warp_preset_renders_text_inside_the_slide() {
let o = RenderOpts { scale: 0.5, ..Default::default() };
for name in
["textArchUp", "textArchDown", "textCircle", "textButton", "textWave1", "textRingInside", "textCirclePour", "textDeflateInflateDeflate"]
{
let mut p = wordart(Some(name), "One line\nTwo line\nThree");
if let Some(b) = std::sync::Arc::make_mut(&mut p.slides[0]).shapes[0].text.as_mut() {
b.body.warp_adj = vec![f64::NAN, -1e12];
}
let img = render_slide(&p, 0, &o);
let ((x0, y0, x1, y1), _) = ink(&img);
assert!(x0 <= x1 && y0 <= y1, "{name}: no ink");
}
}
-209
View File
@@ -1,209 +0,0 @@
//! Drawing laid-out text through a WordArt warp ([`deckcraft_geom::warp`]).
//!
//! Glyph outlines are placed in layout space, flattened, cut into short segments and every point
//! is mapped through the warp, so straight glyph edges bend with the guides. Underlines and
//! highlights are warped the same way.
use deckcraft_geom::warp::{Warp, WarpLine};
use deckcraft_text::{GlyphRun, TextLayout};
use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as _};
use vello_cpu::{RenderContext, peniko};
use crate::{ParaState, color, glyph, scale_of, text_transform};
/// Most points one warped glyph may have (a hostile size or zoom can't explode the work).
const MAX_POINTS: usize = 40_000;
/// The line of `l` a glyph of paragraph `para` on baseline `y` belongs to.
fn line_of(l: &TextLayout, para: usize, y: f64) -> Option<usize> {
let mut best: Option<(usize, f64)> = None;
for (i, li) in l.lines.iter().enumerate().filter(|(_, li)| li.para == para) {
let inside = y >= li.top - 0.01 && y <= li.bottom + 0.01;
let d = if inside { 0.0 } else { (li.baseline - y).abs() };
if best.is_none_or(|(_, bd)| d < bd) {
best = Some((i, d));
}
}
best.map(|(i, _)| i)
}
/// Where a line meets a follow guide, above its baseline: 3/8 of the way up the font's
/// ascent + descent box (measured on Arial, Times New Roman and Courier New in PowerPoint).
fn ref_offset(run: &GlyphRun) -> f64 {
let f = &run.face;
let k = run.size / f.upem.max(1.0);
((0.375 * (f.ascent + f.descent) - f.descent) * k).clamp(-run.size, run.size)
}
/// `path` flattened with tolerance `tol`, cut into pieces of at most `seg` and mapped by `f`.
fn warp_path(path: &BezPath, tol: f64, seg: f64, f: &dyn Fn(Point) -> Point) -> BezPath {
let mut out = BezPath::new();
let mut last: Option<Point> = None;
let mut start: Option<Point> = None;
let mut budget = MAX_POINTS;
let segment = |out: &mut BezPath, a: Point, b: Point, budget: &mut usize| {
let n = ((b - a).hypot() / seg).ceil();
let n = if n.is_finite() { (n as usize).clamp(1, 512) } else { 1 };
for i in 1..=n {
if *budget == 0 {
return;
}
*budget -= 1;
out.line_to(f(a.lerp(b, i as f64 / n as f64)));
}
};
kurbo::flatten(path.iter(), tol, |el| match el {
PathEl::MoveTo(p) => {
if let (Some(a), Some(s)) = (last, start)
&& (a - s).hypot() > 1e-9
{
segment(&mut out, a, s, &mut budget);
}
out.move_to(f(p));
last = Some(p);
start = Some(p);
}
PathEl::LineTo(p) => {
if let Some(a) = last {
segment(&mut out, a, p, &mut budget);
}
last = Some(p);
}
PathEl::ClosePath => {
if let (Some(a), Some(s)) = (last, start) {
segment(&mut out, a, s, &mut budget);
}
out.close_path();
last = start;
}
_ => {}
});
out
}
struct Item<'a> {
run: &'a GlyphRun,
path: BezPath,
line: usize,
state: ParaState,
}
/// Draw `l` (laid out in text rect `tr`, shape transform `m`) through `warp`.
pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine, paras: &[(usize, ParaState)], warp: &Warp) {
let para = |i: usize| paras.iter().find(|(p, _)| *p == i).map(|(_, s)| *s).unwrap_or_default();
let m = text_transform(m, tr, l.rotation);
let n = l.lines.len();
let mut ink: Vec<Option<Rect>> = vec![None; n];
let mut off: Vec<Option<f64>> = vec![None; n];
let mut items: Vec<Item> = Vec::new();
for run in &l.runs {
let k = run.size / run.face.upem.max(1.0);
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
for (gid, x, y) in &run.glyphs {
let Some(li) = line_of(l, run.para, *y) else { continue };
if let Some(o) = off.get_mut(li) {
*o = Some(o.map_or(ref_offset(run), |v| v.max(ref_offset(run))));
}
let outline = glyph(&run.face, *gid);
if outline.elements().is_empty() {
continue;
}
let mut path = (*outline).clone();
path.apply_affine(Affine::translate((*x, *y)) * skew * Affine::scale(k));
let b = path.bounding_box();
if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) {
continue;
}
if let Some(slot) = ink.get_mut(li) {
*slot = Some(slot.map_or(b, |r| r.union(b)));
}
items.push(Item { run, path, line: li, state: para(run.para) });
}
}
let lines: Vec<WarpLine> = l
.lines
.iter()
.enumerate()
.map(|(i, li)| {
let o = off.get(i).copied().flatten().unwrap_or(0.18 * (li.bottom - li.top));
WarpLine { ink: ink.get(i).copied().flatten(), extent: li.extent, ref_y: li.baseline - o }
})
.collect();
let mapper = warp.mapper(&lines, l.inner);
let px = scale_of(m).max(1e-6) * mapper.zoom();
let tol = (0.1 / px).clamp(1e-4, 2.0);
let seg = (2.0 / px).clamp(1e-3, 50.0);
let deco = |ctx: &mut RenderContext, d: &deckcraft_text::Deco| {
let ps = para(d.para);
if !ps.visible || ps.opacity <= 0.001 {
return;
}
let y = (d.rect.y0 + d.rect.y1) / 2.0;
let Some(li) = line_of(l, d.para, y) else { return };
let path = warp_path(&d.rect.to_path(0.1), tol, seg, &|p| mapper.map(li, p));
ctx.set_transform(m * Affine::translate(ps.offset));
ctx.set_paint(color(d.color, ps.opacity));
ctx.fill_path(&path);
};
for d in l.decos.iter().filter(|d| d.behind) {
deco(ctx, d);
}
for it in &items {
let (run, ps) = (it.run, it.state);
if !ps.visible || ps.opacity <= 0.001 {
continue;
}
let alpha = run.alpha * ps.opacity;
if (alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
continue;
}
let path = warp_path(&it.path, tol, seg, &|p| mapper.map(it.line, p));
ctx.set_transform(m * Affine::translate(ps.offset));
if alpha > 0.0 && run.color.a > 0 {
ctx.set_paint(color(run.color, alpha));
ctx.fill_path(&path);
if run.fake_bold {
ctx.set_stroke(kurbo::Stroke::new(run.size * 0.025).with_join(kurbo::Join::Round));
ctx.stroke_path(&path);
}
}
if let Some((oc, ow)) = run.outline {
ctx.set_paint(color(oc, ps.opacity));
ctx.set_stroke(kurbo::Stroke::new(ow.max(0.0)).with_join(kurbo::Join::Round));
ctx.stroke_path(&path);
}
}
for d in l.decos.iter().filter(|d| !d.behind) {
deco(ctx, d);
}
ctx.set_fill_rule(peniko::Fill::NonZero);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn warp_path_subdivides_and_maps_every_point() {
let mut p = BezPath::new();
p.move_to((0.0, 0.0));
p.line_to((10.0, 0.0));
p.line_to((10.0, 10.0));
p.close_path();
let out = warp_path(&p, 0.01, 1.0, &|q| Point::new(q.x, q.y + q.x * q.x * 0.01));
// 10 + 10 + ~15 pieces of at most 1 pt.
let n = out.elements().len();
assert!(n > 30, "{n}");
// The first edge is bent onto the parabola.
let mid = out.elements().iter().find_map(|e| if let PathEl::LineTo(q) = e { (q.x == 5.0).then_some(*q) } else { None });
assert_eq!(mid, Some(Point::new(5.0, 0.25)));
// A huge path with a tiny segment length stays bounded.
let mut big = BezPath::new();
big.move_to((0.0, 0.0));
for i in 0..1000 {
big.line_to((1e6 * (i % 2) as f64, i as f64));
}
let out = warp_path(&big, 0.01, 1e-3, &|q| q);
assert!(out.elements().len() <= MAX_POINTS + 2);
}
}
+2 -4
View File
@@ -261,7 +261,7 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgba>) -> Styl
let family = resolve::font_family(ctx, r);
let bold = r.bold.unwrap_or(false);
let italic = r.italic.unwrap_or(false);
let face = FontDb::global().document_face(&family, style_name(bold, italic));
let face = FontDb::global().face(&family, style_name(bold, italic));
let fake_bold = bold && face.weight < 550.0;
let fake_italic = italic && !face.italic;
let size = (r.size.unwrap_or(18.0) * scale).clamp(0.5, 4000.0);
@@ -1074,9 +1074,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
(r.x1 - bp.inset_r.unwrap_or(7.2)).max(r.x0 + bp.inset_l.unwrap_or(7.2)),
(r.y1 - bp.inset_b.unwrap_or(3.6)).max(r.y0 + bp.inset_t.unwrap_or(3.6)),
);
// Text that follows a WordArt arch or circle is set on one line per paragraph.
let follow = bp.warp.as_deref().is_some_and(deckcraft_geom::warp::is_follow);
let wrap = bp.wrap.unwrap_or(true) && !follow && !matches!(bp.autofit, Some(AutoFit::Shape) if shape.text_box && !bp.wrap.unwrap_or(true));
let wrap = bp.wrap.unwrap_or(true) && !matches!(bp.autofit, Some(AutoFit::Shape) if shape.text_box && !bp.wrap.unwrap_or(true));
let ncols = bp.columns.unwrap_or(1).clamp(1, 16) as usize;
let col_gap = bp.col_spacing.unwrap_or(0.0).max(0.0);
let col_w = ((inner.width() - col_gap * (ncols as f64 - 1.0)) / ncols as f64).max(1.0);
+1 -1
View File
@@ -15,7 +15,7 @@ nothing below L6 uses egui, eframe, winit or rfd.
| Layer | Crates | What they do |
|---|---|---|
| L0 | `geom`, `color`; standalone `bitstream`, `matroska`, `ogg`, `opus` | EMU/pt units, transforms, ~180 preset shape generators re-derived from ECMA-376, adjust handles, hit testing; RGB/HSL and DrawingML colour transforms; MKV/WebM and Ogg demuxers, the Opus decoder |
| L0 | `geom`, `color`; standalone `bitstream`, `matroska`, `ogg`, `opus` | EMU/pt units, transforms, ~150 preset shape generators re-derived from ECMA-376, adjust handles, hit testing; RGB/HSL and DrawingML colour transforms; MKV/WebM and Ogg demuxers, the Opus decoder |
| L1 | `model`, `fonts`; `h264`, `hevc`, `vp9`, `av1`, `isobmff` | Presentation, masters, layouts, slides, shapes, text, themes, tables, charts, transitions, animations, placeholder inheritance (`resolve`); font database (craft-fonts + system) with metric-compatible substitutes for Office fonts (Carlito for Calibri, Caladea for Cambria, Liberation for Arial/Times); video decoders and the MP4/MOV demuxer |
| L2 | `text`, `anim`, `format`, `media` | Shaping (HarfRust), UAX #9 bidi, UAX #14 line breaking, autofit, caret hit testing; per-shape animation state over time, the show state machine, transition frame layouts, Morph matching; `.deckcraft` (zip + JSON + media) and outline text; media probing, decoding, mixer and clock |
| L3 | `render`, `pptx` | `vello_cpu` raster of slides, masters and layouts (fills, outlines, effects, pictures, tables, charts, Morph frames); PPTX import/export from ECMA-376 |
+2 -3
View File
@@ -63,13 +63,13 @@ the round trip even though DeckCraft doesn't model it.
| Themes: colour, font (Latin/EA/CS), effect schemes, background styles | yes | yes | Unknown theme parts kept |
| Notes slides and notes master | yes | yes | No notes-master editing view |
| Handout master | no | no | |
| Shapes: preset geometry (~180 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` (and `preset/extra.rs`) |
| Shapes: preset geometry (~150 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` |
| Fills: solid, gradient, picture (stretch/tile/crop), pattern, group | yes | yes | |
| Lines: dash, compound, caps, joins, arrowheads | yes | yes | |
| Effects: shadow, glow, soft edges, reflection | yes | yes | |
| 3-D: `scene3d`, `sp3d` | kept | kept | Not rendered |
| Text: runs, paragraphs, bullets, numbering, levels, autofit, columns, vertical, RTL, script fonts, fields, hyperlinks | yes | yes | Round-trip tests incl. `justLow` and date fields |
| Text warp (`prstTxWarp`) | yes | yes | All 40 presets rendered (`crates/geom/src/warp.rs`); adjust values (`avLst`) round-trip |
| Text warp (`prstTxWarp`) | yes | yes | Not rendered |
| Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | |
| Tables with styles | yes | yes | |
| Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw` |
@@ -99,5 +99,4 @@ the round trip even though DeckCraft doesn't model it.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Text warps rendered; their adjust values read and written |
| 2026-10-10 | major | Created from PowerPoint 16.113.4's `CFBundleDocumentTypes`, `crates/pptx` reader/writer and tests, and the open round-trip issues |
+3 -8
View File
@@ -165,13 +165,9 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a *
- **Estimate:** 4–6 h.
### Edit points, text warp, 3-D
- **Evidence:** checklist rows "Edit points" (P1 missing), "Text warp transforms" (partial: all 40
`prstTxWarp` presets render and their adjusts round-trip, but there is no Transform gallery or
command to apply one, no adjust handles, and arc/circle placement can differ from PowerPoint by
1–3 % of the path length), "Bevel and 3-D rotation" (missing; `scene3d`/`sp3d` kept as raw XML on
round trip).
- **Estimate:** 4–6 h (edit points), 2–4 h (warp gallery, command and handles), 8–12 h (3-D render
approximation).
- **Evidence:** checklist rows "Edit points" (P1 missing), "Text warp transforms", "Bevel and 3-D
rotation" (missing; `scene3d`/`sp3d` kept as raw XML on round trip).
- **Estimate:** 4–6 h (edit points), 6–10 h (warp), 8–12 h (3-D render approximation).
### Proofing: spelling, thesaurus, AutoCorrect
- **Evidence:** `review.spelling` checks a small built-in list of frequent misspellings
@@ -251,6 +247,5 @@ Detail in [localization-parity.md](localization-parity.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Text warps render (all 40 presets, adjusts round-trip); the warp gap is now the gallery, command and handles |
| 2026-10-10 | minor | Noted that no gap blocks alpha (core-workflow gate passes) |
| 2026-10-10 | major | Created: every gap from the 2026-10-10 re-measure and the 16 open GitHub issues, ranked, with beta blockers marked |
+3 -3
View File
@@ -9,7 +9,7 @@ The summary between `<!-- SUMMARY -->
|---|---|---|---|---|---|
| P0 | 72 | 60 | 12 | 0 | 92% |
| P1 | 65 | 38 | 21 | 6 | 75% |
| P2 | 54 | 13 | 14 | 27 | 37% |
| P2 | 54 | 13 | 13 | 28 | 36% |
| Area | Parity |
|---|---|
@@ -18,7 +18,7 @@ The summary between `<!-- SUMMARY -->
| Slides | 90% |
| Views | 90% |
| Masters & themes | 85% |
| Shapes | 88% |
| Shapes | 86% |
| Format | 81% |
| Arrange | 100% |
| Text | 80% |
@@ -115,7 +115,7 @@ Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Sh
| Shapes | Change shape | P1 | D |
| Shapes | Text boxes | P0 | D |
| Shapes | WordArt | P1 | P |
| Shapes | Text warp transforms | P2 | P |
| Shapes | Text warp transforms | P2 | M |
| Shapes | Shape quick styles | P1 | D |
| Shapes | Set as default shape | P2 | D |
| Format | Solid fill and transparency | P0 | D |
+1 -1
View File
@@ -212,7 +212,7 @@ Carried from the 2026-10-07 ROADMAP status and updated with what has landed sinc
header/footer fields, embedded media, unlimited undo on shared slide snapshots.
- **Themes:** 8 original themes with colour and font schemes, custom colours and fonts,
backgrounds (solid, gradient, picture, pattern), slide size presets.
- **Shapes:** ~180 preset geometries with adjustment handles, text boxes, pictures (crop,
- **Shapes:** ~150 preset geometries with adjustment handles, text boxes, pictures (crop,
corrections, animated GIFs that play and pause), tables with styles, charts (column, bar, line,
area, pie, doughnut, scatter and more), basic SmartArt, groups, connectors glued to sites,
freeform/curve/scribble, merge shapes, ink, action buttons, WordArt; flipped shapes keep their