mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 09:59:47 -06:00
Merge main (433ddb59) into feat/native-save-precision
This commit is contained in:
@@ -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() })
|
||||
|
||||
@@ -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
@@ -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()));
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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(|_| {
|
||||
|
||||
@@ -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>()
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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() });
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user