Merge main (433ddb59) into feat/native-save-precision

This commit is contained in:
dotCooCoo
2026-10-31 17:22:00 -07:00
676 changed files with 74763 additions and 5037 deletions
+9
View File
@@ -53,6 +53,15 @@ impl WidthProfile {
// Two swells between narrow necks.
ProfilePreset { id: "wave", label: "Wave", points: &[(0.0, 0.3, 0.3), (0.25, 1.0, 1.0), (0.5, 0.3, 0.3), (0.75, 1.0, 1.0), (1.0, 0.3, 0.3)] },
];
/// The widest the stroke gets, as a multiple of its weight: the largest total of the left and
/// right factors at any of the points (between them the profile is straight), 1 for a profile
/// without points.
pub fn widest(&self) -> f64 {
if self.points.is_empty() {
return 1.0;
}
self.points.iter().map(|&(_, l, r)| (l.max(0.0) + r.max(0.0)) / 2.0).fold(0.0, f64::max)
}
/// The built-in profile with this id.
pub fn preset(id: &str) -> Option<Self> {
Self::PRESETS.iter().find(|p| p.id == id).map(|p| Self { points: p.points.to_vec() })
+33 -2
View File
@@ -35,8 +35,8 @@ pub struct Artboard {
pub video_ruler_pixel_aspect_ratio: f64,
/// Background color (Properties › Artboards › Fill, Artboard Options). The canvas draws the
/// artboard's paper in it, multiplied with the Simulate Colored Paper color while that is on;
/// without one the paper is white, or the Simulate Colored Paper color. Exports and print leave
/// it out.
/// without one the paper is white, or the Simulate Colored Paper color. Exports and print draw
/// it behind the art ([`super::Document::with_artboard_fills`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<vectorcraft_color::Color>,
}
@@ -77,6 +77,37 @@ impl Artboard {
}
}
impl super::Document {
/// A copy for output with the background colors ([`Artboard::color`]) of the artboards at
/// `indices` drawn behind all the art: each a rectangle of its artboard filled with its color
/// (in the color's own model), on a layer of their own (Artboard Fill) below the
/// others. `None` when none of them has a color.
pub fn with_artboard_fills(&self, indices: impl IntoIterator<Item = usize>) -> Option<Self> {
let fills: Vec<(Rect, vectorcraft_color::Color)> = indices
.into_iter()
.filter_map(|i| {
let a = self.artboards.get(i)?;
Some((a.rect, a.color?))
})
.collect();
if fills.is_empty() {
return None;
}
let mut d = self.clone();
let mut layer = Node::layer(d.alloc_id(), "Artboard Fill", super::LayerColor::Preset(0));
let paths: Vec<Arc<Node>> = fills
.into_iter()
.map(|(r, c)| {
let look = super::Appearance::basic(vectorcraft_color::Paint::solid(c), vectorcraft_color::Paint::None, 0.0);
Arc::new(Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(r), look))
})
.collect();
*layer.children_mut()? = paths;
d.layers.insert(0, Arc::new(layer));
Some(d)
}
}
/// A document's locked artboards ([`Document::locked_artboards`]).
pub struct LockedArtboards<'a> {
pub(crate) rects: Vec<Rect>,
+11 -1
View File
@@ -196,7 +196,11 @@ pub fn lerp_paint(a: &Paint, b: &Paint, t: f32) -> Paint {
/// so dashes open out of a solid line; patterns of different lengths repeat to a common one.
fn lerp_dash(x: Option<&Dash>, y: Option<&Dash>, t: f64) -> Option<Dash> {
let solid_like = |d: &Dash| Dash {
pattern: d.pattern.chunks(2).flat_map(|c| [c.iter().sum::<f64>(), 0.0]).take(d.pattern.len().max(2)).collect(),
// An odd pattern repeats twice per period, so pair up the doubled one to keep every gap closed.
pattern: if d.pattern.len() % 2 == 1 { d.pattern.repeat(2) } else { d.pattern.clone() }
.chunks(2)
.flat_map(|c| [c.iter().sum::<f64>(), 0.0])
.collect(),
offset: d.offset,
align_corners: d.align_corners,
};
@@ -1423,6 +1427,12 @@ mod tests {
assert_eq!(p.at(0.5), (1.0, 1.0));
}
#[test]
fn odd_dash_blends_to_solid_with_closed_gaps() {
let d = lerp_dash(Some(&Dash { pattern: vec![2.0, 3.0, 4.0], ..Default::default() }), None, 1.0).unwrap();
assert!(d.pattern.iter().skip(1).step_by(2).all(|g| *g == 0.0), "{:?}", d.pattern);
}
#[test]
fn copied_steps_get_new_members() {
let rect = |x: f64| Arc::new(Node::path(NodeId(7), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::default()));
+3 -3
View File
@@ -485,7 +485,7 @@ mod tests {
#[test]
fn symbols_and_patterns_are_limited_too() {
let mut d = Document::new(10.0, 10.0);
d.symbols.push(crate::Symbol { name: "s".into(), art: nested(MAX_DEPTH + 3) });
d.symbols.push(crate::Symbol { dynamic: false, name: "s".into(), art: nested(MAX_DEPTH + 3) });
assert!(d.too_deep(None));
assert!(d.limit_depth());
assert!(!d.too_deep(None));
@@ -504,7 +504,7 @@ mod tests {
for _ in 0..levels {
art = Arc::new(Node::group(NodeId(3), vec![art]));
}
d.symbols.push(crate::Symbol { name: format!("s{i}"), art });
d.symbols.push(crate::Symbol { dynamic: false, name: format!("s{i}"), art });
}
d
}
@@ -573,7 +573,7 @@ mod tests {
};
let through_a_symbol = |d: &mut Document| {
d.patterns.push(crate::PatternDef::new("p", vec![instance(2, "s")]));
d.symbols.push(crate::Symbol { name: "s".into(), art: painted(3, "p") });
d.symbols.push(crate::Symbol { dynamic: false, name: "s".into(), art: painted(3, "p") });
};
for make in [&itself as &dyn Fn(&mut Document), &each_other, &through_a_symbol] {
let mut d = doc(painted(1, "p"));
+14 -13
View File
@@ -110,6 +110,12 @@ pub fn hit_test_visible(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit
hit_test_skipping(doc, p, opt, &|_| false, false)
}
/// [`hit_test_visible`] passing over the nodes `skip` names (and their contents): what lies below
/// them at `p`. A skipped clipping path still clips.
pub fn hit_test_visible_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool) -> Option<Hit> {
hit_test_skipping(doc, p, opt, skip, false)
}
/// The object of `selected` (topmost first) whose own art lies under `p` where a clipping mask
/// hides it: a clip group (or a layer with a clipping mask) holding it doesn't clip to `p`. A
/// click there hits nothing ([`hit_test`]), so the selection tools grab the selection by that
@@ -538,20 +544,15 @@ fn hit_children(
}
/// The order a click at `p` tries the children of `parent` in, front first: their stacking order
/// from the top, except that in an Intertwine group the members drawn over the others at `p`
/// come first (from the top among them too).
/// from the top, except that in an Intertwine group the members are tried in the order they are
/// drawn at `p`, from the top ([`crate::intertwine::tier_at`]).
fn hit_order<'a>(parent: &Node, children: &'a [std::sync::Arc<Node>], p: Point) -> impl Iterator<Item = usize> + 'a {
let over: Vec<usize> = if matches!(parent.kind, NodeKind::Intertwine { .. }) {
let contains = |a: &vectorcraft_geom::PathData| fill_contains(&a.to_bezpath(), vectorcraft_geom::FillRule::NonZero, p);
(0..children.len()).rev().filter(|i| children.get(*i).and_then(|c| crate::intertwine::over_area(c)).is_some_and(contains)).collect()
} else {
vec![]
};
let rest = (0..children.len()).rev().filter({
let over = over.clone();
move |i| !over.contains(i)
});
over.into_iter().chain(rest)
let mut order: Vec<usize> = (0..children.len()).rev().collect();
if matches!(parent.kind, NodeKind::Intertwine { .. }) {
let tier = |i: usize| children.get(i).map_or(0, |c| crate::intertwine::tier_at(c, p));
order.sort_by_cached_key(|i| std::cmp::Reverse((tier(*i), *i)));
}
order.into_iter()
}
/// A click on the strokes of a path or compound path `n` along `bp`: what any visible stroke
+1 -1
View File
@@ -841,7 +841,7 @@ fn objects_at_matches_repeated_hits_on_random_documents() {
fn objects_at_finds_symbol_instances_by_their_art() {
let mut d = Document::new(200.0, 200.0);
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 60.0, 140.0, 80.0)), Appearance::default_art());
d.symbols.push(crate::Symbol { name: "Far".into(), art: Arc::new(art) });
d.symbols.push(crate::Symbol { dynamic: false, name: "Far".into(), art: Arc::new(art) });
let (layer, top, inner, group) = (d.layers[0].id, d.alloc_id(), d.alloc_id(), d.alloc_id());
let instance = |id, dy: f64| Node::new(id, NodeKind::SymbolInstance { symbol: "Far".into(), xf: vectorcraft_geom::Affine::translate((0.0, dy)) });
d.insert(Some(layer), 0, instance(top, 0.0)).unwrap();
+59 -20
View File
@@ -1,36 +1,51 @@
//! Object › Intertwine: objects that pass over and under each other.
//!
//! An Intertwine group ([`NodeKind::Intertwine`]) keeps its members as they are, in their own
//! stacking order. A member can be drawn over the other members in part of the group: that area
//! is the member's [`crate::node::NodeExtras::intertwine`], in document coordinates. The group
//! draws (and exports, and is hit) as its live expansion ([`expand`]): the members, then, for
//! each member with an area, a copy of it clipped to that area. Where several members are drawn
//! over the others in one place, the upper one in the stacking order ends on top.
//! stacking order. A member can be drawn over other members in part of the group, at a tier: the
//! area of tier 1 is the member's [`crate::node::NodeExtras::intertwine`], and the areas of tiers 2
//! and up are its [`crate::node::NodeExtras::intertwine_tiers`], in document coordinates. In each
//! place the members are drawn by their tier there (0 outside their areas), and members of one
//! tier in their stacking order, so any order of the members can be set for each overlap area.
//! The group draws (and exports, and is hit) as its live expansion ([`expand`]): the members,
//! then, tier by tier, a copy of each member with an area of that tier clipped to it.
//!
//! The members stay editable, and Release gives them back as they were (the areas let go).
//! Behaviour: <https://helpx.adobe.com/illustrator/using/intertwine-objects.html>.
use std::sync::Arc;
use vectorcraft_geom::{FillRule, PathData};
use vectorcraft_geom::hit::fill_contains;
use vectorcraft_geom::{FillRule, PathData, Point};
use crate::{Node, NodeId, NodeKind};
/// The area where `n` is drawn over the other members of its Intertwine group (empty: nowhere).
pub fn over_area(n: &Node) -> Option<&PathData> {
n.extras().intertwine.as_deref().filter(|a| !a.is_empty())
/// The areas where `n` is drawn over other members of its Intertwine group, each with its tier
/// (1 and up); empty ones are left out.
pub fn tier_areas(n: &Node) -> impl Iterator<Item = (usize, &PathData)> {
let e = n.extras();
e.intertwine
.as_deref()
.map(|a| (1, a))
.into_iter()
.chain(e.intertwine_tiers.iter().enumerate().map(|(k, a)| (k + 2, a)))
.filter(|(_, a)| !a.is_empty())
}
/// The members of an Intertwine group evaluated as plain art: the members (ids kept), then each
/// member with an area drawn over the others, as a clip group of that area around a copy of it
/// (ids 0: generated art).
/// The tier `n` is drawn at at `p`: the highest tier whose area holds `p` (0: none does).
pub fn tier_at(n: &Node, p: Point) -> usize {
tier_areas(n).filter(|(_, a)| fill_contains(&a.to_bezpath(), FillRule::NonZero, p)).map(|(t, _)| t).max().unwrap_or(0)
}
/// The members of an Intertwine group evaluated as plain art: the members (ids kept), then, for
/// each tier from 1 up, each member with an area of that tier, as a clip group of that area
/// around a copy of it (ids 0: generated art).
pub fn expand(children: &[Arc<Node>]) -> Vec<Node> {
let mut out: Vec<Node> = children.iter().map(|c| plain(c)).collect();
for c in children {
let Some(area) = over_area(c) else { continue };
if !c.visible {
continue;
}
let mut over: Vec<(usize, usize, &PathData)> =
children.iter().enumerate().filter(|(_, c)| c.visible).flat_map(|(i, c)| tier_areas(c).map(move |(t, a)| (t, i, a))).collect();
over.sort_by_key(|(t, i, _)| (*t, *i));
for (_, i, area) in over {
let Some(c) = children.get(i) else { continue };
let mut clip = Node::new(NodeId(0), NodeKind::Path { path: area.clone(), rule: FillRule::NonZero, live: None, clipping: true, guide: false });
clip.appearance = crate::Appearance::default().into();
let mut copy = plain(c);
@@ -40,11 +55,13 @@ pub fn expand(children: &[Arc<Node>]) -> Vec<Node> {
out
}
/// `n` without its Intertwine area (the expansion is plain art).
/// `n` without its Intertwine areas (the expansion is plain art).
fn plain(n: &Node) -> Node {
let mut n = n.clone();
if n.extras().intertwine.is_some() {
n.extras_mut().intertwine = None;
if n.extras().intertwine.is_some() || !n.extras().intertwine_tiers.is_empty() {
let mut e = n.extras_mut();
e.intertwine = None;
e.intertwine_tiers = Vec::new();
}
n
}
@@ -88,6 +105,28 @@ mod tests {
assert_eq!((out[2].id, children[1].id), (NodeId(0), NodeId(0)), "generated art has no ids of its own");
}
#[test]
fn copies_are_drawn_tier_by_tier() {
let over = || shapes::rectangle(Rect::new(5.0, 0.0, 10.0, 10.0));
// The lower member at tier 2, the upper one at tier 1: the lower one ends on top.
let mut a = (*square(1, 0.0)).clone();
a.extras_mut().intertwine_tiers = vec![over()];
let mut b = (*square(2, 5.0)).clone();
b.extras_mut().intertwine = Some(Box::new(over()));
let kids = vec![Arc::new(a), Arc::new(b)];
assert_eq!((tier_at(&kids[0], Point::new(7.0, 5.0)), tier_at(&kids[1], Point::new(7.0, 5.0))), (2, 1));
assert_eq!(tier_at(&kids[0], Point::new(2.0, 5.0)), 0);
let out = expand(&kids);
assert_eq!(out.len(), 4, "both members, then the copy of tier 1, then the one of tier 2");
assert!(out.iter().all(|n| n.extras().intertwine.is_none() && n.extras().intertwine_tiers.is_empty()), "the expansion is plain art");
let copied = |n: &Node| match &n.kind {
NodeKind::Group { children, clip: true } => children[1].path_data().cloned(),
k => panic!("a clip group: {k:?}"),
};
assert_eq!(copied(&out[2]).as_ref(), kids[1].path_data());
assert_eq!(copied(&out[3]).as_ref(), kids[0].path_data());
}
#[test]
fn a_hidden_member_is_not_drawn_over_the_others() {
let mut a = (*square(1, 0.0)).clone();
+4 -1
View File
@@ -24,11 +24,13 @@ pub mod intertwine;
pub mod shared;
mod symbol_export_type;
mod symbol_nine_slice;
pub mod symbol_overrides;
mod symbol_registration;
pub use inline::{SYMBOL_HALF, SYMBOL_SIZES};
pub use shared::Shared;
pub use symbol_export_type::{SYMBOL_EXPORT_TYPE, SymbolExportType};
pub use symbol_nine_slice::{SYMBOL_NINE_SLICE, SymbolNineSlice};
pub use symbol_overrides::SymbolOverride;
pub use symbol_registration::{SYMBOL_REGISTRATION, SymbolRegistration};
pub mod links;
pub mod live;
@@ -107,7 +109,8 @@ pub use style_libs::StyleLibrary;
pub use text::{
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt,
JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, RubyPosition,
RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape,
RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextFlow, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign,
WrapShape,
};
pub use trace::TraceView;
pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables};
+109 -2
View File
@@ -63,7 +63,7 @@ pub use expand::{
};
#[cfg(test)]
use mesh::grid_eval;
pub use mesh::{Coons, GradientMesh, MeshAppearance, MeshPoint, MeshQuad, default_fidelity, grid_points, nodes_bounds};
pub use mesh::{Coons, GradientMesh, MeshAppearance, MeshPiece, MeshPoint, MeshQuad, default_fidelity, grid_points, nodes_bounds};
pub use warp::{WarpStyle, fit_piece, map_nonlinear, map_nonlinear_within, pieces, warp_point};
#[cfg(test)]
@@ -181,6 +181,44 @@ mod tests {
assert!((p.x - 37.0).abs() < 1e-6 && (p.y - 81.0).abs() < 1e-6, "{p:?}");
}
/// #3592: a mesh read from a file can have any number of patches, and the view any zoom, so the
/// quads one tessellation makes are budgeted: the subdivision per patch gives way before memory
/// does, and each patch stays at least one quad.
#[test]
fn a_large_mesh_tessellates_within_the_quad_budget() {
let mesh = |n: u32| GradientMesh::from_surface(n, n, &|u, v| Point::new(u * 300.0, v * 300.0), &|u, v| Color::rgb(u as f32, v as f32, 0.5));
// 3,600 patches at 32 x 32 would be 3.7 million quads.
let m = mesh(60);
let quads = m.quads(32);
assert!(quads.len() <= GradientMesh::MAX_QUADS, "{} quads", quads.len());
assert!(quads.len() >= 3600 * 4, "still finer than a quad per patch: {}", quads.len());
// A small mesh keeps the subdivision asked for.
assert_eq!(mesh(3).quads(8).len(), 9 * 64);
// The finest the view asks for, on a mesh of 22,500 patches, is a few quads per patch.
let big = mesh(150).quads(64);
assert!(big.len() <= GradientMesh::MAX_QUADS && big.len() >= 22_500, "{} quads", big.len());
// More patches than the budget has quads is malformed, as a point count that doesn't fit is.
let point = MeshPoint { p: Point::ZERO, color: Color::rgb(0.0, 0.0, 0.0), opacity: 1.0, handles: [Vec2::ZERO; 4] };
let huge = GradientMesh { rows: 2000, cols: 2000, points: vec![point; 2001 * 2001] };
assert!(!huge.is_valid());
assert!(huge.quads(1).is_empty());
}
/// Flat pieces of a mesh over the quad budget follow the coarser subdivision [`GradientMesh::quads`]
/// gives way to: each patch's pieces are its own quads', so together they cover the mesh once.
#[test]
fn a_large_meshs_pieces_follow_the_quad_budget() {
let m = GradientMesh::from_surface(60, 60, &|u, v| Point::new(u * 300.0, v * 300.0), &|u, v| Color::rgb(u as f32, v as f32, 0.5));
let area = |pts: &[Point]| {
let n = pts.len();
(0..n).map(|i| pts[i].x * pts[(i + 1) % n].y - pts[(i + 1) % n].x * pts[i].y).sum::<f64>().abs() / 2.0
};
let pieces = m.pieces(32);
assert!(pieces.len() >= 3600, "{} pieces", pieces.len());
let total: f64 = pieces.iter().map(|p| area(&p.pts)).sum();
assert!((total - 90_000.0).abs() < 90.0, "covered {total}");
}
/// A file's rows and columns can be anything: a point count too large to compute is no match
/// for the points (u32::MAX × u32::MAX wrapped to 0 and passed for a grid without points).
#[test]
@@ -195,17 +233,86 @@ mod tests {
assert_eq!(envelope_mapper(&kind, Rect::new(0.0, 0.0, 100.0, 100.0))(p), p);
}
/// How far the border of `m` strays from the ellipse in `r`, in points (measured across the
/// ellipse's short radius), checked at 20 places along every border edge.
fn mesh_border_off_ellipse(m: &GradientMesh, r: Rect) -> f64 {
let (rows, cols) = (m.rows as usize, m.cols as usize);
let edges = (0..cols).flat_map(|c| [m.edge_u(0, c), m.edge_u(rows, c)]).chain((0..rows).flat_map(|k| [m.edge_v(k, 0), m.edge_v(k, cols)]));
let (c, a, b) = (r.center(), r.width() / 2.0, r.height() / 2.0);
edges
.flat_map(|e| (0..=20).map(move |i| e.eval(f64::from(i) / 20.0)))
.map(|p| ((p.x - c.x) / a).hypot((p.y - c.y) / b) - 1.0)
.fold(0.0f64, |w, d| w.max(d.abs() * a.min(b)))
}
/// #3187: a gradient mesh made from an ellipse keeps the ellipse's shape: a Mesh tool click
/// (a 1×1 mesh with lines through the point) and Create Gradient Mesh's 4×4 grid both lay
/// their border on the outline, within about twice the 0.35 pt the outline is flattened to.
/// The 1×1 mesh was up to 7 pt off this 400 × 300 pt ellipse.
#[test]
fn a_mesh_from_an_ellipse_keeps_its_outline() {
const OFF: f64 = 0.75;
let r = Rect::new(100.0, 100.0, 500.0, 400.0);
let e = shapes::ellipse(r);
for (rows, cols) in [(1, 1), (4, 4), (2, 5)] {
let m = GradientMesh::for_path(&e, rows, cols, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap();
let off = mesh_border_off_ellipse(&m, r);
assert!(off < OFF, "{rows}×{cols} mesh: border {off} pt off the ellipse");
}
let made = GradientMesh::for_path(&e, 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap();
let mut m = made.clone();
// Off centre, so each line splits its edges unevenly.
let i = m.add_lines_at(Point::new(200.0, 330.0), 0.0).unwrap();
let off = mesh_border_off_ellipse(&m, r);
assert!(off < OFF, "after a Mesh tool click: {off} pt");
// Deleting the lines again (Alt-click) gives the border back as it was.
assert!(m.remove_point_lines(i));
assert_eq!((m.rows, m.cols), (1, 1));
for (k, (now, was)) in m.points.iter().zip(&made.points).enumerate() {
let moved = now.handles.iter().zip(&was.handles).map(|(a, b)| (*a - *b).hypot()).fold(0.0, f64::max);
assert!(now.p.distance(was.p) < 1e-6 && moved < 1e-3, "point {k}: {now:?}, made as {was:?}");
}
// With a handle at the new border point shortened since, the two edges no longer make one
// curve: the merged edge is straight rather than grown past its ends.
let mut m = made.clone();
let i = m.add_lines_at(Point::new(200.0, 330.0), 0.0).unwrap();
let top = m.idx(0, 1);
m.points[top].handles[H_LEFT] *= 1e-4;
assert!(m.remove_point_lines(i));
let (a, b) = (m.points[0].p, m.points[1].p);
assert!((m.points[0].handles[H_RIGHT] - (b - a) / 3.0).hypot() < 1e-9, "{:?}", m.points[0].handles);
// A rectangle's mesh keeps straight edges with handles a third of the way along.
let m = GradientMesh::for_path(&shapes::rectangle(Rect::new(0.0, 0.0, 90.0, 60.0)), 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap();
let e = m.edge_u(0, 0);
assert!(e.p1.distance(Point::new(30.0, 0.0)) < 1e-6 && e.p2.distance(Point::new(60.0, 0.0)) < 1e-6, "{e:?}");
}
#[test]
fn mesh_insert_and_remove_lines() {
let mut m =
GradientMesh::for_path(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap();
let i = m.add_lines_at(Point::new(30.0, 60.0)).unwrap();
let i = m.add_lines_at(Point::new(30.0, 60.0), 0.0).unwrap();
assert_eq!((m.rows, m.cols), (2, 2));
assert!(m.points[i].p.distance(Point::new(30.0, 60.0)) < 0.5, "{:?}", m.points[i].p);
assert!(m.remove_point_lines(i));
assert_eq!((m.rows, m.cols), (1, 1));
}
/// A point outside the mesh within the tolerance locates the nearest point of its edge.
#[test]
fn mesh_locate_within_reaches_the_edge() {
let m = GradientMesh::for_path(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), 2, 2, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap();
let p = Point::new(102.0, 30.0);
assert_eq!(m.locate(p), None);
assert_eq!(m.locate_within(p, 1.0), None);
let (r, c, u, v) = m.locate_within(p, 3.0).unwrap();
assert_eq!((r, c), (0, 1));
assert_eq!(u, 1.0, "on the right edge");
let q = m.eval(r, c, u, v);
assert!(q.distance(Point::new(100.0, 30.0)) < 1e-6, "{q:?}");
assert_eq!(m.locate_within(Point::new(30.0, 60.0), 3.0), m.locate(Point::new(30.0, 60.0)));
}
/// Corners all in CMYK mix in CMYK (the inks halfway), not in RGB.
#[test]
fn a_cmyk_mesh_mixes_its_inks() {
+3 -2
View File
@@ -293,9 +293,10 @@ pub fn is_live(n: &Node) -> bool {
)
}
/// Mesh tessellation as filled quad paths (no stroke), `n`×`n` per patch.
/// Mesh tessellation as flat-filled paths (no stroke): each patch cut `n`×`n`, its quads joined
/// where the colour hardly changes ([`GradientMesh::pieces`]).
pub fn mesh_quad_nodes(m: &GradientMesh, n: usize) -> Vec<Node> {
m.quads(n)
m.pieces(n)
.into_iter()
.map(|q| {
let mut ap = Appearance::default();
+291 -16
View File
@@ -35,6 +35,15 @@ pub struct MeshQuad {
pub corners: [(Color, f32); 4],
}
/// One flat-coloured piece of a mesh ([`GradientMesh::pieces`]): a closed outline through
/// tessellation points, its colour and opacity.
#[derive(Clone, Debug, PartialEq)]
pub struct MeshPiece {
pub pts: Vec<Point>,
pub color: Color,
pub opacity: f32,
}
/// Create Gradient Mesh → Appearance.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum MeshAppearance {
@@ -157,6 +166,76 @@ impl Coons {
// Gradient meshes
// =====================================================================================
/// The handles, as offsets from `ends`, of the cubic from `ends.0` to `ends.1` that comes closest
/// (least squares) to `side` between `span.0` and `span.1`; `None` when they can't be computed.
/// Each point of the side is matched to the curve's nearest point, refined over a few rounds.
fn fit_edge(side: &[Point], span: (f64, f64), ends: (Point, Point)) -> Option<(Vec2, Vec2)> {
const N: usize = 32;
const ROUNDS: usize = 6;
let (p0, p3) = (ends.0, ends.1);
let qs: Vec<Point> = (1..N).map(|i| Coons::sample(side, span.0 + (span.1 - span.0) * i as f64 / N as f64)).collect();
let mut ts: Vec<f64> = (1..N).map(|i| i as f64 / N as f64).collect();
let mut fit = None;
for _ in 0..ROUNDS {
let (mut c11, mut c12, mut c22) = (0.0, 0.0, 0.0);
let (mut x1, mut x2) = (Vec2::ZERO, Vec2::ZERO);
for (q, &t) in qs.iter().zip(&ts) {
let s = 1.0 - t;
let (b0, b1, b2, b3) = (s * s * s, 3.0 * s * s * t, 3.0 * s * t * t, t * t * t);
let r = q.to_vec2() - b0 * p0.to_vec2() - b3 * p3.to_vec2();
c11 += b1 * b1;
c12 += b1 * b2;
c22 += b2 * b2;
x1 += b1 * r;
x2 += b2 * r;
}
let det = c11 * c22 - c12 * c12;
if det.abs() < 1e-12 {
break;
}
let (p1, p2) = (((c22 * x1 - c12 * x2) / det).to_point(), ((c11 * x2 - c12 * x1) / det).to_point());
if !(p1.is_finite() && p2.is_finite()) {
break;
}
fit = Some((p1 - p0, p2 - p3));
// One Newton step towards the nearest point of the curve for each point of the side.
let c = CubicBez::new(p0, p1, p2, p3);
let (a, b, k) = (p1 - p0, p2 - p1, p3 - p2);
for (q, t) in qs.iter().zip(ts.iter_mut()) {
let s = 1.0 - *t;
let v = 3.0 * (s * s * a + 2.0 * s * *t * b + *t * *t * k);
let acc = 6.0 * (s * (b - a) + *t * (k - b));
let e = c.eval(*t) - *q;
let den = v.hypot2() + e.dot(acc);
if den.abs() > 1e-12 {
*t = (*t - e.dot(v) / den).clamp(0.0, 1.0);
}
}
}
fit
}
/// The handles of one cubic standing for two edges that meet at point `mid.0`, whose handles
/// towards them are `mid.1` (towards `a`) and `mid.2` (towards `b`), as offsets from their outer
/// ends: each outer handle grown back by the share of the curve its edge had, which undoes the
/// split [`GradientMesh::insert_row`] and [`GradientMesh::insert_col`] make, so deleting a mesh
/// line keeps the curve of the border it crossed (#3187). The share is the split parameter, read
/// from the lengths of the middle handles. When the edges no longer fit one cubic (a point or a
/// handle was moved since the split), the cubic grown back would not pass through the middle
/// point, and the merged edge is straight.
fn merged_handles(a: (Point, Vec2), mid: (Point, Vec2, Vec2), b: (Point, Vec2)) -> (Vec2, Vec2) {
let straight = ((b.0 - a.0) / 3.0, (a.0 - b.0) / 3.0);
let (la, lb) = (mid.1.hypot(), mid.2.hypot());
let t = la / (la + lb);
if !(0.01..=0.99).contains(&t) {
return straight;
}
let (ha, hb) = (a.1 / t, b.1 / (1.0 - t));
let through = CubicBez::new(a.0, a.0 + ha, b.0 + hb, b.0).eval(t);
let tol = 1e-3 * (a.0.distance(mid.0) + mid.0.distance(b.0)).max(1e-9);
if ha.is_finite() && hb.is_finite() && through.distance(mid.0) <= tol { (ha, hb) } else { straight }
}
/// `0, 1/n, …, 1` for `n` (clamped to 1..=200) even steps.
fn even_steps(n: u32) -> Vec<f64> {
let n = n.clamp(1, 200);
@@ -170,11 +249,22 @@ pub(super) fn grid_len(rows: u32, cols: u32) -> Option<usize> {
}
impl GradientMesh {
/// The most quads one tessellation ([`Self::quads`]) makes, and so the most patches a mesh can
/// have: about 170 bytes a quad, so about 170 MB. A file's mesh can have any number of patches
/// and the view any zoom; the subdivision per patch gives way before memory does, and a mesh
/// with more patches than that is malformed, like a point count that doesn't fit (#3592).
pub const MAX_QUADS: usize = 1 << 20;
pub fn idx(&self, r: usize, c: usize) -> usize {
r * (self.cols as usize + 1) + c
}
pub fn is_valid(&self) -> bool {
self.rows >= 1 && self.cols >= 1 && grid_len(self.rows, self.cols) == Some(self.points.len())
self.rows >= 1 && self.cols >= 1 && grid_len(self.rows, self.cols) == Some(self.points.len()) && self.patches() <= Self::MAX_QUADS
}
/// The number of patches (rows times columns), saturating.
fn patches(&self) -> usize {
(self.rows as usize).saturating_mul(self.cols as usize)
}
/// A 1×1 mesh from its four corners (bottom-left, top-left, top-right, bottom-right in u/v
@@ -263,7 +353,49 @@ impl GradientMesh {
/// colour and opacity `color_at` gives at its position (a gradient fill's, say).
pub fn for_path_with(path: &PathData, rows: u32, cols: u32, color_at: &dyn Fn(Point) -> (Color, f32)) -> Option<Self> {
let coons = Coons::from_path(path)?;
Some(Self::from_grid(&even_steps(cols), &even_steps(rows), &|u, v| coons.eval(u, v), &|_, _, p| color_at(p)))
let (us, vs) = (even_steps(cols), even_steps(rows));
let mut m = Self::from_grid(&us, &vs, &|u, v| coons.eval(u, v), &|_, _, p| color_at(p));
m.fit_border(&coons, &us, &vs);
Some(m)
}
/// Give each edge on the mesh's border the handles that make it follow the outline it stands
/// for, in place of the handles [`Self::from_grid`] estimates from the surface's derivatives
/// (#3187). Where two border edges meet along a side, their handles are made collinear, so the
/// border stays smooth there; the four corners keep the handles their own edges were fitted with.
fn fit_border(&mut self, coons: &Coons, us: &[f64], vs: &[f64]) {
let (rows, cols) = (self.rows as usize, self.cols as usize);
if !self.is_valid() || us.len() != cols + 1 || vs.len() != rows + 1 {
return;
}
let edges = [(0, &coons.top), (rows, &coons.bottom)]
.map(|(r, side)| (r, side, true))
.into_iter()
.chain([(0, &coons.left), (cols, &coons.right)].map(|(c, side)| (c, side, false)));
for (line, side, along_u) in edges {
let (n, steps) = if along_u { (cols, us) } else { (rows, vs) };
for k in 0..n {
let (a, b, out, back) = if along_u {
(self.idx(line, k), self.idx(line, k + 1), H_RIGHT, H_LEFT)
} else {
(self.idx(k, line), self.idx(k + 1, line), H_DOWN, H_UP)
};
if let Some((h1, h2)) = fit_edge(side, (steps[k], steps[k + 1]), (self.points[a].p, self.points[b].p)) {
self.points[a].handles[out] = h1;
self.points[b].handles[back] = h2;
}
}
for k in 1..n {
let (i, out, back) = if along_u { (self.idx(line, k), H_RIGHT, H_LEFT) } else { (self.idx(k, line), H_DOWN, H_UP) };
let h = &mut self.points[i].handles;
let (fwd, bwd) = (h[out], h[back]);
let dir = (fwd.normalize() - bwd.normalize()).normalize();
if fwd.hypot2() > 0.0 && bwd.hypot2() > 0.0 && dir.is_finite() {
h[out] = dir * fwd.hypot();
h[back] = -dir * bwd.hypot();
}
}
}
}
/// Create Gradient Mesh → Appearance: lighten the points towards white, to the centre or to
@@ -347,13 +479,20 @@ impl GradientMesh {
(Color::rgb(rgb[0], rgb[1], rgb[2]), op)
}
/// Tessellate into `n`×`n` quads per patch, each coloured at its centre and its corners.
/// The quads per patch side [`Self::quads`] cuts at when `n` is asked: 1 to 64, and coarser
/// where the patches at `n` would pass [`Self::MAX_QUADS`] (a quad per patch is the least).
fn subdivision(&self, n: usize) -> usize {
n.clamp(1, 64).min(((Self::MAX_QUADS / self.patches().max(1)) as f64).sqrt() as usize).max(1)
}
/// Tessellate into `n`×`n` quads per patch ([`Self::subdivision`]), each coloured at its centre
/// and its corners.
pub fn quads(&self, n: usize) -> Vec<MeshQuad> {
if !self.is_valid() {
return vec![];
}
let n = n.clamp(1, 64);
let mut out = Vec::with_capacity(self.rows as usize * self.cols as usize * n * n);
let n = self.subdivision(n);
let mut out = Vec::with_capacity(self.patches() * n * n);
let mut grid = vec![Point::ZERO; (n + 1) * (n + 1)];
let mut colors = vec![(Color::default(), 0.0f32); (n + 1) * (n + 1)];
for r in 0..self.rows as usize {
@@ -383,6 +522,105 @@ impl GradientMesh {
out
}
/// Flat-coloured pieces covering the mesh, for outputs that can't shade a mesh smoothly (SVG,
/// Expand): each patch cut into `n`×`n` quads as by [`Self::quads`], then neighbouring quads
/// joined into square blocks as large as keeps the colour across each block within about one
/// 8-bit step. A patch of one colour is one piece; a patch whose colour changes a lot keeps
/// all its quads. A piece's outline runs through its quads' outer corners, so it covers what
/// they covered, and it takes the colour at its centre.
pub fn pieces(&self, n: usize) -> Vec<MeshPiece> {
let n = self.subdivision(n);
let quads = self.quads(n);
let cols = (self.cols as usize).max(1);
let mut out = Vec::with_capacity(quads.len());
for (k, patch) in quads.chunks_exact(n * n).enumerate() {
let (r, c) = (k / cols, k % cols);
let side = self.block_side(r, c, n, patch);
if side == 1 {
out.extend(patch.iter().map(|q| MeshPiece { pts: q.pts.to_vec(), color: q.color, opacity: q.opacity }));
continue;
}
// Tessellation point (i, j) of the patch (0..=n each), from a quad it is a corner of.
let at = |i: usize, j: usize| -> Option<Point> {
let (qi, qj) = (i.min(n - 1), j.min(n - 1));
let q = patch.get(qj * n + qi)?;
q.pts
.get(match (i > qi, j > qj) {
(false, false) => 0,
(true, false) => 1,
(true, true) => 2,
(false, true) => 3,
})
.copied()
};
for j0 in (0..n).step_by(side) {
for i0 in (0..n).step_by(side) {
let (i1, j1) = (i0 + side, j0 + side);
// Around the block, in the quads' order: along its top, down its right side,
// back along its bottom and up its left side.
let ring = (i0..i1)
.map(|i| (i, j0))
.chain((j0..j1).map(|j| (i1, j)))
.chain((i0 + 1..=i1).rev().map(|i| (i, j1)))
.chain((j0 + 1..=j1).rev().map(|j| (i0, j)));
let Some(pts) = ring.map(|(i, j)| at(i, j)).collect::<Option<Vec<Point>>>() else { continue };
let half = side as f64 / 2.0;
let (color, opacity) = self.color_at(r, c, (i0 as f64 + half) / n as f64, (j0 as f64 + half) / n as f64);
out.push(MeshPiece { pts, color, opacity });
}
}
}
out
}
/// The side, in quads, of the blocks [`Self::pieces`] joins patch (r, c) into, cut into
/// `n`×`n` `quads`: the largest divisor of `n` that keeps the colour changing by at most one
/// 8-bit step across a block. 1 (no joining) where the patch folds over itself, as the outline
/// of a block across the fold wouldn't fill it as its quads do.
fn block_side(&self, r: usize, c: usize, n: usize, quads: &[MeshQuad]) -> usize {
let corner = |dr: usize, dc: usize| self.points.get(self.idx(r + dr, c + dc));
let (Some(p00), Some(p01), Some(p10), Some(p11)) = (corner(0, 0), corner(0, 1), corner(1, 0), corner(1, 1)) else {
return 1;
};
// Corners all in CMYK mix in their inks ([`Self::color_at`]); the RGB they show is checked too.
let inks = [p00, p01, p10, p11].iter().all(|p| matches!(p.color, Color::Cmyk { .. }));
let channels = |p: &MeshPoint| -> Vec<f32> {
let mut v = p.color.to_rgb().to_vec();
if inks && let Color::Cmyk { c, m, y, k } = p.color {
v.extend([c, m, y, k]);
}
v.push(p.opacity);
v
};
let [a, b, cc, d] = [p00, p01, p10, p11].map(channels);
// A bilinear patch changes across a block of side h (0..1 of the patch) by at most
// h × (du + dv) in each channel, du and dv its largest change along each parameter.
let mut steps = 0.0f32;
for k in 0..a.len() {
let ch = |v: &[f32]| v.get(k).copied().unwrap_or_default();
let du = (ch(&a) - ch(&b)).abs().max((ch(&cc) - ch(&d)).abs());
let dv = (ch(&a) - ch(&cc)).abs().max((ch(&b) - ch(&d)).abs());
steps = steps.max((du + dv) * 255.0);
}
if !steps.is_finite() {
return 1;
}
let side = (1..=n).rev().find(|&s| n.is_multiple_of(s) && (n / s) as f32 >= steps).unwrap_or(1);
if side > 1 {
// Every quad turning the same way: the patch doesn't fold, so a block's outline
// encloses just what its quads cover.
let turn = |q: &MeshQuad| {
let [a, b, c, d] = q.pts;
(b - a).cross(c - a) + (c - a).cross(d - a)
};
let first = quads.first().map(turn).unwrap_or_default();
if !quads.iter().all(|q| turn(q) * first > 0.0) {
return 1;
}
}
side
}
/// The mesh's outer boundary as a closed path.
pub fn outline(&self) -> PathData {
if !self.is_valid() {
@@ -466,12 +704,21 @@ impl GradientMesh {
/// Which patch and local (u, v) contains `p` (sampled search; `None` outside the mesh).
pub fn locate(&self, p: Point) -> Option<(usize, usize, f64, f64)> {
self.locate_within(p, 0.0)
}
/// As [`Self::locate`], and for a point outside the mesh within `tol` of its outline, the
/// patch and local (u, v) of the nearest point of the mesh's edge.
pub fn locate_within(&self, p: Point, tol: f64) -> Option<(usize, usize, f64, f64)> {
if !self.is_valid() {
return None;
}
let bp = self.outline().to_bezpath();
if bp.winding(p) == 0 {
return None;
if tol.is_nan() || tol <= 0.0 {
return None;
}
return self.nearest_edge(p).filter(|e| e.0 <= tol * tol).map(|(_, r, c, u, v)| (r, c, u, v));
}
const N: usize = 16;
let mut best = (f64::INFINITY, 0, 0, 0.0, 0.0);
@@ -508,6 +755,30 @@ impl GradientMesh {
Some((r, c, u, v))
}
/// The point of the mesh's outer edge nearest `p`: its squared distance from `p`, and its
/// boundary patch and local (u, v).
fn nearest_edge(&self, p: Point) -> Option<(f64, usize, usize, f64, f64)> {
use vectorcraft_geom::kurbo::ParamCurveNearest;
let (rows, cols) = (self.rows as usize, self.cols as usize);
let mut best: Option<(f64, usize, usize, f64, f64)> = None;
let mut take = |e: CubicBez, at: (usize, usize, Option<f64>, Option<f64>)| {
let n = e.nearest(p, 1e-9);
if best.is_none_or(|b| n.distance_sq < b.0) {
let (r, c, u, v) = at;
best = Some((n.distance_sq, r, c, u.unwrap_or(n.t), v.unwrap_or(n.t)));
}
};
for c in 0..cols {
take(self.edge_u(0, c), (0, c, None, Some(0.0)));
take(self.edge_u(rows, c), (rows - 1, c, None, Some(1.0)));
}
for r in 0..rows {
take(self.edge_v(r, 0), (r, 0, Some(0.0), None));
take(self.edge_v(r, cols), (r, cols - 1, Some(1.0), None));
}
best
}
/// Index of the mesh point within `tol` of `p`.
pub fn point_near(&self, p: Point, tol: f64) -> Option<usize> {
self.points
@@ -595,9 +866,10 @@ impl GradientMesh {
self.cols += 1;
}
/// Mesh tool click: add a row and a column through `p` (inside the mesh). Returns the new point index.
pub fn add_lines_at(&mut self, p: Point) -> Option<usize> {
let (r, c, u, v) = self.locate(p)?;
/// Mesh tool click: add a row and a column through `p` (inside the mesh, or outside within
/// `tol` of its edge, through the nearest point of the edge). Returns the new point index.
pub fn add_lines_at(&mut self, p: Point, tol: f64) -> Option<usize> {
let (r, c, u, v) = self.locate_within(p, tol)?;
let mut rr = r;
let mut cc = c;
let row_new = v > 0.02 && v < 0.98;
@@ -632,10 +904,11 @@ impl GradientMesh {
let above = self.idx(r - 1, cc);
let below = self.idx(r + 1, cc);
let mid = self.idx(r, cc);
let (pa, pm, pb) = (self.points[above].p, self.points[mid].p, self.points[below].p);
let _ = pm;
self.points[above].handles[H_DOWN] = (pb - pa) / 3.0;
self.points[below].handles[H_UP] = (pa - pb) / 3.0;
let (pa, pb, m) = (self.points[above].p, self.points[below].p, &self.points[mid]);
let hm = (m.p, m.handles[H_UP], m.handles[H_DOWN]);
let (down, up) = merged_handles((pa, self.points[above].handles[H_DOWN]), hm, (pb, self.points[below].handles[H_UP]));
self.points[above].handles[H_DOWN] = down;
self.points[below].handles[H_UP] = up;
}
self.points.drain(r * w..(r + 1) * w);
self.rows -= 1;
@@ -646,9 +919,11 @@ impl GradientMesh {
let rows = self.rows as usize;
for rr in 0..=rows {
let (l, rt) = (rr * w + c - 1, rr * w + c + 1);
let (pl, pr) = (self.points[l].p, self.points[rt].p);
self.points[l].handles[H_RIGHT] = (pr - pl) / 3.0;
self.points[rt].handles[H_LEFT] = (pl - pr) / 3.0;
let (pl, pr, m) = (self.points[l].p, self.points[rt].p, &self.points[rr * w + c]);
let hm = (m.p, m.handles[H_LEFT], m.handles[H_RIGHT]);
let (right, left) = merged_handles((pl, self.points[l].handles[H_RIGHT]), hm, (pr, self.points[rt].handles[H_LEFT]));
self.points[l].handles[H_RIGHT] = right;
self.points[rt].handles[H_LEFT] = left;
}
let mut k = 0;
self.points.retain(|_| {
+4
View File
@@ -253,6 +253,10 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize {
b += boxed(&e.dimension) + boxed(&e.live_paint_gaps) + boxed(&e.colorize);
b += e.trace.as_ref().map_or(0, |t| ALLOC + size_of::<Value>() + json_bytes(t));
b += e.intertwine.as_deref().map_or(0, |a| ALLOC + size_of::<PathData>() + path_bytes(a));
if e.intertwine_tiers.capacity() > 0 {
b += ALLOC + e.intertwine_tiers.capacity() * size_of::<PathData>() + e.intertwine_tiers.iter().map(path_bytes).sum::<usize>();
}
b += e.symbol_overrides.capacity() * size_of::<crate::SymbolOverride>();
b += e.objects_on_path.as_deref().map_or(0, |o| {
ALLOC + size_of::<crate::objects_on_path::ObjectsOnPath>() + o.members.capacity() * size_of::<crate::objects_on_path::Member>()
});
+10
View File
@@ -44,6 +44,11 @@ pub struct NodeExtras {
/// Object › Intertwine: as a member of an Intertwine group, the area (document coordinates,
/// filled non-zero) where this object is drawn over the group's other members.
pub intertwine: Option<Box<PathData>>,
/// Object › Intertwine: as a member of an Intertwine group, the areas (document coordinates,
/// filled non-zero) where this object is drawn over members that are themselves drawn over
/// others there. Item `k` is the area of tier `k + 2`; [`NodeExtras::intertwine`] is tier 1
/// (see [`crate::intertwine`]).
pub intertwine_tiers: Vec<PathData>,
/// Live Paint group: its Gap Options (Object › Live Paint › Gap Options; `None`: the defaults).
pub live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
/// Object › Objects on Path: the group's path and the objects attached to it (see
@@ -58,6 +63,9 @@ pub struct NodeExtras {
pub compound_shape: bool,
/// As a member of a compound shape, how this object combines with the members below it.
pub shape_mode: crate::ShapeMode,
/// As an instance of a dynamic symbol, its own appearance changes to objects of the symbol's
/// art (see [`crate::symbol_overrides`]); empty for none.
pub symbol_overrides: Vec<crate::SymbolOverride>,
}
/// No extras: what [`Node::extras`] gives for a node without any.
@@ -76,11 +84,13 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras {
perspective: None,
dimension: None,
intertwine: None,
intertwine_tiers: Vec::new(),
live_paint_gaps: None,
objects_on_path: None,
colorize: None,
compound_shape: false,
shape_mode: crate::ShapeMode::Add,
symbol_overrides: Vec::new(),
};
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
+17 -5
View File
@@ -496,8 +496,13 @@ impl Node {
Arc::make_mut(&mut m.art).transform_scaled(a, sc);
}
// The area where an Intertwine member is drawn over the others moves with it.
if let Some(area) = self.extras_if_any().and_then(|e| e.intertwine.as_mut()) {
area.transform(a);
if let Some(e) = self.extras_if_any() {
if let Some(area) = e.intertwine.as_mut() {
area.transform(a);
}
for area in &mut e.intertwine_tiers {
area.transform(a);
}
}
}
@@ -683,9 +688,15 @@ impl Node {
impl Node {
/// Whether this object shows any transparency, itself or anything inside it: object, fill or
/// stroke opacity below 100%, a blend mode, an opacity mask, effects or transparent gradient
/// stops. Hidden objects show none.
/// stroke opacity below 100%, a blend mode, an opacity mask, effects, transparent gradient
/// stops or a colorized image. Hidden objects show none.
pub fn shows_transparency(&self) -> bool {
self.shows_transparency_with(&|_| true)
}
/// [`Self::shows_transparency`], counting a colorized image by itself only when `colorize`
/// accepts it, for files that write some colorized images in their colorize paint (EPS).
pub fn shows_transparency_with(&self, colorize: &dyn Fn(&Node) -> bool) -> bool {
use vectorcraft_color::Paint;
let see_through = |p: &Paint| matches!(p, Paint::Gradient(g) if g.gradient.stops.iter().any(|s| s.opacity < 1.0));
let effects = |fx: &[crate::Effect]| fx.iter().any(|e| e.visible);
@@ -693,13 +704,14 @@ impl Node {
&& (self.opacity < 1.0
|| self.blend != BlendMode::Normal
|| self.extras().mask.as_ref().is_some_and(|m| !m.disabled)
|| (matches!(self.kind, NodeKind::Image(_)) && self.extras().colorize.is_some() && colorize(self))
|| effects(&self.appearance.effects)
|| self
.appearance
.items
.iter()
.any(|i| i.visible() && (i.opacity() < 1.0 || i.blend() != BlendMode::Normal || see_through(i.paint()) || effects(i.effects())))
|| self.children().is_some_and(|ch| ch.iter().any(|c| c.shows_transparency())))
|| self.children().is_some_and(|ch| ch.iter().any(|c| c.shows_transparency_with(colorize))))
}
}
+46
View File
@@ -339,6 +339,35 @@ fn transform_group_recurses() {
assert_eq!(child.appearance.stroke_width(), 2.0);
}
/// A symbol instance in perspective keeps its picture moved by a transform, and loses its
/// projection when no invertible map of finite numbers draws the moved picture.
#[test]
fn a_projection_follows_the_transform_or_is_removed() {
use vectorcraft_geom::{Homography, Point};
let instance = |h: Homography| {
let mut n = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::IDENTITY });
n.extras_mut().perspective =
Some(Box::new(crate::PerspectiveAttachment { projection: Some(h.to_array()), ..crate::PerspectiveAttachment::new("left", 0.0) }));
n
};
let projection = |n: &Node| n.extras().perspective.as_deref().and_then(|p| p.projection);
// A move by 10 scaled by 1e307: moved by a scale by 10, the product overflows unless scaled down.
let big = Homography([[1e307, 0.0, 1e308], [0.0, 1e307, 0.0], [0.0, 0.0, 1e307]]);
let mut n = instance(big);
n.transform(Affine::scale(10.0), false);
let p = Point::new(3.0, 7.0);
assert!(n.projection().unwrap().apply(p).unwrap().distance(Point::new(103.0, 7.0)) < 1e-9);
// A move by 1e200 moved by a scale by 1e150 sends every point to about (1e350, 0).
let far = Homography([[1.0, 0.0, 1e200], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
let mut n = instance(far);
n.transform(Affine::scale(1e150), false);
assert_eq!(projection(&n), None);
// A transform that is not invertible leaves the projection as it is.
let mut n = instance(far);
n.transform(Affine::scale(0.0), false);
assert_eq!(projection(&n), Some(far.to_array()));
}
#[test]
fn unplaced_gradients_pin_only_when_a_refit_would_differ() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint};
@@ -455,6 +484,23 @@ fn objects_are_read_within_the_node_budget() {
assert!(with_node_budget(3, read).is_ok());
}
/// An Intertwine member's areas of tier 2 and up are written and read back, and each one counts
/// against [`with_node_budget`] as an object does, so a file of many empty areas is refused.
#[test]
fn intertwine_tiers_are_read_within_the_node_budget() {
let mut n = Node::group(NodeId(1), vec![]);
let area = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0));
n.extras_mut().intertwine_tiers = vec![PathData::default(), area];
let json = serde_json::to_string(&n).unwrap();
assert!(json.contains("\"intertwine_tiers\""), "{json}");
let read = || serde_json::from_str::<Node>(&json);
assert_eq!(with_node_budget(3, read).unwrap(), n, "the object and its two areas fit a budget of three");
let why = with_node_budget(2, read).unwrap_err().to_string();
assert!(why.contains("more than 2 objects"), "{why}");
let plain = serde_json::to_string(&Node::group(NodeId(1), vec![])).unwrap();
assert!(!plain.contains("intertwine_tiers"), "an object without tiers writes none: {plain}");
}
/// #3587: fields read before an object's `type` are buffered value by value within
/// [`with_buffered_value_budget`], each counted once: a group read back from the buffer buffers
/// its own fields again without counting them twice. VectorCraft writes `type` first, which
+40 -2
View File
@@ -46,11 +46,13 @@ impl Serialize for Node {
e.perspective.is_none(),
e.dimension.is_none(),
e.intertwine.is_none(),
e.intertwine_tiers.is_empty(),
e.live_paint_gaps.is_none(),
e.objects_on_path.is_none(),
e.colorize.is_none(),
is_default(&e.compound_shape),
is_default(&e.shape_mode),
e.symbol_overrides.is_empty(),
];
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
let mut skip = skips.into_iter();
@@ -85,11 +87,13 @@ impl Serialize for Node {
field!("perspective", &e.perspective);
field!("dimension", &e.dimension);
field!("intertwine", &e.intertwine);
field!("intertwine_tiers", &e.intertwine_tiers);
field!("live_paint_gaps", &e.live_paint_gaps);
field!("objects_on_path", &e.objects_on_path);
field!("colorize", &e.colorize);
field!("compound_shape", &e.compound_shape);
field!("shape_mode", &e.shape_mode);
field!("symbol_overrides", &e.symbol_overrides);
st.end()
}
}
@@ -123,11 +127,39 @@ pub fn with_node_budget<T>(max: usize, f: impl FnOnce() -> T) -> T {
/// each counts once, and the id usually comes first, before the node's children are read.
fn counted_id<'de, D: serde::Deserializer<'de>>(d: D) -> Result<NodeId, D::Error> {
let id = NodeId::deserialize(d)?;
count_one()?;
Ok(id)
}
/// Count one object against [`with_node_budget`].
fn count_one<E: serde::de::Error>() -> Result<(), E> {
if let Some((left, max)) = NODES_LEFT.get() {
let left = left.checked_sub(1).ok_or_else(|| serde::de::Error::custom(format!("the document has more than {max} objects")))?;
let left = left.checked_sub(1).ok_or_else(|| E::custom(format!("the document has more than {max} objects")))?;
NODES_LEFT.set(Some((left, max)));
}
Ok(id)
Ok(())
}
/// A list read from a file (a member's Intertwine areas of tier 2 and up, an instance's symbol
/// overrides), each item counting against [`with_node_budget`] as an object does: a compressed
/// file of repeated empty items stays small while they take memory once read.
fn counted_list<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result<Vec<T>, D::Error> {
struct Items<T>(std::marker::PhantomData<T>);
impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for Items<T> {
type Value = Vec<T>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a list")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<T>, A::Error> {
let mut out = Vec::new();
while let Some(item) = seq.next_element::<T>()? {
count_one()?;
out.push(item);
}
Ok(out)
}
}
d.deserialize_seq(Items(std::marker::PhantomData))
}
/// How a [`Node`] is read: the flat field list of the file format. Fields move into the node
@@ -186,6 +218,8 @@ pub(super) struct NodeWire {
dimension: Option<Box<crate::dimension::Dimension>>,
#[serde(default)]
intertwine: Option<Box<PathData>>,
#[serde(default, deserialize_with = "counted_list")]
intertwine_tiers: Vec<PathData>,
#[serde(default)]
live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
#[serde(default)]
@@ -196,6 +230,8 @@ pub(super) struct NodeWire {
compound_shape: bool,
#[serde(default)]
shape_mode: crate::ShapeMode,
#[serde(default, deserialize_with = "counted_list")]
symbol_overrides: Vec<crate::SymbolOverride>,
}
impl From<NodeWire> for Node {
@@ -215,11 +251,13 @@ impl From<NodeWire> for Node {
perspective: w.perspective,
dimension: w.dimension,
intertwine: w.intertwine,
intertwine_tiers: w.intertwine_tiers,
live_paint_gaps: w.live_paint_gaps,
objects_on_path: w.objects_on_path,
colorize: w.colorize,
compound_shape: w.compound_shape,
shape_mode: w.shape_mode,
symbol_overrides: w.symbol_overrides,
};
Node {
id: w.id,
+1 -1
View File
@@ -337,7 +337,7 @@ mod tests {
// The art is a 40 × 10 box to the right of and below the origin, not the 20 × 20 square
// around it that instances are measured by without the symbols.
let mut d = crate::Document::new(400.0, 400.0);
d.symbols.push(crate::Symbol { name: "Bar".into(), art: Arc::new(rect(9, Rect::new(0.0, 0.0, 40.0, 10.0))) });
d.symbols.push(crate::Symbol { dynamic: false, name: "Bar".into(), art: Arc::new(rect(9, Rect::new(0.0, 0.0, 40.0, 10.0))) });
let line = Node::path(NodeId(1), shapes::line(Point::new(100.0, 300.0), Point::new(300.0, 300.0)), Default::default());
let instance = Node::new(NodeId(2), NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::IDENTITY });
let other = rect(3, Rect::new(0.0, 0.0, 10.0, 10.0));
+1 -1
View File
@@ -200,7 +200,7 @@ mod tests {
fn oriented_bounds_hug_a_rotated_symbol_instances_art() {
let mut d = Document::new(500.0, 500.0);
let bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 10.0)), Appearance::default_art());
d.symbols.push(crate::Symbol { name: "Bar".into(), art: std::sync::Arc::new(bar) });
d.symbols.push(crate::Symbol { dynamic: false, name: "Bar".into(), art: std::sync::Arc::new(bar) });
let (l, id) = (d.layers[0].id, d.alloc_id());
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::translate((100.0, 100.0)) })).unwrap();
let c = Point::new(120.0, 105.0);
+5 -3
View File
@@ -58,12 +58,14 @@ impl Node {
}
}
/// Keep a projection drawing the same picture moved by `a` (the flat art moves by `a` too).
/// Keep a projection drawing the same picture moved by `a` (the flat art moves by `a` too). An
/// invertible `a` that the projection cannot follow removes it, and the art is drawn flat.
pub(crate) fn transform_projection(&mut self, a: vectorcraft_geom::Affine) {
if let Some(p) = self.extras_if_any().and_then(|e| e.perspective.as_deref_mut())
&& let Some(h) = p.homography().and_then(|h| h.conjugated(a))
&& let Some(h) = p.homography()
&& a.determinant().abs() >= 1e-15
{
p.projection = Some(h.to_array());
p.projection = h.conjugated(a).map(|h| h.to_array());
}
}
}
+6 -2
View File
@@ -667,7 +667,7 @@ mod tests {
pixels: None,
})),
);
d.symbols.push(Symbol { name: "Star".into(), art: Arc::new(art) });
d.symbols.push(Symbol { dynamic: false, name: "Star".into(), art: Arc::new(art) });
d.images.insert("pic".into(), ImageBlob::new("image/png", vec![1, 2, 3]));
let mut children = vec![Arc::new(Node::new(NodeId(51), NodeKind::SymbolInstance { symbol: "Star".into(), xf: Affine::IDENTITY }))];
if bytes.starts_with(b"nest") {
@@ -702,7 +702,11 @@ mod tests {
fn the_art_and_its_resources_join_only_a_prepared_copy_under_their_own_names() {
set_loader(stub_loader);
let mut d = Document::new(200.0, 200.0);
d.symbols.push(Symbol { name: "Star".into(), art: Arc::new(Node::new(NodeId(60), NodeKind::Group { children: vec![], clip: false })) });
d.symbols.push(Symbol {
dynamic: false,
name: "Star".into(),
art: Arc::new(Node::new(NodeId(60), NodeKind::Group { children: vec![], clip: false })),
});
d.images.insert("doc1-1".into(), ImageBlob::new("application/json", b"nest".to_vec()));
let p = placed("doc1-1");
let layer = d.layers[0].id;
+13 -6
View File
@@ -18,8 +18,8 @@ pub trait ColorVisitor {
fn image(&mut self, key: &str) -> Option<String>;
}
/// What a recolouring pass reaches: fills (with gradient-mesh points), strokes and the pixels of
/// embedded images.
/// What a recolouring pass reaches: fills (including colorized image fills and gradient-mesh
/// points), strokes and the pixels of embedded images without a colorize paint.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Reach {
pub fill: bool,
@@ -33,11 +33,18 @@ impl Reach {
}
/// Recolour `n` and everything inside it through `v`: the paints of its fills and strokes and of
/// its text runs, its gradient-mesh points and an embedded image's pixels (linked images stay
/// as they are). Members that change are replaced by copies; the others stay shared. Returns the
/// number of paints, meshes and images that changed.
/// its text runs, its gradient-mesh points and an embedded image's pixels (linked and colorized
/// images keep their pixels). Members that change are replaced by copies; the others stay shared.
/// Returns the number of paints, meshes and images that changed.
pub fn recolor_node(n: &mut Node, reach: Reach, v: &mut dyn ColorVisitor) -> usize {
let mut changed = 0;
let colorized = matches!(&n.kind, NodeKind::Image(_)) && n.extras().colorize.is_some();
if colorized
&& reach.fill
&& let Some(paint) = n.extras_mut().colorize.as_deref_mut()
{
changed += v.paint(paint) as usize;
}
for it in &mut n.appearance.items {
match it {
AppearanceItem::Fill(l) if reach.fill => changed += v.paint(&mut l.paint) as usize,
@@ -67,7 +74,7 @@ pub fn recolor_node(n: &mut Node, reach: Reach, v: &mut dyn ColorVisitor) -> usi
}
// An image holding its own pixels is a glyph's (emoji in outlined type): it keeps its
// colours, as the glyph does in type.
NodeKind::Image(im) if reach.images && im.link.is_none() && im.pixels.is_none() => {
NodeKind::Image(im) if reach.images && !colorized && im.link.is_none() && im.pixels.is_none() => {
if let Some(k) = v.image(&im.key) {
im.key = k;
changed += 1;
+4
View File
@@ -108,6 +108,10 @@ impl SavedSelection {
pub struct Symbol {
pub name: String,
pub art: Arc<Node>,
/// Symbol Options › Symbol Type › Dynamic Symbol: each instance may change the appearance of
/// objects in the art and stay linked (see [`crate::symbol_overrides`]). Static by default.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub dynamic: bool,
}
/// Encoded image bytes (PNG/JPEG/WebP…) shared by image objects.
+126 -32
View File
@@ -107,14 +107,17 @@ impl Quotes {
/// Single-quote pairs offered by Document Setup.
pub const SINGLE_CHOICES: &[[char; 2]] = &[['‘', '’'], ['‚', '‘'], ['‚', '’'], ['’', '’'], ['‹', '›'], ['›', '‹'], ['『', '』'], ['\'', '\'']];
/// Is `c` one of these opening quotes?
/// Is `c` one of these opening quotes? A typographic glyph that is also a closing quote does not
/// count; a straight quote has no direction, so it still opens.
fn is_open(&self, c: char) -> bool {
c == self.double[0] || c == self.single[0]
(c == self.double[0] || c == self.single[0]) && (c.is_ascii() || (c != self.double[1] && c != self.single[1]))
}
/// A quote opens after nothing, whitespace, an opening bracket, a dash or another opening quote.
fn opens(&self, prev: Option<char>) -> bool {
prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '—' | '–') || self.is_open(p))
/// `quote_opened` says whether `prev` is a quote this conversion just produced, and if so whether
/// it opened; that settles glyphs which serve as both opening and closing quote.
fn opens(&self, prev: Option<char>, quote_opened: Option<bool>) -> bool {
prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '—' | '–') || quote_opened.unwrap_or_else(|| self.is_open(p)))
}
/// `text` with straight quotes (`"` and `'`) replaced by these quotes: opening or closing by
@@ -122,11 +125,16 @@ impl Quotes {
/// and is updated to its last character, so text can be converted in pieces.
pub fn apply(&self, text: &str, prev: &mut Option<char>) -> String {
let mut out = String::with_capacity(text.len());
let mut quote_opened = None;
for c in text.chars() {
let r = match c {
'"' => self.double[usize::from(!self.opens(*prev))],
'\'' => self.single[usize::from(!self.opens(*prev))],
c => c,
quote_opened = match c {
'"' | '\'' => Some(self.opens(*prev, quote_opened)),
_ => None,
};
let r = match (c, quote_opened) {
('"', Some(open)) => self.double[usize::from(!open)],
('\'', Some(open)) => self.single[usize::from(!open)],
(c, _) => c,
};
out.push(r);
*prev = Some(r);
@@ -135,34 +143,66 @@ impl Quotes {
}
}
/// Document languages and the quote characters each uses (choosing a language in Document Setup
/// also picks its quotes).
pub const LANGUAGES: &[(&str, Quotes)] = &[
("English: USA", Quotes::ENGLISH),
("English: UK", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
("German", Quotes { double: ['„', '“'], single: ['‚', '‘'] }),
("German: Swiss", Quotes { double: ['«', '»'], single: ['‹', '›'] }),
("French", Quotes { double: ['«', '»'], single: ['‹', '›'] }),
("Italian", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
("Spanish", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
("Catalan", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
("Portuguese", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
("Dutch", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
("Danish", Quotes { double: ['»', '«'], single: ['›', '‹'] }),
("Swedish", Quotes { double: ['”', '”'], single: ['’', '’'] }),
("Finnish", Quotes { double: ['”', '”'], single: ['’', '’'] }),
("Norwegian", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
("Polish", Quotes { double: ['„', '”'], single: ['‚', '’'] }),
("Czech", Quotes { double: ['„', '“'], single: ['‚', '‘'] }),
("Russian", Quotes { double: ['«', '»'], single: ['„', '“'] }),
("Turkish", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
("Japanese", Quotes { double: ['「', '」'], single: ['『', '』'] }),
("Chinese", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
/// Document and character languages: the name, the BCP 47 tag text is shaped in (so that fonts use
/// their language's forms, OpenType `locl`) and the quote characters each uses (choosing a
/// language in Document Setup also picks its quotes). The first is the default.
pub const LANGUAGES: &[(&str, &str, Quotes)] = &[
("English: USA", "en-US", Quotes::ENGLISH),
("English: UK", "en-GB", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
("German", "de", Quotes { double: ['„', '“'], single: ['‚', '‘'] }),
("German: Swiss", "de-CH", Quotes { double: ['«', '»'], single: ['‹', '›'] }),
("French", "fr", Quotes { double: ['«', '»'], single: ['‹', '›'] }),
("Italian", "it", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
("Spanish", "es", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
("Catalan", "ca", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
("Portuguese", "pt", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
("Dutch", "nl", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
("Danish", "da", Quotes { double: ['»', '«'], single: ['›', '‹'] }),
("Swedish", "sv", Quotes { double: ['”', '”'], single: ['’', '’'] }),
("Finnish", "fi", Quotes { double: ['”', '”'], single: ['’', '’'] }),
("Norwegian", "nb", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
("Polish", "pl", Quotes { double: ['„', '”'], single: ['‚', '’'] }),
("Czech", "cs", Quotes { double: ['„', '“'], single: ['‚', '‘'] }),
("Russian", "ru", Quotes { double: ['«', '»'], single: ['„', '“'] }),
("Turkish", "tr", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
("Japanese", "ja", Quotes { double: ['「', '」'], single: ['『', '』'] }),
("Chinese", "zh", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
("Hindi", "hi", Quotes::ENGLISH),
("Marathi", "mr", Quotes::ENGLISH),
("Gujarati", "gu", Quotes::ENGLISH),
("Bengali", "bn", Quotes::ENGLISH),
("Punjabi", "pa", Quotes::ENGLISH),
("Tamil", "ta", Quotes::ENGLISH),
("Telugu", "te", Quotes::ENGLISH),
("Kannada", "kn", Quotes::ENGLISH),
("Malayalam", "ml", Quotes::ENGLISH),
("Odia", "or", Quotes::ENGLISH),
("Urdu", "ur", Quotes::ENGLISH),
];
/// The quotes of a [`LANGUAGES`] entry (case-insensitive).
pub fn language_quotes(language: &str) -> Option<Quotes> {
LANGUAGES.iter().find(|(l, _)| l.eq_ignore_ascii_case(language)).map(|(_, q)| *q)
LANGUAGES.iter().find(|(l, _, _)| l.eq_ignore_ascii_case(language)).map(|(_, _, q)| *q)
}
/// The [`LANGUAGES`] entry named or tagged `language` (case-insensitive) as a character's
/// language ([`crate::CharStyle::language`]) stores it: its tag, or `None` for the default (the
/// first entry); `Err` for a language not in the list.
pub fn char_language(language: &str) -> Result<Option<&'static str>, String> {
let i = LANGUAGES
.iter()
.position(|(n, t, _)| n.eq_ignore_ascii_case(language) || t.eq_ignore_ascii_case(language))
.ok_or_else(|| format!("unknown language `{language}` (one of {})", LANGUAGES.iter().map(|(n, _, _)| *n).collect::<Vec<_>>().join(", ")))?;
Ok(LANGUAGES.get(i).filter(|_| i > 0).map(|(_, t, _)| *t))
}
/// The name of the language a character's stored `language` tag stands for: the default's for
/// none, `None` for a tag not in [`LANGUAGES`] (from a file).
pub fn char_language_name(language: Option<&str>) -> Option<&'static str> {
match language {
None => LANGUAGES.first().map(|(n, _, _)| *n),
Some(tag) => LANGUAGES.iter().find(|(_, t, _)| t.eq_ignore_ascii_case(tag)).map(|(n, _, _)| *n),
}
}
/// The transparency flattener preset Document Setup uses unless another is chosen.
@@ -300,9 +340,19 @@ impl DocSetup {
pub fn keep_in_range(&mut self) {
self.bleed = bleed_in_range(self.bleed);
for m in [&mut self.superscript, &mut self.subscript] {
// `clamp` passes NaN through, so a non-finite value falls back to the default first.
if !m.size.is_finite() {
m.size = ScriptMetrics::DEFAULT.size;
}
if !m.position.is_finite() {
m.position = ScriptMetrics::DEFAULT.position;
}
m.size = m.size.clamp(1.0, MAX_SCRIPT);
m.position = m.position.clamp(-MAX_SCRIPT, MAX_SCRIPT);
}
if !self.small_caps_size.is_finite() {
self.small_caps_size = DocSetup::default().small_caps_size;
}
self.small_caps_size = self.small_caps_size.clamp(1.0, MAX_SMALL_CAPS);
}
}
@@ -311,6 +361,24 @@ impl DocSetup {
mod tests {
use super::*;
/// Characters store a language of the list by its tag, the default as none.
#[test]
fn char_languages_are_stored_by_tag() {
assert_eq!(char_language("Turkish"), Ok(Some("tr")));
assert_eq!(char_language("MR"), Ok(Some("mr")));
assert_eq!(char_language("english: usa"), Ok(None));
assert_eq!(char_language("en-US"), Ok(None));
assert!(char_language("Klingon").is_err() && char_language("").is_err());
assert_eq!(
(char_language_name(None), char_language_name(Some("gu")), char_language_name(Some("x-unknown"))),
(Some("English: USA"), Some("Gujarati"), None)
);
// Every entry has a tag of its own, and a name of its own.
for (i, (n, t, _)) in LANGUAGES.iter().enumerate() {
assert!(LANGUAGES.iter().skip(i + 1).all(|(n2, t2, _)| n2 != n && t2 != t), "{n} {t}");
}
}
#[test]
fn quotes_open_and_close_by_context() {
let mut prev = None;
@@ -326,6 +394,18 @@ mod tests {
assert_eq!((a.as_str(), b.as_str()), ("“", "“"));
}
#[test]
fn closing_quote_equal_to_other_kinds_opening_quote_still_closes() {
let q = Quotes { double: ['”', '”'], single: ['‘', '’'] };
let mut prev = None;
assert_eq!(q.apply("'\"a\"'", &mut prev), "‘”a”’");
let q = Quotes { double: ['"', '"'], single: ['‘', '’'] };
let mut prev = None;
assert_eq!(q.apply("'\"a\"'", &mut prev), "‘\"a\"’");
let mut prev = None;
assert_eq!(q.apply("\"'a'\"", &mut prev), "\"‘a’\"");
}
#[test]
fn bleed_grows_the_artboard() {
let s = DocSetup { bleed: [9.0, 9.0, 4.0, 2.0], ..Default::default() };
@@ -333,6 +413,20 @@ mod tests {
assert!(s.has_bleed() && !DocSetup::default().has_bleed());
}
#[test]
fn keep_in_range_replaces_non_finite_type_values() {
let mut s = DocSetup::default();
s.superscript.size = f64::NAN;
s.superscript.position = f64::INFINITY;
s.subscript.size = f64::NEG_INFINITY;
s.subscript.position = f64::NAN;
s.small_caps_size = f64::NAN;
s.keep_in_range();
for v in [s.superscript.size, s.superscript.position, s.subscript.size, s.subscript.position, s.small_caps_size] {
assert!(v.is_finite(), "{v}");
}
}
#[test]
fn presets_and_defaults() {
let d = DocSetup::default();
+190
View File
@@ -0,0 +1,190 @@
//! Dynamic symbols (Symbol Options › Symbol Type › Dynamic Symbol): an instance of a dynamic
//! symbol keeps appearance changes of its own to objects in the symbol's art, while the art stays
//! the symbol's. Editing the symbol reshapes every instance and each keeps its changes; Reset drops
//! them. The changes are stored on the instance ([`crate::node::NodeExtras::symbol_overrides`]) and
//! applied wherever an instance is drawn or exported ([`dynamic_art`]).
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use vectorcraft_color::{BlendMode, Paint};
use crate::{Node, NodeKind};
/// Deepest object an override may name: groups nested this deep in the symbol's art.
pub const MAX_PATH_DEPTH: usize = 64;
/// One instance's appearance changes to one object of its dynamic symbol's art.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct SymbolOverride {
/// The object: child indices (paint order) from the art's root down through its groups; empty
/// for the root itself.
#[serde(deserialize_with = "short_path")]
pub path: Vec<u32>,
/// The object's (top) fill paint; type takes it as its characters' fill.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fill: Option<Paint>,
/// The object's (top) stroke paint; type takes it as its characters' stroke.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stroke: Option<Paint>,
/// The object's opacity, 0..1.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opacity: Option<f32>,
/// The object's blending mode.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blend: Option<BlendMode>,
}
impl SymbolOverride {
/// Does this override change nothing?
pub fn is_empty(&self) -> bool {
self.fill.is_none() && self.stroke.is_none() && self.opacity.is_none() && self.blend.is_none()
}
/// Put this override's changes on object `n`.
fn apply(&self, n: &mut Node) {
if let NodeKind::Text(t) = &mut n.kind {
for r in &mut t.runs {
if let Some(p) = &self.fill {
r.style.fill = p.clone();
}
if let Some(p) = &self.stroke {
r.style.stroke = p.clone();
}
}
} else {
if let Some(p) = &self.fill {
n.appearance.set_fill(p.clone());
}
if let Some(p) = &self.stroke {
n.appearance.set_stroke(p.clone());
}
}
if let Some(o) = self.opacity.filter(|o| o.is_finite()) {
n.opacity = o.clamp(0.0, 1.0);
}
if let Some(b) = self.blend {
n.blend = b;
}
}
}
/// An override's path read from a file: at most [`MAX_PATH_DEPTH`] indices, so a compressed file
/// of one endless path stays small once read.
fn short_path<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<u32>, D::Error> {
struct Path;
impl<'de> serde::de::Visitor<'de> for Path {
type Value = Vec<u32>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a list of child indices")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<u32>, A::Error> {
let mut out = Vec::new();
while let Some(i) = seq.next_element::<u32>()? {
if out.len() == MAX_PATH_DEPTH {
return Err(serde::de::Error::custom(format!("a symbol override path is deeper than {MAX_PATH_DEPTH}")));
}
out.push(i);
}
Ok(out)
}
}
d.deserialize_seq(Path)
}
/// The children a path steps through: a group's (clip groups included) or a layer's.
fn groups_children(n: &Node) -> Option<&Vec<Arc<Node>>> {
matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }).then(|| n.children()).flatten()
}
/// The object `path` names in symbol art `art`, if there is one.
pub fn part<'a>(art: &'a Node, path: &[u32]) -> Option<&'a Node> {
if path.len() > MAX_PATH_DEPTH {
return None;
}
path.iter().try_fold(art, |n, &i| groups_children(n)?.get(i as usize).map(|c| &**c))
}
/// [`part`], to change (the nodes on the way are copied when shared).
fn part_mut<'a>(art: &'a mut Node, path: &[u32]) -> Option<&'a mut Node> {
let Some((&i, rest)) = path.split_first() else { return Some(art) };
if !matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) {
return None;
}
let c = art.children_mut()?.get_mut(i as usize)?;
part_mut(Arc::make_mut(c), rest)
}
/// Put `overrides` on symbol art `art`; an override naming no object of it is left out.
pub fn apply(art: &mut Node, overrides: &[SymbolOverride]) {
for o in overrides.iter().filter(|o| o.path.len() <= MAX_PATH_DEPTH) {
if let Some(n) = part_mut(art, &o.path) {
o.apply(n);
}
}
}
/// Symbol art `art` as instance `inst` shows it: a copy with the instance's overrides applied.
pub fn dynamic_art(art: &Node, inst: &Node) -> Node {
let mut a = art.clone();
apply(&mut a, &inst.extras().symbol_overrides);
a
}
/// Keep the overrides that name an object of `art` (a symbol's new art), dropping the rest.
pub fn retain_parts(overrides: &mut Vec<SymbolOverride>, art: &Node) {
overrides.retain(|o| part(art, &o.path).is_some());
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Appearance, NodeId};
use vectorcraft_color::Color;
use vectorcraft_geom::{Rect, shapes};
fn square(id: u64) -> Arc<Node> {
Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()))
}
fn art() -> Node {
let inner = Node::group(NodeId(3), vec![square(4), square(5)]);
Node::group(NodeId(1), vec![square(2), Arc::new(inner)])
}
#[test]
fn paths_name_objects_through_groups_only() {
let a = art();
assert_eq!(part(&a, &[]).map(|n| n.id), Some(NodeId(1)));
assert_eq!(part(&a, &[1, 0]).map(|n| n.id), Some(NodeId(4)));
assert!(part(&a, &[2]).is_none());
assert!(part(&a, &[0, 0]).is_none(), "a path has no children");
assert!(part(&a, &[0; MAX_PATH_DEPTH + 1]).is_none());
}
#[test]
fn overrides_paint_their_object_and_skip_missing_ones() {
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
let o = vec![
SymbolOverride { path: vec![1, 1], fill: Some(red.clone()), opacity: Some(0.5), ..Default::default() },
SymbolOverride { path: vec![9], fill: Some(red.clone()), ..Default::default() },
];
let mut a = art();
apply(&mut a, &o);
let n = part(&a, &[1, 1]).unwrap();
assert_eq!(n.appearance.fill_paint(), red);
assert_eq!(n.opacity, 0.5);
assert_eq!(part(&a, &[1, 0]).unwrap().appearance.fill_paint(), Paint::solid(Color::WHITE), "its sibling keeps the symbol's look");
let mut kept = o;
retain_parts(&mut kept, &a);
assert_eq!(kept.len(), 1);
}
#[test]
fn a_path_deeper_than_the_cap_does_not_read() {
let deep = format!("{{\"path\":{:?}}}", vec![0; MAX_PATH_DEPTH + 1]);
assert!(serde_json::from_str::<SymbolOverride>(&deep).is_err());
let ok: SymbolOverride = serde_json::from_str("{\"path\":[1,0],\"opacity\":0.25}").unwrap();
assert_eq!(ok, SymbolOverride { path: vec![1, 0], opacity: Some(0.25), ..Default::default() });
}
}
+1 -1
View File
@@ -410,7 +410,7 @@ mod tests {
/// Symbol `name` holding `art` in a group.
fn symbol(d: &mut Document, name: &str, art: Vec<Node>) {
let group = Node::group(d.alloc_id(), art.into_iter().map(Arc::new).collect());
d.symbols.push(Symbol { name: name.into(), art: Arc::new(group) });
d.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(group) });
}
#[test]
+36 -1
View File
@@ -55,6 +55,29 @@ fn doc_with_rects() -> (Document, NodeId, NodeId) {
(d, a, b)
}
/// Output draws the background colors of the artboards asked for, and only theirs, as rectangles
/// of those artboards on a layer below the art; without a color there is nothing to draw.
#[test]
fn artboard_fills_go_below_the_art() {
let (mut d, a, _) = doc_with_rects();
assert!(d.with_artboard_fills([0]).is_none(), "no color, no fill");
let second = Rect::new(700.0, 0.0, 800.0, 100.0);
d.artboards.push(Artboard { id: 2, rect: second, ..d.artboards[0].clone() });
let blue = vectorcraft_color::Color::cmyk(1.0, 0.5, 0.0, 0.0);
d.artboards[1].color = Some(blue);
assert!(d.with_artboard_fills([0]).is_none(), "only the artboards asked for");
let out = d.with_artboard_fills([0, 1]).unwrap();
assert_eq!(out.layers.len(), d.layers.len() + 1);
assert_eq!(out.layers[1..], d.layers[..], "the art stays as it was, above the fills");
let fills = out.layers[0].children().unwrap();
assert_eq!(out.layers[0].display_name(), "Artboard Fill");
assert_eq!(fills.len(), 1);
assert_eq!(fills[0].geometric_bounds(), Some(second));
assert_eq!(fills[0].appearance.fill_paint(), vectorcraft_color::Paint::solid(blue), "in the color's own model");
assert!(out.node(a).is_some() && out.node(fills[0].id).is_some() && fills[0].id != a);
assert_eq!(d.artboards, out.artboards);
}
#[test]
fn new_document_has_layer_and_artboard() {
let d = Document::new(612.0, 792.0);
@@ -202,7 +225,7 @@ fn art_on_a_locked_artboard_cannot_be_selected_or_edited() {
fn art_on_artboard_measures_a_symbol_instance_by_its_art() {
let mut d = Document::new(612.0, 792.0);
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
d.symbols.push(Symbol { name: "Box".into(), art: Arc::new(art) });
d.symbols.push(Symbol { dynamic: false, name: "Box".into(), art: Arc::new(art) });
let (layer, id) = (d.layers[0].id, d.alloc_id());
d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Box".into(), xf: Affine::translate((2.0, 2.0)) })).unwrap();
// The art, (2, 2) to (12, 12), lies inside; the 20 × 20 square, (-8, -8) to (12, 12), doesn't.
@@ -388,6 +411,18 @@ fn char_position_round_trips() {
assert!((scale - 0.583).abs() < 1e-9 && (shift + 3.33).abs() < 1e-9);
}
#[test]
fn optical_kerning_round_trips_and_old_style_defaults_to_auto() {
let st = CharStyle { optical_kerning: true, kerning: Some(120.0), ..CharStyle::default() };
let value = serde_json::to_value(&st).unwrap();
assert_eq!(value["opticalKerning"], true);
assert_eq!(serde_json::from_value::<CharStyle>(value.clone()).unwrap(), st);
let mut old = value;
old.as_object_mut().unwrap().remove("opticalKerning");
assert!(!serde_json::from_value::<CharStyle>(old).unwrap().optical_kerning);
assert!(serde_json::to_value(CharStyle::default()).unwrap().get("opticalKerning").is_none());
}
#[test]
fn units() {
assert_eq!(Unit::Inches.parse("1"), Some(72.0));
+7
View File
@@ -144,3 +144,10 @@ fn mixed_mul_div() {
assert_eq!(Unit::Points.parse("10*2/5"), Some(4.0));
assert_eq!(Unit::Points.parse("10*2/5 px"), Some(Unit::Pixels.to_pt(4.0)));
}
#[test]
fn readout_has_no_negative_zero() {
assert_eq!(Unit::Points.readout(-0.001), "0.00 pt");
assert_eq!(Unit::Points.readout(-0.0), "0.00 pt");
assert_eq!(Unit::Points.readout(-12.5), "-12.50 pt");
}
+1 -1
View File
@@ -13,7 +13,7 @@ mod para_style;
mod runs;
mod tabs_lists;
pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextWrap, VerticalAlign, WrapShape};
pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape};
use char_style::yes;
pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, ScriptMetrics};
pub use para_style::{
+17
View File
@@ -29,6 +29,19 @@ impl VerticalAlign {
}
}
/// Area Type Options › Text Flow: the order text fills the cells of area type with several rows
/// and columns.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TextFlow {
/// Down each column's rows, then on to the next column (the default, and what a document
/// from before the option reads as).
#[default]
Columns,
/// Across each row's columns, then on to the next row.
Rows,
}
/// Text Wrap Options of a wrap object (Object → Text Wrap).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
@@ -135,6 +148,9 @@ pub struct AreaOptions {
pub first_baseline_min: f64,
/// Vertical alignment of the lines in each row/column.
pub vertical_align: VerticalAlign,
/// Text Flow: by columns (the default, left out when saved) or by rows.
#[serde(skip_serializing_if = "crate::skip::is_default")]
pub flow: TextFlow,
/// How the frame and its text fit each other (Auto Size, Shrink Text to Fit).
#[serde(skip_serializing_if = "crate::skip::is_default")]
pub fit: AreaFit,
@@ -150,6 +166,7 @@ impl Default for AreaOptions {
first_baseline: FirstBaseline::Ascent,
first_baseline_min: 0.0,
vertical_align: VerticalAlign::Top,
flow: TextFlow::Columns,
fit: AreaFit::None,
}
}
+83 -3
View File
@@ -25,9 +25,12 @@ pub struct CharStyle {
/// Tracking in 1/1000 em.
#[serde(default)]
pub tracking: f64,
/// Kerning: None = Auto (metrics), Some(v) = manual in 1/1000 em.
/// Manual pair spacing in 1/1000 em; None uses the automatic mode.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kerning: Option<f64>,
/// Use glyph shapes for automatic pair spacing; false uses font metrics.
#[serde(default, rename = "opticalKerning", skip_serializing_if = "crate::skip::is_default")]
pub optical_kerning: bool,
#[serde(default)]
pub baseline_shift: f64,
#[serde(default = "hundred")]
@@ -111,11 +114,18 @@ pub struct CharStyle {
/// <https://helpx.adobe.com/illustrator/using/hyphenation-line-breaks.html>
#[serde(default, rename = "noBreak", skip_serializing_if = "crate::skip::is_default")]
pub no_break: bool,
/// Language (Character panel): the BCP 47 tag of one of [`crate::setup::LANGUAGES`] these
/// characters are shaped in, so that the font uses that language's forms (OpenType `locl`).
/// `None`: the default language, English: USA, shaped without a language. New type takes the
/// document's language (Document Setup). Saved only when set.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
}
/// The numeral system digits are drawn in ([`CharStyle::digits`]): as typed, or every digit of
/// the Western, Arabic-Indic and Eastern Arabic-Indic sets drawn in one of them. The text keeps
/// the digits that were typed; only the glyphs change (like All Caps).
/// the Western, Arabic-Indic and Eastern Arabic-Indic sets drawn in one of them or in the digits
/// of an Indic script. The text keeps the digits that were typed; only the glyphs change (like
/// All Caps).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Digits {
@@ -128,9 +138,68 @@ pub enum Digits {
Hindi,
/// Eastern Arabic-Indic digits ۰–۹ (U+06F0–U+06F9), used in Persian and Urdu.
Farsi,
/// Devanagari digits ०–९ (U+0966–U+096F), used in Hindi, Marathi, Nepali and Sanskrit.
Devanagari,
/// Bengali digits ০–৯ (U+09E6–U+09EF), used in Bengali and Assamese.
Bengali,
/// Gurmukhi digits ੦–੯ (U+0A66–U+0A6F), used in Punjabi.
Gurmukhi,
/// Gujarati digits ૦–૯ (U+0AE6–U+0AEF).
Gujarati,
/// Odia digits ୦–୯ (U+0B66–U+0B6F).
Odia,
/// Tamil digits ௦–௯ (U+0BE6–U+0BEF).
Tamil,
/// Telugu digits ౦–౯ (U+0C66–U+0C6F).
Telugu,
/// Kannada digits ೦–೯ (U+0CE6–U+0CEF).
Kannada,
/// Malayalam digits ൦–൯ (U+0D66–U+0D6F).
Malayalam,
}
impl Digits {
/// Every numeral system, in menu order.
pub const ALL: [Digits; 13] = [
Digits::Default,
Digits::Arabic,
Digits::Hindi,
Digits::Farsi,
Digits::Devanagari,
Digits::Bengali,
Digits::Gurmukhi,
Digits::Gujarati,
Digits::Odia,
Digits::Tamil,
Digits::Telugu,
Digits::Kannada,
Digits::Malayalam,
];
/// The name files and commands give this numeral system (as it is saved).
pub fn id(self) -> &'static str {
match self {
Digits::Default => "default",
Digits::Arabic => "arabic",
Digits::Hindi => "hindi",
Digits::Farsi => "farsi",
Digits::Devanagari => "devanagari",
Digits::Bengali => "bengali",
Digits::Gurmukhi => "gurmukhi",
Digits::Gujarati => "gujarati",
Digits::Odia => "odia",
Digits::Tamil => "tamil",
Digits::Telugu => "telugu",
Digits::Kannada => "kannada",
Digits::Malayalam => "malayalam",
}
}
/// The numeral system [`Digits::id`] names; `None` for any other name.
pub fn from_id(id: &str) -> Option<Digits> {
Digits::ALL.into_iter().find(|d| d.id() == id)
}
/// The digit `c` is drawn as in this numeral system: the same digit in the chosen set, or `c`
/// itself when `c` isn't a digit of the Western, Arabic-Indic or Eastern Arabic-Indic set.
pub fn map(self, c: char) -> char {
@@ -145,6 +214,15 @@ impl Digits {
Digits::Arabic => '0' as u32,
Digits::Hindi => 0x660,
Digits::Farsi => 0x6F0,
Digits::Devanagari => 0x966,
Digits::Bengali => 0x9E6,
Digits::Gurmukhi => 0xA66,
Digits::Gujarati => 0xAE6,
Digits::Odia => 0xB66,
Digits::Tamil => 0xBE6,
Digits::Telugu => 0xC66,
Digits::Kannada => 0xCE6,
Digits::Malayalam => 0xD66,
};
char::from_u32(base + value).unwrap_or(c)
}
@@ -266,6 +344,7 @@ impl Default for CharStyle {
leading: None,
tracking: 0.0,
kerning: None,
optical_kerning: false,
baseline_shift: 0.0,
h_scale: 100.0,
v_scale: 100.0,
@@ -293,6 +372,7 @@ impl Default for CharStyle {
small_caps: None,
digits: Digits::Default,
no_break: false,
language: None,
}
}
}
+1
View File
@@ -223,6 +223,7 @@ impl ParaList {
position: CharPosition::Normal,
tracking: 0.0,
kerning: None,
optical_kerning: first.optical_kerning,
tate_chu_yoko: false,
underline: false,
strikethrough: false,
+58
View File
@@ -60,6 +60,51 @@ fn digits_map_between_the_numeral_systems() {
assert_eq!(serde_json::from_value::<CharStyle>(serde_json::to_value(&farsi).unwrap()).unwrap(), farsi);
}
/// Each Indic numeral system draws 0–9 (and the Arabic-Indic sets) as the ten digits of its
/// script's block; every system saves under its id, which reads back, and other names are refused.
#[test]
fn indic_digits_map_to_their_scripts_block() {
let map = |d: Digits, s: &str| -> String { s.chars().map(|c| d.map(c)).collect() };
for (d, zero) in [
(Digits::Devanagari, 0x966),
(Digits::Bengali, 0x9E6),
(Digits::Gurmukhi, 0xA66),
(Digits::Gujarati, 0xAE6),
(Digits::Odia, 0xB66),
(Digits::Tamil, 0xBE6),
(Digits::Telugu, 0xC66),
(Digits::Kannada, 0xCE6),
(Digits::Malayalam, 0xD66),
] {
let block: String = (zero..zero + 10).map(|u| char::from_u32(u).unwrap()).collect();
assert!(block.chars().all(char::is_numeric), "{d:?}: {block}");
for s in ["0123456789", "٠١٢٣٤٥٦٧٨٩", "۰۱۲۳۴۵۶۷۸۹"] {
assert_eq!(map(d, s), block, "{d:?}");
}
assert_eq!(map(d, "a-Z ٪"), "a-Z ٪");
}
assert_eq!(map(Digits::Devanagari, "page 12"), "page १२");
for d in Digits::ALL {
assert_eq!(serde_json::to_value(d).unwrap(), d.id());
assert_eq!(serde_json::from_value::<Digits>(d.id().into()).unwrap(), d);
assert_eq!(Digits::from_id(d.id()), Some(d));
}
assert_eq!(Digits::from_id("roman"), None);
assert!(serde_json::from_value::<Digits>("roman".into()).is_err());
}
/// Language is saved only when set; styles written before it existed read as the default.
#[test]
fn language_is_saved_only_when_set() {
let st = CharStyle::default();
let old = serde_json::to_value(&st).unwrap();
assert!(old.get("language").is_none());
assert_eq!(serde_json::from_value::<CharStyle>(old).unwrap().language, None);
let turkish = CharStyle { language: Some("tr".into()), ..CharStyle::default() };
assert_eq!(serde_json::to_value(&turkish).unwrap()["language"], "tr");
assert_eq!(serde_json::from_value::<CharStyle>(serde_json::to_value(&turkish).unwrap()).unwrap(), turkish);
}
/// Auto leading follows the paragraph's Auto Leading; a set leading doesn't.
#[test]
fn paragraph_effective_leading_follows_auto_leading() {
@@ -248,6 +293,19 @@ fn paragraph_styles_split_merge_and_normalize() {
assert_eq!(back.para_at(3).space_before, 4.0);
}
/// Text Flow is saved only when by rows: area type by columns (the default) saves as it did before
/// the option, and files from before it read by columns; other values don't read.
#[test]
fn text_flow_is_saved_only_when_by_rows() {
let by_columns = serde_json::to_value(AreaOptions::default()).unwrap();
assert!(by_columns.get("flow").is_none());
assert_eq!(serde_json::from_value::<AreaOptions>(by_columns).unwrap().flow, TextFlow::Columns);
let by_rows = serde_json::to_value(AreaOptions { flow: TextFlow::Rows, ..AreaOptions::default() }).unwrap();
assert_eq!(by_rows["flow"], "rows");
assert_eq!(serde_json::from_value::<AreaOptions>(by_rows).unwrap().flow, TextFlow::Rows);
assert!(serde_json::from_value::<AreaOptions>(serde_json::json!({"flow": "diagonal"})).is_err());
}
/// 120 × 40 area type at (40, 40), its text drawn at twice its size.
fn area_type() -> TextObject {
let mut t = TextObject::point(Point::ZERO, "Some text", CharStyle::default());
+4 -1
View File
@@ -161,7 +161,10 @@ impl Unit {
/// A point value as the canvas measurement labels show it: two decimals and the suffix
/// (`12.50 mm`).
pub fn readout(self, pt: f64) -> String {
format!("{:.2} {}", self.from_pt(pt), self.suffix())
let s = format!("{:.2}", self.from_pt(pt));
// A small negative value rounds to `-0.00`; show it as `0.00`.
let s = if s == "-0.00" { "0.00" } else { &s };
format!("{} {}", s, self.suffix())
}
}