mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ca29399ab | ||
|
|
4d3e63cb04 | ||
|
|
40ba76113e | ||
|
|
a293b68258 | ||
|
|
dedb20890b | ||
|
|
5835f0a106 | ||
|
|
a520dd4d1d | ||
|
|
fd2f8146b4 | ||
|
|
1679836540 | ||
|
|
d8407c11f9 |
@@ -28,6 +28,8 @@ 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
|
||||
|
||||
@@ -128,6 +128,7 @@ 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 |
|
||||
|
||||
+124
-11
@@ -52,6 +52,53 @@ 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]),
|
||||
@@ -65,6 +112,10 @@ 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,
|
||||
@@ -253,6 +304,11 @@ 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>,
|
||||
@@ -316,6 +372,16 @@ 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.
|
||||
@@ -362,8 +428,13 @@ 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);
|
||||
face_names(&skrifa::FontRef::new(&font).ok()?)
|
||||
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))
|
||||
})
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -491,10 +562,12 @@ 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,
|
||||
@@ -571,6 +644,8 @@ 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() }),
|
||||
}
|
||||
}
|
||||
@@ -676,6 +751,8 @@ 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()));
|
||||
@@ -756,17 +833,35 @@ 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>> {
|
||||
if let Some(f) = self.find(family, style) {
|
||||
return Some(f);
|
||||
}
|
||||
// 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"))]
|
||||
if self.load_cataloged(family)
|
||||
&& let Some(f) = self.find(family, style)
|
||||
{
|
||||
return Some(f);
|
||||
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);
|
||||
}
|
||||
}
|
||||
None
|
||||
self.find(family, style)
|
||||
}
|
||||
|
||||
/// Is `family` available (loaded, or installed on the system)?
|
||||
@@ -782,7 +877,9 @@ impl FontDb {
|
||||
}
|
||||
|
||||
pub(crate) fn is_loaded(&self, family: &str) -> bool {
|
||||
self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family))
|
||||
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)))
|
||||
}
|
||||
|
||||
/// A variable font's axes: (tag, name, min, default, max), empty for static fonts.
|
||||
@@ -837,11 +934,27 @@ impl FontDb {
|
||||
return self.instance(family, style);
|
||||
}
|
||||
let faces = self.read_faces();
|
||||
let cands: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect();
|
||||
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());
|
||||
}
|
||||
}
|
||||
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());
|
||||
}
|
||||
|
||||
+62
-1
@@ -14,7 +14,9 @@ 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, base_style, bundled, substitutes, system_font_dirs};
|
||||
pub use fontdb::{
|
||||
FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, MISSING_FONT_FAMILY, base_style, bundled, substitutes, system_font_dirs,
|
||||
};
|
||||
pub use harfrust::Feature;
|
||||
use harfrust::Tag;
|
||||
pub use kurbo::BezPath;
|
||||
@@ -182,6 +184,65 @@ 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"));
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+55
@@ -0,0 +1,55 @@
|
||||
"""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)
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
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};
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
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")]
|
||||
@@ -148,6 +150,7 @@ 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]),
|
||||
@@ -163,6 +166,7 @@ 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]),
|
||||
@@ -171,9 +175,13 @@ 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]),
|
||||
@@ -192,6 +200,9 @@ 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]),
|
||||
@@ -199,6 +210,9 @@ 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]),
|
||||
@@ -209,16 +223,27 @@ 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]),
|
||||
@@ -253,6 +278,7 @@ 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]),
|
||||
@@ -267,6 +293,9 @@ 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]),
|
||||
@@ -276,6 +305,22 @@ 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, []),
|
||||
@@ -1535,7 +1580,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 None,
|
||||
_ => return extra::build(name, w, h, &a),
|
||||
};
|
||||
Some(g)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,796 @@
|
||||
//! 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)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,773 @@
|
||||
//! 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;
|
||||
@@ -0,0 +1,309 @@
|
||||
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());
|
||||
}
|
||||
@@ -512,6 +512,7 @@ pub fn blank_presentation(size: Size, theme: Theme, title_slide: bool) -> Presen
|
||||
first_slide_number: 1,
|
||||
next_id: next.max(256),
|
||||
embedded_fonts: vec![],
|
||||
table_styles: vec![],
|
||||
raw_ext: None,
|
||||
};
|
||||
if title_slide {
|
||||
|
||||
@@ -798,6 +798,9 @@ pub struct Presentation {
|
||||
pub next_id: u32,
|
||||
/// Embedded fonts (family names; bytes in `media`).
|
||||
pub embedded_fonts: Vec<(String, MediaId)>,
|
||||
/// Table styles defined in the file (`ppt/tableStyles.xml`).
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub table_styles: Vec<table::TableStyleDef>,
|
||||
/// Unmodelled presentation-level XML kept for round-trip.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw_ext: Option<String>,
|
||||
|
||||
@@ -223,6 +223,7 @@ 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),
|
||||
});
|
||||
|
||||
+109
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::style::{Fill, Line};
|
||||
use crate::style::{ColorRef, Fill, Line};
|
||||
use crate::text::{Anchor, TextBody};
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
@@ -55,6 +55,9 @@ pub struct Table {
|
||||
pub last_col: bool,
|
||||
pub band_row: bool,
|
||||
pub band_col: bool,
|
||||
/// `a:tblPr` fill: a fill behind the whole table (over the style's own background).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub background: Option<Fill>,
|
||||
}
|
||||
|
||||
impl Default for Table {
|
||||
@@ -69,6 +72,7 @@ impl Default for Table {
|
||||
last_col: false,
|
||||
band_row: true,
|
||||
band_col: false,
|
||||
background: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,6 +203,110 @@ impl Table {
|
||||
}
|
||||
}
|
||||
|
||||
/// A table style defined in the file (`ppt/tableStyles.xml`), as PowerPoint writes the built-in
|
||||
/// styles a deck uses: how each part of a table (whole table, banded rows and columns, first and
|
||||
/// last rows and columns, corner cells) is filled, bordered and lettered.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct TableStyleDef {
|
||||
/// The style's GUID (`a:tableStyleId` on tables).
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub parts: Vec<(TablePart, TablePartStyle)>,
|
||||
/// `a:tblBg`: a fill behind the whole table (only `fill` and `fill_ref` are used).
|
||||
pub background: Option<TablePartStyle>,
|
||||
/// The definition as read, written back unchanged.
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub raw: String,
|
||||
}
|
||||
|
||||
impl TableStyleDef {
|
||||
pub fn part(&self, p: TablePart) -> Option<&TablePartStyle> {
|
||||
self.parts.iter().find(|(k, _)| *k == p).map(|(_, s)| s)
|
||||
}
|
||||
}
|
||||
|
||||
/// The parts of a table style, in the order they apply (later ones win).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TablePart {
|
||||
WholeTbl,
|
||||
Band1H,
|
||||
Band2H,
|
||||
Band1V,
|
||||
Band2V,
|
||||
LastCol,
|
||||
FirstCol,
|
||||
LastRow,
|
||||
FirstRow,
|
||||
SeCell,
|
||||
SwCell,
|
||||
NeCell,
|
||||
NwCell,
|
||||
}
|
||||
|
||||
impl TablePart {
|
||||
pub const ALL: [TablePart; 13] = [
|
||||
TablePart::WholeTbl,
|
||||
TablePart::Band1H,
|
||||
TablePart::Band2H,
|
||||
TablePart::Band1V,
|
||||
TablePart::Band2V,
|
||||
TablePart::LastCol,
|
||||
TablePart::FirstCol,
|
||||
TablePart::LastRow,
|
||||
TablePart::FirstRow,
|
||||
TablePart::SeCell,
|
||||
TablePart::SwCell,
|
||||
TablePart::NeCell,
|
||||
TablePart::NwCell,
|
||||
];
|
||||
|
||||
pub fn xml(self) -> &'static str {
|
||||
match self {
|
||||
TablePart::WholeTbl => "wholeTbl",
|
||||
TablePart::Band1H => "band1H",
|
||||
TablePart::Band2H => "band2H",
|
||||
TablePart::Band1V => "band1V",
|
||||
TablePart::Band2V => "band2V",
|
||||
TablePart::LastCol => "lastCol",
|
||||
TablePart::FirstCol => "firstCol",
|
||||
TablePart::LastRow => "lastRow",
|
||||
TablePart::FirstRow => "firstRow",
|
||||
TablePart::SeCell => "seCell",
|
||||
TablePart::SwCell => "swCell",
|
||||
TablePart::NeCell => "neCell",
|
||||
TablePart::NwCell => "nwCell",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_xml(s: &str) -> Option<TablePart> {
|
||||
TablePart::ALL.into_iter().find(|p| p.xml() == s)
|
||||
}
|
||||
}
|
||||
|
||||
/// One part of a table style. A theme reference (`a:fillRef`, `a:lnRef`) is kept as the theme's
|
||||
/// style index with its colour.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct TablePartStyle {
|
||||
pub fill: Option<Fill>,
|
||||
pub fill_ref: Option<(u32, ColorRef)>,
|
||||
/// Left, right, top, bottom, inside horizontal, inside vertical.
|
||||
pub borders: [Option<TableBorder>; 6],
|
||||
pub text_color: Option<ColorRef>,
|
||||
pub bold: Option<bool>,
|
||||
pub italic: Option<bool>,
|
||||
}
|
||||
|
||||
/// A border of a table style part: a line, or a theme line style reference.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
pub enum TableBorder {
|
||||
Line { line: Line },
|
||||
Ref { idx: u32, color: ColorRef },
|
||||
}
|
||||
|
||||
/// Built-in table styles: (id, label). Styles are generated from the theme accents.
|
||||
pub fn table_styles() -> Vec<(String, String)> {
|
||||
let mut v = vec![("none".to_string(), "No Style, No Grid".to_string()), ("grid".to_string(), "No Style, Table Grid".to_string())];
|
||||
|
||||
@@ -98,6 +98,10 @@ 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>,
|
||||
@@ -119,6 +123,7 @@ 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);
|
||||
|
||||
@@ -321,13 +321,45 @@ 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").map(String::from),
|
||||
"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();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
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)));
|
||||
|
||||
@@ -30,6 +30,8 @@ pub struct Imp<'a> {
|
||||
pub next: u32,
|
||||
pub slide_by_part: HashMap<String, SlideId>,
|
||||
pub custom_show_names: HashMap<String, String>,
|
||||
/// GUIDs (upper case) of the table styles the file defines.
|
||||
pub table_style_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl Imp<'_> {
|
||||
@@ -230,6 +232,7 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
|
||||
next: max_id.max(1),
|
||||
slide_by_part: HashMap::new(),
|
||||
custom_show_names: HashMap::new(),
|
||||
table_style_ids: Vec::new(),
|
||||
};
|
||||
// Slide ids first so hyperlinks anywhere can point at slides.
|
||||
let mut slide_ids: Vec<(u32, String, SlideId)> = vec![];
|
||||
@@ -269,6 +272,13 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
|
||||
if let Some(dts) = pres_el.child("defaultTextStyle") {
|
||||
p.default_text_style = dml::list_style(&mut imp, &pres_part, dts);
|
||||
}
|
||||
if let Some(r) = pres_part.rels.first_of("tableStyles")
|
||||
&& let Some(d) = load(&pkg, &r.target)
|
||||
{
|
||||
let part = Part::new(&pkg, &r.target, &d);
|
||||
p.table_styles = table_styles(&mut imp, &part, &d.root);
|
||||
imp.table_style_ids = p.table_styles.iter().map(|s| s.id.clone()).collect();
|
||||
}
|
||||
|
||||
// Masters and layouts.
|
||||
let mut layout_by_part: HashMap<String, LayoutId> = HashMap::new();
|
||||
@@ -505,6 +515,69 @@ fn walk_shapes_mut(shapes: &mut [deckcraft_model::Shape], f: &mut dyn FnMut(&mut
|
||||
}
|
||||
}
|
||||
|
||||
/// `a:tblStyleLst`: the table styles the file defines.
|
||||
fn table_styles(imp: &mut Imp, part: &Part, root: &El) -> Vec<deckcraft_model::table::TableStyleDef> {
|
||||
use deckcraft_model::table::{TableBorder, TablePart, TablePartStyle, TableStyleDef};
|
||||
root.children_named("tblStyle")
|
||||
.take(256)
|
||||
.filter_map(|st| {
|
||||
let id = st.attr("styleId")?.to_ascii_uppercase();
|
||||
let mut def = TableStyleDef { id, name: st.attr("styleName").unwrap_or("").to_string(), raw: part.keep(st), ..Default::default() };
|
||||
if let Some(bg) = st.child("tblBg") {
|
||||
let mut ps = TablePartStyle::default();
|
||||
if let Some(f) = bg.elements().find(|e| e.local().ends_with("Fill")) {
|
||||
ps.fill = dml::fill_el(imp, part, f);
|
||||
}
|
||||
if let Some(fr) = bg.child("fillRef") {
|
||||
ps.fill_ref = Some((
|
||||
fr.u32("idx").unwrap_or(0),
|
||||
dml::color(fr).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)),
|
||||
));
|
||||
}
|
||||
def.background = Some(ps);
|
||||
}
|
||||
for el in st.elements() {
|
||||
let Some(kind) = TablePart::from_xml(el.local()) else { continue };
|
||||
let mut ps = TablePartStyle::default();
|
||||
if let Some(tx) = el.child("tcTxStyle") {
|
||||
ps.text_color = dml::color(tx);
|
||||
ps.bold = tx.attr("b").map(|v| v == "on");
|
||||
ps.italic = tx.attr("i").map(|v| v == "on");
|
||||
}
|
||||
if let Some(tc) = el.child("tcStyle") {
|
||||
if let Some(f) = tc.child("fill").and_then(|f| f.elements().next()) {
|
||||
ps.fill = dml::fill_el(imp, part, f);
|
||||
}
|
||||
if let Some(fr) = tc.child("fillRef") {
|
||||
ps.fill_ref = Some((
|
||||
fr.u32("idx").unwrap_or(0),
|
||||
dml::color(fr).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)),
|
||||
));
|
||||
}
|
||||
if let Some(bd) = tc.child("tcBdr") {
|
||||
for (i, side) in ["left", "right", "top", "bottom", "insideH", "insideV"].iter().enumerate() {
|
||||
let Some(b) = bd.child(side) else { continue };
|
||||
let border = if let Some(ln) = b.child("ln") {
|
||||
Some(TableBorder::Line { line: dml::line(imp, part, ln) })
|
||||
} else {
|
||||
b.child("lnRef").map(|r| TableBorder::Ref {
|
||||
idx: r.u32("idx").unwrap_or(0),
|
||||
color: dml::color(r).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)),
|
||||
})
|
||||
};
|
||||
if let Some(slot) = ps.borders.get_mut(i) {
|
||||
*slot = border;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
def.parts.push((kind, ps));
|
||||
}
|
||||
Some(def)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn background(imp: &mut Imp, part: &Part, csld: &El) -> Option<Background> {
|
||||
let bg = csld.child("bg")?;
|
||||
if let Some(pr) = bg.child("bgPr") {
|
||||
|
||||
@@ -394,6 +394,7 @@ fn smartart(imp: &mut Imp, part: &Part, gd: &El, frame: &Shape, depth: usize) ->
|
||||
fn table(imp: &mut Imp, part: &Part, tbl: &El) -> Table {
|
||||
let mut t = Table { style: "none".into(), first_row: false, band_row: false, ..Default::default() };
|
||||
if let Some(pr) = tbl.child("tblPr") {
|
||||
t.background = pr.elements().find(|e| e.local().ends_with("Fill") && e.local() != "noFill").and_then(|f| dml::fill_el(imp, part, f));
|
||||
t.first_row = pr.bool("firstRow").unwrap_or(false);
|
||||
t.first_col = pr.bool("firstCol").unwrap_or(false);
|
||||
t.last_row = pr.bool("lastRow").unwrap_or(false);
|
||||
@@ -401,7 +402,11 @@ fn table(imp: &mut Imp, part: &Part, tbl: &El) -> Table {
|
||||
t.band_row = pr.bool("bandRow").unwrap_or(false);
|
||||
t.band_col = pr.bool("bandCol").unwrap_or(false);
|
||||
if let Some(id) = pr.child("tableStyleId").map(|e| e.text()) {
|
||||
t.style = crate::tables::style_from_guid(id.trim());
|
||||
// A style the file defines is drawn from its definition (by its GUID).
|
||||
// DeckCraft's own styles (written with GUIDs derived from their ids) map back to them.
|
||||
let up = id.trim().to_ascii_uppercase();
|
||||
t.style =
|
||||
if imp.table_style_ids.contains(&up) && !crate::tables::is_own_guid(&up) { up } else { crate::tables::style_from_guid(id.trim()) };
|
||||
}
|
||||
}
|
||||
t.cols = tbl
|
||||
|
||||
@@ -47,6 +47,12 @@ fn own_guid(style: &str) -> String {
|
||||
guid(&format!("deckcraft-table-style:{style}"))
|
||||
}
|
||||
|
||||
/// Is `g` the GUID DeckCraft writes for one of its own styles?
|
||||
pub fn is_own_guid(g: &str) -> bool {
|
||||
let up = g.to_ascii_uppercase();
|
||||
deckcraft_model::table::table_styles().iter().any(|(id, _)| own_guid(id) == up)
|
||||
}
|
||||
|
||||
/// Table style GUID → our style id (or the GUID itself when unknown).
|
||||
pub fn style_from_guid(g: &str) -> String {
|
||||
let up = g.to_ascii_uppercase();
|
||||
@@ -198,8 +204,9 @@ fn style_def(w: &mut W, id: &str, name: &str) {
|
||||
w.close("a:tblStyle");
|
||||
}
|
||||
|
||||
/// `ppt/tableStyles.xml` with definitions for the styles in `used`.
|
||||
pub fn table_styles_xml(used: &[String]) -> Vec<u8> {
|
||||
/// `ppt/tableStyles.xml` with definitions for the styles in `used`: ours, generated, and those the
|
||||
/// opened file defined (`defs`), written back as they were read.
|
||||
pub fn table_styles_xml(used: &[String], defs: &[deckcraft_model::table::TableStyleDef]) -> Vec<u8> {
|
||||
let mut w = W::new();
|
||||
w.open("a:tblStyleLst", A::new().a("xmlns:a", NS_A).a("def", "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"));
|
||||
let names = deckcraft_model::table::table_styles();
|
||||
@@ -209,6 +216,12 @@ pub fn table_styles_xml(used: &[String]) -> Vec<u8> {
|
||||
continue;
|
||||
}
|
||||
done.push(s);
|
||||
if let Some(d) = defs.iter().find(|d| d.id.eq_ignore_ascii_case(s))
|
||||
&& crate::xml::parse(d.raw.as_bytes()).is_ok_and(|x| x.root.is("tblStyle"))
|
||||
{
|
||||
w.raw(&d.raw);
|
||||
continue;
|
||||
}
|
||||
let (_, define) = guid_for_style(s);
|
||||
if !define {
|
||||
continue;
|
||||
@@ -231,7 +244,7 @@ mod tests {
|
||||
"nwCell",
|
||||
];
|
||||
let used: Vec<String> = deckcraft_model::table::table_styles().into_iter().map(|(id, _)| id).collect();
|
||||
let d = crate::xml::parse(&table_styles_xml(&used)).unwrap();
|
||||
let d = crate::xml::parse(&table_styles_xml(&used, &[])).unwrap();
|
||||
let mut n = 0;
|
||||
for st in d.root.children_named("tblStyle") {
|
||||
n += 1;
|
||||
|
||||
@@ -392,7 +392,19 @@ 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));
|
||||
w.empty0("a:avLst");
|
||||
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.close("a:prstTxWarp");
|
||||
}
|
||||
match b.autofit {
|
||||
|
||||
@@ -512,7 +512,7 @@ pub fn export(p: &Presentation) -> Result<Vec<u8>, PptxError> {
|
||||
pres.rels.add("tableStyles", "tableStyles.xml", false);
|
||||
x.pkg.xml_part("ppt/presProps.xml", CT_PRES_PROPS, pres_props_xml(p));
|
||||
x.pkg.xml_part("ppt/viewProps.xml", CT_VIEW_PROPS, view_props_xml());
|
||||
let ts = crate::tables::table_styles_xml(&x.table_styles);
|
||||
let ts = crate::tables::table_styles_xml(&x.table_styles, &p.table_styles);
|
||||
x.pkg.xml_part("ppt/tableStyles.xml", CT_TABLE_STYLES, ts);
|
||||
let pxml = presentation_xml(&mut x, &mut pres, &master_rids, notes_master_rid.as_deref(), &slide_rids);
|
||||
x.pkg.xml_part("ppt/presentation.xml", CT_PRES, pxml);
|
||||
|
||||
@@ -494,6 +494,9 @@ fn table(w: &mut W, x: &mut Exp, o: &mut Out, t: &Table) {
|
||||
.t("bandRow", t.band_row)
|
||||
.t("bandCol", t.band_col),
|
||||
);
|
||||
if let Some(f) = &t.background {
|
||||
dml::fill(w, x, o, f);
|
||||
}
|
||||
w.elt("a:tableStyleId", &guid);
|
||||
w.close("a:tblPr");
|
||||
w.open0("a:tblGrid");
|
||||
|
||||
@@ -780,3 +780,80 @@ fn line_break_options_round_trip() {
|
||||
assert_eq!(props.latin_line_break, Some(true));
|
||||
assert_eq!(props.hanging_punct, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_fill_round_trips() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let mut t = Table::new(2, 2, 200.0, 40.0);
|
||||
t.background = Some(Fill::solid(ColorRef::rgb(Rgba::rgb(0, 112, 192))));
|
||||
let mut sh = shape(&mut p, "Filled table", 10.0, 10.0, 200.0, 80.0);
|
||||
sh.kind = deckcraft_model::ShapeKind::Table(t);
|
||||
let first = p.slides[0].id;
|
||||
p.slide_mut(first).expect("slide").shapes.push(sh);
|
||||
let q = round(&p);
|
||||
let deckcraft_model::ShapeKind::Table(t) = &by_name(&q.slides[0], "Filled table").kind else { panic!("table") };
|
||||
assert!(
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Fills and styles read from decks written by other tools.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::style::Fill;
|
||||
use deckcraft_model::{Shape, ShapeId, defaults};
|
||||
|
||||
const STYLE_GUID: &str = "{ABCDEF01-2345-4678-9ABC-DEF012345678}";
|
||||
|
||||
/// A table style definition as PowerPoint writes one: red table background, see-through white
|
||||
/// bands, 2 pt blue outer borders and no inside lines.
|
||||
fn style_xml() -> String {
|
||||
format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<a:tblStyleLst xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" def="{STYLE_GUID}"><a:tblStyle styleId="{STYLE_GUID}" styleName="Probe Style"><a:tblBg><a:fillRef idx="1"><a:srgbClr val="FF0000"/></a:fillRef></a:tblBg><a:wholeTbl><a:tcTxStyle b="on"><a:srgbClr val="00FF00"/></a:tcTxStyle><a:tcStyle><a:tcBdr><a:left><a:ln w="25400"><a:solidFill><a:srgbClr val="0000FF"/></a:solidFill></a:ln></a:left><a:insideH><a:ln><a:noFill/></a:ln></a:insideH></a:tcBdr><a:fill><a:noFill/></a:fill></a:tcStyle></a:wholeTbl><a:band1H><a:tcStyle><a:tcBdr/><a:fill><a:solidFill><a:srgbClr val="FFFFFF"><a:alpha val="20000"/></a:srgbClr></a:solidFill></a:fill></a:tcStyle></a:band1H></a:tblStyle></a:tblStyleLst>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// An exported deck with a 3x2 table using the probe style.
|
||||
fn table_deck() -> Vec<u8> {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let id = ShapeId(p.alloc_id());
|
||||
let first = p.slides[0].id;
|
||||
let mut t = deckcraft_model::Table::new(3, 2, 300.0, 30.0);
|
||||
t.style = "medium2-accent1".into();
|
||||
p.slide_mut(first).expect("slide").shapes.push(Shape {
|
||||
id,
|
||||
name: "Styled".into(),
|
||||
xfrm: Some(Xfrm::new(10.0, 10.0, 300.0, 90.0)),
|
||||
kind: deckcraft_model::ShapeKind::Table(t),
|
||||
..Default::default()
|
||||
});
|
||||
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||
// Point the table at the probe style and replace the style part.
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default();
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).expect("entry");
|
||||
let mut d = vec![];
|
||||
f.read_to_end(&mut d).expect("read");
|
||||
let name = f.name().to_string();
|
||||
if name == "ppt/tableStyles.xml" {
|
||||
d = style_xml().into_bytes();
|
||||
} else if name.starts_with("ppt/slides/slide") && name.ends_with(".xml") {
|
||||
let s = String::from_utf8(d).expect("utf8");
|
||||
let start = s.find("<a:tableStyleId>").map(|i| i + "<a:tableStyleId>".len());
|
||||
let end = s.find("</a:tableStyleId>");
|
||||
d = match (start, end) {
|
||||
(Some(a), Some(b)) => format!("{}{}{}", &s[..a], STYLE_GUID, &s[b..]).into_bytes(),
|
||||
_ => s.into_bytes(),
|
||||
};
|
||||
}
|
||||
out.start_file(name, o).expect("start");
|
||||
out.write_all(&d).expect("write");
|
||||
}
|
||||
out.finish().expect("finish").into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_styles_the_file_defines_are_read_used_and_kept() {
|
||||
use deckcraft_model::table::{TableBorder, TablePart};
|
||||
let p = deckcraft_pptx::import(&table_deck()).expect("import");
|
||||
let def = p.table_styles.iter().find(|d| d.id == STYLE_GUID).expect("the probe style is read");
|
||||
assert_eq!(def.name, "Probe Style");
|
||||
let bg = def.background.as_ref().expect("table background");
|
||||
assert!(matches!(bg.fill_ref, Some((1, _))));
|
||||
let whole = def.part(TablePart::WholeTbl).expect("whole table");
|
||||
assert_eq!(whole.bold, Some(true));
|
||||
assert!(whole.text_color.is_some());
|
||||
assert!(matches!(&whole.borders[0], Some(TableBorder::Line { line }) if line.width == Some(2.0)));
|
||||
assert!(matches!(&whole.borders[4], Some(TableBorder::Line { line }) if matches!(line.fill, Some(Fill::None))));
|
||||
assert!(matches!(def.part(TablePart::Band1H).and_then(|b| b.fill.clone()), Some(Fill::Solid { .. })));
|
||||
// The table keeps the style's GUID, so it's drawn from the definition.
|
||||
let s = p.slides[0].shapes.iter().find(|s| s.name == "Styled").expect("table");
|
||||
let deckcraft_model::ShapeKind::Table(t) = &s.kind else { panic!("table") };
|
||||
assert_eq!(t.style, STYLE_GUID);
|
||||
// Saving writes the definition back, unchanged.
|
||||
let again = deckcraft_pptx::export(&p).expect("export");
|
||||
let q = deckcraft_pptx::import(&again).expect("reimport");
|
||||
let def2 = q.table_styles.iter().find(|d| d.id == STYLE_GUID).expect("kept on save");
|
||||
assert_eq!(def2.parts, def.parts);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ mod pathgrad;
|
||||
mod placed;
|
||||
mod ramp;
|
||||
mod table;
|
||||
mod warp;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -793,11 +794,14 @@ impl Renderer {
|
||||
ctx.push_clip_layer(&outline);
|
||||
let c = rctx.color(&sh.color, fx_ph);
|
||||
let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist;
|
||||
ctx.push_layer(None, None, None, None, Some(blur(sh.blur * 0.5 * scale_of(m))));
|
||||
// As PowerPoint draws it (measured on its export): the shadow falls inside along the
|
||||
// edges it points to (45 degrees: the bottom and right), blurred by a third of the
|
||||
// blur radius.
|
||||
ctx.push_layer(None, None, None, None, Some(blur(sh.blur / 3.0 * scale_of(m))));
|
||||
ctx.set_paint(color(c, 1.0));
|
||||
let mut ring = Rect::new(0.0, 0.0, w, h).inflate(w.max(h), w.max(h)).to_path(0.1);
|
||||
let mut hole = outline.clone();
|
||||
hole.apply_affine(Affine::translate(d));
|
||||
hole.apply_affine(Affine::translate(-d));
|
||||
ring.extend(hole.iter());
|
||||
ctx.set_fill_rule(peniko::Fill::EvenOdd);
|
||||
ctx.fill_path(&ring);
|
||||
@@ -813,7 +817,10 @@ 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 });
|
||||
draw_layout_with(ctx, &l, tr, m, &st.paras);
|
||||
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),
|
||||
}
|
||||
} 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);
|
||||
@@ -868,7 +875,8 @@ impl Renderer {
|
||||
// Shadow offset is in slide space (doesn't rotate with the shape unless asked).
|
||||
let sm = if sh.rotate_with_shape { m * Affine::translate(d) * local } else { pre_translate(m, d * k) * local };
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur * 0.5 * k)));
|
||||
// Blurred by a third of the blur radius, as PowerPoint's export shows.
|
||||
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k)));
|
||||
ctx.set_transform(sm);
|
||||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
||||
ctx.fill_path(&silhouette);
|
||||
@@ -877,10 +885,12 @@ impl Renderer {
|
||||
if let Some(g) = &e.glow {
|
||||
let c = rctx.color(&g.color, ph);
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(g.radius * 0.35 * k)));
|
||||
// Measured on PowerPoint's export: a glow of radius r is half strength about half of r
|
||||
// out from the outline and gone by r.
|
||||
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(g.radius * 0.17 * k)));
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 1.2).with_join(kurbo::Join::Round));
|
||||
ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 0.95).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&silhouette);
|
||||
ctx.fill_path(&silhouette);
|
||||
ctx.pop_layer();
|
||||
@@ -1140,6 +1150,13 @@ 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, &[]);
|
||||
|
||||
+149
-11
@@ -3,7 +3,7 @@
|
||||
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
|
||||
use deckcraft_model::resolve::Ctx;
|
||||
use deckcraft_model::style::{ColorRef, Fill};
|
||||
use deckcraft_model::table::{Cell, Table};
|
||||
use deckcraft_model::table::{Cell, Table, TableBorder, TablePart, TablePartStyle, TableStyleDef};
|
||||
use deckcraft_model::text::{Anchor, TextBody, TextDir};
|
||||
use deckcraft_model::{Presentation, Shape};
|
||||
use deckcraft_text::Fields;
|
||||
@@ -18,6 +18,12 @@ pub struct CellLook {
|
||||
pub text: Option<Rgba>,
|
||||
pub bold: bool,
|
||||
pub border: Option<(Rgba, f64)>,
|
||||
/// Left, right, top and bottom borders from a file-defined style (a fully transparent colour
|
||||
/// means no border); `None` falls back to `border`.
|
||||
pub sides: [Option<(Rgba, f64)>; 4],
|
||||
/// A file-defined style's fill as a paint (gradients, see-through colours), with its
|
||||
/// placeholder colour; drawn instead of `fill`.
|
||||
pub paint: Option<(Fill, Option<Rgba>)>,
|
||||
}
|
||||
|
||||
fn accent(ctx: &Ctx, style: &str) -> Rgba {
|
||||
@@ -33,7 +39,109 @@ fn accent(ctx: &Ctx, style: &str) -> Rgba {
|
||||
ctx.color(&ColorRef::scheme(slot), None)
|
||||
}
|
||||
|
||||
/// The look of cell (`r`, `c`) from a table style the file defines: each part that applies to
|
||||
/// the cell (whole table, bands, first and last rows and columns, corners, in that order) sets the
|
||||
/// fill, text and the borders on its region's edges (its inside lines within it).
|
||||
fn look_def(ctx: &Ctx, def: &TableStyleDef, t: &Table, r: usize, c: usize) -> CellLook {
|
||||
let (rows, cols) = (t.rows.len(), t.cols.len());
|
||||
let (r1, c1) = t.cell(r, c).map(|cell| (r + cell.row_span.max(1) as usize - 1, c + cell.grid_span.max(1) as usize - 1)).unwrap_or((r, c));
|
||||
let (r1, c1) = (r1.min(rows.saturating_sub(1)), c1.min(cols.saturating_sub(1)));
|
||||
let last_r = rows.saturating_sub(1);
|
||||
let last_c = cols.saturating_sub(1);
|
||||
let header = t.first_row && r == 0;
|
||||
let total = t.last_row && r1 == last_r && rows > 1;
|
||||
let first_col = t.first_col && c == 0;
|
||||
let last_col = t.last_col && c1 == last_c && cols > 1;
|
||||
let body_r = r.saturating_sub(usize::from(t.first_row));
|
||||
let body_c = c.saturating_sub(usize::from(t.first_col));
|
||||
let mut look = CellLook { fill: None, text: None, bold: false, border: None, sides: [None; 4], paint: None };
|
||||
for part in TablePart::ALL {
|
||||
// (applies, region rows, region cols)
|
||||
let (on, region) = match part {
|
||||
TablePart::WholeTbl => (true, (0, last_r, 0, last_c)),
|
||||
TablePart::Band1H => (t.band_row && !header && !total && body_r % 2 == 0, (r, r1, 0, last_c)),
|
||||
TablePart::Band2H => (t.band_row && !header && !total && body_r % 2 == 1, (r, r1, 0, last_c)),
|
||||
TablePart::Band1V => (t.band_col && !first_col && !last_col && body_c % 2 == 0, (0, last_r, c, c1)),
|
||||
TablePart::Band2V => (t.band_col && !first_col && !last_col && body_c % 2 == 1, (0, last_r, c, c1)),
|
||||
TablePart::LastCol => (last_col, (0, last_r, c, last_c)),
|
||||
TablePart::FirstCol => (first_col, (0, last_r, 0, c1)),
|
||||
TablePart::LastRow => (total, (r, last_r, 0, last_c)),
|
||||
TablePart::FirstRow => (header, (0, r1, 0, last_c)),
|
||||
TablePart::SeCell => (total && last_col, (r, r1, c, c1)),
|
||||
TablePart::SwCell => (total && first_col, (r, r1, c, c1)),
|
||||
TablePart::NeCell => (header && last_col, (r, r1, c, c1)),
|
||||
TablePart::NwCell => (header && first_col, (r, r1, c, c1)),
|
||||
};
|
||||
if !on {
|
||||
continue;
|
||||
}
|
||||
let Some(ps) = def.part(part) else { continue };
|
||||
if let Some(p) = style_fill(ctx, ps) {
|
||||
look.fill = fill_color(ctx, &p.0, p.1);
|
||||
look.paint = Some(p);
|
||||
}
|
||||
if let Some(tc) = &ps.text_color {
|
||||
look.text = Some(ctx.color(tc, None));
|
||||
}
|
||||
if let Some(b) = ps.bold {
|
||||
look.bold = b;
|
||||
}
|
||||
let (r0, rr1, c0, cc1) = region;
|
||||
// Left, right, top, bottom of the cell: the part's edge or its inside line.
|
||||
let pick = [if c == c0 { 0 } else { 5 }, if c1 == cc1 { 1 } else { 5 }, if r == r0 { 2 } else { 4 }, if r1 == rr1 { 3 } else { 4 }];
|
||||
for (side, i) in pick.iter().enumerate() {
|
||||
if let (Some(Some(b)), Some(slot)) = (ps.borders.get(*i), look.sides.get_mut(side)) {
|
||||
*slot = Some(border_look(ctx, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
look
|
||||
}
|
||||
|
||||
/// A style part's fill, a theme fill reference resolved to the theme's fill with its colour as
|
||||
/// the placeholder colour.
|
||||
fn style_fill(ctx: &Ctx, ps: &TablePartStyle) -> Option<(Fill, Option<Rgba>)> {
|
||||
if let Some((idx, col)) = &ps.fill_ref {
|
||||
let ph = ctx.color(col, None);
|
||||
return Some(match ctx.master.theme.format.fills.get((*idx as usize).saturating_sub(1)) {
|
||||
Some(f) if *idx > 0 => (f.clone(), Some(ph)),
|
||||
_ if *idx == 0 => (Fill::None, None),
|
||||
_ => (Fill::solid(ColorRef::rgb(ph)), None),
|
||||
});
|
||||
}
|
||||
ps.fill.clone().map(|f| (f, None))
|
||||
}
|
||||
|
||||
/// A fill's colour for a table cell (gradients and pictures by their first colour).
|
||||
fn fill_color(ctx: &Ctx, f: &Fill, ph: Option<Rgba>) -> Option<Rgba> {
|
||||
match f {
|
||||
Fill::None => Some(Rgba::TRANSPARENT),
|
||||
Fill::Solid { color: c } => Some(ctx.color(c, ph)),
|
||||
Fill::Gradient(g) => g.stops.first().map(|s| ctx.color(&s.color, ph)),
|
||||
Fill::Pattern(p) => Some(ctx.color(&p.bg, ph)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A style border's colour and width (a transparent colour for no line).
|
||||
fn border_look(ctx: &Ctx, b: &TableBorder) -> (Rgba, f64) {
|
||||
match b {
|
||||
TableBorder::Line { line } => match &line.fill {
|
||||
Some(Fill::Solid { color: c }) => (ctx.color(c, None), line.width.unwrap_or(0.75)),
|
||||
Some(Fill::Gradient(g)) => (g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), line.width.unwrap_or(0.75)),
|
||||
_ => (Rgba::TRANSPARENT, 0.0),
|
||||
},
|
||||
TableBorder::Ref { idx, color } => {
|
||||
let w = ctx.master.theme.format.lines.get((*idx as usize).saturating_sub(1)).and_then(|l| l.width).unwrap_or(0.75);
|
||||
if *idx == 0 { (Rgba::TRANSPARENT, 0.0) } else { (ctx.color(color, None), w) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
|
||||
if let Some(def) = ctx.pres.table_styles.iter().find(|d| d.id.eq_ignore_ascii_case(&t.style)) {
|
||||
return look_def(ctx, def, t, r, c);
|
||||
}
|
||||
let a = accent(ctx, &t.style);
|
||||
let kind = t.style.split('-').next().unwrap_or("medium2");
|
||||
let header = t.first_row && r == 0;
|
||||
@@ -47,19 +155,23 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
|
||||
let white = Rgba::WHITE;
|
||||
let dark_text = ctx.color(&ColorRef::scheme(SchemeSlot::Tx1), None);
|
||||
match kind {
|
||||
"none" => CellLook { fill: None, text: None, bold: false, border: None },
|
||||
"grid" => CellLook { fill: None, text: None, bold: false, border: Some((dark_text, 0.75)) },
|
||||
"none" => CellLook { fill: None, text: None, bold: false, border: None, sides: [None; 4], paint: None },
|
||||
"grid" => CellLook { fill: None, text: None, bold: false, border: Some((dark_text, 0.75)), sides: [None; 4], paint: None },
|
||||
"light1" => CellLook {
|
||||
fill: if band || band_c { Some(tint(20000)) } else { None },
|
||||
text: None,
|
||||
bold: header || total || first_col || last_col,
|
||||
border: if header { Some((a, 1.0)) } else { None },
|
||||
sides: [None; 4],
|
||||
paint: None,
|
||||
},
|
||||
"light2" => CellLook {
|
||||
fill: if header { Some(a) } else { None },
|
||||
text: if header { Some(white) } else { None },
|
||||
bold: header || total || first_col,
|
||||
border: Some((a, 1.0)),
|
||||
sides: [None; 4],
|
||||
paint: None,
|
||||
},
|
||||
"dark1" => CellLook {
|
||||
fill: Some(if header {
|
||||
@@ -72,12 +184,16 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
|
||||
text: Some(white),
|
||||
bold: header || total || first_col,
|
||||
border: Some((white, 1.0)),
|
||||
sides: [None; 4],
|
||||
paint: None,
|
||||
},
|
||||
"medium4" => CellLook {
|
||||
fill: Some(if header || band || band_c { tint(40000) } else { tint(20000) }),
|
||||
text: None,
|
||||
bold: header || total || first_col || last_col,
|
||||
border: Some((a, 0.75)),
|
||||
sides: [None; 4],
|
||||
paint: None,
|
||||
},
|
||||
_ => CellLook {
|
||||
// medium2: strong header, banded light tints, white grid.
|
||||
@@ -91,6 +207,8 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
|
||||
text: if header || total || first_col || last_col { Some(white) } else { None },
|
||||
bold: header || total || first_col || last_col,
|
||||
border: Some((white, 1.0)),
|
||||
sides: [None; 4],
|
||||
paint: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -182,6 +300,21 @@ pub fn cell_rects(t: &Table, heights: &[f64]) -> Vec<(usize, usize, Rect)> {
|
||||
pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table, m: Affine, _w: f64, _h: f64, fields: &dyn deckcraft_text::Fields) {
|
||||
let _ = pres;
|
||||
let rects = cell_rects(t, &row_heights(rctx, t, fields));
|
||||
// Table backgrounds behind every cell: a file-defined style's, then the table's own fill.
|
||||
let style_bg = rctx
|
||||
.pres
|
||||
.table_styles
|
||||
.iter()
|
||||
.find(|d| d.id.eq_ignore_ascii_case(&t.style))
|
||||
.and_then(|d| d.background.as_ref())
|
||||
.and_then(|b| style_fill(rctx, b));
|
||||
let own_bg = t.background.clone().map(|f| (f, None));
|
||||
if let Some(all) = rects.iter().map(|(_, _, r)| *r).reduce(|a, b| a.union(b)) {
|
||||
for (bg, ph) in style_bg.into_iter().chain(own_bg) {
|
||||
ctx.set_transform(m);
|
||||
crate::paint::fill_path(ctx, rctx, &bg, ph, &all.to_path(0.1), all, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres);
|
||||
}
|
||||
}
|
||||
for (r, c, rect) in &rects {
|
||||
let Some(cell) = t.cell(*r, *c) else { continue };
|
||||
let lk = look(rctx, t, *r, *c);
|
||||
@@ -190,12 +323,17 @@ pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table,
|
||||
Some(f) => {
|
||||
crate::paint::fill_path(ctx, rctx, f, None, &rect.to_path(0.1), *rect, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres)
|
||||
}
|
||||
None => {
|
||||
if let Some(fc) = lk.fill {
|
||||
ctx.set_paint(color(fc, 1.0));
|
||||
ctx.fill_rect(rect);
|
||||
None => match &lk.paint {
|
||||
Some((f, ph)) => {
|
||||
crate::paint::fill_path(ctx, rctx, f, *ph, &rect.to_path(0.1), *rect, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres)
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if let Some(fc) = lk.fill.filter(|f| f.a > 0) {
|
||||
ctx.set_paint(color(fc, 1.0));
|
||||
ctx.fill_rect(rect);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
// Borders: explicit cell borders, else the style's grid.
|
||||
@@ -216,9 +354,9 @@ pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table,
|
||||
Some(Fill::None) => continue,
|
||||
_ => continue,
|
||||
},
|
||||
None => match lk.border {
|
||||
Some(b) => b,
|
||||
None => continue,
|
||||
None => match lk.sides.get(i).copied().flatten().or(lk.border) {
|
||||
Some(b) if b.0.a > 0 && b.1 > 0.0 => b,
|
||||
_ => continue,
|
||||
},
|
||||
};
|
||||
let mut p = BezPath::new();
|
||||
|
||||
+206
-3
@@ -119,9 +119,10 @@ fn effects_tables_charts_render() {
|
||||
all.extend(charts);
|
||||
let p = deck_with(all);
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
// Shadow darkens below-right of the shape.
|
||||
let px = img.pixel(258, 156);
|
||||
assert!(px[0] < 250, "{px:?}");
|
||||
// Shadow darkens below-right of the shape (grey, past the glow), blurred by a third of its
|
||||
// 8 pt blur radius.
|
||||
let px = img.pixel(255, 155);
|
||||
assert!(px[0] < 240 && px[0] == px[2], "{px:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -872,3 +873,205 @@ fn soft_edges_fade_to_the_outline() {
|
||||
// Near the ends of the bounding box's top edge, outside the ellipse, nothing is drawn.
|
||||
assert!(alpha(110, 105) < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_defined_table_style_draws_its_parts() {
|
||||
// Red table background (tblBg), see-through white on banded rows, a 4 pt blue left border and
|
||||
// no inside lines: drawn from the definition, not DeckCraft's own look for the style.
|
||||
use deckcraft_model::style::{Fill, Line};
|
||||
use deckcraft_model::table::{TableBorder, TablePart, TablePartStyle, TableStyleDef};
|
||||
let rgb = |r, g, b| ColorRef::rgb(deckcraft_color::Rgba::rgb(r, g, b));
|
||||
let mut t = Table::new(4, 2, 400.0, 50.0);
|
||||
t.style = "{ABCDEF01-2345-4678-9ABC-DEF012345678}".into();
|
||||
t.first_row = false;
|
||||
t.band_row = true;
|
||||
let line = |c: ColorRef, w| TableBorder::Line { line: Line { fill: Some(Fill::solid(c)), width: Some(w), ..Default::default() } };
|
||||
let mut whole = TablePartStyle { fill: Some(Fill::None), ..Default::default() };
|
||||
whole.borders[0] = Some(line(rgb(0, 0, 255), 4.0));
|
||||
whole.borders[4] = Some(TableBorder::Line { line: Line { fill: Some(Fill::None), ..Default::default() } });
|
||||
let see_through = ColorRef::rgb(deckcraft_color::Rgba { r: 255, g: 255, b: 255, a: 51 });
|
||||
let band = TablePartStyle { fill: Some(Fill::solid(see_through)), ..Default::default() };
|
||||
let def = TableStyleDef {
|
||||
id: t.style.clone(),
|
||||
name: "Probe".into(),
|
||||
parts: vec![(TablePart::WholeTbl, whole), (TablePart::Band1H, band)],
|
||||
background: Some(TablePartStyle { fill: Some(Fill::solid(rgb(255, 0, 0))), ..Default::default() }),
|
||||
raw: String::new(),
|
||||
};
|
||||
let tbl = Shape { id: ShapeId(790), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), kind: ShapeKind::Table(t), ..Default::default() };
|
||||
let mut p = deck_with(vec![tbl]);
|
||||
p.table_styles.push(def);
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
let px = |x, y| img.pixel(x, y);
|
||||
// Row 1 (band1H): red with 20 % white over it; row 2 (band2H, undefined): plain red.
|
||||
let banded = px(300, 125);
|
||||
assert!(banded[0] > 250 && (40..65).contains(&banded[1]), "{banded:?}");
|
||||
assert_eq!(px(300, 175)[..3], [255, 0, 0]);
|
||||
// No line between the rows; a blue left border.
|
||||
let edge = px(300, 150);
|
||||
assert!(edge[0] > 240, "no inside line: {edge:?}");
|
||||
let left = px(100, 175);
|
||||
assert!(left[2] > 200 && left[0] < 60, "{left:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_fill_shows_behind_cells_without_their_own() {
|
||||
// `a:tblPr` solidFill (tdf#127237): cells with no fill show the table's blue.
|
||||
use deckcraft_model::style::Fill;
|
||||
let mut t = Table::new(2, 2, 200.0, 50.0);
|
||||
t.style = "none".into();
|
||||
t.background = Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 112, 192))));
|
||||
for r in 0..2 {
|
||||
for c in 0..2 {
|
||||
t.cell_mut(r, c).unwrap().fill = Some(Fill::None);
|
||||
}
|
||||
}
|
||||
let tbl = Shape { id: ShapeId(795), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), kind: ShapeKind::Table(t), ..Default::default() };
|
||||
let p = deck_with(vec![tbl]);
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
assert_eq!(img.pixel(150, 160)[..3], [0, 112, 192]);
|
||||
assert_eq!(img.pixel(50, 50)[..3], [255, 255, 255]);
|
||||
}
|
||||
|
||||
/// A white 280x200 pt rectangle at (20, 20) with `effects`, rendered at 1 px/pt.
|
||||
fn effect_box(effects: deckcraft_model::Effects) -> Image {
|
||||
use deckcraft_model::style::{Fill, Line};
|
||||
let sh = Shape {
|
||||
id: ShapeId(800),
|
||||
xfrm: Some(Xfrm::new(20.0, 20.0, 280.0, 200.0)),
|
||||
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255)))),
|
||||
line: Some(Line { fill: Some(Fill::None), ..Default::default() }),
|
||||
effects: Some(effects),
|
||||
..Default::default()
|
||||
};
|
||||
render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() })
|
||||
}
|
||||
|
||||
fn shadow(blur: f64, dist: f64, dir: f64) -> deckcraft_model::style::Shadow {
|
||||
deckcraft_model::style::Shadow {
|
||||
color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)),
|
||||
blur,
|
||||
dist,
|
||||
dir,
|
||||
inner: false,
|
||||
sx: 1.0,
|
||||
sy: 1.0,
|
||||
kx: 0.0,
|
||||
ky: 0.0,
|
||||
align: String::new(),
|
||||
rotate_with_shape: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadows_and_glows_spread_like_powerpoints() {
|
||||
let red = |img: &Image, x: u32, y: u32| img.pixel(x, y)[0] as i32;
|
||||
// Outer shadow, 9 pt blur, no offset: PowerPoint's export reads 144, 204, 238 at 1, 3 and 5
|
||||
// px outside the left edge.
|
||||
let img = effect_box(deckcraft_model::Effects { outer_shadow: Some(shadow(9.0, 0.0, 0.0)), ..Default::default() });
|
||||
for (d, want) in [(1, 144), (3, 204), (5, 238)] {
|
||||
assert!((red(&img, 20 - d, 120) - want).abs() <= 6, "outer {d}: {}", red(&img, 20 - d, 120));
|
||||
}
|
||||
// Inner shadow pointing down-right (45 degrees): it falls inside along the bottom and right
|
||||
// edges, not the top and left.
|
||||
let mut sh = shadow(0.0, 10.0, 45.0);
|
||||
sh.inner = true;
|
||||
let img = effect_box(deckcraft_model::Effects { inner_shadow: Some(sh), ..Default::default() });
|
||||
assert!(red(&img, 296, 120) < 20, "right edge {}", red(&img, 296, 120));
|
||||
assert!(red(&img, 160, 216) < 20, "bottom edge {}", red(&img, 160, 216));
|
||||
assert!(red(&img, 24, 120) > 250 && red(&img, 160, 24) > 250, "top and left stay clear");
|
||||
// Inner shadow with a 9 pt blur and no offset: the same profile as the outer one, inside.
|
||||
let mut sh = shadow(9.0, 0.0, 0.0);
|
||||
sh.inner = true;
|
||||
let img = effect_box(deckcraft_model::Effects { inner_shadow: Some(sh), ..Default::default() });
|
||||
for (d, want) in [(0, 144), (2, 204), (4, 238)] {
|
||||
assert!((red(&img, 20 + d, 120) - want).abs() <= 6, "inner {d}: {}", red(&img, 20 + d, 120));
|
||||
}
|
||||
// Glow of radius 10 pt: nearly solid 1 px out, about half strength 5 px out, gone by 10.
|
||||
let glow = deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)), radius: 10.0 };
|
||||
let img = effect_box(deckcraft_model::Effects { glow: Some(glow), ..Default::default() });
|
||||
assert!(red(&img, 19, 120) < 20);
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
+88
-4
@@ -203,6 +203,8 @@ struct Style {
|
||||
cs: Option<String>,
|
||||
/// Heading font (`+mj-…`): the theme's major font set applies.
|
||||
major: bool,
|
||||
/// Pair kerning on: `a:rPr/@kern` is set and the size reaches it.
|
||||
kern: bool,
|
||||
}
|
||||
|
||||
/// One character of a paragraph after shaping.
|
||||
@@ -259,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().face(&family, style_name(bold, italic));
|
||||
let face = FontDb::global().document_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);
|
||||
@@ -314,6 +316,7 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgba>) -> Styl
|
||||
ea,
|
||||
cs,
|
||||
major,
|
||||
kern: r.kern.is_some_and(|k| k > 0.0 && size + 1e-6 >= k * scale),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,6 +496,17 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
|
||||
// Symbol fonts (Wingdings) aren't available: map common private-use bullets.
|
||||
let ch = map_symbol_bullet(char, props.bullet_font.as_deref());
|
||||
bullet_face(db, &mut st, &ch);
|
||||
|
||||
// A symbol font (Wingdings, Symbol…) that is installed draws its own glyph, found
|
||||
// at U+F000 + the character code as such fonts map them; otherwise common symbol
|
||||
// bullets are mapped to Unicode look-alikes.
|
||||
let ch = match symbol_font_char(&st.face, char, props.bullet_font.as_deref()) {
|
||||
Some(c) => c.to_string(),
|
||||
None => map_symbol_bullet(char, props.bullet_font.as_deref()),
|
||||
};
|
||||
if !st.face.covers(ch.chars().next().unwrap_or('•')) {
|
||||
st.face = db.face("Inter", "Regular");
|
||||
}
|
||||
Some((ch, st))
|
||||
}
|
||||
Some(Bullet::AutoNum { scheme, start_at }) => {
|
||||
@@ -602,7 +616,8 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa
|
||||
// Items: maximal ranges of one level, face, script and shaping style (case mapping, language).
|
||||
let upper = |i: usize| styles.get(char_style.get(i).copied().unwrap_or(0)).is_some_and(|st| st.caps != Caps::None);
|
||||
let lang = |i: usize| styles.get(char_style.get(i).copied().unwrap_or(0)).and_then(|st| st.lang.as_deref());
|
||||
let key = |i: usize| (levels.get(i).copied(), char_face.get(i).copied(), scripts.get(i).map(|s| s.0), upper(i), lang(i));
|
||||
let kern = |i: usize| styles.get(char_style.get(i).copied().unwrap_or(0)).is_some_and(|st| st.kern);
|
||||
let key = |i: usize| (levels.get(i).copied(), char_face.get(i).copied(), scripts.get(i).map(|s| s.0), upper(i), lang(i), kern(i));
|
||||
let mut a = 0;
|
||||
while a < n {
|
||||
let mut b = a + 1;
|
||||
@@ -618,6 +633,24 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa
|
||||
/// Size of superscript and subscript text relative to the run's size.
|
||||
const SUB_SUPER: f64 = 0.66;
|
||||
|
||||
/// The OpenType features to change from the shaper's defaults for an item, as PowerPoint draws
|
||||
/// it: kerning only where the run asks for it (`kern`), and no standard or contextual ligatures in
|
||||
/// Latin, Greek and Cyrillic ("fi", "ffi" and Calibri's "ti" stay apart). Other scripts need
|
||||
/// their ligatures (Arabic lam-alef), and emoji sequences are joined by them.
|
||||
fn shaping_features(cells: &[Cell], styles: &[Style], script: Option<ScriptTag>) -> Vec<deckcraft_fonts::Feature> {
|
||||
let mut f = vec![];
|
||||
if !cells.first().and_then(|c| styles.get(c.style)).is_some_and(|st| st.kern) {
|
||||
f.extend(deckcraft_fonts::feature("-kern"));
|
||||
}
|
||||
let alphabetic = matches!(script.as_ref(), Some(b"Latn" | b"Grek" | b"Cyrl"));
|
||||
let emoji = cells.iter().any(|c| matches!(c.ch as u32, 0x200D | 0x20E3 | 0xFE0F | 0x2600..=0x27BF | 0x1F000..=0x1FAFF | 0xE0020..=0xE007F));
|
||||
if alphabetic && !emoji {
|
||||
f.extend(deckcraft_fonts::feature("-liga"));
|
||||
f.extend(deckcraft_fonts::feature("-clig"));
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// Shape cells `range` (one item) and hang each cluster's glyphs on its first cell, sharing the
|
||||
/// cluster's advance among its graphemes (by the ligature's GDEF carets when it has them).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -638,7 +671,8 @@ fn shape_item(
|
||||
let Some(face) = faces.get(fi) else { return };
|
||||
let rtl = level % 2 == 1;
|
||||
let (Some(&b0), Some(&b1)) = (byte_of.get(range.start), byte_of.get(range.end)) else { return };
|
||||
let params = ShapeParams { features: &[], rtl, script, language: lang };
|
||||
let features = shaping_features(cells.get(range.clone()).unwrap_or(&[]), styles, script);
|
||||
let params = ShapeParams { features: &features, rtl, script, language: lang };
|
||||
let glyphs = deckcraft_fonts::shape_range(face, text, b0..b1, ¶ms, |ch| if upper { ch.to_uppercase().next().unwrap_or(ch) } else { ch });
|
||||
let upem = face.upem.max(1.0);
|
||||
let scale_of = |cell: &Cell| {
|
||||
@@ -715,11 +749,59 @@ fn shape_item(
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `family` a symbol font (characters at U+F000 + code)?
|
||||
fn is_symbol_font(family: &str) -> bool {
|
||||
let f = family.to_ascii_lowercase();
|
||||
f.starts_with("wingdings") || f == "symbol" || f.starts_with("webdings") || f.starts_with("marlett")
|
||||
}
|
||||
|
||||
/// The character to draw bullet `c` with the installed symbol font `face`, when `font` names a
|
||||
/// symbol font that `face` is (not a substitute) and it has the glyph.
|
||||
fn symbol_font_char(face: &FontFace, c: &str, font: Option<&str>) -> Option<char> {
|
||||
let font = font?;
|
||||
if !is_symbol_font(font) || !face.family.eq_ignore_ascii_case(font) {
|
||||
return None;
|
||||
}
|
||||
let first = c.chars().next()?;
|
||||
let pua = char::from_u32(0xF000 | ((first as u32) & 0xFF))?;
|
||||
if face.covers(pua) {
|
||||
Some(pua)
|
||||
} else if face.covers(first) {
|
||||
Some(first)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn map_symbol_bullet(c: &str, font: Option<&str>) -> String {
|
||||
let f = font.unwrap_or("").to_ascii_lowercase();
|
||||
let first = c.chars().next().unwrap_or('•');
|
||||
if f.contains("wingdings") || f.contains("symbol") || ('\u{f000}'..='\u{f0ff}').contains(&first) {
|
||||
let code = (first as u32) & 0xff;
|
||||
// Wingdings 2 and 3 put different shapes at the same codes.
|
||||
if f.starts_with("wingdings 2") {
|
||||
let m = match code {
|
||||
0x97 | 0x98 => '●',
|
||||
0x9e..=0xa1 => '■',
|
||||
0xa2..=0xa5 => '◆',
|
||||
0xea..=0xf3 => '★',
|
||||
0xb7..=0xbf => '○',
|
||||
0x50 => '✓',
|
||||
_ => '•',
|
||||
};
|
||||
return m.to_string();
|
||||
}
|
||||
if f.starts_with("wingdings 3") {
|
||||
let m = match code {
|
||||
0x7d | 0x75 => '►',
|
||||
0x7c | 0x74 => '◄',
|
||||
0x70 => '▲',
|
||||
0x71 => '▼',
|
||||
0xc6 | 0xc7 => '➔',
|
||||
_ => '▶',
|
||||
};
|
||||
return m.to_string();
|
||||
}
|
||||
let m = match code {
|
||||
0xa7 => '▪',
|
||||
0xd8 => '➢',
|
||||
@@ -992,7 +1074,9 @@ 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)),
|
||||
);
|
||||
let wrap = bp.wrap.unwrap_or(true) && !matches!(bp.autofit, Some(AutoFit::Shape) if shape.text_box && !bp.wrap.unwrap_or(true));
|
||||
// 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 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);
|
||||
|
||||
@@ -45,6 +45,75 @@ fn bullets_are_drawn_for_body_text() {
|
||||
assert_eq!(l.para_lines.len(), 2);
|
||||
}
|
||||
|
||||
/// Glyph count and width of `text` laid out in one line of `family` at 40 pt, kern threshold `kern`.
|
||||
fn glyphs_and_width(family: &str, text: &str, kern: Option<f64>) -> (usize, f64) {
|
||||
let (p, mut sh) = setup(text, 2000.0, 300.0);
|
||||
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
|
||||
para.props.bullet = Some(Bullet::None);
|
||||
for r in &mut para.runs {
|
||||
r.props.font = Some(family.into());
|
||||
r.props.size = Some(40.0);
|
||||
r.props.kern = kern;
|
||||
}
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
let n = l.runs.iter().map(|r| r.glyphs.len()).sum();
|
||||
(n, l.lines[0].extent.1 - l.lines[0].extent.0)
|
||||
}
|
||||
|
||||
/// The first installed or bundled family of `families` for which `pick` holds.
|
||||
fn first_family(families: &[&str], pick: impl Fn(&FontFace) -> bool) -> Option<String> {
|
||||
let db = FontDb::global();
|
||||
families.iter().filter(|f| db.has_family(f)).map(|f| db.face(f, "Regular")).find(|f| pick(f)).map(|f| f.family.clone())
|
||||
}
|
||||
|
||||
const LATIN_FAMILIES: &[&str] = &["Carlito", "Calibri", "DejaVu Sans", "Liberation Sans", "Inter", "Arial", "Cambria", "Times New Roman"];
|
||||
|
||||
#[test]
|
||||
fn latin_text_draws_no_ligatures() {
|
||||
// PowerPoint keeps "fi", "ffi" and Calibri's "ti" apart, although the fonts have them as
|
||||
// standard ligatures.
|
||||
let ligates = |f: &FontFace| deckcraft_fonts::shape(f, "office", &[], |c| c).len() < 6;
|
||||
let Some(family) = first_family(LATIN_FAMILIES, ligates) else {
|
||||
eprintln!("skipped: no font with an ffi ligature");
|
||||
return;
|
||||
};
|
||||
let (n, _) = glyphs_and_width(&family, "office fifth", Some(0.0));
|
||||
assert_eq!(n, 12, "{family}: one glyph per character");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pairs_are_kerned_only_from_the_kern_size() {
|
||||
let kerns = |f: &FontFace| {
|
||||
let w = |feat: &[deckcraft_fonts::Feature]| deckcraft_fonts::shape(f, "AVATAR", feat, |c| c).iter().map(|g| g.x_advance).sum::<i32>();
|
||||
w(&[]) != w(&deckcraft_fonts::feature("-kern").into_iter().collect::<Vec<_>>())
|
||||
};
|
||||
let Some(family) = first_family(LATIN_FAMILIES, kerns) else {
|
||||
eprintln!("skipped: no font that kerns AVATAR");
|
||||
return;
|
||||
};
|
||||
// `kern` is the smallest size kerned; 0 is no kerning.
|
||||
let (_, off) = glyphs_and_width(&family, "AVATAR WAVE", Some(0.0));
|
||||
let (_, on) = glyphs_and_width(&family, "AVATAR WAVE", Some(12.0));
|
||||
let (_, at) = glyphs_and_width(&family, "AVATAR WAVE", Some(40.0));
|
||||
let (_, above) = glyphs_and_width(&family, "AVATAR WAVE", Some(48.0));
|
||||
assert!(on < off - 2.0, "{family}: kerned {on} vs {off}");
|
||||
assert!((at - on).abs() < 0.01, "kerned at exactly the threshold size");
|
||||
assert!((above - off).abs() < 0.01, "40 pt is below a 48 pt threshold");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ligatures_stay_for_scripts_that_need_them() {
|
||||
let has = |f: &[deckcraft_fonts::Feature], tag: &str| f.iter().any(|x| Some(*x) == deckcraft_fonts::feature(tag));
|
||||
let cells =
|
||||
|s: &str| s.chars().map(|ch| Cell { ch, style: 0, face: 0, level: 0, glyphs: vec![], adv: 0.0, head: 0, boundary: true }).collect::<Vec<_>>();
|
||||
let latin = shaping_features(&cells("office"), &[], Some(*b"Latn"));
|
||||
assert!(has(&latin, "-liga") && has(&latin, "-clig") && has(&latin, "-kern"));
|
||||
// Arabic needs its ligatures; emoji sequences are joined by them.
|
||||
assert!(!has(&shaping_features(&cells("\u{644}\u{627}"), &[], Some(*b"Arab")), "-liga"));
|
||||
assert!(!has(&shaping_features(&cells("a\u{1F469}\u{200D}\u{1F4BB}"), &[], Some(*b"Latn")), "-liga"));
|
||||
assert!(!has(&shaping_features(&cells("12"), &[], None), "-liga"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_first_character_of_a_bullet_is_drawn() {
|
||||
// "••" (seen in the wild) draws one dot in PowerPoint.
|
||||
@@ -624,3 +693,28 @@ fn a_bullet_the_text_font_lacks_falls_back_to_a_face_that_has_it() {
|
||||
assert!(b.glyphs.iter().all(|g| g.0 != 0), "no missing-glyph box");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_font_bullets_map_by_font() {
|
||||
// Wingdings 2 code 0xEA is a star, Wingdings 3 0x7D a right-pointing triangle; plain
|
||||
// Wingdings keeps its own table (0xA7 is a small square).
|
||||
let star = String::from_utf8(vec![0xEF, 0x83, 0xAA]).unwrap(); // U+F0EA
|
||||
assert_eq!(map_symbol_bullet(&star, Some("Wingdings 2")), "★");
|
||||
assert_eq!(map_symbol_bullet("\u{7d}", Some("Wingdings 3")), "►");
|
||||
assert_eq!(map_symbol_bullet("\u{f0a7}", Some("Wingdings")), "▪");
|
||||
assert_eq!(map_symbol_bullet("•", Some("Arial")), "•");
|
||||
assert!(is_symbol_font("Wingdings 2") && is_symbol_font("Symbol") && !is_symbol_font("Arial"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_installed_symbol_font_draws_its_own_bullet() {
|
||||
// Where the deck's symbol font is installed, its glyph is drawn (as PowerPoint does), not a
|
||||
// look-alike. Skipped without Wingdings 2.
|
||||
let db = FontDb::global();
|
||||
if !db.has_family("Wingdings 2") {
|
||||
return;
|
||||
}
|
||||
let face = db.face("Wingdings 2", "Regular");
|
||||
assert_eq!(symbol_font_char(&face, "\u{ea}", Some("Wingdings 2")), Some('\u{f0ea}'));
|
||||
assert_eq!(symbol_font_char(&face, "\u{ea}", Some("Arial")), None);
|
||||
}
|
||||
|
||||
@@ -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, ~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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
@@ -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 (~150 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` |
|
||||
| Shapes: preset geometry (~180 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` (and `preset/extra.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 | Not rendered |
|
||||
| Text warp (`prstTxWarp`) | yes | yes | All 40 presets rendered (`crates/geom/src/warp.rs`); adjust values (`avLst`) round-trip |
|
||||
| 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,4 +99,5 @@ 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 |
|
||||
|
||||
+8
-3
@@ -165,9 +165,13 @@ 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", "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).
|
||||
- **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).
|
||||
|
||||
### Proofing: spelling, thesaurus, AutoCorrect
|
||||
- **Evidence:** `review.spelling` checks a small built-in list of frequent misspellings
|
||||
@@ -247,5 +251,6 @@ 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 |
|
||||
|
||||
@@ -9,7 +9,7 @@ The summary between `<!-- SUMMARY -->
|
||||
|---|---|---|---|---|---|
|
||||
| P0 | 72 | 60 | 12 | 0 | 92% |
|
||||
| P1 | 65 | 38 | 21 | 6 | 75% |
|
||||
| P2 | 54 | 13 | 13 | 28 | 36% |
|
||||
| P2 | 54 | 13 | 14 | 27 | 37% |
|
||||
|
||||
| Area | Parity |
|
||||
|---|---|
|
||||
@@ -18,7 +18,7 @@ The summary between `<!-- SUMMARY -->
|
||||
| Slides | 90% |
|
||||
| Views | 90% |
|
||||
| Masters & themes | 85% |
|
||||
| Shapes | 86% |
|
||||
| Shapes | 88% |
|
||||
| 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 | M |
|
||||
| Shapes | Text warp transforms | P2 | P |
|
||||
| Shapes | Shape quick styles | P1 | D |
|
||||
| Shapes | Set as default shape | P2 | D |
|
||||
| Format | Solid fill and transparency | P0 | D |
|
||||
|
||||
@@ -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:** ~150 preset geometries with adjustment handles, text boxes, pictures (crop,
|
||||
- **Shapes:** ~180 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
|
||||
|
||||
Reference in New Issue
Block a user