Merge main into feat/native-save-precision

This commit is contained in:
dotCooCoo
2026-10-27 20:34:51 -07:00
1138 changed files with 176049 additions and 145413 deletions
File diff suppressed because it is too large Load Diff
+240
View File
@@ -0,0 +1,240 @@
use super::profile::{PressureProfile, WidthProfile};
use super::stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign};
use super::*;
/// A live effect in an appearance stack. Parameters are interpreted by `vectorcraft-effects`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Effect {
/// Stable id, e.g. `stylize.dropShadow`, `distort.roughen`, `path.offsetPath`, `warp.arc`.
pub id: String,
#[serde(default)]
pub params: serde_json::Value,
#[serde(default = "yes")]
pub visible: bool,
}
fn yes() -> bool {
true
}
fn one() -> f32 {
1.0
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FillLayer {
pub paint: Paint,
#[serde(default = "one", skip_serializing_if = "crate::skip::is_one")]
pub opacity: f32,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub blend: BlendMode,
#[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")]
pub visible: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub effects: Vec<Effect>,
/// Overprint Fill: on press the fill's inks print over the inks below instead of knocking
/// them out (Overprint Preview and Separations Preview show it).
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub overprint: bool,
}
impl FillLayer {
pub fn new(paint: Paint) -> Self {
Self { paint, opacity: 1.0, blend: BlendMode::Normal, visible: true, effects: vec![], overprint: false }
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct StrokeLayer {
pub paint: Paint,
/// Weight in points.
pub width: f64,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub cap: LineCap,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub join: LineJoin,
#[serde(default = "ten", skip_serializing_if = "is_ten")]
pub miter_limit: f64,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub align: StrokeAlign,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dash: Option<Dash>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_arrow: Option<Arrowhead>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end_arrow: Option<Arrowhead>,
/// Arrowhead scale in percent (start, end).
#[serde(default = "hundreds", skip_serializing_if = "is_hundreds")]
pub arrow_scale: (f64, f64),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<WidthProfile>,
/// Brush applied to the stroke (by brush name).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub brush: Option<String>,
#[serde(default = "one", skip_serializing_if = "crate::skip::is_one")]
pub opacity: f32,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub blend: BlendMode,
#[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")]
pub visible: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub effects: Vec<Effect>,
/// Arrowhead placement at both ends.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub arrow_align: ArrowAlign,
/// Overprint Stroke (see [`FillLayer::overprint`]).
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub overprint: bool,
/// How a gradient paint maps onto the stroke (within, along or across it).
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub gradient_mode: StrokeGradientMode,
/// The pen pressure the stroke was drawn with (Paintbrush with a tablet): pressure-sensitive
/// brushes vary with it. It belongs to this path's shape, so appearances copied to other
/// art leave it behind ([`Appearance::without_pressure`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pressure: Option<PressureProfile>,
}
/// How a gradient on a stroke is laid out (the Gradient panel's Stroke buttons). Saved by variant
/// name: new modes are appended, never renamed.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum StrokeGradientMode {
/// Placed on the page like a fill's gradient and seen through the stroke.
#[default]
Within,
/// From the start of each subpath to its end, following the path.
Along,
/// From the stroke's left edge to its right edge (left of the path's direction), all along it.
Across,
}
impl StrokeGradientMode {
pub const ALL: [StrokeGradientMode; 3] = [StrokeGradientMode::Within, StrokeGradientMode::Along, StrokeGradientMode::Across];
/// The name commands use (`within`, `along`, `across`).
pub fn name(self) -> &'static str {
match self {
StrokeGradientMode::Within => "within",
StrokeGradientMode::Along => "along",
StrokeGradientMode::Across => "across",
}
}
/// Parse a mode name (any case).
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|m| m.name().eq_ignore_ascii_case(s))
}
}
/// How far any arrowhead reaches from its tip, in units of its weight (the open arrow's arms are
/// cut square a little behind its 4 × 4 box).
pub(crate) const HEAD_REACH: f64 = 4.6;
fn ten() -> f64 {
10.0
}
fn hundreds() -> (f64, f64) {
(100.0, 100.0)
}
fn is_ten(v: &f64) -> bool {
*v == 10.0
}
fn is_hundreds(v: &(f64, f64)) -> bool {
*v == (100.0, 100.0)
}
impl StrokeLayer {
pub fn new(paint: Paint, width: f64) -> Self {
Self {
paint,
width,
cap: LineCap::Butt,
join: LineJoin::Miter,
miter_limit: 10.0,
align: StrokeAlign::Center,
dash: None,
start_arrow: None,
end_arrow: None,
arrow_scale: (100.0, 100.0),
profile: None,
brush: None,
opacity: 1.0,
blend: BlendMode::Normal,
visible: true,
effects: vec![],
arrow_align: ArrowAlign::Extend,
overprint: false,
gradient_mode: StrokeGradientMode::Within,
pressure: None,
}
}
/// Weight of the start (`end == false`) or end arrowhead: stroke weight × its scale, at least
/// a quarter point. A head of weight `hw` fits a box about `4·hw` long and wide.
pub fn arrow_weight(&self, end: bool) -> f64 {
let pct = if end { self.arrow_scale.1 } else { self.arrow_scale.0 };
(self.width * pct / 100.0).max(0.25)
}
/// How far the start (`end == false`) or end arrowhead can reach from its end point (0
/// without one): the head's diagonal, or with [`ArrowAlign::Extend`] its length, plus the cap.
pub fn head_reach(&self, end: bool) -> f64 {
let head = if end { self.end_arrow } else { self.start_arrow };
head.map_or(0.0, |_| HEAD_REACH * self.arrow_weight(end) + self.width / 2.0)
}
/// How far the arrowheads can reach from the path's end points (0 without heads).
pub fn arrow_reach(&self) -> f64 {
self.head_reach(false).max(self.head_reach(true))
}
/// The widest the width profile makes the stroke, as a factor of its weight (1 without one).
pub fn profile_max(&self) -> f64 {
self.profile.as_ref().and_then(|p| p.points.iter().map(|&(_, l, r)| l.max(r)).reduce(f64::max)).unwrap_or(1.0).max(0.0)
}
/// The stroke's body across a path that is `closed` or open: how far it reaches from the path
/// at full width (half the weight centred; the whole weight on one side when aligned inside or
/// outside a closed path, which open paths ignore), and for aligned strokes which side it
/// paints (`Some(true)`: inside).
pub fn body(&self, closed: bool) -> (f64, Option<bool>) {
match self.align {
StrokeAlign::Outside if closed => (self.width, Some(false)),
StrokeAlign::Inside if closed => (self.width, Some(true)),
_ => (self.width / 2.0, None),
}
}
/// How far the body paints outside a path that is `closed` or open, at the profile's widest.
pub fn side_reach(&self, closed: bool) -> f64 {
match self.body(closed) {
(_, Some(true)) => 0.0,
(half, _) => half * self.profile_max(),
}
}
/// The farthest this stroke can paint from any path: its body, miter spikes up to the miter
/// limit, projecting caps' corners and the arrowheads ([`Appearance::outset`]).
pub fn reach(&self) -> f64 {
let side = self.side_reach(true).max(self.side_reach(false));
let miter = if self.join == LineJoin::Miter { self.miter_limit.max(1.0) } else { 1.0 };
let cap = if self.cap == LineCap::Square { std::f64::consts::SQRT_2 } else { 1.0 };
(side * miter.max(cap)).max(self.arrow_reach())
}
/// The box an unplaced gradient on this stroke fits to (see [`stroke_paint_bounds`]).
pub fn paint_bounds(&self, geometric: vectorcraft_geom::Rect) -> vectorcraft_geom::Rect {
stroke_paint_bounds(geometric, self.width)
}
/// The gradient this stroke lays along or across its path: its linear or radial gradient
/// paint when [`Self::gradient_mode`] isn't Within (freeform gradients always paint within).
pub fn path_gradient(&self) -> Option<&vectorcraft_color::GradientPaint> {
match &self.paint {
Paint::Gradient(g)
if self.gradient_mode != StrokeGradientMode::Within && g.gradient.kind != vectorcraft_color::GradientKind::Freeform =>
{
Some(g)
}
_ => None,
}
}
}
/// The box an unplaced gradient on a stroke of `width` fits to: the geometric bounds grown by half
/// the weight (object strokes and type strokes alike, on screen and in exports).
pub fn stroke_paint_bounds(geometric: vectorcraft_geom::Rect, width: f64) -> vectorcraft_geom::Rect {
geometric.inflate(width / 2.0, width / 2.0)
}
+222
View File
@@ -0,0 +1,222 @@
use super::*;
/// Variable-width profile: (position 0..1 along the path, left width factor, right width factor),
/// in order along the path. Two points at the same position make a discontinuous point: the
/// first is the width just before it, the second the width just after it (a step).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct WidthProfile {
pub points: Vec<(f64, f64, f64)>,
}
/// A built-in width profile (the Stroke panel's Profile list).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ProfilePreset {
/// Stable id used by `stroke.set {profile}`.
pub id: &'static str,
/// Menu label.
pub label: &'static str,
/// (t, left, right) width points.
pub points: &'static [(f64, f64, f64)],
}
impl WidthProfile {
/// (left, right) width factors at `t`, linear between points.
pub fn at(&self, t: f64) -> (f64, f64) {
Self::at_points(&self.points, t)
}
/// [`Self::at`] of a profile's points (a preset's or a saved profile's).
pub fn at_points(p: &[(f64, f64, f64)], t: f64) -> (f64, f64) {
if p.is_empty() {
return (1.0, 1.0);
}
if t <= p[0].0 {
return (p[0].1, p[0].2);
}
for w in p.windows(2) {
if t <= w[1].0 {
let u = (t - w[0].0) / (w[1].0 - w[0].0).max(1e-9);
return (w[0].1 + (w[1].1 - w[0].1) * u, w[0].2 + (w[1].2 - w[0].2) * u);
}
}
p.last().map_or((1.0, 1.0), |l| (l.1, l.2))
}
/// The built-in profiles, in menu order. "uniform" is the plain stroke (no profile).
pub const PRESETS: [ProfilePreset; 7] = [
ProfilePreset { id: "uniform", label: "Uniform", points: &[(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] },
ProfilePreset { id: "lens", label: "Lens", points: &[(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)] },
ProfilePreset { id: "taperStart", label: "Taper Start", points: &[(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)] },
ProfilePreset { id: "taperEnd", label: "Taper End", points: &[(0.0, 1.0, 1.0), (1.0, 0.0, 0.0)] },
// Full width at both ends, pinched to a quarter in the middle.
ProfilePreset { id: "pinch", label: "Pinch", points: &[(0.0, 1.0, 1.0), (0.5, 0.25, 0.25), (1.0, 1.0, 1.0)] },
// A round head a fifth of the way along, then a long taper to the end.
ProfilePreset { id: "teardrop", label: "Teardrop", points: &[(0.0, 0.0, 0.0), (0.2, 1.0, 1.0), (1.0, 0.0, 0.0)] },
// 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 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() })
}
/// The id of the built-in profile these points match, if any.
pub fn preset_id(&self) -> Option<&'static str> {
Self::PRESETS.iter().find(|p| p.points == self.points.as_slice()).map(|p| p.id)
}
/// The id of a stroke's profile: "uniform" without one, "custom" when it matches no preset.
pub fn id_of(p: Option<&Self>) -> &'static str {
p.map_or(Some("uniform"), Self::preset_id).unwrap_or("custom")
}
/// The lens profile (thin ends, full width in the middle).
pub fn lens() -> Self {
Self::preset("lens").unwrap_or_default()
}
pub fn taper_end() -> Self {
Self::preset("taperEnd").unwrap_or_default()
}
pub fn taper_start() -> Self {
Self::preset("taperStart").unwrap_or_default()
}
/// The (left, right) factors just before and just after `t`: they differ only at a
/// discontinuous point (two points at `t`, within `1e-7`).
pub fn around(&self, t: f64) -> ((f64, f64), (f64, f64)) {
let p = &self.points;
if let Some(i) = p.iter().position(|q| (q.0 - t).abs() <= 1e-7) {
let j = i + p[i..].iter().take_while(|q| (q.0 - p[i].0).abs() <= 1e-9).count() - 1;
if j > i {
return ((p[i].1, p[i].2), (p[j].1, p[j].2));
}
}
let v = self.at(t);
(v, v)
}
}
/// Pen pressure recorded along a stroke (the Paintbrush with a graphics tablet): (position 0..1
/// along the path, pressure 0..1) samples in order, positions measured as a [`WidthProfile`]'s
/// are. Calligraphic brushes with Pressure variation read it; everything else ignores it.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct PressureProfile {
pub points: Vec<(f64, f64)>,
}
impl PressureProfile {
/// The pressure of a stroke drawn without one (a mouse, or a path the brush was applied to):
/// half way, so a Pressure brush draws its own value there.
pub const MID: f64 = 0.5;
/// The most samples a stroke keeps (after simplifying them).
pub const MAX_POINTS: usize = 512;
/// How far (in pressure) simplifying may move the recorded curve.
const TOLERANCE: f64 = 0.01;
/// The pressure at `t` (0..1), linear between samples; [`Self::MID`] without any.
pub fn at(&self, t: f64) -> f64 {
let p = &self.points;
let (Some(first), Some(last)) = (p.first(), p.last()) else { return Self::MID };
// (Positions go forward; a profile read from a file is searched as if they do, in log time.)
let v = if t <= first.0 {
first.1
} else {
let i = p.partition_point(|q| q.0 < t);
match (i.checked_sub(1).and_then(|j| p.get(j)), p.get(i)) {
(Some(a), Some(b)) => a.1 + (b.1 - a.1) * ((t - a.0) / (b.0 - a.0).max(1e-9)).clamp(0.0, 1.0),
_ => last.1,
}
};
if v.is_finite() { v.clamp(0.0, 1.0) } else { Self::MID }
}
/// A profile from (position, pressure) samples in order along the stroke: values are clamped
/// into 0..1 (non-finite ones dropped), the curve is simplified to within 1 % and kept to
/// [`Self::MAX_POINTS`]. `None` without a sample.
pub fn from_samples(samples: impl IntoIterator<Item = (f64, f64)>) -> Option<Self> {
let pts: Vec<(f64, f64)> =
samples.into_iter().filter(|(t, p)| t.is_finite() && p.is_finite()).map(|(t, p)| (t.clamp(0.0, 1.0), p.clamp(0.0, 1.0))).collect();
if pts.is_empty() {
return None;
}
// Simplifying is quadratic at worst: a huge stroke is thinned evenly first.
let mut pts = thin(pts, 16 * Self::MAX_POINTS);
// Positions only ever go forward.
let mut prev = 0.0;
for p in &mut pts {
p.0 = p.0.max(prev);
prev = p.0;
}
let keep = simplify_1d(&pts, Self::TOLERANCE);
let pts = thin(pts.iter().zip(keep).filter(|(_, k)| *k).map(|(p, _)| *p).collect(), Self::MAX_POINTS);
let round = |v: f64| (v * 1e4).round() / 1e4;
Some(Self { points: pts.into_iter().map(|(t, p)| (round(t), round(p))).collect() })
}
/// The pressure of a path `old_len` long (`old`; None: drawn without pressure) continued at its
/// end by a stroke `added_len` long (`added`). None when neither has pressure.
pub fn extended(old: Option<&Self>, old_len: f64, added: Option<&Self>, added_len: f64) -> Option<Self> {
if old.is_none() && added.is_none() {
return None;
}
let (old_len, added_len) = (old_len.max(0.0), added_len.max(0.0));
let total = old_len + added_len;
if !total.is_finite() || total <= 1e-9 {
return old.or(added).cloned();
}
let flat = Self { points: vec![(0.0, Self::MID), (1.0, Self::MID)] };
let (old, added) = (old.unwrap_or(&flat), added.unwrap_or(&flat));
let o = old_len / total;
let old_pts = old.points.iter().map(|&(t, p)| (t * o, p));
Self::from_samples(old_pts.chain(added.points.iter().map(|&(u, p)| (o + u * (1.0 - o), p))))
}
/// The pressure of the path reversed.
pub fn reversed(&self) -> Self {
Self { points: self.points.iter().rev().map(|&(t, p)| (1.0 - t, p)).collect() }
}
/// A light–heavy–light stroke, for previews of pressure-sensitive brushes.
pub fn sample() -> Self {
Self { points: vec![(0.0, 0.0), (0.5, 1.0), (1.0, 0.0)] }
}
}
/// At most `max` of `pts`, evenly spread (the ends kept).
fn thin(pts: Vec<(f64, f64)>, max: usize) -> Vec<(f64, f64)> {
let n = pts.len();
if n <= max || max < 2 {
return pts;
}
(0..max).filter_map(|i| pts.get(i * (n - 1) / (max - 1)).copied()).collect()
}
/// Douglas–Peucker over a function sampled at `pts`: which samples to keep so the polyline through
/// them stays within `tol` (vertically) of every sample. The ends are always kept.
fn simplify_1d(pts: &[(f64, f64)], tol: f64) -> Vec<bool> {
let mut keep = vec![false; pts.len()];
let Some(last) = pts.len().checked_sub(1) else { return keep };
for i in [0, last] {
if let Some(k) = keep.get_mut(i) {
*k = true;
}
}
let mut stack = vec![(0, last)];
while let Some((i, j)) = stack.pop() {
let (Some(&a), Some(&b)) = (pts.get(i), pts.get(j)) else { continue };
let line = |t: f64| if b.0 - a.0 > 1e-12 { a.1 + (b.1 - a.1) * (t - a.0) / (b.0 - a.0) } else { (a.1 + b.1) / 2.0 };
let far = (i + 1..j).filter_map(|k| pts.get(k).map(|q| (k, (q.1 - line(q.0)).abs()))).max_by(|x, y| x.1.total_cmp(&y.1));
if let Some((k, d)) = far
&& d > tol
{
if let Some(f) = keep.get_mut(k) {
*f = true;
}
stack.push((i, k));
stack.push((k, j));
}
}
keep
}
/// A width profile saved to the Profile list under a name (Add to Profiles), kept with the
/// preferences.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SavedProfile {
pub name: String,
pub profile: WidthProfile,
}
+200
View File
@@ -0,0 +1,200 @@
use super::*;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum LineCap {
#[default]
Butt,
Round,
Square,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum LineJoin {
#[default]
Miter,
Round,
Bevel,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum StrokeAlign {
#[default]
Center,
Inside,
Outside,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Dash {
/// Dash, gap, dash, gap… (up to 6 values in the Stroke panel).
pub pattern: Vec<f64>,
#[serde(default)]
pub offset: f64,
/// "Aligns dashes to corners and path ends, adjusting lengths to fit".
#[serde(default)]
pub align_corners: bool,
}
impl Dash {
/// Whether the pattern draws dashes. As in PDF (`d` operator) and SVG (`stroke-dasharray`), a
/// pattern with a negative or non-finite value, or whose values are all zero, is invalid and the
/// stroke is drawn solid.
pub fn is_dashed(&self) -> bool {
self.pattern.iter().all(|v| v.is_finite() && *v >= 0.0) && self.pattern.iter().any(|v| *v > 0.0)
}
/// Scale the dash and gap lengths and the offset by `s`.
pub fn scale(&mut self, s: f64) {
self.pattern.iter_mut().for_each(|v| *v *= s);
self.offset *= s;
}
}
/// An arrowhead shape. Saved by variant name: new shapes are appended, never renamed or reordered.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Arrowhead {
Triangle,
TriangleOpen,
Circle,
CircleOpen,
Square,
SquareOpen,
Bar,
Diamond,
Arrow,
ArrowOpen,
Barbed,
/// One barb, on the left of the direction the head points.
HalfArrowLeft,
/// One barb, on the right of the direction the head points.
HalfArrowRight,
Concave,
DoubleBar,
Feather,
DotOnBar,
Chevron,
DoubleArrow,
Target,
Star,
Cross,
Plus,
Hexagon,
HexagonOpen,
Tag,
TagOpen,
HalfCircle,
Drop,
Slash,
DoubleSlash,
DiamondOpen,
TriangleReverse,
Swallowtail,
Bracket,
Fork,
Leaf,
Kite,
TriangleBar,
Oval,
}
impl Arrowhead {
/// Every arrowhead, in menu order (arrows, then outlined shapes, then bars and marks).
pub const ALL: [Arrowhead; 40] = [
Arrowhead::Arrow,
Arrowhead::ArrowOpen,
Arrowhead::Barbed,
Arrowhead::Concave,
Arrowhead::DoubleArrow,
Arrowhead::HalfArrowLeft,
Arrowhead::HalfArrowRight,
Arrowhead::Chevron,
Arrowhead::Feather,
Arrowhead::Swallowtail,
Arrowhead::Triangle,
Arrowhead::TriangleOpen,
Arrowhead::TriangleReverse,
Arrowhead::TriangleBar,
Arrowhead::Kite,
Arrowhead::Leaf,
Arrowhead::Drop,
Arrowhead::Circle,
Arrowhead::CircleOpen,
Arrowhead::HalfCircle,
Arrowhead::Oval,
Arrowhead::Target,
Arrowhead::Square,
Arrowhead::SquareOpen,
Arrowhead::Tag,
Arrowhead::TagOpen,
Arrowhead::Diamond,
Arrowhead::DiamondOpen,
Arrowhead::Hexagon,
Arrowhead::HexagonOpen,
Arrowhead::Star,
Arrowhead::Cross,
Arrowhead::Plus,
Arrowhead::Bar,
Arrowhead::DoubleBar,
Arrowhead::DotOnBar,
Arrowhead::Slash,
Arrowhead::DoubleSlash,
Arrowhead::Bracket,
Arrowhead::Fork,
];
/// The name the arrowhead menus show.
pub fn label(self) -> &'static str {
match self {
Arrowhead::Triangle => "Triangle",
Arrowhead::TriangleOpen => "Triangle (open)",
Arrowhead::Circle => "Circle",
Arrowhead::CircleOpen => "Circle (open)",
Arrowhead::Square => "Square",
Arrowhead::SquareOpen => "Square (open)",
Arrowhead::Bar => "Bar",
Arrowhead::Diamond => "Diamond",
Arrowhead::Arrow => "Arrow",
Arrowhead::ArrowOpen => "Arrow (open)",
Arrowhead::Barbed => "Barbed",
Arrowhead::HalfArrowLeft => "Half Arrow (left)",
Arrowhead::HalfArrowRight => "Half Arrow (right)",
Arrowhead::Concave => "Concave",
Arrowhead::DoubleBar => "Double Bar",
Arrowhead::Feather => "Feather",
Arrowhead::DotOnBar => "Dot on Bar",
Arrowhead::Chevron => "Chevron",
Arrowhead::DoubleArrow => "Double Arrow",
Arrowhead::Target => "Target",
Arrowhead::Star => "Star",
Arrowhead::Cross => "Cross",
Arrowhead::Plus => "Plus",
Arrowhead::Hexagon => "Hexagon",
Arrowhead::HexagonOpen => "Hexagon (open)",
Arrowhead::Tag => "Tag",
Arrowhead::TagOpen => "Tag (open)",
Arrowhead::HalfCircle => "Half Circle",
Arrowhead::Drop => "Drop",
Arrowhead::Slash => "Slash",
Arrowhead::DoubleSlash => "Double Slash",
Arrowhead::DiamondOpen => "Diamond (open)",
Arrowhead::TriangleReverse => "Reverse Triangle",
Arrowhead::Swallowtail => "Swallowtail",
Arrowhead::Bracket => "Bracket",
Arrowhead::Fork => "Fork",
Arrowhead::Leaf => "Leaf",
Arrowhead::Kite => "Kite",
Arrowhead::TriangleBar => "Triangle to Bar",
Arrowhead::Oval => "Oval",
}
}
}
/// Where an arrowhead sits relative to the end of its path. In both modes the stroke stops under
/// the head, so the line never shows through a hollow head or past its tip.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ArrowAlign {
/// The tip extends past the end point (the path keeps its length).
#[default]
Extend,
/// The tip sits on the end point (the stroke is shortened by the head).
Tip,
}
+349
View File
@@ -0,0 +1,349 @@
use super::*;
#[test]
fn hidden_attributes_show_again() {
let mut a = Appearance::default_art();
assert!(!a.has_hidden() && !a.show_all());
a.stroke_mut().unwrap().visible = false;
a.items[0].effects_mut().push(Effect { id: "distort.roughen".into(), params: serde_json::Value::Null, visible: false });
assert!(a.has_hidden());
assert!(a.show_all());
assert!(!a.has_hidden() && a.items.iter().all(AppearanceItem::visible) && a.items[0].effects()[0].visible);
}
#[test]
fn basic_appearance() {
let a = Appearance::default_art();
assert!(a.is_basic());
assert_eq!(a.fill_paint(), Paint::solid(Color::WHITE));
assert_eq!(a.stroke_width(), 1.0);
}
#[test]
fn rebase_moves_fills_by_the_box_and_strokes_by_their_inflated_box() {
use vectorcraft_color::{Gradient, GradientGeom, GradientPaint};
use vectorcraft_geom::{Point, Rect};
let placed = |x: f64| {
let mut g = GradientPaint::new(Gradient::default());
g.geom = Some(GradientGeom { start: Point::new(x, 0.0), end: Point::new(x + 10.0, 0.0), aspect: 1.0, focal: None });
Paint::Gradient(Box::new(g))
};
let mut a = Appearance::basic(placed(0.0), placed(-5.0), 10.0);
a.rebase_gradients(Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(100.0, 0.0, 300.0, 100.0));
let start = |p: Paint| match p {
Paint::Gradient(g) => g.geom.unwrap().start,
_ => panic!("a gradient"),
};
// The fill's start stays on the left edge; the stroke's on its inflated box's (−5 → 95).
assert_eq!((start(a.fill_paint()), start(a.stroke_paint())), (Point::new(100.0, 0.0), Point::new(95.0, 0.0)));
}
#[test]
fn items_by_index() {
let mut a = Appearance::default_art();
a.items.push(AppearanceItem::Fill(FillLayer::new(Paint::None)));
// [Fill white, Stroke black, Fill none]
assert_eq!(a.fill_at(None).unwrap().paint, Paint::None);
assert_eq!(a.fill_at(Some(0)).unwrap().paint, Paint::solid(Color::WHITE));
assert!(a.fill_at(Some(1)).is_none() && a.stroke_at(Some(0)).is_none() && a.fill_at(Some(9)).is_none());
assert_eq!(a.item_of_kind(Some(1), false), Some(1));
assert_eq!(a.item_of_kind(Some(1), true), None);
assert_eq!(a.fill_for(Some(1)).unwrap().paint, Paint::None);
assert!(a.set_fill_at(Some(0), Paint::solid(Color::BLACK)));
assert!(!a.set_stroke_at(Some(0), Paint::None));
assert_eq!(a.fill_at(Some(0)).unwrap().paint, Paint::solid(Color::BLACK));
a.effects_mut(Some(1)).unwrap().push(Effect { id: "distort.roughen".into(), params: serde_json::Value::Null, visible: true });
assert_eq!(a.effects_at(Some(1)).unwrap().len(), 1);
assert!(a.effects_at(None).unwrap().is_empty() && a.effects_mut(Some(3)).is_none());
assert_eq!((a.items[1].kind_name(), a.items[2].kind_name()), ("stroke", "fill"));
}
#[test]
fn basic_means_one_plain_fill_and_stroke() {
let stroke = || AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 1.0));
let mut a = Appearance { items: vec![stroke(), stroke()], ..Appearance::default() };
assert!(!a.is_basic(), "two strokes");
a.items.pop();
assert!(a.is_basic() && Appearance::default().is_basic());
let mut b = Appearance::default_art();
b.stroke_mut().unwrap().visible = false;
assert!(!b.is_basic(), "a hidden stroke");
let mut c = Appearance::default_art();
c.fill_mut().unwrap().opacity = 0.5;
assert!(!c.is_basic(), "fill opacity");
let mut d = Appearance::default_art();
d.items.push(AppearanceItem::Fill(FillLayer::new(Paint::None)));
assert!(!d.is_basic(), "two fills");
let mut e = Appearance::default_art();
e.effects.push(Effect { id: "distort.roughen".into(), params: serde_json::Value::Null, visible: true });
assert!(!e.is_basic(), "an effect");
}
#[test]
fn set_creates_missing() {
let mut a = Appearance::default();
a.set_fill(Paint::solid(Color::BLACK));
a.set_stroke(Paint::solid(Color::WHITE));
assert_eq!(a.items.len(), 2);
assert!(matches!(a.items[0], AppearanceItem::Fill(_)));
}
/// #852: pen pressure is kept simplified along the stroke and read back by position.
#[test]
fn pressure_profile_samples_simplify_and_interpolate() {
// A steady ramp keeps only its ends; a bump keeps its peak.
let ramp = PressureProfile::from_samples((0..=100).map(|i| (i as f64 / 100.0, i as f64 / 100.0))).unwrap();
assert_eq!(ramp.points, vec![(0.0, 0.0), (1.0, 1.0)]);
assert!((ramp.at(0.25) - 0.25).abs() < 1e-9);
let bump = PressureProfile::from_samples([(0.0, 0.2), (0.3, 0.2), (0.5, 0.9), (0.7, 0.2), (1.0, 0.2)]).unwrap();
assert!(bump.points.contains(&(0.5, 0.9)) && (bump.at(0.4) - 0.55).abs() < 1e-9, "{bump:?}");
// Junk is clamped or dropped; positions never go back; nothing at all is no profile.
let junk = PressureProfile::from_samples([(f64::NAN, 0.5), (0.5, 7.0), (0.2, -1.0), (2.0, f64::INFINITY)]).unwrap();
assert_eq!(junk.points, vec![(0.5, 1.0), (0.5, 0.0)]);
assert!(PressureProfile::from_samples([(f64::NAN, 0.5)]).is_none());
assert_eq!(PressureProfile::default().at(0.3), PressureProfile::MID);
// A long noisy stroke stays bounded.
let noisy = PressureProfile::from_samples((0..1_000_000).map(|i| (i as f64 / 1e6, (i % 2) as f64))).unwrap();
assert!(noisy.points.len() <= PressureProfile::MAX_POINTS);
// Out-of-order points (a hand-edited file) still read within 0..1.
let odd = PressureProfile { points: vec![(1.0, 5.0), (0.0, -3.0)] };
assert!((0.0..=1.0).contains(&odd.at(0.5)));
}
#[test]
fn pressure_profile_extends_and_reverses() {
let old = PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] };
let added = PressureProfile { points: vec![(0.0, 1.0), (1.0, 0.0)] };
// A 30 pt path continued by 10 pt: its ramp fills the first three quarters.
let joined = PressureProfile::extended(Some(&old), 30.0, Some(&added), 10.0).unwrap();
assert!((joined.at(0.75) - 1.0).abs() < 1e-9 && (joined.at(0.375) - 0.5).abs() < 1e-9 && joined.at(1.0) == 0.0, "{joined:?}");
// A side without pressure is half way.
let half = PressureProfile::extended(None, 10.0, Some(&added), 10.0).unwrap();
assert_eq!(half.at(0.25), PressureProfile::MID);
assert!(PressureProfile::extended(None, 10.0, None, 10.0).is_none());
assert_eq!(PressureProfile::extended(Some(&old), 0.0, None, f64::NAN), Some(old.clone()));
assert_eq!(old.reversed().points, vec![(0.0, 1.0), (1.0, 0.0)]);
}
/// Strokes saved before pen pressure load as they were, and are written without it.
#[test]
fn strokes_without_pressure_round_trip_unchanged() {
let json = serde_json::to_value(StrokeLayer::new(Paint::None, 2.0)).unwrap();
assert!(json.get("pressure").is_none());
let st: StrokeLayer = serde_json::from_value(json).unwrap();
assert_eq!(st.pressure, None);
let pressed = StrokeLayer { pressure: Some(PressureProfile::sample()), ..st };
let back: StrokeLayer = serde_json::from_value(serde_json::to_value(&pressed).unwrap()).unwrap();
assert_eq!(back, pressed);
let mut ap = Appearance::basic(Paint::None, Paint::None, 1.0);
ap.stroke_mut().unwrap().pressure = Some(PressureProfile::sample());
assert_eq!(ap.without_pressure().stroke().unwrap().pressure, None);
}
#[test]
fn profile_interp() {
let p = WidthProfile::lens();
assert_eq!(p.at(0.5), (1.0, 1.0));
assert_eq!(p.at(0.25), (0.5, 0.5));
assert_eq!(p.at(2.0), (0.0, 0.0));
}
#[test]
fn profile_presets_round_trip_their_ids() {
for p in WidthProfile::PRESETS {
let prof = WidthProfile::preset(p.id).unwrap();
assert_eq!(prof.preset_id(), Some(p.id));
assert_eq!(WidthProfile::id_of(Some(&prof)), p.id);
}
assert_eq!(WidthProfile::id_of(None), "uniform");
assert_eq!(WidthProfile::id_of(Some(&WidthProfile { points: vec![(0.0, 0.3, 0.3)] })), "custom");
assert!(WidthProfile::preset("nope").is_none());
assert_eq!(WidthProfile::lens().points, vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)]);
}
#[test]
fn arrowhead_names_are_stable_and_every_one_is_listed_once() {
// Saved files store these names: they may only ever be appended to.
const SAVED: [&str; 40] = [
"Triangle",
"TriangleOpen",
"Circle",
"CircleOpen",
"Square",
"SquareOpen",
"Bar",
"Diamond",
"Arrow",
"ArrowOpen",
"Barbed",
"HalfArrowLeft",
"HalfArrowRight",
"Concave",
"DoubleBar",
"Feather",
"DotOnBar",
"Chevron",
"DoubleArrow",
"Target",
"Star",
"Cross",
"Plus",
"Hexagon",
"HexagonOpen",
"Tag",
"TagOpen",
"HalfCircle",
"Drop",
"Slash",
"DoubleSlash",
"DiamondOpen",
"TriangleReverse",
"Swallowtail",
"Bracket",
"Fork",
"Leaf",
"Kite",
"TriangleBar",
"Oval",
];
for name in SAVED {
let a: Arrowhead = serde_json::from_value(serde_json::json!(name)).unwrap();
assert_eq!(serde_json::to_value(a).unwrap(), name);
assert_eq!(Arrowhead::ALL.iter().filter(|x| **x == a).count(), 1, "{name} in the menu once");
}
let labels: std::collections::HashSet<&str> = Arrowhead::ALL.iter().map(|a| a.label()).collect();
assert_eq!(labels.len(), Arrowhead::ALL.len(), "labels are distinct");
}
#[test]
fn arrow_align_defaults_to_extend_and_round_trips() {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0);
assert_eq!(st.arrow_align, ArrowAlign::Extend);
// The default is not written, so older readers see the same JSON as before.
assert!(!serde_json::to_string(&st).unwrap().contains("arrow_align"));
let old: StrokeLayer = serde_json::from_str(r#"{"paint":{"type":"none"},"width":2.0}"#).unwrap();
assert_eq!(old.arrow_align, ArrowAlign::Extend);
st.arrow_align = ArrowAlign::Tip;
let back: StrokeLayer = serde_json::from_str(&serde_json::to_string(&st).unwrap()).unwrap();
assert_eq!(back, st);
}
#[test]
fn outset_covers_arrowheads() {
let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 4.0);
a.stroke_mut().unwrap().join = LineJoin::Round;
assert_eq!(a.outset(), 2.0);
let st = a.stroke_mut().unwrap();
st.end_arrow = Some(Arrowhead::Triangle);
st.arrow_scale = (100.0, 200.0);
assert_eq!(st.arrow_weight(false), 4.0);
assert_eq!(st.arrow_weight(true), 8.0);
assert_eq!((st.head_reach(false), st.head_reach(true)), (0.0, HEAD_REACH * 8.0 + 2.0));
// A 32 pt head whose tip sits up to its length (plus the cap) past the end point.
assert_eq!(a.outset(), HEAD_REACH * 8.0 + 2.0);
a.stroke_mut().unwrap().width = 0.01;
assert_eq!(a.stroke().unwrap().arrow_weight(true), 0.25, "heads keep a minimum size");
}
#[test]
fn outset_depends_on_align() {
let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 10.0);
a.stroke_mut().unwrap().join = LineJoin::Round;
assert_eq!(a.outset(), 5.0);
a.stroke_mut().unwrap().align = StrokeAlign::Outside;
assert_eq!(a.outset(), 10.0);
// Open paths stroke inside-aligned strokes centred.
a.stroke_mut().unwrap().align = StrokeAlign::Inside;
assert_eq!(a.outset(), 5.0);
assert_eq!((a.stroke().unwrap().side_reach(true), a.stroke().unwrap().body(true)), (0.0, (10.0, Some(true))));
}
#[test]
fn outset_covers_miter_spikes_projecting_caps_and_profile_maxima() {
let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0);
// Miter joins can spike out to the limit × half the weight.
assert_eq!(a.outset(), 10.0);
let st = a.stroke_mut().unwrap();
st.join = LineJoin::Bevel;
st.cap = LineCap::Square;
assert!((a.outset() - std::f64::consts::SQRT_2).abs() < 1e-12, "a projecting cap's corner");
let st = a.stroke_mut().unwrap();
st.cap = LineCap::Round;
st.profile = Some(WidthProfile { points: vec![(0.0, 0.5, 2.5), (1.0, 1.0, 1.0)] });
assert_eq!(st.profile_max(), 2.5);
assert_eq!(a.outset(), 2.5);
a.stroke_mut().unwrap().paint = Paint::None;
assert_eq!(a.outset(), 0.0, "an unpainted stroke");
}
#[test]
fn contents_slot_follows_item_edits_and_round_trips() {
let fill = || AppearanceItem::Fill(FillLayer::new(Paint::None));
let mut a = Appearance { items: vec![fill(), fill(), fill()], ..Appearance::default() };
assert_eq!((a.contents_at(), a.split_contents().1.len()), (0, 3), "all items above by default");
a.set_contents_at(1);
assert!(!a.is_basic());
// [0 | 1 2]: removing below shifts the slot, removing above doesn't.
a.remove_item(2);
assert_eq!(a.contents_at(), 1);
a.remove_item(0);
assert_eq!((a.contents_at(), a.contents_index), (0, None));
// Inserting next to the slot joins the item below it.
a.items.push(fill());
a.set_contents_at(1); // [0 | 1]
a.insert_item(1, fill());
assert_eq!(a.contents_at(), 2);
a.insert_item(3, fill()); // [0 1 | 2 3]
assert_eq!(a.contents_at(), 2);
// Moves keep their side when landing next to the slot, cross it otherwise.
assert_eq!(a.move_item(3, 2), Some(2));
assert_eq!(a.contents_at(), 2, "stays above");
a.move_item(3, 0);
assert_eq!(a.contents_at(), 3, "crossed below");
a.move_item(0, 3);
assert_eq!(a.contents_at(), 2, "crossed above");
assert_eq!(a.move_item(9, 0), None);
// Saved only when set; older files load with every item above.
let json = serde_json::to_string(&a).unwrap();
assert!(json.contains("\"contents_index\":2"));
assert_eq!(serde_json::from_str::<Appearance>(&json).unwrap(), a);
let old: Appearance = serde_json::from_str(r#"{"items":[]}"#).unwrap();
assert_eq!(old.contents_index, None);
assert!(!serde_json::to_string(&Appearance::default_art()).unwrap().contains("contents_index"));
}
/// The issue's case (#4174): two circles with different fills, the same stroke and the same
/// effect share the stroke and the effect, not the fills; a shared stroke at another place in one
/// stack maps to its own index there; nothing shared shares nothing; equal stacks share all.
#[test]
fn mixed_appearances_share_their_equal_rows() {
let stroke = AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 11.0));
let fill = |c: Color| AppearanceItem::Fill(FillLayer::new(Paint::solid(c)));
let feather = Effect { id: "stylize.feather".into(), params: serde_json::json!({"radius": 5}), visible: true };
let a = Appearance { items: vec![fill(Color::rgb(1.0, 1.0, 0.0)), stroke.clone()], effects: vec![feather.clone()], ..Appearance::default() };
let b = Appearance { items: vec![fill(Color::rgb(1.0, 0.0, 0.0)), stroke.clone()], effects: vec![feather.clone()], ..Appearance::default() };
let shared = SharedStack::of(&[&a, &b]);
assert_eq!(shared.items, vec![vec![1, 1]], "the stroke, not the fills");
assert_eq!(shared.effects, vec![vec![0, 0]]);
// The stroke under an extra fill in the second object.
let c =
Appearance { items: vec![fill(Color::BLACK), fill(Color::WHITE), stroke.clone()], effects: vec![feather.clone()], ..Appearance::default() };
let shared = SharedStack::of(&[&a, &c]);
assert_eq!(shared.items, vec![vec![1, 2]]);
assert_eq!(shared.item_in(1, 1), Some(2));
assert_eq!(shared.item_in(0, 1), None);
// Nothing alike.
let d = Appearance { items: vec![fill(Color::WHITE)], ..Appearance::default() };
let none = SharedStack::of(&[&a, &d]);
assert!(none.items.is_empty() && none.effects.is_empty());
// Equal stacks share everything, each row where it is.
assert_eq!(SharedStack::of(&[&a, &a.clone(), &a.clone()]).items, vec![vec![0, 0, 0], vec![1, 1, 1]]);
}
/// Matches keep their stacking order: two equal rows in the opposite order match once.
#[test]
fn aligned_rows_never_cross() {
assert_eq!(align(&[1, 2], &[2, 1], |a, b| a == b).iter().filter(|m| m.is_some()).count(), 1);
assert_eq!(align(&[1, 2, 3], &[9, 1, 3], |a, b| a == b), vec![Some(1), None, Some(2)]);
assert_eq!(align::<i32>(&[], &[1], |a, b| a == b), Vec::<Option<usize>>::new());
}
+152
View File
@@ -0,0 +1,152 @@
use super::*;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Artboard {
pub id: u32,
pub name: String,
/// Artboard rectangle in document points.
pub rect: Rect,
/// Locked (the Artboards panel's lock column): the Artboard tool and the artboard commands
/// don't move, resize or delete it, Rearrange leaves it where it is, and the art on it can't be
/// selected or edited ([`Document::on_locked_artboard`]), except new art while it stays
/// selected ([`Document::new_art`]).
#[serde(default, skip_serializing_if = "skip::is_default")]
pub locked: bool,
/// Show Center Mark (Artboard Options): the canvas draws a mark at the artboard's center.
/// Exports and print leave it out. Always written, as before it could be set.
#[serde(default)]
pub show_center_mark: bool,
/// Show Cross Hairs (Artboard Options): the canvas draws a line from the middle of each side
/// through the center. Exports and print leave them out. Always written, as before it could
/// be set.
#[serde(default)]
pub show_cross_hairs: bool,
/// Show Video Safe Areas (Artboard Options): the canvas draws the artboard's action-safe and
/// title-safe areas ([`Artboard::video_safe_areas`]). Exports and print leave them out. Written
/// only when on, so a document that never shows them saves as before.
#[serde(default, skip_serializing_if = "skip::is_default")]
pub show_video_safe_areas: bool,
/// Video Ruler Pixel Aspect Ratio (Artboard Options): the width of one video pixel over its
/// height, which View › Rulers › Show Video Rulers counts the artboard in: a pixel is this many
/// points wide and one point tall. 1 (square pixels) unless set, and written only when it isn't
/// 1; always within [`VIDEO_PIXEL_ASPECT_RANGE`]: the commands refuse others, and a file's is
/// brought into it as it opens.
#[serde(default = "square_pixels", deserialize_with = "pixel_aspect_within_range", skip_serializing_if = "is_square_pixels")]
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.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<vectorcraft_color::Color>,
}
/// The Video Ruler Pixel Aspect Ratios an artboard takes (Artboard Options): from a pixel ten times
/// as tall as it is wide to one ten times as wide as it is tall, which holds the anamorphic and
/// standard-definition ratios video uses (0.9, 1.2, 1.33, 2) with room to spare.
pub const VIDEO_PIXEL_ASPECT_RANGE: std::ops::RangeInclusive<f64> = 0.1..=10.0;
/// How far in from each edge of an artboard the video safe areas lie, as a share of its width
/// (left and right) and of its height (top and bottom): the action-safe area 3.5% and the
/// title-safe (graphics-safe) area 5%, as EBU R 95 sets them for 16:9 television
/// (https://tech.ebu.ch/docs/r/r095.pdf, note 5).
pub const VIDEO_SAFE_INSETS: [f64; 2] = [0.035, 0.05];
fn square_pixels() -> f64 {
1.0
}
fn is_square_pixels(v: &f64) -> bool {
*v == 1.0
}
/// A file's Video Ruler Pixel Aspect Ratio, brought into [`VIDEO_PIXEL_ASPECT_RANGE`]: one that
/// isn't a number (NaN, infinite) reads as square pixels.
fn pixel_aspect_within_range<'de, D: serde::Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
let v = f64::deserialize(d)?;
let (lo, hi) = (*VIDEO_PIXEL_ASPECT_RANGE.start(), *VIDEO_PIXEL_ASPECT_RANGE.end());
Ok(if v.is_finite() { v.clamp(lo, hi) } else { 1.0 })
}
impl Artboard {
/// The action-safe and the title-safe area of the artboard, in that order, each inset from its
/// edges by the share of [`VIDEO_SAFE_INSETS`] (Show Video Safe Areas).
pub fn video_safe_areas(&self) -> [Rect; 2] {
let r = self.rect;
VIDEO_SAFE_INSETS.map(|k| r.inflate(-k * r.width(), -k * r.height()))
}
}
/// A document's locked artboards ([`Document::locked_artboards`]).
pub struct LockedArtboards<'a> {
pub(crate) rects: Vec<Rect>,
pub(crate) symbols: SymbolBoxes<'a>,
/// The new art, which isn't on them ([`Document::new_art`]).
pub(crate) new_art: Arc<std::collections::HashSet<NodeId>>,
/// The objects of pattern editing mode's layer ([`Document::pattern_edit`]), which are on no
/// artboard.
pub(crate) pattern_art: std::collections::HashSet<NodeId>,
/// What [`LockedArtboards::overlaps`] found for each object it measured: the members of a
/// group are each answered with one measurement of the group.
pub(crate) held: std::cell::RefCell<std::collections::HashMap<NodeId, bool>>,
}
impl LockedArtboards<'_> {
/// No artboard is locked.
pub fn is_empty(&self) -> bool {
self.rects.is_empty()
}
/// Is `n`, an object of a layer or sublayer, on a locked artboard: shown, not a guide, and its
/// visual bounds (with its strokes, a symbol instance by its art) overlapping the artboard? An
/// object counts for every artboard it overlaps, as Fit to Artwork Bounds counts it. A layer or
/// sublayer is on none, nor is the art of pattern editing mode ([`Document::pattern_edit`]) or
/// new art while it stays selected ([`Document::new_art`]).
pub fn holds(&self, n: &Node) -> bool {
!self.new_art.contains(&n.id) && self.overlaps(n)
}
/// [`LockedArtboards::holds`] for new art too: is `n` on a locked artboard, whether or not it
/// is new art?
pub fn overlaps(&self, n: &Node) -> bool {
if self.rects.is_empty()
|| !n.visible
|| n.is_layer()
|| matches!(n.kind, NodeKind::Path { guide: true, .. })
|| self.pattern_art.contains(&n.id)
{
return false;
}
if let Some(held) = self.held.borrow().get(&n.id) {
return *held;
}
let held =
n.visual_bounds_with(&self.symbols).is_some_and(|b| self.rects.iter().any(|r| b.x0 < r.x1 && b.x1 > r.x0 && b.y0 < r.y1 && b.y1 > r.y0));
self.held.borrow_mut().insert(n.id, held);
held
}
/// Is node `id` on a locked artboard: is the object of a layer or sublayer that holds it (`id`
/// itself when it is one, a group counting as one object) on one ([`LockedArtboards::holds`])?
pub fn holds_id(&self, id: NodeId) -> bool {
!self.rects.is_empty() && self.symbols.document().layer_object(id).is_some_and(|n| self.holds(n))
}
}
/// [`Document::retired_artboard_id`]: not document content, so two documents are equal whatever it
/// holds. A clone shares the value with the document it was cloned from.
#[derive(Clone, Debug, Default)]
pub struct RetiredArtboardId(pub(crate) Arc<std::sync::atomic::AtomicU32>);
impl RetiredArtboardId {
pub fn get(&self) -> u32 {
self.0.load(std::sync::atomic::Ordering::Relaxed)
}
/// Raise the value to `id` when `id` is higher.
pub fn raise(&self, id: u32) {
self.0.fetch_max(id, std::sync::atomic::Ordering::Relaxed);
}
}
impl PartialEq for RetiredArtboardId {
fn eq(&self, _: &Self) -> bool {
true
}
}
+786
View File
@@ -0,0 +1,786 @@
use super::artboard::{Artboard, LockedArtboards};
use super::graphic_style::default_graphic_styles;
use super::guides::{GridPrefs, RulerMode};
use super::limits::{DocError, FORMAT_VERSION, MAX_COORD, MAX_NEXT_ID};
use super::units::{ColorMode, Unit};
use super::*;
impl Document {
/// Is everything but the layers the same in `other`, as the next version of the document after
/// an edit of its art? The id counter and the id index are left out, and art outside the layers
/// (symbols, patterns, graph designs) compares by identity, which an edit keeps for what it
/// doesn't change: quick for every frame of an animation.
pub fn same_outside_layers(&self, other: &Self) -> bool {
let same_art = |a: &[Arc<Node>], b: &[Arc<Node>]| a.len() == b.len() && a.iter().zip(b).all(|(a, b)| Arc::ptr_eq(a, b));
let same_pattern = |a: &PatternDef, b: &PatternDef| {
same_art(&a.art, &b.art) && PatternDef { art: Vec::new(), ..a.clone() } == PatternDef { art: Vec::new(), ..b.clone() }
};
let Self {
version,
title,
template,
units,
ruler_origin,
rulers,
artboard_ruler_origins,
color_mode,
artboards,
layers: _,
swatches,
swatch_groups,
graphic_styles,
char_styles,
para_styles,
text_threads,
symbols,
graph_designs,
guides,
views,
saved_selections,
variables,
grid,
raster_effects_ppi,
images,
patterns,
pattern_edit,
mask_edit,
next_id: _,
unknown,
page_isolate,
page_knockout,
spot_use_lab,
setup,
paste_remembers_layers,
metadata,
raster_effects,
last_view,
transparency_grid,
layers_open,
color_profiles,
export_settings,
extra,
slices,
slices_clip_to_artboard,
print_setup,
assets,
svg_scripts,
puppet,
new_art,
dimensions,
retired_artboard_id: _,
index: _,
} = self;
*version == other.version
&& *title == other.title
&& *template == other.template
&& *units == other.units
&& *ruler_origin == other.ruler_origin
&& *rulers == other.rulers
&& *artboard_ruler_origins == other.artboard_ruler_origins
&& *color_mode == other.color_mode
&& *artboards == other.artboards
&& *swatches == other.swatches
&& *swatch_groups == other.swatch_groups
&& *graphic_styles == other.graphic_styles
&& *char_styles == other.char_styles
&& *para_styles == other.para_styles
&& *text_threads == other.text_threads
&& symbols.len() == other.symbols.len()
&& symbols.iter().zip(&other.symbols).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art))
&& graph_designs.len() == other.graph_designs.len()
&& graph_designs.iter().zip(&other.graph_designs).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art))
&& *guides == other.guides
&& *views == other.views
&& *saved_selections == other.saved_selections
&& *variables == other.variables
&& *grid == other.grid
&& *raster_effects_ppi == other.raster_effects_ppi
&& *images == other.images
&& patterns.len() == other.patterns.len()
&& patterns.iter().zip(&other.patterns).all(|(a, b)| same_pattern(a, b))
&& match (pattern_edit, &other.pattern_edit) {
(Some(a), Some(b)) => {
a.pattern == b.pattern
&& a.layer == b.layer
&& match (&a.original, &b.original) {
(Some(a), Some(b)) => same_pattern(a, b),
(a, b) => a.is_none() && b.is_none(),
}
}
(a, b) => a.is_none() && b.is_none(),
}
&& *mask_edit == other.mask_edit
&& *unknown == other.unknown
&& *page_isolate == other.page_isolate
&& *page_knockout == other.page_knockout
&& *spot_use_lab == other.spot_use_lab
&& *setup == other.setup
&& *paste_remembers_layers == other.paste_remembers_layers
&& *metadata == other.metadata
&& *raster_effects == other.raster_effects
&& *last_view == other.last_view
&& *transparency_grid == other.transparency_grid
&& *layers_open == other.layers_open
&& *color_profiles == other.color_profiles
&& *export_settings == other.export_settings
&& *extra == other.extra
&& *slices == other.slices
&& *slices_clip_to_artboard == other.slices_clip_to_artboard
&& *print_setup == other.print_setup
&& *assets == other.assets
&& *svg_scripts == other.svg_scripts
&& match (puppet, &other.puppet) {
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
(a, b) => a.is_none() && b.is_none(),
}
&& *new_art == other.new_art
&& *dimensions == other.dimensions
}
/// Document Raster Effects Settings › Resolution in ppi, 1 to 2400 (72 when the stored value
/// isn't a number).
pub fn raster_ppi(&self) -> f64 {
Some(self.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0)
}
/// The ruler zero point, or zero on both axes when either stored coordinate is invalid.
/// Rulers, coordinate fields and guide snapping use the same supported range.
pub fn ruler_origin_or_zero(&self) -> Point {
let origin = self.ruler_origin;
if origin.is_finite() && origin.x.abs() <= MAX_COORD && origin.y.abs() <= MAX_COORD { origin } else { Point::ZERO }
}
/// Where the rulers measure from with artboard `index` active: [`Self::ruler_origin_or_zero`]
/// with global rulers; with artboard rulers that artboard's top-left corner moved by its own
/// offset (the first artboard's when `index` is out of range, the global zero point when the
/// document has no artboard or the result is out of the supported range).
pub fn ruler_origin_for(&self, index: usize) -> Point {
if self.rulers == RulerMode::Global {
return self.ruler_origin_or_zero();
}
let Some(ab) = self.artboards.get(index).or_else(|| self.artboards.first()) else { return self.ruler_origin_or_zero() };
let corner = Point::new(ab.rect.x0, ab.rect.y0);
let offset = self.artboard_ruler_origins.get(&ab.id).copied().unwrap_or(Vec2::ZERO);
let origin = corner + offset;
if origin.is_finite() && origin.x.abs() <= MAX_COORD && origin.y.abs() <= MAX_COORD {
origin
} else if corner.is_finite() && corner.x.abs() <= MAX_COORD && corner.y.abs() <= MAX_COORD {
corner
} else {
Point::ZERO
}
}
/// A new RGB document with one artboard of `size` and one layer ("Layer 1").
pub fn new(width: f64, height: f64) -> Self {
Self::new_with_mode(width, height, ColorMode::Rgb)
}
/// [`Document::new`] in colour `mode`, with that mode's default swatches.
pub fn new_with_mode(width: f64, height: f64, mode: ColorMode) -> Self {
let (swatches, swatch_groups) = vectorcraft_color::default_swatches(mode.model());
let mut d = Self {
version: FORMAT_VERSION,
title: "Untitled-1".into(),
template: false,
units: Unit::Points,
ruler_origin: Point::ZERO,
rulers: RulerMode::Artboard,
artboard_ruler_origins: BTreeMap::new(),
color_mode: mode,
artboards: vec![Artboard {
id: 1,
name: "Artboard 1".into(),
rect: Rect::new(0.0, 0.0, width, height),
locked: false,
show_center_mark: false,
show_cross_hairs: false,
show_video_safe_areas: false,
video_ruler_pixel_aspect_ratio: 1.0,
color: None,
}],
layers: vec![],
swatches: swatches.into(),
swatch_groups: swatch_groups.into(),
graphic_styles: default_graphic_styles().into(),
char_styles: Default::default(),
para_styles: Default::default(),
text_threads: vec![],
symbols: vec![],
graph_designs: vec![],
guides: vec![],
views: vec![],
saved_selections: Default::default(),
variables: Default::default(),
grid: GridPrefs::default(),
raster_effects_ppi: 72.0,
images: BTreeMap::new(),
patterns: vec![],
pattern_edit: None,
mask_edit: None,
next_id: 1,
unknown: Default::default(),
page_isolate: false,
page_knockout: false,
spot_use_lab: true,
setup: DocSetup::default(),
paste_remembers_layers: false,
metadata: DocMetadata::default(),
raster_effects: RasterEffectsSettings::default(),
last_view: None,
transparency_grid: false,
layers_open: None,
color_profiles: ColorProfiles::default(),
export_settings: Default::default(),
extra: Default::default(),
slices: vec![],
slices_clip_to_artboard: true,
print_setup: None,
assets: vec![],
svg_scripts: vec![],
puppet: None,
new_art: Default::default(),
dimensions: false,
retired_artboard_id: Default::default(),
index: Default::default(),
};
let id = d.alloc_id();
d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0))));
d
}
/// Allocate a fresh node id.
pub fn alloc_id(&mut self) -> NodeId {
let id = NodeId(self.next_id);
// Never reached from a counter [`Self::fix_next_id`] has seen (it stays under
// [`MAX_NEXT_ID`]); saturating so that no counter can overflow.
self.next_id = self.next_id.saturating_add(1);
id
}
pub fn peek_next_id(&self) -> u64 {
self.next_id
}
/// Ensure `next_id` is above every id in the tree (after deserializing foreign data).
pub fn fix_next_id(&mut self) {
// Ids from [`MAX_NEXT_ID`] up (a damaged or hand-made file: #1718) are left out, as is a
// counter up there: new ids start under them and never reach them, where counting on from
// the largest would run out and hand out ids the document already uses.
let mut max = 0;
let mut see = |id: u64| {
if id < MAX_NEXT_ID {
max = max.max(id);
}
};
for l in &self.layers {
l.walk(&mut |n| see(n.id.0));
}
self.slices.iter().for_each(|s| see(s.id.0));
self.assets.iter().for_each(|a| see(a.id));
let counter = Some(self.next_id).filter(|n| *n <= MAX_NEXT_ID).unwrap_or(0);
self.next_id = counter.max(max.saturating_add(1));
}
/// Find a node anywhere in the tree.
pub fn node(&self, id: NodeId) -> Option<&Node> {
self.index_path(id).and_then(|p| self.node_at(&p))
}
/// [`Document::node`] without building the lookup cache when there is none: for a look at a
/// document kept in the undo history ([`Document::drop_lookup_cache`]).
pub fn node_uncached(&self, id: NodeId) -> Option<&Node> {
match self.index.is_built() {
true => self.node(id),
false => self.walk_index_path(id).and_then(|p| self.node_at(&p)),
}
}
/// Let go of the lookup cache ([`id_index`]), as a document kept in the undo history does: a
/// table per step would add up. Lookups still work; the first one builds the cache again.
pub fn drop_lookup_cache(&self) {
self.index.clear();
}
/// Whether the lookup cache ([`id_index`]) is built (see [`Document::drop_lookup_cache`]).
pub fn has_lookup_cache(&self) -> bool {
self.index.is_built()
}
/// Index path from the top-level layer list to `id` (e.g. `[0, 3, 1]`): the first place in
/// tree order where `id` appears.
pub fn index_path(&self, id: NodeId) -> Option<Vec<usize>> {
// The cached path, while the node there still has the id ([`id_index`]).
let cached = self.index.get();
match cached.as_ref().and_then(|m| m.get(&id)) {
Some(Some(p)) if self.node_at(&p).is_some_and(|n| n.id == id) => return Some(p),
Some(None) => return self.walk_index_path(id),
_ => {}
}
// A stale path (the tree changed), an id the cache doesn't have (deleted or added since),
// or no cache yet: walk, and rebuild now and then.
let found = self.walk_index_path(id);
let stale = cached.as_ref().is_some_and(|m| m.contains_key(&id));
if cached.is_none() || ((stale || found.is_some()) && self.index.missed()) {
self.index.rebuild(&self.layers);
}
found
}
/// [`Document::index_path`] by walking the tree.
pub(crate) fn walk_index_path(&self, id: NodeId) -> Option<Vec<usize>> {
fn find(nodes: &[Arc<Node>], id: NodeId, path: &mut Vec<usize>) -> bool {
for (i, n) in nodes.iter().enumerate() {
path.push(i);
if n.id == id {
return true;
}
if let Some(ch) = n.children()
&& find(ch, id, path)
{
return true;
}
path.pop();
}
false
}
let mut p = vec![];
find(&self.layers, id, &mut p).then_some(p)
}
/// [`Document::index_path`] of every id in `ids` that is in the tree, in one walk of it: an
/// edit of many objects looks them all up for the cost of one search instead of one each. Like
/// `index_path`, an id that appears twice maps to its first place in tree order.
pub fn index_paths(&self, ids: impl IntoIterator<Item = NodeId>) -> std::collections::HashMap<NodeId, Vec<usize>> {
use std::collections::{HashMap, HashSet};
fn walk(nodes: &[Arc<Node>], want: &HashSet<NodeId>, path: &mut Vec<usize>, out: &mut HashMap<NodeId, Vec<usize>>) -> bool {
for (i, n) in nodes.iter().enumerate() {
path.push(i);
if want.contains(&n.id) && !out.contains_key(&n.id) {
out.insert(n.id, path.clone());
if out.len() == want.len() {
return true;
}
}
if let Some(ch) = n.children()
&& walk(ch, want, path, out)
{
return true;
}
path.pop();
}
false
}
let mut want: HashSet<NodeId> = ids.into_iter().collect();
let mut out = HashMap::with_capacity(want.len());
// Cached paths that still check out ([`id_index`]); the rest take one walk. For a good
// share of the document, one walk is quicker than checking each cached path.
if let Some(cache) = self.index.get().filter(|c| want.len().saturating_mul(8) < c.len()) {
want.retain(|id| match cache.get(id) {
Some(Some(p)) if self.node_at(&p).is_some_and(|n| n.id == *id) => {
out.insert(*id, p);
false
}
_ => true,
});
}
if !want.is_empty() {
let mut rest = HashMap::with_capacity(want.len());
walk(&self.layers, &want, &mut Vec::new(), &mut rest);
out.extend(rest);
}
out
}
/// The node at index path `path` (as [`Document::index_path`] gives it).
pub fn node_at(&self, path: &[usize]) -> Option<&Node> {
let (first, rest) = path.split_first()?;
let mut cur: &Node = self.layers.get(*first)?;
for &i in rest {
cur = cur.children()?.get(i)?;
}
Some(cur)
}
/// Mutable access to the node at index path `path`; clones the Arc path (copy-on-write), as
/// [`Document::node_mut`] does.
pub fn node_mut_at(&mut self, path: &[usize]) -> Option<&mut Node> {
let (first, rest) = path.split_first()?;
let mut cur: &mut Arc<Node> = self.layers.get_mut(*first)?;
for &i in rest {
cur = Arc::make_mut(cur).children_mut()?.get_mut(i)?;
}
Some(Arc::make_mut(cur))
}
/// [`Document::ancestry`] of the node at index path `path`.
pub fn ancestry_at(&self, path: &[usize]) -> Option<Vec<NodeId>> {
let mut out = Vec::with_capacity(path.len());
let mut nodes = &self.layers;
for &i in path {
let n = nodes.get(i)?;
out.push(n.id);
if let Some(ch) = n.children() {
nodes = ch;
}
}
Some(out)
}
/// Ids from the top-level layer down to `id` inclusive.
pub fn ancestry(&self, id: NodeId) -> Option<Vec<NodeId>> {
let path = self.index_path(id)?;
let mut out = Vec::with_capacity(path.len());
let mut nodes = &self.layers;
for &i in &path {
let n = &nodes[i];
out.push(n.id);
if let Some(ch) = n.children() {
nodes = ch;
}
}
Some(out)
}
pub fn parent_of(&self, id: NodeId) -> Option<NodeId> {
let a = self.ancestry(id)?;
(a.len() >= 2).then(|| a[a.len() - 2])
}
/// The top-level layer that contains `id`.
pub fn layer_of(&self, id: NodeId) -> Option<NodeId> {
self.ancestry(id).and_then(|a| a.first().copied())
}
/// Mutable access to a node; clones the Arc path (copy-on-write).
pub fn node_mut(&mut self, id: NodeId) -> Option<&mut Node> {
let path = self.index_path(id)?;
let mut cur: &mut Arc<Node> = &mut self.layers[path[0]];
for &i in &path[1..] {
cur = &mut Arc::make_mut(cur).children_mut()?[i];
}
Some(Arc::make_mut(cur))
}
/// Children list of a container (or the top-level layer list for `None`).
pub fn children_mut(&mut self, parent: Option<NodeId>) -> Result<&mut Vec<Arc<Node>>, DocError> {
match parent {
None => Ok(&mut self.layers),
Some(p) => self.node_mut(p).ok_or(DocError::NoNode(p))?.children_mut().ok_or(DocError::NotContainer(p)),
}
}
pub fn children(&self, parent: Option<NodeId>) -> Option<&Vec<Arc<Node>>> {
match parent {
None => Some(&self.layers),
Some(p) => self.node(p)?.children(),
}
}
/// Insert `node` into `parent` at `index` (clamped; `usize::MAX` = top).
pub fn insert(&mut self, parent: Option<NodeId>, index: usize, node: Node) -> Result<NodeId, DocError> {
let id = node.id;
let ch = self.children_mut(parent)?;
let i = index.min(ch.len());
ch.insert(i, Arc::new(node));
Ok(id)
}
/// Remove a node and return it.
pub fn remove(&mut self, id: NodeId) -> Result<Arc<Node>, DocError> {
let parent = self.parent_of(id);
let path = self.index_path(id).ok_or(DocError::NoNode(id))?;
let idx = *path.last().ok_or(DocError::NoNode(id))?;
let ch = self.children_mut(parent)?;
if idx >= ch.len() {
return Err(DocError::NoNode(id));
}
Ok(ch.remove(idx))
}
/// Move a node to `parent` at `index` (index measured after removal).
pub fn move_node(&mut self, id: NodeId, parent: Option<NodeId>, index: usize) -> Result<(), DocError> {
if let Some(p) = parent
&& self.ancestry(p).is_some_and(|a| a.contains(&id))
{
return Err(DocError::Invalid("cannot move an object into itself".into()));
}
let n = self.remove(id)?;
let ch = self.children_mut(parent)?;
let i = index.min(ch.len());
ch.insert(i, n);
Ok(())
}
/// Position of `id` within its parent: (parent, index, sibling count).
pub fn position(&self, id: NodeId) -> Option<(Option<NodeId>, usize, usize)> {
let parent = self.parent_of(id);
let idx = *self.index_path(id)?.last()?;
let n = self.children(parent)?.len();
Some((parent, idx, n))
}
/// Remove the nodes `ids` names and return them in the order of `ids`, in one pass over the
/// children of each parent. Each id is listed once and none lies inside another listed node, as
/// in a list of top-level objects. For an id that is not in the tree, is listed twice or lies
/// inside another listed node, it fails with `NoNode` and leaves the document as it was.
pub fn remove_many(&mut self, ids: &[NodeId]) -> Result<Vec<Arc<Node>>, DocError> {
let places = self.places(ids)?;
let removed = self.take(places, ids.len())?;
self.index.clear();
Ok(removed)
}
/// Remove the nodes `ids` names, as [`Document::remove_many`] does, and put the node that `make`
/// builds from them (in the order of `ids`) in the parent of the last of them, at that node's
/// index less the number of listed nodes below it there. Returns the new node's id.
pub fn wrap(&mut self, ids: &[NodeId], make: impl FnOnce(Vec<Arc<Node>>) -> Node) -> Result<NodeId, DocError> {
let places = self.places(ids)?;
let last = ids.len().checked_sub(1);
let found = places.iter().find_map(|(up, kids)| kids.iter().find(|(_, k)| Some(*k) == last).map(|(i, _)| (up.clone(), *i)));
let Some((up, i)) = found else { return Err(DocError::Invalid("nothing to wrap".into())) };
// The parent and each node it is in move down by the listed nodes before them.
let below = |parent: &[usize], index: usize| places.get(parent).map_or(0, |kids| kids.partition_point(|(j, _)| *j < index));
let to: Vec<usize> = up.iter().enumerate().map(|(l, &j)| j.saturating_sub(below(&up[..l], j))).collect();
let at = i.saturating_sub(below(&up, i));
let node = make(self.take(places, ids.len())?);
let id = node.id;
let children = self.children_at(&to).ok_or_else(|| DocError::Invalid("the container of the listed objects is gone".into()))?;
let at = at.min(children.len());
children.insert(at, Arc::new(node));
self.index.clear();
Ok(id)
}
/// Put each list of nodes in `items` directly above its anchor (`replace`: in its place), in
/// list order, in one pass over the children of each parent. Each anchor is listed once and none
/// lies inside another. For an anchor that is not in the tree, is listed twice or lies inside
/// another listed anchor, it fails with `NoNode` and leaves the document as it was.
pub fn insert_beside(&mut self, items: Vec<(NodeId, Vec<Node>)>, replace: bool) -> Result<(), DocError> {
let anchors: Vec<NodeId> = items.iter().map(|(id, _)| *id).collect();
let places = self.places(&anchors)?;
let mut lists: Vec<Vec<Node>> = items.into_iter().map(|(_, nodes)| nodes).collect();
self.edit_children(places, |k, anchor, children| {
if !replace {
children.push(anchor);
}
let nodes = lists.get_mut(k).map(std::mem::take).unwrap_or_default();
children.extend(nodes.into_iter().map(Arc::new));
})?;
self.index.clear();
Ok(())
}
/// Where each of `ids` is ([`Places`]). Fails with `NoNode` for an id that is not in the tree,
/// is listed twice or lies inside another listed node.
fn places(&self, ids: &[NodeId]) -> Result<Places, DocError> {
let paths = self.index_paths(ids.iter().copied());
let mut places = Places::new();
for (k, id) in ids.iter().enumerate() {
let (&i, up) = paths.get(id).and_then(|p| p.split_last()).ok_or(DocError::NoNode(*id))?;
match places.get_mut(up) {
Some(kids) => kids.push((i, k)),
None => {
places.insert(up.to_vec(), vec![(i, k)]);
}
}
}
for kids in places.values_mut() {
kids.sort_unstable();
}
// An id listed twice has one path, so its parent lists the same child twice. A listed node
// lies inside another when its parent's path, or a part of it from the top, is the path of a
// listed node.
let twice = places.values().flat_map(|kids| kids.windows(2)).find(|w| w[0].0 == w[1].0).map(|w| w[1].1);
let listed = |path: &[usize]| {
path.split_last().is_some_and(|(i, up)| places.get(up).is_some_and(|kids| kids.binary_search_by_key(i, |(j, _)| *j).is_ok()))
};
let nested = places.iter().find(|(up, _)| (1..=up.len()).any(|l| listed(&up[..l])));
let inner = nested.and_then(|(_, kids)| kids.first()).map(|(_, k)| *k);
if let Some(id) = twice.or(inner).and_then(|k| ids.get(k)) {
return Err(DocError::NoNode(*id));
}
Ok(places)
}
/// Take the nodes `places` lists out of their parents and return them in the order of the list,
/// which is `len` long.
fn take(&mut self, places: Places, len: usize) -> Result<Vec<Arc<Node>>, DocError> {
let mut taken = vec![None; len];
self.edit_children(places, |k, node, _| {
if let Some(t) = taken.get_mut(k) {
*t = Some(node);
}
})?;
Ok(taken.into_iter().flatten().collect())
}
/// Rebuild the children of each parent in `places` once, the parents later in tree order
/// first. `each` gets each listed child, its place in the list and the parent's new children,
/// onto which it pushes what takes the child's place.
fn edit_children(&mut self, places: Places, mut each: impl FnMut(usize, Arc<Node>, &mut Vec<Arc<Node>>)) -> Result<(), DocError> {
for (up, kids) in places.into_iter().rev() {
let children = self.children_at(&up).ok_or_else(|| DocError::Invalid("a container of the listed objects is gone".into()))?;
let old = std::mem::take(children);
children.reserve(old.len());
let mut kids = kids.into_iter().peekable();
for (i, node) in old.into_iter().enumerate() {
match kids.next_if(|(j, _)| *j == i) {
Some((_, k)) => each(k, node, children),
None => children.push(node),
}
}
}
Ok(())
}
/// The children of the node at index path `path`, or the top-level layers for an empty path.
fn children_at(&mut self, path: &[usize]) -> Option<&mut Vec<Arc<Node>>> {
match path {
[] => Some(&mut self.layers),
_ => self.node_mut_at(path)?.children_mut(),
}
}
/// Default target layer: the topmost visible, unlocked layer.
pub fn default_layer(&self) -> Option<NodeId> {
self.layers.iter().rev().find(|l| l.visible && !l.locked).or(self.layers.last()).map(|l| l.id)
}
/// Add a new top-level layer above all others.
pub fn add_layer(&mut self, name: Option<&str>) -> NodeId {
let id = self.alloc_id();
let n = self.layers.len();
let name = name.map(str::to_string).unwrap_or_else(|| self.next_layer_name());
self.layers.push(Arc::new(Node::layer(id, &name, LayerColor::Preset((n % LAYER_COLORS.len()) as u8))));
id
}
/// The default name of a new layer or sublayer, "Layer N". Layers and sublayers share the
/// numbering: it starts after the number of layers there are and skips any layer's name.
pub fn next_layer_name(&self) -> String {
let (mut count, mut taken) = (0, std::collections::HashSet::new());
self.walk(|n| {
if n.is_layer() {
count += 1;
taken.extend(n.name.as_deref());
}
});
(count + 1..).map(|i| format!("Layer {i}")).find(|name| !taken.contains(name.as_str())).unwrap_or_default()
}
/// Visit every node depth first in paint order (bottom to top).
pub fn walk<'a>(&'a self, mut f: impl FnMut(&'a Node)) {
for l in &self.layers {
l.walk(&mut f);
}
}
pub fn node_count(&self) -> usize {
self.layers.iter().map(|l| l.count()).sum()
}
/// Is the node (and every ancestor) visible and unlocked, and not on a locked artboard
/// ([`Document::on_locked_artboard`])?
pub fn is_editable(&self, id: NodeId) -> bool {
self.is_editable_with(&self.locked_artboards(), id)
}
/// [`Document::is_editable`] with the locked artboards `locks` of this document, measured
/// once for many ids.
pub fn is_editable_with(&self, locks: &LockedArtboards, id: NodeId) -> bool {
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible && !n.locked))) && !locks.holds_id(id)
}
/// [`Document::is_editable`] for many ids: the art on the locked artboards is measured once
/// for all of them, a group once for all its members.
pub fn editable(&self) -> impl Fn(NodeId) -> bool + '_ {
let locks = self.locked_artboards();
move |id| self.is_editable_with(&locks, id)
}
/// The locked artboards, for asking which art is on them ([`LockedArtboards::holds`]).
pub fn locked_artboards(&self) -> LockedArtboards<'_> {
self.locked_artboards_except(usize::MAX)
}
/// [`Document::locked_artboards`] leaving out artboard `index`.
pub fn locked_artboards_except(&self, index: usize) -> LockedArtboards<'_> {
// Art flush with an edge from outside can overlap it by a rounding error, as in
// `art_bounds_on` (#1295).
let rects: Vec<Rect> =
self.artboards.iter().enumerate().filter(|(i, a)| a.locked && *i != index).map(|(_, a)| a.rect.inflate(-1e-6, -1e-6)).collect();
// The objects of pattern editing mode's layer, a sublayer's objects included.
fn objects(n: &Node, out: &mut std::collections::HashSet<NodeId>) {
for c in n.children().into_iter().flatten() {
if c.is_layer() {
objects(c, out);
} else {
out.insert(c.id);
}
}
}
let mut pattern_art = std::collections::HashSet::new();
if let Some(layer) = self.pattern_edit.as_ref().filter(|_| !rects.is_empty()).and_then(|e| self.layers.iter().find(|l| l.id == e.layer)) {
objects(layer, &mut pattern_art);
}
LockedArtboards { rects, symbols: self.symbol_boxes(), new_art: self.new_art.get(), pattern_art, held: Default::default() }
}
/// Is `id` on a locked artboard ([`LockedArtboards::holds_id`])? A layer or sublayer is on none.
pub fn on_locked_artboard(&self, id: NodeId) -> bool {
self.locked_artboards().holds_id(id)
}
/// The object of a layer or sublayer that holds node `id`: `id` itself when it is one, else
/// the group or other object around it right inside a layer. None for a layer or sublayer.
pub fn layer_object(&self, id: NodeId) -> Option<&Node> {
self.layer_object_at(&self.index_path(id)?)
}
/// [`Document::layer_object`] for the node at index path `path`.
pub fn layer_object_at(&self, path: &[usize]) -> Option<&Node> {
// Down the path to the first node that isn't a layer.
let mut nodes = &self.layers;
for &i in path {
let n = nodes.get(i)?;
if !n.is_layer() {
return Some(n.as_ref());
}
nodes = n.children()?;
}
None
}
pub fn is_visible(&self, id: NodeId) -> bool {
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible)))
}
/// The innermost layer or sublayer containing `id` (`id` itself when it is a layer).
pub fn layer_containing(&self, id: NodeId) -> Option<NodeId> {
let a = self.ancestry(id)?;
a.iter().rev().copied().find(|i| self.node(*i).is_some_and(Node::is_layer))
}
/// Every art object the Selection tool can reach (Select All): the visible, unlocked objects
/// of the visible, unlocked, non-template layers, looking through sublayers (bottom first),
/// except those on a locked artboard.
pub fn selectable_art(&self) -> Vec<NodeId> {
let locks = self.locked_artboards();
self.layers
.iter()
.filter(|l| l.visible && !l.locked && !l.is_template())
.flat_map(|l| l.layer_art(true))
.filter(|id| locks.is_empty() || !self.node(*id).is_some_and(|n| locks.holds(n)))
.collect()
}
/// Colour of the innermost layer or sublayer containing `id` (selection highlight colour).
pub fn layer_color(&self, id: NodeId) -> [u8; 3] {
let l = self.layer_containing(id).and_then(|l| self.node(l));
match l.map(|n| &n.kind) {
Some(NodeKind::Layer { color, .. }) => color.rgb(),
_ => LAYER_COLORS[0].1,
}
}
}
+389
View File
@@ -0,0 +1,389 @@
use super::graphic_style::GraphicStyle;
use super::limits::MAX_NEXT_ID;
use super::records::{ImageBlob, SavedSelection, SavedView};
use super::*;
impl Document {
/// The pixels image `im` shows: its own ([`ImageObject::pixels`]) or this document's.
pub fn image_blob<'a>(&'a self, im: &'a ImageObject) -> Option<&'a ImageBlob> {
im.pixels.as_deref().or_else(|| self.images.get(&im.key))
}
/// Take the pixels the images in `n` (its opacity masks' art included) hold themselves (art
/// made outside a document) into [`Self::images`]: under the image's key, or a free one when
/// the key shows other pixels.
pub fn adopt_pixels(&mut self, n: &mut Node) {
// Only what holds such pixels is copied out of the art it shares.
fn holds(n: &Node) -> bool {
matches!(&n.kind, NodeKind::Image(im) if im.pixels.is_some())
|| n.extras().mask.as_ref().is_some_and(|m| holds(&m.art))
|| n.children().into_iter().flatten().any(|c| holds(c))
}
if let NodeKind::Image(im) = &mut n.kind {
self.adopt_image_pixels(im);
}
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut())
&& holds(&m.art)
{
self.adopt_pixels(Arc::make_mut(&mut m.art));
}
for c in n.children_mut().into_iter().flatten() {
if holds(c) {
self.adopt_pixels(Arc::make_mut(c));
}
}
}
pub(crate) fn adopt_image_pixels(&mut self, im: &mut ImageObject) {
let Some(blob) = im.pixels.take() else { return };
let mut key = im.key.clone();
let mut i = 1;
while self.images.get(&key).is_some_and(|b| b.bytes != blob.bytes) {
i += 1;
key = format!("{}-{i}", im.key);
}
self.images.entry(key.clone()).or_insert_with(|| (*blob).clone());
im.key = key;
}
/// Saved selections as a file gives them, made safe to list and recall: at most
/// [`SavedSelection::MAX`], each with a clean, unique name and naming objects this document
/// has, each once. (An id it doesn't have would select whatever object takes that id later.)
pub fn tidy_saved_selections(&mut self) {
if self.saved_selections.is_empty() {
return;
}
let mut ids = std::collections::HashSet::new();
self.walk(|n| {
ids.insert(n.id);
});
let mut kept: Vec<SavedSelection> = vec![];
for s in std::mem::take(&mut self.saved_selections) {
if kept.len() >= SavedSelection::MAX {
break;
}
let Some(name) = SavedSelection::clean_name(&s.name) else { continue };
if kept.iter().any(|k| k.name == name) {
continue;
}
let mut seen = std::collections::HashSet::new();
let objects = s.objects.into_iter().filter(|id| ids.contains(id) && seen.insert(*id)).collect();
kept.push(SavedSelection { name, objects });
}
self.saved_selections = kept.into();
}
/// Saved views as a file gives them, made safe to list and go to: at most [`SavedView::MAX`],
/// each with a clean, unique name, a finite centre and a finite, positive zoom (a rotation
/// that isn't finite becomes 0°).
pub fn tidy_views(&mut self) {
if self.views.is_empty() {
return;
}
let mut kept: Vec<SavedView> = vec![];
for v in std::mem::take(&mut self.views) {
if kept.len() >= SavedView::MAX {
break;
}
let Some(name) = SavedView::clean_name(&v.name) else { continue };
if !v.is_usable() || kept.iter().any(|k| k.name == name) {
continue;
}
let rotation = if v.rotation.is_finite() { v.rotation } else { 0.0 };
kept.push(SavedView { name, rotation, ..v });
}
self.views = kept;
}
/// Leave opacity-mask editing: drop the temporary editing layer, a working copy of art the
/// mask already holds (the engine syncs it after every edit).
pub fn drop_edit_modes(&mut self) {
if let Some(me) = self.mask_edit.take() {
self.layers.retain(|l| l.id != me.layer);
}
}
/// The document as saved and exported: without the opacity-mask editing layer. Borrowed when
/// no mask is being edited. (Pattern editing is saved: its tile layer holds unapplied edits.)
pub fn without_edit_modes(&self) -> std::borrow::Cow<'_, Document> {
if self.mask_edit.is_none() {
return std::borrow::Cow::Borrowed(self);
}
let mut d = self.clone();
d.drop_edit_modes();
std::borrow::Cow::Owned(d)
}
/// Round every path's anchors and handles to `decimals` decimal places: path objects (with
/// Live Corners' uncut outlines), blend spines, envelopes' top-object outlines and Intertwine
/// areas, in the layers, symbol definitions, graph designs, pattern swatches (the one being
/// edited included), opacity masks and the brushes kept as foreign data. Colors, transforms,
/// opacity, live shapes' own sizes, meshes and type (its frames and paths included) are left
/// as they are.
pub fn round_geometry(&mut self, decimals: u8) {
let scale = 10f64.powi(i32::from(decimals));
let round = |v: f64| {
let r = (v * scale).round() / scale;
if !r.is_finite() {
v
} else if r == 0.0 {
// `-0.0` is written as `0.0`.
0.0
} else {
r
}
};
let round_path = |path: &mut vectorcraft_geom::PathData| {
for a in path.subpaths.iter_mut().flat_map(|s| s.anchors.iter_mut()) {
for p in [&mut a.p, &mut a.h_in, &mut a.h_out] {
*p = Point::new(round(p.x), round(p.y));
}
}
};
let mut fix = |n: &mut Node| {
match &mut n.kind {
NodeKind::Path { path, live, .. } => {
round_path(path);
// Live Corners' uncut outline.
if let Some(LiveShape::Path { base, .. }) = live.as_deref_mut() {
round_path(base);
}
}
NodeKind::Blend { spec, .. } => {
if let Some(spine) = &mut spec.spine {
round_path(spine);
}
}
NodeKind::Envelope { kind, .. } => {
if let EnvelopeKind::TopObject { path } = kind.as_mut() {
round_path(path);
}
}
_ => {}
}
if let Some(area) = n.extras_if_any().and_then(|e| e.intertwine.as_deref_mut()) {
round_path(area);
}
};
let roots = self
.layers
.iter_mut()
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
.chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut()))
.chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut()));
for root in roots {
placed_document::each_mut(Arc::make_mut(root), &mut fix);
}
if self.unknown.contains_key("brushes")
&& let Some(brushes) = self.unknown.get_mut("brushes")
{
round_json_paths(brushes, &round);
}
}
}
/// Round with `round` the anchors of the paths in `v`, nodes in their file form kept as JSON: every
/// `{"subpaths": [{"anchors": [{"p", "in", "out"}]}]}` outside type (`{"type": "text"}`), as
/// [`Document::round_geometry`] rounds the nodes it holds.
fn round_json_paths(v: &mut serde_json::Value, round: &impl Fn(f64) -> f64) {
use serde_json::Value;
match v {
Value::Object(o) => {
if o.get("type").and_then(Value::as_str) == Some("text") {
return;
}
if let Some(subpaths) = o.get_mut("subpaths").and_then(Value::as_array_mut) {
let anchors = subpaths.iter_mut().filter_map(|s| s.get_mut("anchors").and_then(Value::as_array_mut)).flatten();
for a in anchors.filter_map(Value::as_object_mut) {
let points = a.iter_mut().filter(|(k, _)| matches!(k.as_str(), "p" | "in" | "out")).filter_map(|(_, v)| v.as_array_mut());
for xy in points {
for n in xy.iter_mut().filter(|n| n.is_f64()) {
if let Some(r) = n.as_f64().and_then(|x| serde_json::Number::from_f64(round(x))) {
*n = Value::Number(r);
}
}
}
}
}
for c in o.values_mut() {
round_json_paths(c, round);
}
}
Value::Array(a) => {
for c in a {
round_json_paths(c, round);
}
}
_ => {}
}
}
/// Graphic styles.
impl Document {
/// Index of the graphic style named `name`.
pub fn graphic_style_index(&self, name: &str) -> Option<usize> {
self.graphic_styles.iter().position(|g| g.name == name)
}
/// The graphic style named `name`.
pub fn graphic_style(&self, name: &str) -> Option<&GraphicStyle> {
self.graphic_styles.iter().find(|g| g.name == name)
}
/// The graphic style with id `id` (0 never matches).
pub fn graphic_style_by_id(&self, id: u32) -> Option<&GraphicStyle> {
self.graphic_styles.iter().find(|g| g.id != 0 && g.id == id)
}
/// An id no graphic style has.
pub fn next_graphic_style_id(&self) -> u32 {
free_id(self.graphic_styles.iter().map(|g| g.id))
}
/// The id of graphic style `index`, assigning one if it has none yet.
pub fn graphic_style_id(&mut self, index: usize) -> u32 {
if self.graphic_styles[index].id == 0 {
self.graphic_styles[index].id = self.next_graphic_style_id();
}
self.graphic_styles[index].id
}
/// The default name of a new graphic style: the first free "Graphic Style N", N counting on
/// from the number of styles.
pub fn new_graphic_style_name(&self) -> String {
(self.graphic_styles.len() + 1..).map(|i| format!("Graphic Style {i}")).find(|n| self.graphic_style(n).is_none()).unwrap_or_default()
}
/// `base`, or the first free "`base` N" (N from 2) when a symbol already has that name.
pub fn unique_symbol_name(&self, base: &str) -> String {
let taken = |n: &str| self.symbols.iter().any(|s| s.name == n);
if !taken(base) {
return base.to_string();
}
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
}
/// Symbol Options › Registration for `name` (default: centre).
pub fn symbol_registration(&self, name: &str) -> SymbolRegistration {
self.unknown
.get(SYMBOL_REGISTRATION)
.and_then(|m| m.get(name))
.and_then(|v| v.as_u64())
.and_then(SymbolRegistration::from_u64)
.unwrap_or_default()
}
/// Set or clear (when centre) the registration point stored for `name`.
pub fn set_symbol_registration(&mut self, name: &str, reg: SymbolRegistration) {
let m = self.unknown.entry(SYMBOL_REGISTRATION.to_string()).or_insert_with(|| serde_json::json!({}));
let Some(o) = m.as_object_mut() else { return };
if reg.is_center() {
o.remove(name);
if o.is_empty() {
self.unknown.remove(SYMBOL_REGISTRATION);
}
} else {
o.insert(name.into(), serde_json::json!(reg.0));
}
}
/// Move the stored registration when a symbol is renamed.
pub fn rename_symbol_registration(&mut self, from: &str, to: &str) {
if from == to {
return;
}
let reg = self.symbol_registration(from);
self.set_symbol_registration(from, SymbolRegistration::CENTER);
self.set_symbol_registration(to, reg);
}
/// Symbol Options › Export Type for `name` (default: Movie Clip).
pub fn symbol_export_type(&self, name: &str) -> SymbolExportType {
self.unknown.get(SYMBOL_EXPORT_TYPE).and_then(|m| m.get(name)).and_then(SymbolExportType::from_value).unwrap_or_default()
}
/// Set or clear (when Movie Clip, the default) the export type stored for `name`.
pub fn set_symbol_export_type(&mut self, name: &str, kind: SymbolExportType) {
if kind.is_default() {
self.clear_symbol_export_type(name);
return;
}
let m = self.unknown.entry(SYMBOL_EXPORT_TYPE.to_string()).or_insert_with(|| serde_json::json!({}));
let Some(o) = m.as_object_mut() else { return };
o.insert(name.into(), serde_json::json!(kind.id()));
}
/// Drop any stored export type for `name` (Movie Clip default).
pub fn clear_symbol_export_type(&mut self, name: &str) {
let Some(m) = self.unknown.get_mut(SYMBOL_EXPORT_TYPE) else { return };
let Some(o) = m.as_object_mut() else { return };
o.remove(name);
if o.is_empty() {
self.unknown.remove(SYMBOL_EXPORT_TYPE);
}
}
/// Move the stored export type when a symbol is renamed.
pub fn rename_symbol_export_type(&mut self, from: &str, to: &str) {
if from == to {
return;
}
let kind = self.symbol_export_type(from);
self.clear_symbol_export_type(from);
self.set_symbol_export_type(to, kind);
}
/// Symbol Options › 9-slice guides for `name`: `None` when 9-slice scaling is off (default).
pub fn symbol_nine_slice(&self, name: &str) -> Option<SymbolNineSlice> {
self.unknown.get(SYMBOL_NINE_SLICE).and_then(|m| m.get(name)).and_then(SymbolNineSlice::from_value)
}
/// Turn 9-slice scaling on with `guides`, or off with `None`, for `name`.
pub fn set_symbol_nine_slice(&mut self, name: &str, guides: Option<SymbolNineSlice>) {
let Some(g) = guides else {
let Some(o) = self.unknown.get_mut(SYMBOL_NINE_SLICE).and_then(|m| m.as_object_mut()) else { return };
o.remove(name);
if o.is_empty() {
self.unknown.remove(SYMBOL_NINE_SLICE);
}
return;
};
let m = self.unknown.entry(SYMBOL_NINE_SLICE.to_string()).or_insert_with(|| serde_json::json!({}));
if let Some(o) = m.as_object_mut() {
o.insert(name.into(), g.to_value());
}
}
/// Move the stored 9-slice guides when a symbol is renamed.
pub fn rename_symbol_nine_slice(&mut self, from: &str, to: &str) {
if from == to {
return;
}
let g = self.symbol_nine_slice(from);
self.set_symbol_nine_slice(from, None);
self.set_symbol_nine_slice(to, g);
}
}
/// An id (never 0) none of `used` has: one past the largest, or, when that is the largest there
/// is (a damaged file), the smallest one free.
pub(crate) fn free_id(used: impl Iterator<Item = u32>) -> u32 {
let mut used: Vec<u32> = used.collect();
if let Some(next) = used.iter().max().copied().unwrap_or(0).checked_add(1) {
return next;
}
used.sort_unstable();
used.dedup();
// Ids 1, 2, 3… as far as they are all taken: the first that isn't follows them.
let taken = used.iter().filter(|&&id| id != 0).zip(1u32..).take_while(|&(&id, n)| id == n).count();
u32::try_from(taken).map_or(u32::MAX, |n| n.saturating_add(1))
}
impl Document {
/// Make the next id allocated at least `next`: ids handed out outside the layer tree (such
/// as an importer's opacity mask and pattern art), which [`Document::fix_next_id`] doesn't see.
pub fn reserve_ids(&mut self, next: u64) {
self.next_id = self.next_id.max(next.min(MAX_NEXT_ID));
}
}
+313
View File
@@ -0,0 +1,313 @@
use super::document_ext::free_id;
use super::guides::Guide;
use super::*;
impl Document {
/// Union of the geometric bounds of `ids` (their visual bounds when `visual`), with symbol
/// instances measured by their art ([`Node::geometric_bounds_with`]).
pub fn bounds_of(&self, ids: &[NodeId], visual: bool) -> Option<Rect> {
let symbols = self.symbol_boxes();
ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| {
vectorcraft_geom::union_opt(acc, if visual { n.visual_bounds_with(&symbols) } else { n.geometric_bounds_with(&symbols) })
})
}
/// Visual bounds of all art, with symbol instances measured by their art.
pub fn art_bounds(&self) -> Option<Rect> {
let symbols = self.symbol_boxes();
self.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, l.visual_bounds_with(&symbols)))
}
/// Artboard index containing point `p` (topmost = last), its edges included
/// ([`Document::artboard_holding`]).
pub fn artboard_at(&self, p: Point) -> Option<usize> {
self.artboard_holding(p, true)
}
/// Index of the artboard that holds point `p`: the first (the last when `topmost`) that holds it
/// inside or on its left or top edge, else the first (the last) that holds it by
/// [`artboard_holds`]: on its right or bottom edge, or within a millionth of a point outside an
/// edge. On an edge two artboards share, the artboard to the right of it or below it holds the
/// point, except at the end of the edge that lies on that artboard's bottom or right edge, such
/// as the point where both artboards' bottom edges meet; there the first (the last) holds it.
pub fn artboard_holding(&self, p: Point, topmost: bool) -> Option<usize> {
let pick = |on: &dyn Fn(Rect) -> bool| {
let on = |i: &usize| self.artboards.get(*i).is_some_and(|a| on(a.rect));
let mut all = 0..self.artboards.len();
if topmost { all.rfind(on) } else { all.find(on) }
};
pick(&|r| r.contains(p)).or_else(|| pick(&|r| artboard_holds(r, p)))
}
/// The art that moves with an artboard at `rect`: top-level objects (children of layers and
/// sublayers) lying entirely inside it, with symbol instances measured by their art. Locked and
/// hidden objects and layers stay put unless `locked_and_hidden` (Selection & Anchor Display ›
/// Move Locked and Hidden Artwork with Artboard).
pub fn art_on_artboard(&self, rect: Rect, locked_and_hidden: bool) -> Vec<NodeId> {
// Art drawn flush with the artboard can end up a rounding error outside it (a background
// 3e-14 pt past the top edge, #1295): a millionth of a point is far below anything visible.
let rect = rect.inflate(1e-6, 1e-6);
fn collect(n: &Node, s: &SymbolBoxes, rect: Rect, all: bool, out: &mut Vec<NodeId>) {
for c in n.children().into_iter().flatten().filter(|c| c.rides_with_artboard(all)) {
if c.is_layer() {
collect(c, s, rect, all, out);
} else if let Some(b) = c.geometric_bounds_with(s)
// Inside it, edges included: art flush with the right or bottom edge is on it too
// (`Rect::contains` leaves those out, #890).
&& b.x0 >= rect.x0
&& b.y0 >= rect.y0
&& b.x1 <= rect.x1
&& b.y1 <= rect.y1
{
out.push(c.id);
}
}
}
let symbols = self.symbol_boxes();
let mut art = vec![];
for l in self.layers.iter().filter(|l| l.rides_with_artboard(locked_and_hidden)) {
collect(l, &symbols, rect, locked_and_hidden, &mut art);
}
art
}
/// Does any artwork lie on `rect` (an artboard)? Artwork is any object under the layers,
/// hidden and locked ones and those on template layers included, whose visual bounds (symbol
/// instances measured by their art) meet `rect`; touching its edge counts. A group (not a clip
/// group) counts by its members, so one whose members sit on two other artboards leaves the one
/// between them empty. A clip group or clipping layer counts by its members inside its clip (and
/// by its clipping path where that paints). Guides (paths made into guides too) aren't artwork
/// (Delete Empty Artboards).
pub fn artboard_has_art(&self, rect: Rect) -> bool {
// As in `art_on_artboard`: a millionth of a point covers rounding at the edges.
let rect = rect.inflate(1e-6, 1e-6);
fn meets(b: Option<Rect>, r: Rect) -> bool {
b.is_some_and(|b| b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1)
}
fn has(c: &Node, s: &SymbolBoxes, rect: Rect) -> bool {
match &c.kind {
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
let Some((clip, rest)) = children.split_first() else { return false };
let paint = clip.clip_paint();
if (paint.fill.is_some() || paint.stroke.is_some()) && meets(clip.visual_bounds_with(s), rect) {
return true;
}
// The members show only inside the clip's box.
let Some(b) = c.visual_bounds_with(s).filter(|b| meets(Some(*b), rect)) else { return false };
let inside = Rect::new(b.x0.max(rect.x0), b.y0.max(rect.y0), b.x1.min(rect.x1), b.y1.min(rect.y1));
rest.iter().any(|m| has(m, s, inside))
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().any(|m| has(m, s, rect)),
NodeKind::Path { guide: true, .. } => false,
_ => meets(c.visual_bounds_with(s), rect),
}
}
let symbols = self.symbol_boxes();
self.layers.iter().any(|l| has(l, &symbols, rect))
}
/// The art that moves, scales or is cut with artboard `index`: [`Document::art_on_artboard`]
/// without the objects on another locked artboard, which stay unless `locked_and_hidden`, as
/// locked art does.
pub fn art_moving_with(&self, index: usize, locked_and_hidden: bool) -> Vec<NodeId> {
let Some(rect) = self.artboards.get(index).map(|a| a.rect) else { return vec![] };
let mut art = self.art_on_artboard(rect, locked_and_hidden);
let locks = self.locked_artboards_except(index);
if !locked_and_hidden && !locks.is_empty() {
art.retain(|id| !self.node(*id).is_some_and(|n| locks.holds(n)));
}
art
}
/// Where ruler guide `g` runs along its line (the y range of a vertical guide): across its
/// artboard for an artboard guide, None (the whole canvas) for a canvas guide or one whose
/// artboard is gone.
pub fn guide_span(&self, g: &Guide) -> Option<(f64, f64)> {
let r = self.artboards.iter().find(|a| Some(a.id) == g.artboard)?.rect;
Some(if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) })
}
/// The layer (or sublayer) ruler guide `g` is on, if it is on one this document has.
fn guide_layer(&self, g: &Guide) -> Option<NodeId> {
g.layer.filter(|l| self.node(*l).is_some_and(Node::is_layer))
}
/// Is ruler guide `g` shown: on no layer, or on a layer that (with the layers around it) is
/// visible? A guide whose layer is gone shows as one on no layer.
pub fn guide_shown(&self, g: &Guide) -> bool {
self.guide_layer(g).is_none_or(|l| self.is_visible(l))
}
/// Can ruler guide `g` be picked, moved and deleted: shown, and its layer unlocked?
pub fn guide_editable(&self, g: &Guide) -> bool {
self.guide_layer(g).is_none_or(|l| self.is_editable(l))
}
/// Put the ruler guides on no layer this document has (from files saved before guides had
/// layers, or of layers merged into `layer`) on layer `layer`. Returns how many.
pub fn rehome_guides(&mut self, layer: NodeId) -> usize {
let lost: Vec<bool> = self.guides.iter().map(|g| self.guide_layer(g).is_none()).collect();
let mut n = 0;
for (g, lost) in self.guides.iter_mut().zip(lost) {
if lost {
g.layer = Some(layer);
n += 1;
}
}
n
}
/// Put the ruler guides on no layer (from files saved before guides had layers), or on one that
/// is gone, on the top visible, unlocked layer ([`Self::default_layer`]). Returns how many.
pub fn adopt_guides(&mut self) -> usize {
self.default_layer().map_or(0, |home| self.rehome_guides(home))
}
/// Delete the ruler guides whose layer was deleted (they go with it), keeping `sel`'s guides in
/// step. Guides on no layer stay. Returns how many went.
pub fn drop_guides_of_deleted_layers(&mut self, sel: &mut Selection) -> usize {
let gone: Vec<bool> = self.guides.iter().map(|g| g.layer.is_some() && self.guide_layer(g).is_none()).collect();
self.retain_guides(sel, |i, _| !gone.get(i).copied().unwrap_or(false))
}
/// Copy the ruler guides of layer `from` and of its sublayers onto their copies in `copy` (made
/// from `from` by [`Self::reid`]: the same tree with fresh ids), as duplicating a layer copies
/// the objects on it. Returns how many.
pub fn copy_layer_guides(&mut self, from: &Node, copy: &Node) -> usize {
let layers = |n: &Node| {
let mut out = vec![];
n.walk(&mut |c| {
if c.is_layer() {
out.push(c.id);
}
});
out
};
let pairs: Vec<(NodeId, NodeId)> = layers(from).into_iter().zip(layers(copy)).collect();
let copies: Vec<Guide> = self
.guides
.iter()
.filter_map(|g| {
let to = pairs.iter().find(|(a, _)| Some(*a) == g.layer)?.1;
Some(Guide { layer: Some(to), ..g.clone() })
})
.collect();
let n = copies.len();
self.guides.extend(copies);
n
}
/// The ruler guides on layer `layer` (not its sublayers'), by index.
pub fn guides_on(&self, layer: NodeId) -> impl Iterator<Item = (usize, &Guide)> + '_ {
self.guides.iter().enumerate().filter(move |(_, g)| g.layer == Some(layer))
}
/// Does ruler guide `g` run past `p` (up to `tol` beyond its ends)?
pub fn guide_passes(&self, g: &Guide, p: Point, tol: f64) -> bool {
let along = if g.vertical { p.y } else { p.x };
self.guide_span(g).is_none_or(|(a, b)| along >= a - tol && along <= b + tol)
}
/// Keep the ruler guides `keep` accepts (by index), the selected ones left keeping their
/// place among the rest (the selected art stays selected). Returns how many went.
pub fn retain_guides(&mut self, sel: &mut Selection, keep: impl Fn(usize, &Guide) -> bool) -> usize {
let kept: Vec<bool> = self.guides.iter().enumerate().map(|(i, g)| keep(i, g)).collect();
// Where each kept guide ends up.
let (mut to, mut n) = (Vec::with_capacity(kept.len()), 0);
for k in &kept {
to.push(k.then_some(n));
n += usize::from(*k);
}
sel.guides = sel.guides.iter().filter_map(|i| to.get(*i).copied().flatten()).collect();
sel.key_guide = sel.key_guide.and_then(|i| to.get(i).copied().flatten());
let before = self.guides.len();
let mut flags = kept.into_iter();
self.guides.retain(|_| flags.next().unwrap_or(true));
before - self.guides.len()
}
/// Move the guides of artboard `id` by `d` (along with their artboard).
pub fn move_artboard_guides(&mut self, id: u32, d: Vec2) {
for g in self.guides.iter_mut().filter(|g| g.artboard == Some(id)) {
*g = g.moved(d);
}
}
/// Map the guides of artboard `id` through `xf`, an axis-aligned scale and move (Scale Artwork
/// with Artboard).
pub fn map_artboard_guides(&mut self, id: u32, xf: Affine) {
for g in self.guides.iter_mut().filter(|g| g.artboard == Some(id)) {
g.pos = if g.vertical { (xf * Point::new(g.pos, 0.0)).x } else { (xf * Point::new(0.0, g.pos)).y };
}
}
/// Copy the guides of artboard `from` onto artboard `to`, `d` away (with a copy of their
/// artboard).
pub fn copy_artboard_guides(&mut self, from: u32, to: u32, d: Vec2) {
let copies: Vec<Guide> =
self.guides.iter().filter(|g| g.artboard == Some(from)).map(|g| Guide { artboard: Some(to), ..g.moved(d) }).collect();
self.guides.extend(copies);
}
/// An id for a new artboard: one no artboard has, above the ids given out before and the ids of
/// the artboards deleted from the document ([`Document::retired_artboard_id`]). The id counts
/// as given out, so an artboard made after an undo does not take the id of one the undo took
/// away.
pub fn next_artboard_id(&self) -> u32 {
let id = free_id(self.artboards.iter().map(|a| a.id).chain([self.retired_artboard_id.get()]));
self.retired_artboard_id.raise(id);
id
}
/// Record that the artboard with `id` was deleted: no later artboard takes its id.
pub fn retire_artboard_id(&self, id: u32) {
self.retired_artboard_id.raise(id);
}
pub fn pattern(&self, name: &str) -> Option<&PatternDef> {
self.patterns.iter().find(|p| p.name == name)
}
pub fn pattern_mut(&mut self, name: &str) -> Option<&mut PatternDef> {
self.patterns.iter_mut().find(|p| p.name == name)
}
/// Deep-clone `node` with fresh ids for it and all descendants, its images' own pixels taken
/// in ([`Self::adopt_pixels`]).
pub fn reid(&mut self, node: &Node) -> Node {
let mut n = node.clone();
n.id = self.alloc_id();
if let NodeKind::Image(im) = &mut n.kind {
self.adopt_image_pixels(im);
}
// A copied annotation must never bind its source ids to unrelated art in a different
// document. Keep the measured geometry; new measurements can be linked after pasting.
if let Some(e) = n.extras_if_any()
&& let Some(dimension) = &mut e.dimension
{
dimension.source = None;
}
// A copy of a threaded frame is in no thread, so its first paragraph starts in it and its
// layout caches are dropped. A caller that keeps the copy as an object fills them again.
if let NodeKind::Text(t) = &mut n.kind
&& t.first_para_continues
{
t.first_para_continues = false;
t.cached_bounds = None;
t.cached_baselines.clear();
}
let mut renamed = std::collections::HashMap::new();
if let Some(ch) = n.children_mut() {
let old: Vec<Arc<Node>> = std::mem::take(ch);
*ch = old.iter().map(|c| Arc::new(self.reid(c))).collect();
renamed.extend(old.iter().zip(ch.iter()).map(|(o, c)| (o.id, c.id)));
}
// An Objects on Path group names its path and its objects by id: the copy names the copies.
if let Some(e) = n.extras_if_any()
&& let Some(on_path) = &mut e.objects_on_path
{
on_path.rename(&renamed);
}
n
}
}
+92
View File
@@ -0,0 +1,92 @@
use super::*;
/// A named graphic style (Graphic Styles panel): an appearance plus the object's transparency.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GraphicStyle {
pub name: String,
pub appearance: Appearance,
/// Stable id linked objects refer to ([`Node::graphic_style`]); 0 until first needed (styles
/// from older files get one when an object is first linked to them).
#[serde(default, skip_serializing_if = "skip::is_default")]
pub id: u32,
#[serde(default = "one", skip_serializing_if = "skip::is_one")]
pub opacity: f32,
#[serde(default, skip_serializing_if = "skip::is_default")]
pub blend: vectorcraft_color::BlendMode,
#[serde(default, skip_serializing_if = "skip::is_default")]
pub isolate: bool,
#[serde(default, skip_serializing_if = "skip::is_default")]
pub knockout: Knockout,
/// Placed gradients are stored relative to the unit box (0, 0)–(1, 1), so applying the style
/// places them on each object's own bounds. Styles saved before this kept document
/// coordinates (false).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub unit_box: bool,
}
fn one() -> f32 {
1.0
}
/// The name of the style new documents list first (Sort by Name keeps it first).
pub const DEFAULT_GRAPHIC_STYLE: &str = "Default Graphic Style";
impl GraphicStyle {
/// The box a style's placed gradients are stored relative to.
pub const UNIT_BOX: Rect = Rect::new(0.0, 0.0, 1.0, 1.0);
/// A style with `appearance` (its placed gradients, if any, in unit-box space) and default
/// transparency (no id yet).
pub fn new(name: impl Into<String>, appearance: Appearance) -> Self {
Self {
name: name.into(),
appearance,
id: 0,
opacity: 1.0,
blend: Default::default(),
isolate: false,
knockout: Knockout::Neutral,
unit_box: true,
}
}
/// A style capturing `n`'s transparency and `appearance` (in unit-box space).
pub fn of(name: impl Into<String>, appearance: Appearance, n: &Node) -> Self {
Self { opacity: n.opacity, blend: n.blend, isolate: n.isolate, knockout: n.knockout, ..Self::new(name, appearance) }
}
/// The style's appearance as object `n` takes it: placed gradients stored in unit-box space
/// land at the same place relative to `n`'s geometric bounds.
pub fn appearance_on(&self, n: &Node) -> std::borrow::Cow<'_, Appearance> {
let placed = self.unit_box && self.appearance.has_placed_gradient();
match n.geometric_bounds().filter(|_| placed) {
Some(b) => {
let mut ap = self.appearance.clone();
ap.rebase_gradients(Self::UNIT_BOX, b);
std::borrow::Cow::Owned(ap)
}
None => std::borrow::Cow::Borrowed(&self.appearance),
}
}
/// Give `n` this style's transparency.
pub fn apply_transparency(&self, n: &mut Node) {
(n.opacity, n.blend, n.isolate, n.knockout) = (self.opacity, self.blend, self.isolate, self.knockout);
}
/// Does `n` have this style's transparency?
pub fn transparency_matches(&self, n: &Node) -> bool {
(n.opacity, n.blend, n.isolate, n.knockout) == (self.opacity, self.blend, self.isolate, self.knockout)
}
}
pub(crate) fn default_graphic_styles() -> Vec<GraphicStyle> {
use vectorcraft_color::{Color, Paint};
let solid = |hex| Paint::solid(Color::from_hex(hex).unwrap_or(Color::BLACK));
[
GraphicStyle::new(DEFAULT_GRAPHIC_STYLE, Appearance::default_art()),
GraphicStyle::new("Black Outline", Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)),
GraphicStyle::new("Heavy Ink", Appearance::basic(solid("#1b1464"), Paint::solid(Color::BLACK), 4.0)),
GraphicStyle::new("Sunshine", Appearance::basic(solid("#fbb03b"), solid("#f15a24"), 2.0)),
]
.into_iter()
.zip(1..)
.map(|(g, id)| GraphicStyle { id, ..g })
.collect()
}
+58
View File
@@ -0,0 +1,58 @@
use super::*;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Guide {
/// true = vertical guide at `pos` (x), false = horizontal at `pos` (y).
pub vertical: bool,
pub pos: f64,
/// An artboard guide: the [`Artboard::id`] it belongs to. It runs across that artboard only
/// and moves, is copied and is deleted with it. None: a canvas guide, across the whole canvas.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artboard: Option<u32>,
/// The layer (or sublayer) it is on: it shows, hides and locks with that layer and is listed
/// in it in the Layers panel, and goes when the layer is deleted. None: on no layer (guides
/// from before guides had layers, and from files that don't say), always there.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layer: Option<NodeId>,
}
impl Guide {
/// A canvas guide.
pub fn new(vertical: bool, pos: f64) -> Self {
Self { vertical, pos, artboard: None, layer: None }
}
/// Moved by `d` (a vertical guide across, a horizontal one down).
pub fn moved(&self, d: Vec2) -> Self {
Self { pos: self.pos + if self.vertical { d.x } else { d.y }, ..self.clone() }
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GridPrefs {
pub spacing: f64,
pub subdivisions: u32,
}
impl Default for GridPrefs {
fn default() -> Self {
Self { spacing: 72.0, subdivisions: 8 }
}
}
/// What the rulers measure from (View › Rulers): the active artboard's origin, the default of a
/// new document, or one origin for the whole document.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RulerMode {
#[default]
Artboard,
Global,
}
impl RulerMode {
/// The mode of a document saved before the choice existed: global, as its rulers measured.
pub fn legacy() -> Self {
Self::Global
}
}
+1 -840
View File
@@ -731,843 +731,4 @@ fn marquee_in_clip(clip: &Node, r: Rect) -> Option<Rect> {
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Appearance, Node};
use std::sync::Arc;
use vectorcraft_color::{Color, Paint};
use vectorcraft_geom::shapes;
#[test]
fn hits_topmost() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let a = d.alloc_id();
d.insert(Some(l), 9, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
let b = d.alloc_id();
d.insert(Some(l), 9, Node::path(b, shapes::rectangle(Rect::new(25.0, 25.0, 75.0, 75.0)), Appearance::default_art())).unwrap();
let h = hit_test(&d, Point::new(30.0, 30.0), HitOptions::default()).unwrap();
assert_eq!(h.leaf, b);
assert_eq!(h.kind, HitKind::Fill);
let h = hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).unwrap();
assert_eq!(h.leaf, a);
assert!(hit_test(&d, Point::new(150.0, 150.0), HitOptions::default()).is_none());
// Outline mode: interior misses.
assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions { outline: true, ..Default::default() }).is_none());
assert!(hit_test(&d, Point::new(0.5, 10.0), HitOptions { outline: true, ..Default::default() }).is_some());
}
#[test]
fn unfilled_interior_misses() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let a = d.alloc_id();
d.insert(
Some(l),
9,
Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)),
)
.unwrap();
assert!(hit_test(&d, Point::new(25.0, 25.0), HitOptions::default()).is_none());
assert!(hit_test(&d, Point::new(50.5, 25.0), HitOptions::default()).is_some());
}
/// #1103: a marquee drawn over a background picks what is on it, not the background, whose
/// outline it doesn't touch.
#[test]
fn a_marquee_inside_a_background_leaves_the_background() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let (back, small) = (d.alloc_id(), d.alloc_id());
d.insert(Some(l), 0, Node::path(back, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Appearance::default_art())).unwrap();
d.insert(Some(l), 1, Node::path(small, shapes::rectangle(Rect::new(50.0, 50.0, 60.0, 60.0)), Appearance::default_art())).unwrap();
assert_eq!(marquee(&d, Rect::new(40.0, 40.0, 70.0, 70.0), None, false), vec![small]);
// Reaching the background's edge takes it too.
assert_eq!(marquee(&d, Rect::new(-5.0, 40.0, 70.0, 70.0), None, false), vec![back, small]);
}
#[test]
fn group_top_object() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let a = d.alloc_id();
let g = d.alloc_id();
let p = Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art());
d.insert(Some(l), 0, Node::group(g, vec![Arc::new(p)])).unwrap();
let h = hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).unwrap();
assert_eq!(h.leaf, a);
assert_eq!(h.top_object(None), g);
assert_eq!(h.top_object(Some(g)), a);
assert_eq!(marquee(&d, Rect::new(-5.0, -5.0, 5.0, 5.0), None, false), vec![g]);
assert_eq!(marquee(&d, Rect::new(-5.0, -5.0, 5.0, 5.0), None, true), vec![a]);
assert!(marquee(&d, Rect::new(100.0, 100.0, 105.0, 105.0), None, false).is_empty());
}
/// The art of a 200 pt wide rectangle clipped to its left half (0..100 × 0..100): the mask and
/// the art, in that order.
fn half_clipped(d: &mut Document) -> (Node, Node) {
let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default());
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 100.0)), Appearance::default_art());
(mask, art)
}
/// #2345: a marquee touches a clip group only where its art shows. Over the clipped-away
/// half alone it selects nothing, and around a rectangle drawn beside the group's mask it
/// takes that rectangle alone; reaching into the mask takes the group. The same holds for
/// Group Selection's leaves and in isolation mode.
#[test]
fn a_marquee_leaves_a_clip_group_by_its_clipped_away_art() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
let (mask, art) = half_clipped(&mut d);
let (g, art_id, beside) = (d.alloc_id(), art.id, d.alloc_id());
let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]);
clip.set_clips(true);
d.insert(Some(l), 0, clip).unwrap();
d.insert(Some(l), 1, Node::path(beside, shapes::rectangle(Rect::new(150.0, 80.0, 190.0, 130.0)), Appearance::default_art())).unwrap();
// This marquee crosses only the hidden art's top and right edges.
let hidden = Rect::new(150.0, -10.0, 210.0, 50.0);
assert!(marquee(&d, hidden, None, false).is_empty());
assert!(marquee(&d, hidden, None, true).is_empty());
assert!(marquee(&d, hidden, Some(g), false).is_empty());
assert!(marquee(&d, hidden, Some(art_id), false).is_empty());
// This marquee surrounds the rectangle beside and crosses the hidden art's bottom edge.
assert_eq!(marquee(&d, Rect::new(140.0, 70.0, 195.0, 140.0), None, false), vec![beside]);
// This marquee crosses the shown half's top edge and goes on into the hidden half.
let shown = Rect::new(50.0, -10.0, 160.0, 10.0);
assert_eq!(marquee(&d, shown, None, false), vec![g]);
assert!(marquee(&d, shown, None, true).contains(&art_id));
assert_eq!(marquee(&d, shown, Some(g), false).len(), 2, "the mask and the art");
}
/// #2345: in the corner of a diamond mask's box, outside the diamond, a marquee over the
/// clipped art's corner leaves the clip group. An open mask clips to its area closed by a
/// straight line, and a marquee across that line takes the art it reaches inside.
#[test]
fn a_marquee_must_meet_the_clipping_path_itself() {
// A clip group of a mask through `pts` (closed or open) over the art `art`.
let clipped = |pts: &[(f64, f64)], closed: bool, art: Rect| {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let mut mask = vectorcraft_geom::BezPath::new();
mask.move_to(pts[0]);
for &p in &pts[1..] {
mask.line_to(p);
}
if closed {
mask.close_path();
}
let mask = Node::path(d.alloc_id(), vectorcraft_geom::PathData::from_bezpath(&mask), Appearance::default());
let art = Node::path(d.alloc_id(), shapes::rectangle(art), Appearance::default_art());
let g = d.alloc_id();
let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]);
clip.set_clips(true);
d.insert(Some(l), 0, clip).unwrap();
(d, g)
};
let (d, g) = clipped(&[(50.0, 0.0), (100.0, 50.0), (50.0, 100.0), (0.0, 50.0)], true, Rect::new(0.0, 0.0, 100.0, 100.0));
assert!(marquee(&d, Rect::new(-10.0, -10.0, 10.0, 10.0), None, false).is_empty());
assert_eq!(marquee(&d, Rect::new(-10.0, 40.0, 10.0, 60.0), None, false), vec![g], "over the diamond's left point");
// The mask is the triangle above the line from (0, 0) to (100, 100), and the art lies inside it.
let (d, g) = clipped(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false, Rect::new(55.0, 40.0, 70.0, 50.0));
assert_eq!(marquee(&d, Rect::new(40.0, 45.0, 60.0, 70.0), None, false), vec![g]);
}
/// #2345: the objects of a layer or sublayer with a clipping mask are touched only where the
/// mask shows them, as in a clip group.
#[test]
fn a_marquee_leaves_art_a_layer_clips_away() {
for sublayer in [false, true] {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
let beside = d.alloc_id();
d.insert(Some(l), 0, Node::path(beside, shapes::rectangle(Rect::new(150.0, 80.0, 190.0, 130.0)), Appearance::default_art())).unwrap();
let masked = d.alloc_id();
let mut layer = Node::layer(masked, "Masked", crate::LayerColor::Preset(2));
layer.set_clips(true);
d.insert(sublayer.then_some(l), 9, layer).unwrap();
let (mask, art) = half_clipped(&mut d);
let (mask_id, art_id) = (mask.id, art.id);
d.insert(Some(masked), 0, mask).unwrap();
d.insert(Some(masked), 1, art).unwrap();
assert!(marquee(&d, Rect::new(150.0, -10.0, 210.0, 50.0), None, false).is_empty(), "sublayer: {sublayer}");
assert_eq!(marquee(&d, Rect::new(140.0, 70.0, 195.0, 140.0), None, false), vec![beside], "sublayer: {sublayer}");
assert_eq!(marquee(&d, Rect::new(50.0, -10.0, 160.0, 10.0), None, false), vec![mask_id, art_id], "sublayer: {sublayer}");
}
}
/// A marquee touches a Shaper Group only by the art it shows, as clicks do ([`may_hit`]): over
/// the original shape of a face the group erased it selects nothing, with Group Selection's
/// leaves too, and over a face it shows it takes the group. With the original shapes isolated
/// (Construction mode) it takes the original shape it reaches.
#[test]
fn a_marquee_leaves_a_shaper_group_by_its_original_shapes() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
// Two original squares, 0..100 and 50..150; the group shows only the first one's face.
let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0));
let a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
let b = Node::path(d.alloc_id(), square(50.0), Appearance::default_art());
let b_id = b.id;
let mut sources = Node::group(d.alloc_id(), vec![Arc::new(a), Arc::new(b)]);
sources.name = Some(crate::shaper::SOURCES.into());
sources.opacity = 0.0;
let sources_id = sources.id;
let mut face = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
face.name = Some(format!("{}0", crate::shaper::FILL_PREFIX));
let face_id = face.id;
let g = d.alloc_id();
let mut group = Node::group(g, vec![Arc::new(sources), Arc::new(face)]);
group.extras_mut().shaper = Some(Box::default());
d.insert(Some(l), 0, group).unwrap();
// This marquee crosses only the right edge of the second square, whose face is erased.
let erased = Rect::new(140.0, 40.0, 160.0, 60.0);
assert!(hit_test(&d, Point::new(150.0, 50.0), HitOptions::default()).is_none(), "a click there misses");
assert!(marquee(&d, erased, None, false).is_empty());
assert!(marquee(&d, erased, None, true).is_empty());
assert!(marquee(&d, erased, Some(g), false).is_empty());
// This marquee crosses the shown face's left edge.
let shown = Rect::new(-10.0, 40.0, 10.0, 60.0);
assert_eq!(marquee(&d, shown, None, false), vec![g]);
assert_eq!(marquee(&d, shown, None, true), vec![face_id]);
assert_eq!(marquee(&d, shown, Some(g), false), vec![face_id]);
// Construction mode: the original shapes are the objects.
assert_eq!(marquee(&d, erased, Some(sources_id), false), vec![b_id]);
}
/// A marquee touches an Intertwine group by its members, as it touches a plain group (#1103) and
/// as clicks do: in the gap between two members, or inside one member's fill, it selects nothing.
/// Across a member's edge it selects the group, and with the leaves that Direct and Group
/// Selection take, or in isolation mode, it selects the member.
#[test]
fn a_marquee_meets_an_intertwine_group_by_its_members() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
// Two squares, 0..100 and 150..250, with a gap between them inside the group's box.
let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0));
let mut a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
a.extras_mut().intertwine = Some(Box::new(shapes::rectangle(Rect::new(50.0, 0.0, 100.0, 100.0))));
let b = Node::path(d.alloc_id(), square(150.0), Appearance::default_art());
let (a_id, g) = (a.id, d.alloc_id());
d.insert(Some(l), 0, Node::new(g, NodeKind::Intertwine { children: vec![Arc::new(a), Arc::new(b)] })).unwrap();
let gap = Rect::new(110.0, 40.0, 140.0, 60.0);
assert!(hit_test(&d, gap.center(), HitOptions::default()).is_none(), "a click in the gap misses");
assert!(marquee(&d, gap, None, false).is_empty());
assert!(marquee(&d, gap, None, true).is_empty());
assert!(marquee(&d, gap, Some(g), false).is_empty());
assert!(marquee(&d, Rect::new(20.0, 20.0, 40.0, 40.0), None, false).is_empty(), "inside a member's fill");
// This marquee crosses the first square's left edge.
let edge = Rect::new(-10.0, 40.0, 10.0, 60.0);
assert_eq!(marquee(&d, edge, None, false), vec![g]);
assert_eq!(marquee(&d, edge, None, true), vec![a_id]);
assert_eq!(marquee(&d, edge, Some(g), false), vec![a_id]);
}
/// A marquee touches a repeat by the copies it draws, as clicks do: between two copies of a
/// grid repeat it selects nothing, and across a copy's edge it takes the repeat.
#[test]
fn a_marquee_meets_a_repeat_by_its_copies() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
let source = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art());
// Copies at 0..20, 40..60 and 80..100.
let kind = crate::pattern::RepeatKind::Grid {
h_spacing: 20.0,
v_spacing: 20.0,
rows: 1,
cols: 3,
grid_type: crate::pattern::TileType::Grid,
flip_rows: false,
flip_cols: false,
};
let r = d.alloc_id();
d.insert(Some(l), 0, Node::new(r, NodeKind::Repeat(crate::pattern::RepeatSpec { kind, source: vec![Arc::new(source)] }))).unwrap();
let gap = Rect::new(25.0, 5.0, 35.0, 15.0);
assert!(hit_test(&d, gap.center(), HitOptions::default()).is_none(), "a click between copies misses");
assert!(marquee(&d, gap, None, false).is_empty());
assert!(marquee(&d, gap, None, true).is_empty());
assert_eq!(marquee(&d, Rect::new(35.0, 5.0, 45.0, 15.0), None, false), vec![r], "across the second copy's left edge");
assert_eq!(marquee(&d, Rect::new(35.0, 5.0, 45.0, 15.0), None, true), vec![r]);
}
/// A blend and an envelope are clicked anywhere in their box, and a marquee touches them there
/// too: inside the box, away from every key object's and the content's outline, it takes them.
#[test]
fn a_marquee_meets_blends_and_envelopes_by_their_box() {
let mut d = Document::new(300.0, 300.0);
let l = d.layers[0].id;
let square = |id, x: f64, y: f64| Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, y, x + 20.0, y + 20.0)), Appearance::default_art()));
let blend = d.alloc_id();
let keys = vec![square(d.alloc_id(), 0.0, 0.0), square(d.alloc_id(), 100.0, 0.0)];
d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Default::default() })).unwrap();
let envelope = d.alloc_id();
let content = vec![Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 150.0, 100.0, 250.0)), Appearance::default_art()))];
let e = Node::new(
envelope,
NodeKind::Envelope {
content,
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }),
fidelity: 100.0,
editing: false,
options: Default::default(),
frame: Box::new(vectorcraft_geom::Affine::IDENTITY),
},
);
d.insert(Some(l), 1, e).unwrap();
for (r, id) in [(Rect::new(50.0, 5.0, 60.0, 15.0), blend), (Rect::new(40.0, 190.0, 60.0, 210.0), envelope)] {
assert_eq!(hit_test(&d, r.center(), HitOptions::default()).map(|h| h.leaf), Some(id));
assert_eq!(marquee(&d, r, None, false), vec![id]);
assert_eq!(marquee(&d, r, None, true), vec![id]);
}
}
/// A rectangle (40, 40)–(140, 140) clipping a filled 200 × 200 square drawn over its edges, in a
/// clip group (or, `layer`, as the first layer's clipping mask): the group (or the layer), the
/// clipping path and the art.
fn masked_square(layer: bool) -> (Document, NodeId, NodeId, NodeId) {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let (g, clip, art) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
let mut path = Node::path(clip, shapes::rectangle(Rect::new(40.0, 40.0, 140.0, 140.0)), Appearance::basic(Paint::None, Paint::None, 0.0));
if let NodeKind::Path { clipping, .. } = &mut path.kind {
*clipping = true;
}
let picture = Node::path(art, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Appearance::default_art());
if layer {
d.insert(Some(l), 0, path).unwrap();
d.insert(Some(l), 1, picture).unwrap();
d.node_mut(l).unwrap().set_clips(true);
(d, l, clip, art)
} else {
d.insert(Some(l), 0, Node::new(g, NodeKind::Group { children: vec![Arc::new(path), Arc::new(picture)], clip: true })).unwrap();
(d, g, clip, art)
}
}
const SELECTING: HitOptions =
HitOptions { tol: 3.0, outline: false, path_only: false, type_path_only: false, skip_guides: false, clip_outlines: true, scope: None };
/// For the selection tools a clip group's clipping path is hit on its outline, within the
/// tolerance, before the art it clips and just outside it too; inside, the art is hit.
#[test]
fn a_clipping_path_is_hit_on_its_outline_before_its_art() {
let (d, g, clip, art) = masked_square(false);
for (x, y) in [(140.0, 90.0), (141.5, 90.0), (138.5, 90.0), (90.0, 40.0)] {
let h = hit_test(&d, Point::new(x, y), SELECTING).unwrap();
assert_eq!((h.leaf, h.kind, h.top_object(None)), (clip, HitKind::Outline, g), "at {x}, {y}");
}
let h = hit_test(&d, Point::new(90.0, 90.0), SELECTING).unwrap();
assert_eq!((h.leaf, h.top_object(None)), (art, g));
// Beyond the tolerance outside it, the clipped art is not there to hit.
assert!(hit_test(&d, Point::new(150.0, 90.0), SELECTING).is_none());
}
/// Without `clip_outlines` (the tools that act on what is drawn) and for read-only sampling
/// (the eyedropper), the art is hit on the mask's edge, as before: the clipping path paints
/// nothing there.
#[test]
fn only_the_selection_tools_hit_a_clipping_path_on_its_outline() {
let (d, _, clip, art) = masked_square(false);
let p = Point::new(138.5, 90.0);
assert_eq!(hit_test(&d, p, HitOptions::default()).map(|h| h.leaf), Some(art));
assert_eq!(hit_test_visible(&d, p, HitOptions::default()).map(|h| h.leaf), Some(art));
assert_eq!(hit_test_visible(&d, p, SELECTING).map(|h| h.leaf), Some(art));
assert_eq!(hit_test(&d, p, SELECTING).map(|h| h.leaf), Some(clip));
// A hidden or locked clipping path is passed over.
for change in [|n: &mut Node| n.visible = false, |n: &mut Node| n.locked = true] {
let mut d = d.clone();
change(d.node_mut(clip).unwrap());
assert_ne!(hit_test(&d, p, SELECTING).map(|h| h.leaf), Some(clip));
}
}
/// A layer's clipping mask is hit on its outline too, and Select Behind lists what the
/// repeated hit test finds: the mask on and just outside its edge, then the art inside it;
/// well inside, the art alone (the mask paints nothing there).
#[test]
fn select_behind_takes_a_layer_masks_outline_first() {
let (d, _, clip, art) = masked_square(true);
for (x, want) in [(140.0, vec![clip]), (141.5, vec![clip]), (138.5, vec![clip, art]), (90.0, vec![art])] {
let p = Point::new(x, 90.0);
assert_eq!(objects_at(&d, p, SELECTING, None), want, "x = {x}");
assert_eq!(objects_at_repeated(&d, p, SELECTING, None), want, "x = {x}");
}
// Without clip_outlines, as before.
assert_eq!(objects_at(&d, Point::new(138.5, 90.0), HitOptions::default(), None), vec![art, clip]);
}
/// Art in a sublayer is an object of its own: a click or a marquee takes it, not the
/// sublayer; hidden, locked and template sublayers block both.
#[test]
fn sublayers_are_not_objects() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let sub = d.alloc_id();
d.insert(Some(l), 0, Node::layer(sub, "Sub", crate::LayerColor::Preset(2))).unwrap();
let inner = d.alloc_id();
d.insert(Some(sub), 0, Node::layer(inner, "Inner", crate::LayerColor::Preset(3))).unwrap();
let a = d.alloc_id();
d.insert(Some(inner), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
let g = d.alloc_id();
let b = d.alloc_id();
let pb = Node::path(b, shapes::rectangle(Rect::new(100.0, 0.0, 150.0, 50.0)), Appearance::default_art());
d.insert(Some(sub), 9, Node::group(g, vec![Arc::new(pb)])).unwrap();
let h = hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).unwrap();
assert_eq!((h.leaf, h.top_object(None), h.layers), (a, a, 3));
let h = hit_test(&d, Point::new(110.0, 10.0), HitOptions::default()).unwrap();
assert_eq!((h.top_object(None), h.top_object(Some(g))), (g, b));
let all = Rect::new(-5.0, -5.0, 200.0, 200.0);
assert_eq!(marquee(&d, all, None, false), vec![a, g]);
assert_eq!(d.layer_containing(a), Some(inner));
assert_eq!(d.layer_color(a), crate::LayerColor::Preset(3).rgb(), "the sublayer's own colour");
assert_eq!(d.selectable_art(), vec![a, g]);
for f in [
|n: &mut Node| n.visible = false,
|n: &mut Node| n.locked = true,
|n: &mut Node| {
if let NodeKind::Layer { template, .. } = &mut n.kind {
*template = true;
}
},
] {
let mut d2 = d.clone();
f(d2.node_mut(inner).unwrap());
assert!(hit_test(&d2, Point::new(10.0, 10.0), HitOptions::default()).is_none());
assert_eq!(marquee(&d2, all, None, false), vec![g]);
assert_eq!(d2.selectable_art(), vec![g]);
}
// A sublayer whose Preview is off is clicked in outline: its fill no longer hits.
if let NodeKind::Layer { preview, .. } = &mut d.node_mut(inner).unwrap().kind {
*preview = false;
}
assert!(hit_test(&d, Point::new(25.0, 25.0), HitOptions::default()).is_none());
assert!(hit_test(&d, Point::new(0.5, 25.0), HitOptions::default()).is_some());
}
/// Object Selection by Path Only: a filled path or compound path hits on its outline only.
#[test]
fn path_only_leaves_out_fills() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let (a, c) = (d.alloc_id(), d.alloc_id());
d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
let inner = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 0.0, 150.0, 50.0)), Appearance::default());
let mut compound = Node::new(c, NodeKind::Compound { children: vec![Arc::new(inner)], rule: vectorcraft_geom::FillRule::NonZero });
compound.appearance = Appearance::default_art().into();
d.insert(Some(l), 9, compound).unwrap();
let path_only = HitOptions { path_only: true, ..Default::default() };
for (id, inside, edge) in [(a, Point::new(25.0, 25.0), Point::new(0.5, 25.0)), (c, Point::new(125.0, 25.0), Point::new(100.5, 25.0))] {
assert_eq!(hit_test(&d, inside, HitOptions::default()).map(|h| h.leaf), Some(id), "the fill hits");
assert!(hit_test(&d, inside, path_only).is_none(), "path only: the fill of {id:?} doesn't hit");
assert_eq!(hit_test(&d, edge, path_only).map(|h| h.leaf), Some(id), "path only: the outline does");
}
}
/// #3291: with Object Selection by Path Only, a placed image is picked on its frame's edge, so
/// a path under it can be picked through it.
#[test]
fn path_only_picks_paths_through_images() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let (path, image) = (d.alloc_id(), d.alloc_id());
d.insert(Some(l), 0, Node::path(path, shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)), Appearance::default_art())).unwrap();
// 100×50 pixels, scaled to 100×100 and moved to (10, 10): over the whole path.
let xf = vectorcraft_geom::Affine::translate((10.0, 10.0)) * vectorcraft_geom::Affine::scale_non_uniform(1.0, 2.0);
let im = crate::ImageObject { key: "k".into(), width: 100, height: 50, xf, link: None, placement: Default::default(), pixels: None };
d.insert(Some(l), 1, Node::new(image, NodeKind::Image(Box::new(im)))).unwrap();
let path_only = HitOptions { path_only: true, ..Default::default() };
let hit = |p: Point, opt| hit_test(&d, p, opt).map(|h| h.leaf);
let (path_edge, inside, image_edge) = (Point::new(20.5, 40.0), Point::new(80.0, 80.0), Point::new(80.0, 109.5));
assert_eq!(hit(path_edge, HitOptions::default()), Some(image), "the image covers the path");
assert_eq!(hit(inside, HitOptions::default()), Some(image));
assert_eq!(hit(path_edge, path_only), Some(path), "path only: the path through the image");
assert_eq!(hit(inside, path_only), None, "path only: not inside the image");
assert_eq!(hit(image_edge, path_only), Some(image), "path only: the image's frame");
}
/// Type Object Selection by Path Only: point type is picked on its baseline, area type on its
/// frame and type on a path on its path, no longer anywhere in its bounds.
#[test]
fn type_path_only_picks_type_on_its_path() {
use crate::{CharStyle, TextKind, TextObject};
let mut d = Document::new(400.0, 400.0);
let l = d.layers[0].id;
let mut add = |t: TextObject| {
let id = d.alloc_id();
d.insert(Some(l), 9, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap();
id
};
// Point type at (10, 100), 20 pt: its glyphs rise above the baseline, its layout is 60 wide.
let mut point = TextObject::point(Point::new(10.0, 100.0), "Hello", CharStyle { size: 20.0, ..CharStyle::default() });
point.cached_bounds = Some(Rect::new(0.0, -16.0, 60.0, 4.0));
let point = add(point);
let mut area = TextObject::point(Point::new(200.0, 200.0), "Area", CharStyle::default());
area.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)) };
let area = add(area);
let mut on_path = TextObject::point(Point::new(10.0, 300.0), "Path", CharStyle::default());
on_path.kind = TextKind::OnPath { path: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 40.0)), start: 0.0, end: None };
let on_path = add(on_path);
let path_only = HitOptions { type_path_only: true, ..Default::default() };
let hit = |p: Point, opt: HitOptions| hit_test(&d, p, opt).map(|h| (h.leaf, h.kind));
// Off: anywhere in the bounds.
assert_eq!(hit(Point::new(40.0, 90.0), HitOptions::default()), Some((point, HitKind::Bounds)));
assert_eq!(hit(Point::new(250.0, 225.0), HitOptions::default()), Some((area, HitKind::Bounds)));
assert_eq!(hit(Point::new(60.0, 320.0), HitOptions::default()), Some((on_path, HitKind::Bounds)));
// On: the glyphs and the inside of the frame or path no longer pick the type.
assert_eq!(hit(Point::new(40.0, 90.0), path_only), None, "point type: among its glyphs");
assert_eq!(hit(Point::new(250.0, 225.0), path_only), None, "area type: inside its frame");
assert_eq!(hit(Point::new(60.0, 320.0), path_only), None, "type on a path: inside its path");
// The baseline (within the tolerance, across the layout's width), frame and path do.
assert_eq!(hit(Point::new(40.0, 101.5), path_only), Some((point, HitKind::Outline)), "point type: its baseline");
assert_eq!(hit(Point::new(75.0, 100.0), path_only), None, "point type: past the end of its layout");
assert_eq!(hit(Point::new(250.0, 200.5), path_only), Some((area, HitKind::Outline)), "area type: its frame");
assert_eq!(hit(Point::new(60.0, 339.5), path_only), Some((on_path, HitKind::Outline)), "type on a path: its path");
}
/// Type Object Selection by Path Only with the layout's baselines: every line's for point type,
/// area type's besides its frame, a vertical column's centre line, and type in perspective
/// where it is drawn.
#[test]
fn type_path_only_picks_every_baseline() {
use crate::{CharStyle, PerspectiveAttachment, TextKind, TextObject};
use vectorcraft_geom::{Affine, Homography};
let mut d = Document::new(400.0, 400.0);
let l = d.layers[0].id;
let mut add = |t: TextObject, perspective: Option<Affine>| {
let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
n.extras_mut().perspective = perspective.map(|a| {
Box::new(PerspectiveAttachment { projection: Some(Homography::from_affine(a).to_array()), ..PerspectiveAttachment::new("left", 0.0) })
});
let id = n.id;
d.insert(Some(l), 9, n).unwrap();
id
};
let style = CharStyle { size: 20.0, ..CharStyle::default() };
// Two lines of point type at (10, 100), 24 pt apart.
let mut point = TextObject::point(
Point::new(10.0, 100.0),
"Hello
world",
style.clone(),
);
point.cached_bounds = Some(Rect::new(0.0, -16.0, 60.0, 28.0));
point.cached_baselines = vec![(Point::ZERO, Point::new(60.0, 0.0)), (Point::new(0.0, 24.0), Point::new(50.0, 24.0))];
let point = add(point, None);
// Area type: a line inside its frame.
let mut area = TextObject::point(Point::new(200.0, 50.0), "Area", style.clone());
area.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)) };
area.cached_baselines = vec![(Point::new(0.0, 16.0), Point::new(40.0, 16.0))];
let area = add(area, None);
// Vertical point type at (300, 200) with no baselines cached: down its first column.
let mut vertical = TextObject::point(Point::new(300.0, 200.0), "Tate", style.clone());
vertical.vertical = true;
vertical.cached_bounds = Some(Rect::new(-10.0, 0.0, 10.0, 80.0));
let vertical = add(vertical, None);
// Point type drawn 100 to the right of where it is laid out.
let mut moved = TextObject::point(Point::new(10.0, 350.0), "Moved", style);
moved.cached_bounds = Some(Rect::new(0.0, -16.0, 60.0, 4.0));
let moved = add(moved, Some(Affine::translate((100.0, 0.0))));
let path_only = HitOptions { type_path_only: true, ..Default::default() };
let hit = |p: Point| hit_test(&d, p, path_only).map(|h| h.leaf);
assert_eq!(hit(Point::new(40.0, 100.0)), Some(point), "the first line's baseline");
assert_eq!(hit(Point::new(40.0, 125.0)), Some(point), "the second line's");
assert_eq!(hit(Point::new(40.0, 112.0)), None, "between the lines");
assert_eq!(hit(Point::new(65.0, 124.0)), None, "past the end of the shorter second line");
assert_eq!(hit(Point::new(220.0, 66.0)), Some(area), "area type: a line's baseline");
assert_eq!(hit(Point::new(220.0, 80.0)), None, "area type: inside the frame, off the baselines");
assert_eq!(hit(Point::new(299.0, 260.0)), Some(vertical), "vertical type: down the column");
assert_eq!(hit(Point::new(306.0, 260.0)), None, "vertical type: beside it");
assert_eq!(hit(Point::new(140.0, 351.0)), Some(moved), "in perspective: where it is drawn");
assert_eq!(hit(Point::new(40.0, 351.0)), None, "in perspective: not where it is laid out");
}
/// `objects_at`: every object under the point, topmost first; a group counts once.
#[test]
fn objects_at_lists_the_stack() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let rect =
|d: &mut Document, x: f64| Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default_art());
let a = rect(&mut d, 0.0);
let (b, c) = (rect(&mut d, 10.0), rect(&mut d, 20.0));
let (a_id, b_id, c_id, g) = (a.id, b.id, c.id, d.alloc_id());
d.insert(Some(l), 0, a).unwrap();
d.insert(Some(l), 9, Node::group(g, vec![Arc::new(b), Arc::new(c)])).unwrap();
let far = rect(&mut d, 120.0);
d.insert(Some(l), 9, far).unwrap();
let at = |x: f64, scope| objects_at(&d, Point::new(x, 25.0), HitOptions::default(), scope);
assert_eq!(at(30.0, None), vec![g, a_id]);
assert_eq!(at(30.0, Some(g)), [c_id, b_id], "isolated: the group's own objects");
// Isolated, the art around the group is locked: clicks pass through it (#833).
assert!(at(5.0, Some(g)).is_empty());
assert!(at(130.0, Some(g)).is_empty());
assert!(hit_test(&d, Point::new(5.0, 25.0), HitOptions { scope: Some(g), ..HitOptions::default() }).is_none());
assert_eq!(at(5.0, None), vec![a_id]);
assert!(at(90.0, None).is_empty());
}
/// Many overlapping paths: Select Behind must list each exactly once, front to back.
/// This is quadratic with repeated full-document hit tests, but linear in tree size with
/// the per-object walk.
#[test]
fn objects_at_large_overlapping_stack() {
let mut d = Document::new(100.0, 100.0);
let layer = d.layers[0].id;
let mut ids = Vec::new();
for _ in 0..256 {
let id = d.alloc_id();
d.insert(Some(layer), usize::MAX, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
ids.push(id);
}
ids.reverse();
assert_eq!(objects_at(&d, Point::new(25.0, 25.0), HitOptions::default(), None), ids);
}
/// Match the original skip-and-retest algorithm for normal groups, sublayers, clipping,
/// invisibility, and path/outline-only settings.
#[test]
fn objects_at_one_pass_matches_repeated_hits() {
let mut d = Document::new(200.0, 200.0);
let layer = d.layers[0].id;
let add = |d: &mut Document, parent, bounds| {
let id = d.alloc_id();
d.insert(Some(parent), usize::MAX, Node::path(id, shapes::rectangle(bounds), Appearance::default_art())).unwrap();
id
};
add(&mut d, layer, Rect::new(0.0, 0.0, 60.0, 60.0));
let sub = d.alloc_id();
d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sublayer", crate::LayerColor::Preset(2))).unwrap();
add(&mut d, sub, Rect::new(0.0, 0.0, 60.0, 60.0));
let group = d.alloc_id();
d.insert(Some(layer), usize::MAX, Node::group(group, Vec::new())).unwrap();
add(&mut d, group, Rect::new(0.0, 0.0, 60.0, 60.0));
add(&mut d, group, Rect::new(5.0, 5.0, 60.0, 60.0));
let clip = d.alloc_id();
let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(5.0, 5.0, 45.0, 45.0)), Appearance::default_art());
let content = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 80.0, 80.0)), Appearance::default_art());
let mut clipping = Node::group(clip, vec![Arc::new(mask), Arc::new(content)]);
clipping.set_clips(true);
d.insert(Some(layer), usize::MAX, clipping).unwrap();
for (point, opt) in [
(Point::new(25.0, 25.0), HitOptions::default()),
(Point::new(3.0, 25.0), HitOptions::default()),
(Point::new(25.0, 25.0), HitOptions { outline: true, ..Default::default() }),
(Point::new(6.0, 25.0), HitOptions { path_only: true, ..Default::default() }),
(Point::new(100.0, 100.0), HitOptions::default()),
] {
assert_eq!(objects_at(&d, point, opt, None), objects_at_repeated(&d, point, opt, None), "point={point:?}, opt={opt:?}");
}
if let NodeKind::Layer { preview, .. } = &mut d.node_mut(sub).unwrap().kind {
*preview = false;
}
let p = Point::new(25.0, 25.0);
let opt = HitOptions::default();
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None));
d.node_mut(group).unwrap().locked = true;
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None));
}
/// Documents built at random (nested groups, sublayers, clipping groups, compound paths, blends
/// of nothing, hidden and locked objects, outline layers): Select Behind lists the same objects
/// in the same order as the hit test repeated over what is left.
#[test]
fn objects_at_matches_repeated_hits_on_random_documents() {
let mut seed = 0x9e37_79b9_u32;
let mut next = move |n: u32| {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
seed % n
};
fn fill(d: &mut Document, parent: NodeId, depth: u32, next: &mut impl FnMut(u32) -> u32) {
for _ in 0..2 + next(4) {
let id = d.alloc_id();
let (x, y) = (f64::from(next(60)), f64::from(next(60)));
let rect = Rect::new(x, y, x + 10.0 + f64::from(next(50)), y + 10.0 + f64::from(next(50)));
let appearance = if next(4) == 0 { Appearance::default() } else { Appearance::default_art() };
let mut n = match next(if depth < 3 { 8 } else { 4 }) {
4 | 5 => Node::group(id, Vec::new()),
6 => Node::layer(id, "Sublayer", crate::LayerColor::Preset(2)),
7 => {
let mut g = Node::group(id, Vec::new());
g.set_clips(true);
g
}
_ => Node::path(id, shapes::rectangle(rect), appearance),
};
n.visible = next(8) != 0;
n.locked = next(8) == 0;
if let NodeKind::Layer { preview, .. } = &mut n.kind {
*preview = next(3) != 0;
}
let container = n.is_container();
// A sublayer belongs in a layer; in a group it is a group like another to the hit test.
d.insert(Some(parent), usize::MAX, n).unwrap();
if container {
fill(d, id, depth + 1, next);
}
}
}
for round in 0..200 {
let mut d = Document::new(120.0, 120.0);
let layer = d.layers[0].id;
fill(&mut d, layer, 0, &mut next);
// A clipping mask on the layer, or on a sublayer, now and then.
let sublayers: Vec<NodeId> = d.layers[0].children().into_iter().flatten().filter(|n| n.is_layer()).map(|n| n.id).collect();
for id in std::iter::once(layer).chain(sublayers) {
if next(3) == 0 {
d.node_mut(id).unwrap().set_clips(true);
}
}
for _ in 0..12 {
let p = Point::new(f64::from(next(120)), f64::from(next(120)));
let opt = HitOptions { outline: next(5) == 0, path_only: next(5) == 0, clip_outlines: next(2) == 0, ..Default::default() };
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None), "round {round}, {p:?}, {opt:?}");
}
}
}
/// Select Behind finds a symbol instance by its symbol's art, as a click does: art from
/// (100, 60) to (140, 80), outside the 20 × 20 square around the origin, drawn where it lies by
/// one instance and 100 pt lower by an instance in a group.
#[test]
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) });
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();
d.insert(Some(layer), 1, Node::group(group, vec![Arc::new(instance(inner, 100.0))])).unwrap();
let opt = HitOptions::default();
for (x, y, want) in [(120.0, 70.0, vec![top]), (120.0, 170.0, vec![group]), (0.0, 0.0, vec![]), (0.0, 100.0, vec![])] {
let p = Point::new(x, y);
assert_eq!(objects_at(&d, p, opt, None), want, "{p:?}");
assert_eq!(objects_at_repeated(&d, p, opt, None), want, "{p:?}");
}
}
/// Edit Contents can expose descendants of a top-level group: preserve the original
/// selection order by falling back to the general hit traversal.
#[test]
fn objects_at_edit_contents_uses_general_order() {
let mut d = Document::new(100.0, 100.0);
let layer = d.layers[0].id;
let inner = |id| Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art()));
let (a, b) = (d.alloc_id(), d.alloc_id());
let e = Node::new(
d.alloc_id(),
NodeKind::Envelope {
content: vec![inner(a), inner(b)],
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }),
fidelity: 100.0,
editing: true,
options: Default::default(),
frame: Box::new(vectorcraft_geom::Affine::IDENTITY),
},
);
d.insert(Some(layer), usize::MAX, e).unwrap();
let p = Point::new(25.0, 25.0);
let opt = HitOptions::default();
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None));
assert_eq!(objects_at(&d, p, opt, None), vec![b, a]);
}
#[test]
fn locked_and_hidden_skip() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let a = d.alloc_id();
d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
d.node_mut(a).unwrap().locked = true;
assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).is_none());
d.node_mut(a).unwrap().locked = false;
d.node_mut(l).unwrap().visible = false;
assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).is_none());
}
/// Insert `n` locked at the top of `parent`.
fn insert_locked(d: &mut Document, parent: NodeId, mut n: Node) -> NodeId {
let id = n.id;
n.locked = true;
d.insert(Some(parent), usize::MAX, n).unwrap();
id
}
/// `locked_objects_at`: the locked objects under the point, topmost first. A locked group is
/// listed rather than its contents, and a locked object in an unlocked group is listed itself.
/// Hidden objects, locked layers and the art outside an isolated group are left out.
#[test]
fn locked_objects_at_lists_the_locked_stack() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let rect =
|d: &mut Document, x: f64| Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default_art());
let (a, b) = (rect(&mut d, 0.0), rect(&mut d, 10.0));
let (a, b) = (insert_locked(&mut d, l, a), insert_locked(&mut d, l, b));
let (mut inner, locked_group) = (rect(&mut d, 20.0), d.alloc_id());
inner.locked = true;
let inner_id = inner.id;
let mut g = Node::group(locked_group, vec![Arc::new(inner)]);
g.locked = true;
d.insert(Some(l), usize::MAX, g).unwrap();
let open_group = d.alloc_id();
let mut child = rect(&mut d, 25.0);
child.locked = true;
let child_id = child.id;
let free = rect(&mut d, 25.0);
d.insert(Some(l), usize::MAX, Node::group(open_group, vec![Arc::new(child), Arc::new(free)])).unwrap();
let at = |d: &Document, x: f64, scope| locked_objects_at(d, Point::new(x, 25.0), HitOptions { scope, ..HitOptions::default() });
assert_eq!(at(&d, 30.0, None), [child_id, locked_group, b, a]);
assert!(!at(&d, 30.0, None).contains(&inner_id), "the locked group stands for what it holds");
assert_eq!(at(&d, 5.0, None), [a]);
assert!(at(&d, 150.0, None).is_empty());
// Isolated, only the group's own objects are looked at.
assert_eq!(at(&d, 30.0, Some(open_group)), [child_id]);
// A hidden object is left out, and nothing on a locked or hidden layer is listed.
d.node_mut(b).unwrap().visible = false;
assert_eq!(at(&d, 30.0, None), [child_id, locked_group, a]);
d.node_mut(l).unwrap().locked = true;
assert!(at(&d, 30.0, None).is_empty());
d.node_mut(l).unwrap().locked = false;
d.node_mut(l).unwrap().visible = false;
assert!(at(&d, 30.0, None).is_empty());
}
/// `locked_objects_at` lists at most `MAX_LOCKED_AT` objects, the topmost ones.
#[test]
fn locked_objects_at_stops_at_its_limit() {
let mut d = Document::new(100.0, 100.0);
let l = d.layers[0].id;
let ids: Vec<NodeId> = (0..MAX_LOCKED_AT + 5)
.map(|_| {
let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art());
insert_locked(&mut d, l, n)
})
.collect();
let found = locked_objects_at(&d, Point::new(25.0, 25.0), HitOptions::default());
assert_eq!(found, ids.iter().rev().take(MAX_LOCKED_AT).copied().collect::<Vec<_>>());
}
#[test]
fn visible_sampling_reads_locked_art_and_layers_but_excludes_hidden_art() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let a = d.alloc_id();
d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
let p = Point::new(10.0, 10.0);
for lock in [a, l] {
d.node_mut(lock).unwrap().locked = true;
assert!(hit_test(&d, p, HitOptions::default()).is_none());
assert_eq!(hit_test_visible(&d, p, HitOptions::default()).unwrap().leaf, a);
assert!(d.node(lock).unwrap().locked);
d.node_mut(lock).unwrap().locked = false;
}
d.node_mut(a).unwrap().visible = false;
assert!(hit_test_visible(&d, p, HitOptions::default()).is_none());
d.node_mut(a).unwrap().visible = true;
d.node_mut(l).unwrap().visible = false;
assert!(hit_test_visible(&d, p, HitOptions::default()).is_none());
}
}
mod tests;
+835
View File
@@ -0,0 +1,835 @@
use super::*;
use crate::{Appearance, Node};
use std::sync::Arc;
use vectorcraft_color::{Color, Paint};
use vectorcraft_geom::shapes;
#[test]
fn hits_topmost() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let a = d.alloc_id();
d.insert(Some(l), 9, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
let b = d.alloc_id();
d.insert(Some(l), 9, Node::path(b, shapes::rectangle(Rect::new(25.0, 25.0, 75.0, 75.0)), Appearance::default_art())).unwrap();
let h = hit_test(&d, Point::new(30.0, 30.0), HitOptions::default()).unwrap();
assert_eq!(h.leaf, b);
assert_eq!(h.kind, HitKind::Fill);
let h = hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).unwrap();
assert_eq!(h.leaf, a);
assert!(hit_test(&d, Point::new(150.0, 150.0), HitOptions::default()).is_none());
// Outline mode: interior misses.
assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions { outline: true, ..Default::default() }).is_none());
assert!(hit_test(&d, Point::new(0.5, 10.0), HitOptions { outline: true, ..Default::default() }).is_some());
}
#[test]
fn unfilled_interior_misses() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let a = d.alloc_id();
d.insert(
Some(l),
9,
Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)),
)
.unwrap();
assert!(hit_test(&d, Point::new(25.0, 25.0), HitOptions::default()).is_none());
assert!(hit_test(&d, Point::new(50.5, 25.0), HitOptions::default()).is_some());
}
/// #1103: a marquee drawn over a background picks what is on it, not the background, whose
/// outline it doesn't touch.
#[test]
fn a_marquee_inside_a_background_leaves_the_background() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let (back, small) = (d.alloc_id(), d.alloc_id());
d.insert(Some(l), 0, Node::path(back, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Appearance::default_art())).unwrap();
d.insert(Some(l), 1, Node::path(small, shapes::rectangle(Rect::new(50.0, 50.0, 60.0, 60.0)), Appearance::default_art())).unwrap();
assert_eq!(marquee(&d, Rect::new(40.0, 40.0, 70.0, 70.0), None, false), vec![small]);
// Reaching the background's edge takes it too.
assert_eq!(marquee(&d, Rect::new(-5.0, 40.0, 70.0, 70.0), None, false), vec![back, small]);
}
#[test]
fn group_top_object() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let a = d.alloc_id();
let g = d.alloc_id();
let p = Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art());
d.insert(Some(l), 0, Node::group(g, vec![Arc::new(p)])).unwrap();
let h = hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).unwrap();
assert_eq!(h.leaf, a);
assert_eq!(h.top_object(None), g);
assert_eq!(h.top_object(Some(g)), a);
assert_eq!(marquee(&d, Rect::new(-5.0, -5.0, 5.0, 5.0), None, false), vec![g]);
assert_eq!(marquee(&d, Rect::new(-5.0, -5.0, 5.0, 5.0), None, true), vec![a]);
assert!(marquee(&d, Rect::new(100.0, 100.0, 105.0, 105.0), None, false).is_empty());
}
/// The art of a 200 pt wide rectangle clipped to its left half (0..100 × 0..100): the mask and
/// the art, in that order.
fn half_clipped(d: &mut Document) -> (Node, Node) {
let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default());
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 100.0)), Appearance::default_art());
(mask, art)
}
/// #2345: a marquee touches a clip group only where its art shows. Over the clipped-away
/// half alone it selects nothing, and around a rectangle drawn beside the group's mask it
/// takes that rectangle alone; reaching into the mask takes the group. The same holds for
/// Group Selection's leaves and in isolation mode.
#[test]
fn a_marquee_leaves_a_clip_group_by_its_clipped_away_art() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
let (mask, art) = half_clipped(&mut d);
let (g, art_id, beside) = (d.alloc_id(), art.id, d.alloc_id());
let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]);
clip.set_clips(true);
d.insert(Some(l), 0, clip).unwrap();
d.insert(Some(l), 1, Node::path(beside, shapes::rectangle(Rect::new(150.0, 80.0, 190.0, 130.0)), Appearance::default_art())).unwrap();
// This marquee crosses only the hidden art's top and right edges.
let hidden = Rect::new(150.0, -10.0, 210.0, 50.0);
assert!(marquee(&d, hidden, None, false).is_empty());
assert!(marquee(&d, hidden, None, true).is_empty());
assert!(marquee(&d, hidden, Some(g), false).is_empty());
assert!(marquee(&d, hidden, Some(art_id), false).is_empty());
// This marquee surrounds the rectangle beside and crosses the hidden art's bottom edge.
assert_eq!(marquee(&d, Rect::new(140.0, 70.0, 195.0, 140.0), None, false), vec![beside]);
// This marquee crosses the shown half's top edge and goes on into the hidden half.
let shown = Rect::new(50.0, -10.0, 160.0, 10.0);
assert_eq!(marquee(&d, shown, None, false), vec![g]);
assert!(marquee(&d, shown, None, true).contains(&art_id));
assert_eq!(marquee(&d, shown, Some(g), false).len(), 2, "the mask and the art");
}
/// #2345: in the corner of a diamond mask's box, outside the diamond, a marquee over the
/// clipped art's corner leaves the clip group. An open mask clips to its area closed by a
/// straight line, and a marquee across that line takes the art it reaches inside.
#[test]
fn a_marquee_must_meet_the_clipping_path_itself() {
// A clip group of a mask through `pts` (closed or open) over the art `art`.
let clipped = |pts: &[(f64, f64)], closed: bool, art: Rect| {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let mut mask = vectorcraft_geom::BezPath::new();
mask.move_to(pts[0]);
for &p in &pts[1..] {
mask.line_to(p);
}
if closed {
mask.close_path();
}
let mask = Node::path(d.alloc_id(), vectorcraft_geom::PathData::from_bezpath(&mask), Appearance::default());
let art = Node::path(d.alloc_id(), shapes::rectangle(art), Appearance::default_art());
let g = d.alloc_id();
let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]);
clip.set_clips(true);
d.insert(Some(l), 0, clip).unwrap();
(d, g)
};
let (d, g) = clipped(&[(50.0, 0.0), (100.0, 50.0), (50.0, 100.0), (0.0, 50.0)], true, Rect::new(0.0, 0.0, 100.0, 100.0));
assert!(marquee(&d, Rect::new(-10.0, -10.0, 10.0, 10.0), None, false).is_empty());
assert_eq!(marquee(&d, Rect::new(-10.0, 40.0, 10.0, 60.0), None, false), vec![g], "over the diamond's left point");
// The mask is the triangle above the line from (0, 0) to (100, 100), and the art lies inside it.
let (d, g) = clipped(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false, Rect::new(55.0, 40.0, 70.0, 50.0));
assert_eq!(marquee(&d, Rect::new(40.0, 45.0, 60.0, 70.0), None, false), vec![g]);
}
/// #2345: the objects of a layer or sublayer with a clipping mask are touched only where the
/// mask shows them, as in a clip group.
#[test]
fn a_marquee_leaves_art_a_layer_clips_away() {
for sublayer in [false, true] {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
let beside = d.alloc_id();
d.insert(Some(l), 0, Node::path(beside, shapes::rectangle(Rect::new(150.0, 80.0, 190.0, 130.0)), Appearance::default_art())).unwrap();
let masked = d.alloc_id();
let mut layer = Node::layer(masked, "Masked", crate::LayerColor::Preset(2));
layer.set_clips(true);
d.insert(sublayer.then_some(l), 9, layer).unwrap();
let (mask, art) = half_clipped(&mut d);
let (mask_id, art_id) = (mask.id, art.id);
d.insert(Some(masked), 0, mask).unwrap();
d.insert(Some(masked), 1, art).unwrap();
assert!(marquee(&d, Rect::new(150.0, -10.0, 210.0, 50.0), None, false).is_empty(), "sublayer: {sublayer}");
assert_eq!(marquee(&d, Rect::new(140.0, 70.0, 195.0, 140.0), None, false), vec![beside], "sublayer: {sublayer}");
assert_eq!(marquee(&d, Rect::new(50.0, -10.0, 160.0, 10.0), None, false), vec![mask_id, art_id], "sublayer: {sublayer}");
}
}
/// A marquee touches a Shaper Group only by the art it shows, as clicks do ([`may_hit`]): over
/// the original shape of a face the group erased it selects nothing, with Group Selection's
/// leaves too, and over a face it shows it takes the group. With the original shapes isolated
/// (Construction mode) it takes the original shape it reaches.
#[test]
fn a_marquee_leaves_a_shaper_group_by_its_original_shapes() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
// Two original squares, 0..100 and 50..150; the group shows only the first one's face.
let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0));
let a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
let b = Node::path(d.alloc_id(), square(50.0), Appearance::default_art());
let b_id = b.id;
let mut sources = Node::group(d.alloc_id(), vec![Arc::new(a), Arc::new(b)]);
sources.name = Some(crate::shaper::SOURCES.into());
sources.opacity = 0.0;
let sources_id = sources.id;
let mut face = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
face.name = Some(format!("{}0", crate::shaper::FILL_PREFIX));
let face_id = face.id;
let g = d.alloc_id();
let mut group = Node::group(g, vec![Arc::new(sources), Arc::new(face)]);
group.extras_mut().shaper = Some(Box::default());
d.insert(Some(l), 0, group).unwrap();
// This marquee crosses only the right edge of the second square, whose face is erased.
let erased = Rect::new(140.0, 40.0, 160.0, 60.0);
assert!(hit_test(&d, Point::new(150.0, 50.0), HitOptions::default()).is_none(), "a click there misses");
assert!(marquee(&d, erased, None, false).is_empty());
assert!(marquee(&d, erased, None, true).is_empty());
assert!(marquee(&d, erased, Some(g), false).is_empty());
// This marquee crosses the shown face's left edge.
let shown = Rect::new(-10.0, 40.0, 10.0, 60.0);
assert_eq!(marquee(&d, shown, None, false), vec![g]);
assert_eq!(marquee(&d, shown, None, true), vec![face_id]);
assert_eq!(marquee(&d, shown, Some(g), false), vec![face_id]);
// Construction mode: the original shapes are the objects.
assert_eq!(marquee(&d, erased, Some(sources_id), false), vec![b_id]);
}
/// A marquee touches an Intertwine group by its members, as it touches a plain group (#1103) and
/// as clicks do: in the gap between two members, or inside one member's fill, it selects nothing.
/// Across a member's edge it selects the group, and with the leaves that Direct and Group
/// Selection take, or in isolation mode, it selects the member.
#[test]
fn a_marquee_meets_an_intertwine_group_by_its_members() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
// Two squares, 0..100 and 150..250, with a gap between them inside the group's box.
let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0));
let mut a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
a.extras_mut().intertwine = Some(Box::new(shapes::rectangle(Rect::new(50.0, 0.0, 100.0, 100.0))));
let b = Node::path(d.alloc_id(), square(150.0), Appearance::default_art());
let (a_id, g) = (a.id, d.alloc_id());
d.insert(Some(l), 0, Node::new(g, NodeKind::Intertwine { children: vec![Arc::new(a), Arc::new(b)] })).unwrap();
let gap = Rect::new(110.0, 40.0, 140.0, 60.0);
assert!(hit_test(&d, gap.center(), HitOptions::default()).is_none(), "a click in the gap misses");
assert!(marquee(&d, gap, None, false).is_empty());
assert!(marquee(&d, gap, None, true).is_empty());
assert!(marquee(&d, gap, Some(g), false).is_empty());
assert!(marquee(&d, Rect::new(20.0, 20.0, 40.0, 40.0), None, false).is_empty(), "inside a member's fill");
// This marquee crosses the first square's left edge.
let edge = Rect::new(-10.0, 40.0, 10.0, 60.0);
assert_eq!(marquee(&d, edge, None, false), vec![g]);
assert_eq!(marquee(&d, edge, None, true), vec![a_id]);
assert_eq!(marquee(&d, edge, Some(g), false), vec![a_id]);
}
/// A marquee touches a repeat by the copies it draws, as clicks do: between two copies of a
/// grid repeat it selects nothing, and across a copy's edge it takes the repeat.
#[test]
fn a_marquee_meets_a_repeat_by_its_copies() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
let source = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art());
// Copies at 0..20, 40..60 and 80..100.
let kind = crate::pattern::RepeatKind::Grid {
h_spacing: 20.0,
v_spacing: 20.0,
rows: 1,
cols: 3,
grid_type: crate::pattern::TileType::Grid,
flip_rows: false,
flip_cols: false,
};
let r = d.alloc_id();
d.insert(Some(l), 0, Node::new(r, NodeKind::Repeat(crate::pattern::RepeatSpec { kind, source: vec![Arc::new(source)] }))).unwrap();
let gap = Rect::new(25.0, 5.0, 35.0, 15.0);
assert!(hit_test(&d, gap.center(), HitOptions::default()).is_none(), "a click between copies misses");
assert!(marquee(&d, gap, None, false).is_empty());
assert!(marquee(&d, gap, None, true).is_empty());
assert_eq!(marquee(&d, Rect::new(35.0, 5.0, 45.0, 15.0), None, false), vec![r], "across the second copy's left edge");
assert_eq!(marquee(&d, Rect::new(35.0, 5.0, 45.0, 15.0), None, true), vec![r]);
}
/// A blend and an envelope are clicked anywhere in their box, and a marquee touches them there
/// too: inside the box, away from every key object's and the content's outline, it takes them.
#[test]
fn a_marquee_meets_blends_and_envelopes_by_their_box() {
let mut d = Document::new(300.0, 300.0);
let l = d.layers[0].id;
let square = |id, x: f64, y: f64| Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, y, x + 20.0, y + 20.0)), Appearance::default_art()));
let blend = d.alloc_id();
let keys = vec![square(d.alloc_id(), 0.0, 0.0), square(d.alloc_id(), 100.0, 0.0)];
d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Default::default() })).unwrap();
let envelope = d.alloc_id();
let content = vec![Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 150.0, 100.0, 250.0)), Appearance::default_art()))];
let e = Node::new(
envelope,
NodeKind::Envelope {
content,
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }),
fidelity: 100.0,
editing: false,
options: Default::default(),
frame: Box::new(vectorcraft_geom::Affine::IDENTITY),
},
);
d.insert(Some(l), 1, e).unwrap();
for (r, id) in [(Rect::new(50.0, 5.0, 60.0, 15.0), blend), (Rect::new(40.0, 190.0, 60.0, 210.0), envelope)] {
assert_eq!(hit_test(&d, r.center(), HitOptions::default()).map(|h| h.leaf), Some(id));
assert_eq!(marquee(&d, r, None, false), vec![id]);
assert_eq!(marquee(&d, r, None, true), vec![id]);
}
}
/// A rectangle (40, 40)–(140, 140) clipping a filled 200 × 200 square drawn over its edges, in a
/// clip group (or, `layer`, as the first layer's clipping mask): the group (or the layer), the
/// clipping path and the art.
fn masked_square(layer: bool) -> (Document, NodeId, NodeId, NodeId) {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let (g, clip, art) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
let mut path = Node::path(clip, shapes::rectangle(Rect::new(40.0, 40.0, 140.0, 140.0)), Appearance::basic(Paint::None, Paint::None, 0.0));
if let NodeKind::Path { clipping, .. } = &mut path.kind {
*clipping = true;
}
let picture = Node::path(art, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Appearance::default_art());
if layer {
d.insert(Some(l), 0, path).unwrap();
d.insert(Some(l), 1, picture).unwrap();
d.node_mut(l).unwrap().set_clips(true);
(d, l, clip, art)
} else {
d.insert(Some(l), 0, Node::new(g, NodeKind::Group { children: vec![Arc::new(path), Arc::new(picture)], clip: true })).unwrap();
(d, g, clip, art)
}
}
const SELECTING: HitOptions =
HitOptions { tol: 3.0, outline: false, path_only: false, type_path_only: false, skip_guides: false, clip_outlines: true, scope: None };
/// For the selection tools a clip group's clipping path is hit on its outline, within the
/// tolerance, before the art it clips and just outside it too; inside, the art is hit.
#[test]
fn a_clipping_path_is_hit_on_its_outline_before_its_art() {
let (d, g, clip, art) = masked_square(false);
for (x, y) in [(140.0, 90.0), (141.5, 90.0), (138.5, 90.0), (90.0, 40.0)] {
let h = hit_test(&d, Point::new(x, y), SELECTING).unwrap();
assert_eq!((h.leaf, h.kind, h.top_object(None)), (clip, HitKind::Outline, g), "at {x}, {y}");
}
let h = hit_test(&d, Point::new(90.0, 90.0), SELECTING).unwrap();
assert_eq!((h.leaf, h.top_object(None)), (art, g));
// Beyond the tolerance outside it, the clipped art is not there to hit.
assert!(hit_test(&d, Point::new(150.0, 90.0), SELECTING).is_none());
}
/// Without `clip_outlines` (the tools that act on what is drawn) and for read-only sampling
/// (the eyedropper), the art is hit on the mask's edge, as before: the clipping path paints
/// nothing there.
#[test]
fn only_the_selection_tools_hit_a_clipping_path_on_its_outline() {
let (d, _, clip, art) = masked_square(false);
let p = Point::new(138.5, 90.0);
assert_eq!(hit_test(&d, p, HitOptions::default()).map(|h| h.leaf), Some(art));
assert_eq!(hit_test_visible(&d, p, HitOptions::default()).map(|h| h.leaf), Some(art));
assert_eq!(hit_test_visible(&d, p, SELECTING).map(|h| h.leaf), Some(art));
assert_eq!(hit_test(&d, p, SELECTING).map(|h| h.leaf), Some(clip));
// A hidden or locked clipping path is passed over.
for change in [|n: &mut Node| n.visible = false, |n: &mut Node| n.locked = true] {
let mut d = d.clone();
change(d.node_mut(clip).unwrap());
assert_ne!(hit_test(&d, p, SELECTING).map(|h| h.leaf), Some(clip));
}
}
/// A layer's clipping mask is hit on its outline too, and Select Behind lists what the
/// repeated hit test finds: the mask on and just outside its edge, then the art inside it;
/// well inside, the art alone (the mask paints nothing there).
#[test]
fn select_behind_takes_a_layer_masks_outline_first() {
let (d, _, clip, art) = masked_square(true);
for (x, want) in [(140.0, vec![clip]), (141.5, vec![clip]), (138.5, vec![clip, art]), (90.0, vec![art])] {
let p = Point::new(x, 90.0);
assert_eq!(objects_at(&d, p, SELECTING, None), want, "x = {x}");
assert_eq!(objects_at_repeated(&d, p, SELECTING, None), want, "x = {x}");
}
// Without clip_outlines, as before.
assert_eq!(objects_at(&d, Point::new(138.5, 90.0), HitOptions::default(), None), vec![art, clip]);
}
/// Art in a sublayer is an object of its own: a click or a marquee takes it, not the
/// sublayer; hidden, locked and template sublayers block both.
#[test]
fn sublayers_are_not_objects() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let sub = d.alloc_id();
d.insert(Some(l), 0, Node::layer(sub, "Sub", crate::LayerColor::Preset(2))).unwrap();
let inner = d.alloc_id();
d.insert(Some(sub), 0, Node::layer(inner, "Inner", crate::LayerColor::Preset(3))).unwrap();
let a = d.alloc_id();
d.insert(Some(inner), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
let g = d.alloc_id();
let b = d.alloc_id();
let pb = Node::path(b, shapes::rectangle(Rect::new(100.0, 0.0, 150.0, 50.0)), Appearance::default_art());
d.insert(Some(sub), 9, Node::group(g, vec![Arc::new(pb)])).unwrap();
let h = hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).unwrap();
assert_eq!((h.leaf, h.top_object(None), h.layers), (a, a, 3));
let h = hit_test(&d, Point::new(110.0, 10.0), HitOptions::default()).unwrap();
assert_eq!((h.top_object(None), h.top_object(Some(g))), (g, b));
let all = Rect::new(-5.0, -5.0, 200.0, 200.0);
assert_eq!(marquee(&d, all, None, false), vec![a, g]);
assert_eq!(d.layer_containing(a), Some(inner));
assert_eq!(d.layer_color(a), crate::LayerColor::Preset(3).rgb(), "the sublayer's own colour");
assert_eq!(d.selectable_art(), vec![a, g]);
for f in [
|n: &mut Node| n.visible = false,
|n: &mut Node| n.locked = true,
|n: &mut Node| {
if let NodeKind::Layer { template, .. } = &mut n.kind {
*template = true;
}
},
] {
let mut d2 = d.clone();
f(d2.node_mut(inner).unwrap());
assert!(hit_test(&d2, Point::new(10.0, 10.0), HitOptions::default()).is_none());
assert_eq!(marquee(&d2, all, None, false), vec![g]);
assert_eq!(d2.selectable_art(), vec![g]);
}
// A sublayer whose Preview is off is clicked in outline: its fill no longer hits.
if let NodeKind::Layer { preview, .. } = &mut d.node_mut(inner).unwrap().kind {
*preview = false;
}
assert!(hit_test(&d, Point::new(25.0, 25.0), HitOptions::default()).is_none());
assert!(hit_test(&d, Point::new(0.5, 25.0), HitOptions::default()).is_some());
}
/// Object Selection by Path Only: a filled path or compound path hits on its outline only.
#[test]
fn path_only_leaves_out_fills() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let (a, c) = (d.alloc_id(), d.alloc_id());
d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
let inner = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 0.0, 150.0, 50.0)), Appearance::default());
let mut compound = Node::new(c, NodeKind::Compound { children: vec![Arc::new(inner)], rule: vectorcraft_geom::FillRule::NonZero });
compound.appearance = Appearance::default_art().into();
d.insert(Some(l), 9, compound).unwrap();
let path_only = HitOptions { path_only: true, ..Default::default() };
for (id, inside, edge) in [(a, Point::new(25.0, 25.0), Point::new(0.5, 25.0)), (c, Point::new(125.0, 25.0), Point::new(100.5, 25.0))] {
assert_eq!(hit_test(&d, inside, HitOptions::default()).map(|h| h.leaf), Some(id), "the fill hits");
assert!(hit_test(&d, inside, path_only).is_none(), "path only: the fill of {id:?} doesn't hit");
assert_eq!(hit_test(&d, edge, path_only).map(|h| h.leaf), Some(id), "path only: the outline does");
}
}
/// #3291: with Object Selection by Path Only, a placed image is picked on its frame's edge, so
/// a path under it can be picked through it.
#[test]
fn path_only_picks_paths_through_images() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let (path, image) = (d.alloc_id(), d.alloc_id());
d.insert(Some(l), 0, Node::path(path, shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)), Appearance::default_art())).unwrap();
// 100×50 pixels, scaled to 100×100 and moved to (10, 10): over the whole path.
let xf = vectorcraft_geom::Affine::translate((10.0, 10.0)) * vectorcraft_geom::Affine::scale_non_uniform(1.0, 2.0);
let im = crate::ImageObject { key: "k".into(), width: 100, height: 50, xf, link: None, placement: Default::default(), pixels: None };
d.insert(Some(l), 1, Node::new(image, NodeKind::Image(Box::new(im)))).unwrap();
let path_only = HitOptions { path_only: true, ..Default::default() };
let hit = |p: Point, opt| hit_test(&d, p, opt).map(|h| h.leaf);
let (path_edge, inside, image_edge) = (Point::new(20.5, 40.0), Point::new(80.0, 80.0), Point::new(80.0, 109.5));
assert_eq!(hit(path_edge, HitOptions::default()), Some(image), "the image covers the path");
assert_eq!(hit(inside, HitOptions::default()), Some(image));
assert_eq!(hit(path_edge, path_only), Some(path), "path only: the path through the image");
assert_eq!(hit(inside, path_only), None, "path only: not inside the image");
assert_eq!(hit(image_edge, path_only), Some(image), "path only: the image's frame");
}
/// Type Object Selection by Path Only: point type is picked on its baseline, area type on its
/// frame and type on a path on its path, no longer anywhere in its bounds.
#[test]
fn type_path_only_picks_type_on_its_path() {
use crate::{CharStyle, TextKind, TextObject};
let mut d = Document::new(400.0, 400.0);
let l = d.layers[0].id;
let mut add = |t: TextObject| {
let id = d.alloc_id();
d.insert(Some(l), 9, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap();
id
};
// Point type at (10, 100), 20 pt: its glyphs rise above the baseline, its layout is 60 wide.
let mut point = TextObject::point(Point::new(10.0, 100.0), "Hello", CharStyle { size: 20.0, ..CharStyle::default() });
point.cached_bounds = Some(Rect::new(0.0, -16.0, 60.0, 4.0));
let point = add(point);
let mut area = TextObject::point(Point::new(200.0, 200.0), "Area", CharStyle::default());
area.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)) };
let area = add(area);
let mut on_path = TextObject::point(Point::new(10.0, 300.0), "Path", CharStyle::default());
on_path.kind = TextKind::OnPath { path: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 40.0)), start: 0.0, end: None };
let on_path = add(on_path);
let path_only = HitOptions { type_path_only: true, ..Default::default() };
let hit = |p: Point, opt: HitOptions| hit_test(&d, p, opt).map(|h| (h.leaf, h.kind));
// Off: anywhere in the bounds.
assert_eq!(hit(Point::new(40.0, 90.0), HitOptions::default()), Some((point, HitKind::Bounds)));
assert_eq!(hit(Point::new(250.0, 225.0), HitOptions::default()), Some((area, HitKind::Bounds)));
assert_eq!(hit(Point::new(60.0, 320.0), HitOptions::default()), Some((on_path, HitKind::Bounds)));
// On: the glyphs and the inside of the frame or path no longer pick the type.
assert_eq!(hit(Point::new(40.0, 90.0), path_only), None, "point type: among its glyphs");
assert_eq!(hit(Point::new(250.0, 225.0), path_only), None, "area type: inside its frame");
assert_eq!(hit(Point::new(60.0, 320.0), path_only), None, "type on a path: inside its path");
// The baseline (within the tolerance, across the layout's width), frame and path do.
assert_eq!(hit(Point::new(40.0, 101.5), path_only), Some((point, HitKind::Outline)), "point type: its baseline");
assert_eq!(hit(Point::new(75.0, 100.0), path_only), None, "point type: past the end of its layout");
assert_eq!(hit(Point::new(250.0, 200.5), path_only), Some((area, HitKind::Outline)), "area type: its frame");
assert_eq!(hit(Point::new(60.0, 339.5), path_only), Some((on_path, HitKind::Outline)), "type on a path: its path");
}
/// Type Object Selection by Path Only with the layout's baselines: every line's for point type,
/// area type's besides its frame, a vertical column's centre line, and type in perspective
/// where it is drawn.
#[test]
fn type_path_only_picks_every_baseline() {
use crate::{CharStyle, PerspectiveAttachment, TextKind, TextObject};
use vectorcraft_geom::{Affine, Homography};
let mut d = Document::new(400.0, 400.0);
let l = d.layers[0].id;
let mut add = |t: TextObject, perspective: Option<Affine>| {
let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
n.extras_mut().perspective = perspective.map(|a| {
Box::new(PerspectiveAttachment { projection: Some(Homography::from_affine(a).to_array()), ..PerspectiveAttachment::new("left", 0.0) })
});
let id = n.id;
d.insert(Some(l), 9, n).unwrap();
id
};
let style = CharStyle { size: 20.0, ..CharStyle::default() };
// Two lines of point type at (10, 100), 24 pt apart.
let mut point = TextObject::point(
Point::new(10.0, 100.0),
"Hello
world",
style.clone(),
);
point.cached_bounds = Some(Rect::new(0.0, -16.0, 60.0, 28.0));
point.cached_baselines = vec![(Point::ZERO, Point::new(60.0, 0.0)), (Point::new(0.0, 24.0), Point::new(50.0, 24.0))];
let point = add(point, None);
// Area type: a line inside its frame.
let mut area = TextObject::point(Point::new(200.0, 50.0), "Area", style.clone());
area.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)) };
area.cached_baselines = vec![(Point::new(0.0, 16.0), Point::new(40.0, 16.0))];
let area = add(area, None);
// Vertical point type at (300, 200) with no baselines cached: down its first column.
let mut vertical = TextObject::point(Point::new(300.0, 200.0), "Tate", style.clone());
vertical.vertical = true;
vertical.cached_bounds = Some(Rect::new(-10.0, 0.0, 10.0, 80.0));
let vertical = add(vertical, None);
// Point type drawn 100 to the right of where it is laid out.
let mut moved = TextObject::point(Point::new(10.0, 350.0), "Moved", style);
moved.cached_bounds = Some(Rect::new(0.0, -16.0, 60.0, 4.0));
let moved = add(moved, Some(Affine::translate((100.0, 0.0))));
let path_only = HitOptions { type_path_only: true, ..Default::default() };
let hit = |p: Point| hit_test(&d, p, path_only).map(|h| h.leaf);
assert_eq!(hit(Point::new(40.0, 100.0)), Some(point), "the first line's baseline");
assert_eq!(hit(Point::new(40.0, 125.0)), Some(point), "the second line's");
assert_eq!(hit(Point::new(40.0, 112.0)), None, "between the lines");
assert_eq!(hit(Point::new(65.0, 124.0)), None, "past the end of the shorter second line");
assert_eq!(hit(Point::new(220.0, 66.0)), Some(area), "area type: a line's baseline");
assert_eq!(hit(Point::new(220.0, 80.0)), None, "area type: inside the frame, off the baselines");
assert_eq!(hit(Point::new(299.0, 260.0)), Some(vertical), "vertical type: down the column");
assert_eq!(hit(Point::new(306.0, 260.0)), None, "vertical type: beside it");
assert_eq!(hit(Point::new(140.0, 351.0)), Some(moved), "in perspective: where it is drawn");
assert_eq!(hit(Point::new(40.0, 351.0)), None, "in perspective: not where it is laid out");
}
/// `objects_at`: every object under the point, topmost first; a group counts once.
#[test]
fn objects_at_lists_the_stack() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let rect = |d: &mut Document, x: f64| Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default_art());
let a = rect(&mut d, 0.0);
let (b, c) = (rect(&mut d, 10.0), rect(&mut d, 20.0));
let (a_id, b_id, c_id, g) = (a.id, b.id, c.id, d.alloc_id());
d.insert(Some(l), 0, a).unwrap();
d.insert(Some(l), 9, Node::group(g, vec![Arc::new(b), Arc::new(c)])).unwrap();
let far = rect(&mut d, 120.0);
d.insert(Some(l), 9, far).unwrap();
let at = |x: f64, scope| objects_at(&d, Point::new(x, 25.0), HitOptions::default(), scope);
assert_eq!(at(30.0, None), vec![g, a_id]);
assert_eq!(at(30.0, Some(g)), [c_id, b_id], "isolated: the group's own objects");
// Isolated, the art around the group is locked: clicks pass through it (#833).
assert!(at(5.0, Some(g)).is_empty());
assert!(at(130.0, Some(g)).is_empty());
assert!(hit_test(&d, Point::new(5.0, 25.0), HitOptions { scope: Some(g), ..HitOptions::default() }).is_none());
assert_eq!(at(5.0, None), vec![a_id]);
assert!(at(90.0, None).is_empty());
}
/// Many overlapping paths: Select Behind must list each exactly once, front to back.
/// This is quadratic with repeated full-document hit tests, but linear in tree size with
/// the per-object walk.
#[test]
fn objects_at_large_overlapping_stack() {
let mut d = Document::new(100.0, 100.0);
let layer = d.layers[0].id;
let mut ids = Vec::new();
for _ in 0..256 {
let id = d.alloc_id();
d.insert(Some(layer), usize::MAX, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
ids.push(id);
}
ids.reverse();
assert_eq!(objects_at(&d, Point::new(25.0, 25.0), HitOptions::default(), None), ids);
}
/// Match the original skip-and-retest algorithm for normal groups, sublayers, clipping,
/// invisibility, and path/outline-only settings.
#[test]
fn objects_at_one_pass_matches_repeated_hits() {
let mut d = Document::new(200.0, 200.0);
let layer = d.layers[0].id;
let add = |d: &mut Document, parent, bounds| {
let id = d.alloc_id();
d.insert(Some(parent), usize::MAX, Node::path(id, shapes::rectangle(bounds), Appearance::default_art())).unwrap();
id
};
add(&mut d, layer, Rect::new(0.0, 0.0, 60.0, 60.0));
let sub = d.alloc_id();
d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sublayer", crate::LayerColor::Preset(2))).unwrap();
add(&mut d, sub, Rect::new(0.0, 0.0, 60.0, 60.0));
let group = d.alloc_id();
d.insert(Some(layer), usize::MAX, Node::group(group, Vec::new())).unwrap();
add(&mut d, group, Rect::new(0.0, 0.0, 60.0, 60.0));
add(&mut d, group, Rect::new(5.0, 5.0, 60.0, 60.0));
let clip = d.alloc_id();
let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(5.0, 5.0, 45.0, 45.0)), Appearance::default_art());
let content = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 80.0, 80.0)), Appearance::default_art());
let mut clipping = Node::group(clip, vec![Arc::new(mask), Arc::new(content)]);
clipping.set_clips(true);
d.insert(Some(layer), usize::MAX, clipping).unwrap();
for (point, opt) in [
(Point::new(25.0, 25.0), HitOptions::default()),
(Point::new(3.0, 25.0), HitOptions::default()),
(Point::new(25.0, 25.0), HitOptions { outline: true, ..Default::default() }),
(Point::new(6.0, 25.0), HitOptions { path_only: true, ..Default::default() }),
(Point::new(100.0, 100.0), HitOptions::default()),
] {
assert_eq!(objects_at(&d, point, opt, None), objects_at_repeated(&d, point, opt, None), "point={point:?}, opt={opt:?}");
}
if let NodeKind::Layer { preview, .. } = &mut d.node_mut(sub).unwrap().kind {
*preview = false;
}
let p = Point::new(25.0, 25.0);
let opt = HitOptions::default();
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None));
d.node_mut(group).unwrap().locked = true;
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None));
}
/// Documents built at random (nested groups, sublayers, clipping groups, compound paths, blends
/// of nothing, hidden and locked objects, outline layers): Select Behind lists the same objects
/// in the same order as the hit test repeated over what is left.
#[test]
fn objects_at_matches_repeated_hits_on_random_documents() {
let mut seed = 0x9e37_79b9_u32;
let mut next = move |n: u32| {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
seed % n
};
fn fill(d: &mut Document, parent: NodeId, depth: u32, next: &mut impl FnMut(u32) -> u32) {
for _ in 0..2 + next(4) {
let id = d.alloc_id();
let (x, y) = (f64::from(next(60)), f64::from(next(60)));
let rect = Rect::new(x, y, x + 10.0 + f64::from(next(50)), y + 10.0 + f64::from(next(50)));
let appearance = if next(4) == 0 { Appearance::default() } else { Appearance::default_art() };
let mut n = match next(if depth < 3 { 8 } else { 4 }) {
4 | 5 => Node::group(id, Vec::new()),
6 => Node::layer(id, "Sublayer", crate::LayerColor::Preset(2)),
7 => {
let mut g = Node::group(id, Vec::new());
g.set_clips(true);
g
}
_ => Node::path(id, shapes::rectangle(rect), appearance),
};
n.visible = next(8) != 0;
n.locked = next(8) == 0;
if let NodeKind::Layer { preview, .. } = &mut n.kind {
*preview = next(3) != 0;
}
let container = n.is_container();
// A sublayer belongs in a layer; in a group it is a group like another to the hit test.
d.insert(Some(parent), usize::MAX, n).unwrap();
if container {
fill(d, id, depth + 1, next);
}
}
}
for round in 0..200 {
let mut d = Document::new(120.0, 120.0);
let layer = d.layers[0].id;
fill(&mut d, layer, 0, &mut next);
// A clipping mask on the layer, or on a sublayer, now and then.
let sublayers: Vec<NodeId> = d.layers[0].children().into_iter().flatten().filter(|n| n.is_layer()).map(|n| n.id).collect();
for id in std::iter::once(layer).chain(sublayers) {
if next(3) == 0 {
d.node_mut(id).unwrap().set_clips(true);
}
}
for _ in 0..12 {
let p = Point::new(f64::from(next(120)), f64::from(next(120)));
let opt = HitOptions { outline: next(5) == 0, path_only: next(5) == 0, clip_outlines: next(2) == 0, ..Default::default() };
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None), "round {round}, {p:?}, {opt:?}");
}
}
}
/// Select Behind finds a symbol instance by its symbol's art, as a click does: art from
/// (100, 60) to (140, 80), outside the 20 × 20 square around the origin, drawn where it lies by
/// one instance and 100 pt lower by an instance in a group.
#[test]
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) });
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();
d.insert(Some(layer), 1, Node::group(group, vec![Arc::new(instance(inner, 100.0))])).unwrap();
let opt = HitOptions::default();
for (x, y, want) in [(120.0, 70.0, vec![top]), (120.0, 170.0, vec![group]), (0.0, 0.0, vec![]), (0.0, 100.0, vec![])] {
let p = Point::new(x, y);
assert_eq!(objects_at(&d, p, opt, None), want, "{p:?}");
assert_eq!(objects_at_repeated(&d, p, opt, None), want, "{p:?}");
}
}
/// Edit Contents can expose descendants of a top-level group: preserve the original
/// selection order by falling back to the general hit traversal.
#[test]
fn objects_at_edit_contents_uses_general_order() {
let mut d = Document::new(100.0, 100.0);
let layer = d.layers[0].id;
let inner = |id| Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art()));
let (a, b) = (d.alloc_id(), d.alloc_id());
let e = Node::new(
d.alloc_id(),
NodeKind::Envelope {
content: vec![inner(a), inner(b)],
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }),
fidelity: 100.0,
editing: true,
options: Default::default(),
frame: Box::new(vectorcraft_geom::Affine::IDENTITY),
},
);
d.insert(Some(layer), usize::MAX, e).unwrap();
let p = Point::new(25.0, 25.0);
let opt = HitOptions::default();
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None));
assert_eq!(objects_at(&d, p, opt, None), vec![b, a]);
}
#[test]
fn locked_and_hidden_skip() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let a = d.alloc_id();
d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
d.node_mut(a).unwrap().locked = true;
assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).is_none());
d.node_mut(a).unwrap().locked = false;
d.node_mut(l).unwrap().visible = false;
assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).is_none());
}
/// Insert `n` locked at the top of `parent`.
fn insert_locked(d: &mut Document, parent: NodeId, mut n: Node) -> NodeId {
let id = n.id;
n.locked = true;
d.insert(Some(parent), usize::MAX, n).unwrap();
id
}
/// `locked_objects_at`: the locked objects under the point, topmost first. A locked group is
/// listed rather than its contents, and a locked object in an unlocked group is listed itself.
/// Hidden objects, locked layers and the art outside an isolated group are left out.
#[test]
fn locked_objects_at_lists_the_locked_stack() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let rect = |d: &mut Document, x: f64| Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default_art());
let (a, b) = (rect(&mut d, 0.0), rect(&mut d, 10.0));
let (a, b) = (insert_locked(&mut d, l, a), insert_locked(&mut d, l, b));
let (mut inner, locked_group) = (rect(&mut d, 20.0), d.alloc_id());
inner.locked = true;
let inner_id = inner.id;
let mut g = Node::group(locked_group, vec![Arc::new(inner)]);
g.locked = true;
d.insert(Some(l), usize::MAX, g).unwrap();
let open_group = d.alloc_id();
let mut child = rect(&mut d, 25.0);
child.locked = true;
let child_id = child.id;
let free = rect(&mut d, 25.0);
d.insert(Some(l), usize::MAX, Node::group(open_group, vec![Arc::new(child), Arc::new(free)])).unwrap();
let at = |d: &Document, x: f64, scope| locked_objects_at(d, Point::new(x, 25.0), HitOptions { scope, ..HitOptions::default() });
assert_eq!(at(&d, 30.0, None), [child_id, locked_group, b, a]);
assert!(!at(&d, 30.0, None).contains(&inner_id), "the locked group stands for what it holds");
assert_eq!(at(&d, 5.0, None), [a]);
assert!(at(&d, 150.0, None).is_empty());
// Isolated, only the group's own objects are looked at.
assert_eq!(at(&d, 30.0, Some(open_group)), [child_id]);
// A hidden object is left out, and nothing on a locked or hidden layer is listed.
d.node_mut(b).unwrap().visible = false;
assert_eq!(at(&d, 30.0, None), [child_id, locked_group, a]);
d.node_mut(l).unwrap().locked = true;
assert!(at(&d, 30.0, None).is_empty());
d.node_mut(l).unwrap().locked = false;
d.node_mut(l).unwrap().visible = false;
assert!(at(&d, 30.0, None).is_empty());
}
/// `locked_objects_at` lists at most `MAX_LOCKED_AT` objects, the topmost ones.
#[test]
fn locked_objects_at_stops_at_its_limit() {
let mut d = Document::new(100.0, 100.0);
let l = d.layers[0].id;
let ids: Vec<NodeId> = (0..MAX_LOCKED_AT + 5)
.map(|_| {
let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art());
insert_locked(&mut d, l, n)
})
.collect();
let found = locked_objects_at(&d, Point::new(25.0, 25.0), HitOptions::default());
assert_eq!(found, ids.iter().rev().take(MAX_LOCKED_AT).copied().collect::<Vec<_>>());
}
#[test]
fn visible_sampling_reads_locked_art_and_layers_but_excludes_hidden_art() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let a = d.alloc_id();
d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
let p = Point::new(10.0, 10.0);
for lock in [a, l] {
d.node_mut(lock).unwrap().locked = true;
assert!(hit_test(&d, p, HitOptions::default()).is_none());
assert_eq!(hit_test_visible(&d, p, HitOptions::default()).unwrap().leaf, a);
assert!(d.node(lock).unwrap().locked);
d.node_mut(lock).unwrap().locked = false;
}
d.node_mut(a).unwrap().visible = false;
assert!(hit_test_visible(&d, p, HitOptions::default()).is_none());
d.node_mut(a).unwrap().visible = true;
d.node_mut(l).unwrap().visible = false;
assert!(hit_test_visible(&d, p, HitOptions::default()).is_none());
}
+10 -3
View File
@@ -20,8 +20,9 @@ use std::sync::{Arc, RwLock};
use crate::{Node, NodeId};
/// Hasher for node ids: one multiply. Ids are small counters, not attacker-chosen keys, and the
/// default SipHash dominated the cost of building the cache.
/// Hasher for node ids: one multiply, by a number chosen at random once per run (a document's ids
/// are its author's, and a fixed multiplier lets them be picked to collide). The default SipHash
/// dominated the cost of building the cache.
#[derive(Default)]
pub struct IdHasher(u64);
@@ -32,13 +33,19 @@ impl Hasher for IdHasher {
}
}
fn write_u64(&mut self, v: u64) {
self.0 = (self.0 ^ v).wrapping_mul(0x9e37_79b9_7f4a_7c15);
self.0 = (self.0 ^ v).wrapping_mul(multiplier());
}
fn finish(&self) -> u64 {
self.0 ^ (self.0 >> 29)
}
}
/// The odd multiplier of [`IdHasher`], random per run.
fn multiplier() -> u64 {
static KEY: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
*KEY.get_or_init(|| std::hash::BuildHasher::hash_one(&std::hash::RandomState::new(), 0x9e37_79b9_7f4a_7c15_u64) | 1)
}
/// A map keyed by node id with [`IdHasher`].
pub type IdMap<V> = HashMap<NodeId, V, BuildHasherDefault<IdHasher>>;
/// Where each node is: its parent's entry (`u32::MAX` for a top-level layer) and its index among
+22 -2520
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
use super::*;
/// Coordinates beyond this many points exceed the supported document range.
pub const MAX_COORD: f64 = 4.0e6;
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum DocError {
#[error("no such object {0}")]
NoNode(NodeId),
#[error("object {0} cannot have children")]
NotContainer(NodeId),
#[error("{0}")]
Invalid(String),
}
pub const FORMAT_VERSION: u32 = 1;
/// Node, slice and asset ids are handed out below this: no document gets near it by counting, so
/// larger ids in a file are damage (or made by hand) and the counter never follows them (#1718).
pub(crate) const MAX_NEXT_ID: u64 = 1 << 62;
+49 -1
View File
@@ -139,7 +139,17 @@ fn proxy_png(bytes: &[u8]) -> Option<Vec<u8>> {
if w.max(h) <= PROXY_SIZE {
return None;
}
let small = image::load_from_memory(bytes).ok()?.thumbnail(PROXY_SIZE, PROXY_SIZE);
// The proxy decodes within the project's raster cap, counted as RGBA like every other
// decode path (#3597): the header alone decides whether the pixels could ever fit, and the
// decoder gets the same budget instead of image's own default.
if u64::from(w) * u64::from(h) > crate::MAX_RASTER_ALLOC / 4 {
return None;
}
let mut reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?;
let mut limits = image::Limits::default();
limits.max_alloc = Some(crate::MAX_RASTER_ALLOC);
reader.limits(limits);
let small = reader.decode().ok()?.thumbnail(PROXY_SIZE, PROXY_SIZE);
let mut png = Vec::new();
small.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
Some(png)
@@ -432,6 +442,44 @@ mod tests {
assert!(ImageBlob::new("image/png", vec![1, 2, 3]).with_proxy().proxy.is_none(), "undecodable");
}
/// PNG CRC-32 (the same polynomial ZIP uses), so a hand-written header is one a decoder accepts.
fn crc32(data: &[u8]) -> u32 {
let mut crc = !0u32;
for &b in data {
crc ^= u32::from(b);
for _ in 0..8 {
crc = (crc >> 1) ^ (0xEDB8_8320 & u32::from(crc & 1 != 0).wrapping_neg());
}
}
!crc
}
/// A PNG header and nothing else, declaring `w` × `h` 8-bit gray pixels.
fn png_header_declaring(w: u32, h: u32) -> Vec<u8> {
let mut out = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
let mut chunk = Vec::new();
chunk.extend(b"IHDR");
chunk.extend(w.to_be_bytes());
chunk.extend(h.to_be_bytes());
chunk.extend([8, 0, 0, 0, 0]);
let crc = crc32(&chunk);
out.extend(13u32.to_be_bytes());
out.extend(&chunk);
out.extend(crc.to_be_bytes());
out
}
/// A linked image past the raster cap gets no proxy: the header alone decides (#3597).
#[test]
fn an_image_past_the_raster_cap_gets_no_proxy() {
// 65536 × 65536 gray pixels are 4 GiB as RGBA, past the 2 GiB cap on every target.
let huge = png_header_declaring(1 << 16, 1 << 16);
assert!(proxy_png(&huge).is_none());
// A header within the cap but with no image data decodes to no preview, as before.
let truncated = png_header_declaring(600, 300);
assert!(proxy_png(&truncated).is_none());
}
#[test]
fn only_blobs_no_embedded_image_shows_are_linked_only() {
let mut d = Document::new(100.0, 100.0);
+22 -1924
View File
File diff suppressed because it is too large Load Diff
+157
View File
@@ -0,0 +1,157 @@
use super::*;
// =====================================================================================
// Data
// =====================================================================================
/// Blend Options → Spacing.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum BlendSpacing {
/// Step count chosen from the colour difference (or the distance when colours match).
#[default]
SmoothColor,
/// Specified number of steps between each pair of key objects (1–1000).
Steps(u32),
/// Specified distance (pt) between the steps.
Distance(f64),
}
/// Blend Options → Orientation.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum BlendOrientation {
#[default]
AlignToPage,
AlignToPath,
}
/// Distribution of blend steps, as offered by the Blend panel's Type menu.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum BlendEaseType {
#[default]
In,
Out,
InOut,
OutIn,
}
impl BlendEaseType {
pub fn id(self) -> &'static str {
match self {
Self::In => "easeIn",
Self::Out => "easeOut",
Self::InOut => "easeInOut",
Self::OutIn => "easeOutIn",
}
}
}
/// Blend panel Type and Ramp. Ramp is a fraction from 0 (even) to 1.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct BlendEase {
#[serde(default)]
pub kind: BlendEaseType,
#[serde(default, deserialize_with = "blend_ramp")]
pub ramp: f64,
}
impl BlendEase {
pub fn is_linear(&self) -> bool {
self.ramp <= 0.0
}
/// Only the default Type and Ramp can be omitted without losing a chosen Type at Ramp 0.
pub fn is_default(&self) -> bool {
self.kind == BlendEaseType::In && self.ramp == 0.0
}
/// A monotone polynomial distribution, blended with even spacing by Ramp. Public help
/// describes the four distributions, not their numeric curves; these curves are original.
pub fn apply(&self, u: f64) -> f64 {
let u = if u.is_finite() { u.clamp(0.0, 1.0) } else { 0.0 };
let r = if self.ramp.is_finite() { self.ramp.clamp(0.0, 1.0) } else { 0.0 };
let eased = match self.kind {
BlendEaseType::In => u * u,
BlendEaseType::Out => u * (2.0 - u),
BlendEaseType::InOut => u * u * (3.0 - 2.0 * u),
BlendEaseType::OutIn if u < 0.5 => 2.0 * u * (1.0 - u),
BlendEaseType::OutIn => 0.5 + 2.0 * (u - 0.5) * (u - 0.5),
};
u + r * (eased - u)
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct BlendSpec {
#[serde(default)]
pub spacing: BlendSpacing,
#[serde(default)]
pub orientation: BlendOrientation,
/// Acceleration of the steps' shapes and positions along the spine between two keys.
#[serde(default, skip_serializing_if = "BlendEase::is_default")]
pub ease: BlendEase,
/// Independent color acceleration. -100..100, with 0 preserving the even color ramp.
#[serde(default, rename = "colorShift", skip_serializing_if = "is_zero", deserialize_with = "blend_color_shift")]
pub color_shift: f64,
/// Replaced spine. `None` = the straight lines between the key objects' centres.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spine: Option<PathData>,
/// The anchor of the spine's first subpath each key object sits on (a spine edited on the
/// canvas: moving a key moves its anchor and the other way round). Empty: the keys spread
/// evenly along the spine by arc length (Replace Spine).
#[serde(default, rename = "keyAnchors", skip_serializing_if = "Vec::is_empty")]
pub key_anchors: Vec<u32>,
/// Per key object, the anchor of its first subpath the blend starts from (Blend tool clicks
/// on anchor points); `None`: chosen so closed shapes don't twist.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub starts: Vec<Option<u32>>,
}
impl BlendSpec {
/// The start anchor of key `i` (see [`Self::starts`]).
pub fn start(&self, i: usize) -> Option<usize> {
self.starts.get(i).copied().flatten().map(|a| a as usize)
}
}
/// Blend Options set with no blend selected: what new blends start with.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct BlendDefaults {
#[serde(default)]
pub spacing: BlendSpacing,
#[serde(default)]
pub orientation: BlendOrientation,
}
/// Reading a native file never fails over one bad blend number: out-of-range values clamp,
/// and anything that is not a finite number (NaN, null, text) becomes the default. The
/// commands still refuse such input (`ease_param`).
fn blend_number<'de, D: serde::Deserializer<'de>>(d: D, min: f64, max: f64) -> Result<f64, D::Error> {
let v = serde_json::Value::deserialize(d)?;
Ok(v.as_f64().filter(|n| n.is_finite()).map_or(0.0, |n| n.clamp(min, max)))
}
pub(super) fn blend_ramp<'de, D: serde::Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
blend_number(d, 0.0, 1.0)
}
pub(super) fn blend_color_shift<'de, D: serde::Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
blend_number(d, -100.0, 100.0)
}
/// Whether an optional stored color shift has its unchanged default.
fn is_zero(v: &f64) -> bool {
*v == 0.0
}
impl BlendSpec {
/// Shift the color transition independently of shape placement. The exponential curve is
/// original; the documented behavior specifies the direction, not numeric sample values.
pub fn color_position(&self, u: f64) -> f64 {
let u = if u.is_finite() { u.clamp(0.0, 1.0) } else { 0.0 };
let shift = if self.color_shift.is_finite() { self.color_shift.clamp(-100.0, 100.0) } else { 0.0 };
if shift == 0.0 { u } else { u.powf(2.0f64.powf(shift / 50.0)) }
}
}
+332
View File
@@ -0,0 +1,332 @@
use super::expand::{Hooks, Outliner, Warper, map_node};
use super::mesh::{GradientMesh, MeshPoint, grid_len, grid_points, nodes_bounds, surface_of};
use super::*;
/// How an envelope distorts its content.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum EnvelopeKind {
/// One of the 15 warp styles (`arc`, `arcLower`, … — see [`WarpStyle::from_id`]); bend and
/// distortions in percent (-100..100).
Warp {
style: String,
#[serde(default)]
bend: f64,
#[serde(default)]
h: f64,
#[serde(default)]
v: f64,
#[serde(default = "yes")]
horizontal: bool,
},
/// A `(rows+1)×(cols+1)` grid of points (row-major) the content's bounding box maps onto.
Mesh {
rows: u32,
cols: u32,
points: Vec<Point>,
/// Each point's bezier handles (offsets, see [`H_RIGHT`] …), row-major like `points`.
/// Empty: the smooth (Catmull-Rom) mesh through the points, as before handles existed;
/// editing the mesh's points or handles on the canvas fills them in.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
handles: Vec<[Vec2; 4]>,
},
/// The content's bounding box maps onto the outline of this path.
TopObject { path: PathData },
}
/// Envelope Options → Preserve Shape Using: how a distorted raster keeps the envelope's shape.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PreserveShape {
#[default]
ClippingMask,
Transparency,
}
impl PreserveShape {
pub fn id(self) -> &'static str {
match self {
Self::ClippingMask => "clippingMask",
Self::Transparency => "transparency",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"clippingMask" => Some(Self::ClippingMask),
"transparency" => Some(Self::Transparency),
_ => None,
}
}
}
/// Envelope Options besides Fidelity. `Default` is what envelopes saved before these options
/// existed do (their appearance applies after the distortion); new envelopes start from
/// [`EnvelopeOptions::NEW`], as in the reference app.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct EnvelopeOptions {
/// Rasters: Anti-Alias.
pub anti_alias: bool,
/// Rasters: Preserve Shape Using.
pub preserve_shape: PreserveShape,
/// Distort Appearance: strokes and live effects are applied before the distortion, so they
/// bend with the art (off: they apply to the distorted paths).
pub distort_appearance: bool,
/// Distort Linear Gradients (with Distort Appearance): placed gradients bend with the art.
pub distort_linear_gradients: bool,
/// Distort Pattern Fills (with Distort Appearance): pattern tiles bend with the art.
pub distort_pattern_fills: bool,
}
impl Default for EnvelopeOptions {
fn default() -> Self {
Self {
anti_alias: true,
preserve_shape: PreserveShape::ClippingMask,
distort_appearance: false,
distort_linear_gradients: false,
distort_pattern_fills: false,
}
}
}
impl EnvelopeOptions {
/// The options a new envelope gets (the reference app's defaults).
pub const NEW: Self = Self {
anti_alias: true,
preserve_shape: PreserveShape::ClippingMask,
distort_appearance: true,
distort_linear_gradients: false,
distort_pattern_fills: false,
};
/// Do gradients bend with the art?
pub fn gradients(&self) -> bool {
self.distort_appearance && self.distort_linear_gradients
}
/// Do pattern tiles bend with the art?
pub fn patterns(&self) -> bool {
self.distort_appearance && self.distort_pattern_fills
}
}
/// Handle slots of a [`MeshPoint`]: towards the next column, previous column, next row, previous row.
pub const H_RIGHT: usize = 0;
pub const H_LEFT: usize = 1;
pub const H_DOWN: usize = 2;
pub const H_UP: usize = 3;
/// The envelope's point map for content whose bounding box is `src` (page axes).
pub fn envelope_mapper<'a>(kind: &'a EnvelopeKind, src: Rect) -> Box<dyn Fn(Point) -> Point + 'a> {
let s = surface_of(kind, src, Affine::IDENTITY);
let (w, h) = (src.width().max(1e-9), src.height().max(1e-9));
Box::new(move |q: Point| s((q.x - src.x0) / w, (q.y - src.y0) / h))
}
/// Catmull-Rom tangents of a `(rows+1)×(cols+1)` point grid (row-major) as mesh handles, a third
/// of the tangent towards each neighbour (past the borders the grid goes on straight): the curves
/// an envelope mesh without handles of its own runs along between its points.
pub fn smooth_handles(rows: u32, cols: u32, points: &[Point]) -> Vec<[Vec2; 4]> {
let (rows, cols) = (rows as usize, cols as usize);
let at = |r: usize, c: usize| points.get(r * (cols + 1) + c).copied().unwrap_or_default();
let tangent = |prev: Option<Point>, p: Point, next: Option<Point>| match (prev, next) {
(Some(a), Some(b)) => (b - a) / 2.0,
(None, Some(b)) => b - p,
(Some(a), None) => p - a,
(None, None) => Vec2::ZERO,
};
let mut out = Vec::with_capacity(points.len());
for r in 0..=rows {
for c in 0..=cols {
let p = at(r, c);
let du = tangent(c.checked_sub(1).map(|c| at(r, c)), p, (c < cols).then(|| at(r, c + 1))) / 3.0;
let dv = tangent(r.checked_sub(1).map(|r| at(r, c)), p, (r < rows).then(|| at(r + 1, c))) / 3.0;
// Handles pointing out of the grid are unused: they stay at the point.
let keep = |inside: bool, d: Vec2| if inside { d } else { Vec2::ZERO };
let mut h = [Vec2::ZERO; 4];
(h[H_RIGHT], h[H_LEFT], h[H_DOWN], h[H_UP]) = (keep(c < cols, du), keep(c > 0, -du), keep(r < rows, dv), keep(r > 0, -dv));
out.push(h);
}
}
out
}
/// A mesh envelope's grid (`rows`×`cols` patches, `points` and their `handles`; without handles
/// the smooth ones, see [`smooth_handles`]) as a gradient mesh with black points: what the
/// canvas draws and the Mesh tool and Direct Selection edit. `None` for a malformed grid.
pub fn envelope_grid(rows: u32, cols: u32, points: &[Point], handles: &[[Vec2; 4]]) -> Option<GradientMesh> {
if rows == 0 || cols == 0 || grid_len(rows, cols) != Some(points.len()) {
return None;
}
let smooth;
let handles = if handles.len() == points.len() {
handles
} else {
smooth = smooth_handles(rows, cols, points);
&smooth
};
let points = points.iter().zip(handles).map(|(p, h)| MeshPoint { p: *p, color: Color::BLACK, opacity: 1.0, handles: *h }).collect();
Some(GradientMesh { rows, cols, points })
}
/// The point of mesh `m` at (u, v) over the whole mesh (each patch an equal share; past the edges
/// the outer patches go on).
pub(super) fn mesh_at(m: &GradientMesh, u: f64, v: f64) -> Point {
let (rows, cols) = (m.rows.max(1) as i64, m.cols.max(1) as i64);
let (su, sv) = (u * cols as f64, v * rows as f64);
let (c, r) = ((su.floor() as i64).clamp(0, cols - 1), (sv.floor() as i64).clamp(0, rows - 1));
m.eval(r as usize, c as usize, su - c as f64, sv - r as f64)
}
/// The envelope `id` is, or the innermost one it sits in.
pub fn envelope_of(doc: &crate::Document, id: NodeId) -> Option<&Node> {
doc.ancestry(id)?.into_iter().rev().find_map(|a| doc.node(a).filter(|n| matches!(n.kind, NodeKind::Envelope { .. })))
}
/// What a selected envelope shows on the canvas: its mesh lines (a warp's surface on a 4×4 grid,
/// a mesh envelope's own grid) or its top object's outline, and for a mesh envelope the grid to
/// edit (its points and handles).
pub fn envelope_overlay(n: &Node) -> Option<(PathData, Option<GradientMesh>)> {
let NodeKind::Envelope { kind, .. } = &n.kind else { return None };
match &**kind {
EnvelopeKind::Mesh { rows, cols, points, handles } => {
let grid = envelope_grid(*rows, *cols, points, handles)?;
Some((grid.lines(), Some(grid)))
}
EnvelopeKind::Warp { .. } => Some((EnvelopeMap::of(n)?.surface_mesh(4, 4, Color::BLACK).lines(), None)),
EnvelopeKind::TopObject { path } => Some((path.clone(), None)),
}
}
/// The frame an envelope keeps after transform `m` (its frame so far composed with the
/// transform): identity when its axes stay square to the page, unflipped, since evaluating on
/// the page axes gives the same result then.
pub fn envelope_frame(m: Affine) -> Affine {
let [a, b, c, d, _, _] = m.as_coeffs();
if b.abs() < 1e-12 && c.abs() < 1e-12 && a > 0.0 && d > 0.0 { Affine::IDENTITY } else { m }
}
/// An envelope's point map: content placed in the envelope's own frame maps, by its bounds there,
/// onto the unit square and from there onto the envelope's surface ([`surface_of`]).
pub struct EnvelopeMap<'a> {
src: Rect,
frame: Affine,
to_frame: Affine,
surface: Box<dyn Fn(f64, f64) -> Point + 'a>,
}
impl<'a> EnvelopeMap<'a> {
/// The map of an envelope of `kind` around `content` with `frame`. `None` when the content
/// has no bounds.
pub fn new(content: &[Arc<Node>], kind: &'a EnvelopeKind, frame: Affine) -> Option<Self> {
let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY };
let to_frame = frame.inverse();
let src = if frame == Affine::IDENTITY {
nodes_bounds(content)?
} else {
content.iter().try_fold(None, |acc, c| {
let mut m = (**c).clone();
m.transform(to_frame, false);
Some(vectorcraft_geom::union_opt(acc, m.geometric_bounds()))
})??
};
Some(Self { src, frame, to_frame, surface: surface_of(kind, src, frame) })
}
/// The map of envelope node `n` (`None` for other nodes).
pub fn of(n: &'a Node) -> Option<Self> {
match &n.kind {
NodeKind::Envelope { content, kind, frame, .. } => Self::new(content, kind, **frame),
_ => None,
}
}
/// The content's bounds in the envelope's frame.
pub fn src(&self) -> Rect {
self.src
}
/// The surface point at (u, v) ∈ [0,1]².
pub fn at(&self, u: f64, v: f64) -> Point {
(self.surface)(u, v)
}
/// Where the envelope puts document point `p`.
pub fn map(&self, p: Point) -> Point {
let q = self.to_frame * p;
let (w, h) = (self.src.width().max(1e-9), self.src.height().max(1e-9));
self.at((q.x - self.src.x0) / w, (q.y - self.src.y0) / h)
}
/// A flat `(rows+1)×(cols+1)` grid over the content, in the envelope's frame (row-major).
pub fn grid(&self, rows: u32, cols: u32) -> Vec<Point> {
grid_points(self.src, rows, cols).into_iter().map(|p| self.frame * p).collect()
}
/// The surface sampled on a `rows`×`cols` grid as a mesh whose handles follow it and whose
/// points are `color`: Release gives it back as the envelope's shape, Reset with Mesh starts
/// from it.
pub fn surface_mesh(&self, rows: u32, cols: u32, color: Color) -> GradientMesh {
GradientMesh::from_surface(rows, cols, &|u, v| self.at(u, v), &|_, _| color)
}
/// The surface's bounds (sampled; a mesh's points and a top object's path bound it exactly).
fn bounds(&self) -> Option<Rect> {
const N: usize = 16;
let mut r: Option<Rect> = None;
for j in 0..=N {
for i in 0..=N {
let q = self.at(i as f64 / N as f64, j as f64 / N as f64);
r = Some(r.map_or(Rect::from_points(q, q), |r| r.union_pt(q)));
}
}
r
}
}
/// Evaluate an envelope of `kind` around `content` on the page axes with the options envelopes
/// had before Envelope Options (see [`expand_live_hooks`] for an envelope node).
pub fn envelope_expand(content: &[Arc<Node>], kind: &EnvelopeKind, fidelity: f64, outline: Outliner) -> Vec<Node> {
expand_envelope(content, kind, fidelity, EnvelopeOptions::default(), Affine::IDENTITY, outline.into())
}
/// An envelope's content pushed through its map: Fidelity sets how finely curves are cut (and
/// into how many pieces images are), the options what bends with the art.
pub(super) fn expand_envelope(
content: &[Arc<Node>],
kind: &EnvelopeKind,
fidelity: f64,
options: EnvelopeOptions,
frame: Affine,
hooks: Hooks,
) -> Vec<Node> {
let Some(map) = EnvelopeMap::new(content, kind, frame) else { return content.iter().map(|c| (**c).clone()).collect() };
let src = map.src();
let fid = if fidelity.is_finite() { fidelity.clamp(0.0, 100.0) / 100.0 } else { 0.5 };
let f = |p: Point| map.map(p);
let w = Warper {
f: &f,
piece: src.width().hypot(src.height()).max(1e-6) / (8.0 + fid * 56.0),
eps: (src.width().max(src.height()) * 0.05).max(0.5),
cells: (4.0 + fid * 12.0).round() as usize,
hooks,
options,
depth: 0,
};
content.iter().map(|c| map_node(c, &w)).collect()
}
/// Envelope bounds: a mesh's points, a top object's path, the warp's surface (sampled).
pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind, frame: Affine) -> Option<Rect> {
match kind {
EnvelopeKind::Mesh { rows, cols, points, handles } if !handles.is_empty() => envelope_grid(*rows, *cols, points, handles)?.outline().bounds(),
EnvelopeKind::Mesh { points, .. } => {
let first = *points.first()?;
Some(points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p)))
}
EnvelopeKind::TopObject { path } => path.bounds(),
EnvelopeKind::Warp { .. } => EnvelopeMap::new(content, kind, frame)?.bounds(),
}
}
+444
View File
@@ -0,0 +1,444 @@
use super::envelope::{EnvelopeOptions, expand_envelope};
use super::mesh::GradientMesh;
use super::warp::map_nonlinear;
use super::*;
/// Optional hook that converts nodes the pure evaluation can't map (text) to outlines.
pub type Outliner<'a> = Option<&'a dyn Fn(&Node) -> Option<Node>>;
/// Distort Pattern Fills: the tiles pattern swatch `name` painted with placement `xf` lays over
/// `region` (see [`crate::PatternDef::instances_in`]); `None` without such a swatch.
pub type PatternTiles<'a> = &'a dyn Fn(&str, Affine, Rect) -> Option<Vec<Node>>;
/// What live evaluation asks of the crates above this one (`vectorcraft-effects` supplies them).
#[derive(Clone, Copy, Default)]
pub struct Hooks<'a> {
/// Type as its glyph outlines, a symbol instance as its art (see [`Outliner`]).
pub outline: Outliner<'a>,
/// Distort Appearance: a path's strokes and geometry effects baked into filled art (`None`
/// when it has none to bake).
pub appearance: Outliner<'a>,
/// Distort Pattern Fills: the tiles a pattern fill lays (see [`PatternTiles`]).
pub pattern: Option<PatternTiles<'a>>,
/// The document whose symbols' art counts toward the instance budget of a Repeat of their
/// instances ([`crate::RepeatSpec::expand`]).
pub symbols: Option<&'a crate::Document>,
}
impl<'a> From<Outliner<'a>> for Hooks<'a> {
fn from(outline: Outliner<'a>) -> Self {
Self { outline, ..Default::default() }
}
}
/// The affine map that best matches `f` near `p` (finite differences with step `eps`).
pub fn affine_near(f: &dyn Fn(Point) -> Point, p: Point, eps: f64) -> Affine {
let o = f(p);
let ex = (f(p + Vec2::new(eps, 0.0)) - f(p - Vec2::new(eps, 0.0))) / (2.0 * eps);
let ey = (f(p + Vec2::new(0.0, eps)) - f(p - Vec2::new(0.0, eps))) / (2.0 * eps);
let lin = Affine::new([ex.x, ex.y, ey.x, ey.y, 0.0, 0.0]);
Affine::translate(o.to_vec2()) * lin * Affine::translate(-p.to_vec2())
}
/// How deep hooks may nest art inside art (symbols in symbols, envelopes in envelopes) before
/// the rest is left as it is: a file can make a symbol contain itself.
const MAX_HOOK_DEPTH: u32 = 16;
/// How an envelope pushes art through its point map.
#[derive(Clone, Copy)]
pub(super) struct Warper<'a> {
pub(super) f: &'a dyn Fn(Point) -> Point,
/// Longest piece a segment is cut into before mapping.
pub(super) piece: f64,
/// Finite-difference step for the local affine maps gradients follow.
pub(super) eps: f64,
/// Rows and columns of the pieces an image is cut into.
pub(super) cells: usize,
pub(super) hooks: Hooks<'a>,
pub(super) options: EnvelopeOptions,
/// How many hooks the art being mapped came through.
pub(super) depth: u32,
}
impl Warper<'_> {
/// This warper one hook deeper (`None` past [`MAX_HOOK_DEPTH`]).
fn deeper(&self) -> Option<Self> {
(self.depth < MAX_HOOK_DEPTH).then_some(Self { depth: self.depth + 1, ..*self })
}
}
/// `to` (art standing in for `from`: its outlines, its art) with `from`'s name, transparency,
/// opacity mask and effects.
fn carry_look(from: &Node, mut to: Node) -> Node {
to.name = from.name.clone();
to.opacity = from.opacity;
to.blend = from.blend;
to.isolate = from.isolate;
to.knockout = from.knockout;
to.knockout_shape = from.knockout_shape;
to.extras_mut().mask = from.extras().mask.clone();
to.appearance.effects = from.appearance.effects.clone();
to
}
pub(super) fn map_node(n: &Node, w: &Warper) -> Node {
let deeper = w.deeper();
// Distort Appearance: strokes and geometry effects bend with the art.
if w.options.distort_appearance
&& let Some(d) = &deeper
&& let Some(o) = w.hooks.appearance.and_then(|h| h(n))
{
return map_node(&o, d);
}
// Distort Pattern Fills: the tiles bend with the art (not the patterns inside them).
if w.options.patterns()
&& let Some(d) = &deeper
&& let Some(o) = w.hooks.pattern.and_then(|p| pattern_art(n, p))
{
let inner = Warper { options: EnvelopeOptions { distort_pattern_fills: false, ..d.options }, ..*d };
return map_node(&o, &inner);
}
let mut out = n.clone();
// Distort Linear Gradients: the gradient vectors follow the warp where they sit.
if w.options.gradients()
&& let Some(b) = n.geometric_bounds()
{
out.appearance.warp_linear_gradients(b, &|p| affine_near(w.f, p, w.eps));
}
if let Some(m) = out.extras_if_any().and_then(|e| e.mask.as_mut()) {
m.art = Arc::new(map_node(&m.art, w));
}
match &mut out.kind {
NodeKind::Path { path, live, .. } => {
*path = map_nonlinear(path, w.piece, w.f);
*live = None;
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => {
for c in children.iter_mut() {
*c = Arc::new(map_node(c, w));
}
}
NodeKind::Mesh(m) => m.map(w.f),
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => {
if let Some(d) = &deeper {
return map_node(&expanded_group_hooks(n, w.hooks), d);
}
}
NodeKind::Text(_) | NodeKind::SymbolInstance { .. } => {
if let Some(d) = &deeper
&& let Some(o) = w.hooks.outline.and_then(|h| h(n))
{
return map_node(&carry_look(n, o), d);
}
}
NodeKind::Image(im) => {
let im = im.clone();
return warp_image(&out, &im, w);
}
// Its art is plain, so it warps at this depth.
NodeKind::PlacedDocument(_) => return map_node(&expanded_group_hooks(n, w.hooks), w),
}
out
}
/// The affine map taking triangle `s` to triangle `d` (`None` when `s` is degenerate).
fn affine_between(s: [Point; 3], d: [Point; 3]) -> Option<Affine> {
let basis = |p: [Point; 3]| Affine::new([p[1].x - p[0].x, p[1].y - p[0].y, p[2].x - p[0].x, p[2].y - p[0].y, p[0].x, p[0].y]);
let bs = basis(s);
if bs.determinant().abs() < 1e-12 {
return None;
}
let a = basis(d) * bs.inverse();
a.is_finite().then_some(a)
}
/// Image `n` (`im`) pushed through the warp as a raster mesh warp: its frame cut into
/// `w.cells`² cells of two triangles, each a clip group showing the image under the affine map
/// that takes the triangle where the warp takes its corners. Each piece reaches a little past its
/// edges (at most half a point) so neighbours overlap and no seams show.
fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node {
let k = w.cells.max(1);
// Two triangles a cell, three objects each; an image an inner envelope already cut into
// pieces is cut again here, so the pieces count against [`MAX_GENERATED`]. Past it, the image
// is left out.
if !Generating::charge(k.saturating_mul(k).saturating_mul(6)) {
let mut g = n.clone();
g.kind = NodeKind::Group { children: vec![], clip: false };
return g;
}
let (pw, ph) = (im.width as f64, im.height as f64);
let at = |i: usize, j: usize| Point::new(pw * i as f64 / k as f64, ph * j as f64 / k as f64);
let mut pieces = Vec::with_capacity(2 * k * k);
for j in 0..k {
for i in 0..k {
let s = [at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1)];
let d = s.map(|p| (w.f)(im.xf * p));
for t in [[0, 1, 2], [0, 2, 3]] {
let (st, dt) = (t.map(|v| s[v]), t.map(|v| d[v]));
let Some(xf) = affine_between(st, dt) else { continue };
let c = Point::new((dt[0].x + dt[1].x + dt[2].x) / 3.0, (dt[0].y + dt[1].y + dt[2].y) / 3.0);
let grow = (dt[0].distance(dt[1]).max(dt[1].distance(dt[2])) * 0.02).min(0.5);
let tri: Vec<Point> = dt
.iter()
.map(|p| {
let v = *p - c;
let len = v.hypot();
if len > 1e-12 { *p + v * (grow / len) } else { *p }
})
.collect();
let mut clip = Node::path(n.id, PathData::single(SubPath::polyline(&tri, true)), Appearance::default());
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
let image = Node::new(n.id, NodeKind::Image(Box::new(ImageObject { xf, ..im.clone() })));
pieces.push(Arc::new(Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true })));
}
}
}
let mut g = n.clone();
g.kind = NodeKind::Group { children: pieces, clip: false };
g
}
/// Distort Pattern Fills: path or compound path `n` with each visible pattern fill turned into the
/// tiles it paints in a clip group shaped like `n` (with the fill's opacity and blend mode), its
/// other fills and strokes on copies of it, in paint order, under `n`'s transparency. `None`
/// without a pattern fill whose definition `pattern` finds.
fn pattern_art(n: &Node, tiles: PatternTiles) -> Option<Node> {
let (path, rule, _) = crate::blend::node_path(n)?;
let pattern_fill = |i: &AppearanceItem| matches!(i, AppearanceItem::Fill(f) if f.visible && matches!(f.paint, Paint::Pattern { .. }));
if !n.appearance.items.iter().any(pattern_fill) {
return None;
}
let bounds = path.bounds()?;
let def = |i: &AppearanceItem| match i {
AppearanceItem::Fill(f) if f.visible => match &f.paint {
Paint::Pattern { pattern: name, xf } => tiles(name, *xf, bounds).map(|t| (t, f.opacity, f.blend)),
_ => None,
},
_ => None,
};
let plain = |items: Vec<AppearanceItem>| {
let mut p = Node {
appearance: Appearance { items, ..Default::default() }.into(),
opacity: 1.0,
blend: Default::default(),
isolate: false,
knockout: Default::default(),
knockout_shape: false,
name: None,
..n.clone()
};
p.extras_mut().mask = None;
Arc::new(p)
};
let mut pieces = vec![];
let mut rest = vec![];
for i in &n.appearance.items {
let Some((tiles, opacity, blend)) = def(i) else {
rest.push(i.clone());
continue;
};
if !rest.is_empty() {
pieces.push(plain(std::mem::take(&mut rest)));
}
let mut clip = Node::path(n.id, path.clone(), Appearance::default());
if let NodeKind::Path { clipping, rule: r, .. } = &mut clip.kind {
(*clipping, *r) = (true, rule);
}
let children = std::iter::once(clip).chain(tiles).map(Arc::new).collect();
let mut g = Node::new(n.id, NodeKind::Group { children, clip: true });
g.opacity = opacity;
g.blend = blend;
pieces.push(Arc::new(g));
}
if !rest.is_empty() {
pieces.push(plain(rest));
}
Some(Node {
kind: NodeKind::Group { children: pieces, clip: false },
appearance: Appearance { effects: n.appearance.effects.clone(), ..Default::default() }.into(),
..n.clone()
})
}
// =====================================================================================
// Expansion
// =====================================================================================
/// Is this one of the live kinds?
pub fn is_live(n: &Node) -> bool {
matches!(
n.kind,
NodeKind::Blend { .. }
| NodeKind::Envelope { .. }
| NodeKind::Mesh(_)
| NodeKind::Repeat(_)
| NodeKind::PlacedDocument(_)
| NodeKind::Intertwine { .. }
)
}
/// Mesh tessellation as filled quad paths (no stroke), `n`×`n` per patch.
pub fn mesh_quad_nodes(m: &GradientMesh, n: usize) -> Vec<Node> {
m.quads(n)
.into_iter()
.map(|q| {
let mut ap = Appearance::default();
let mut fl = FillLayer::new(Paint::solid(q.color));
fl.opacity = q.opacity;
ap.items.push(AppearanceItem::Fill(fl));
Node::path(NodeId(0), PathData::single(SubPath::polyline(&q.pts, true)), ap)
})
.collect()
}
/// One level of evaluation of a live object (non-live nodes return themselves). Blend steps and
/// envelope results may still contain live objects (e.g. a blend of two meshes).
pub fn expand_live(n: &Node) -> Vec<Node> {
expand_live_with(n, None)
}
pub fn expand_live_with(n: &Node, outline: Outliner) -> Vec<Node> {
expand_live_hooks(n, outline.into())
}
/// [`expand_live_with`] with everything an envelope can ask of the crates above (see [`Hooks`]).
pub fn expand_live_hooks(n: &Node, hooks: Hooks) -> Vec<Node> {
let _generating = Generating::enter();
match &n.kind {
NodeKind::Blend { children, spec } => blend_expand(children, spec),
NodeKind::Envelope { content, kind, fidelity, options, frame, .. } => expand_envelope(content, kind, *fidelity, *options, **frame, hooks),
NodeKind::Mesh(m) => mesh_quad_nodes(m, 8),
NodeKind::Repeat(r) => r.expand(hooks.symbols),
NodeKind::PlacedDocument(p) => p.art(n),
NodeKind::Intertwine { children } => crate::intertwine::expand(children),
_ => vec![n.clone()],
}
}
/// A live object evaluated into a plain group (keeping the object's id, name and transparency).
pub fn expanded_group(n: &Node, outline: Outliner) -> Node {
expanded_group_hooks(n, outline.into())
}
/// [`expanded_group`] with [`Hooks`].
pub fn expanded_group_hooks(n: &Node, hooks: Hooks) -> Node {
let _generating = Generating::enter();
let children = expand_live_hooks(n, hooks).into_iter().map(Arc::new).collect();
let mut g = Node::new(n.id, NodeKind::Group { children, clip: false });
g.name = n.name.clone();
g.visible = n.visible;
g.locked = n.locked;
g.opacity = n.opacity;
g.blend = n.blend;
g.isolate = n.isolate;
g.knockout = n.knockout;
// Knocking out opaque blend steps changes nothing: don't make the outputs work for it.
if matches!(n.kind, NodeKind::Blend { .. }) && g.knockout == crate::Knockout::On && !crate::blend::knockout_shows(&g) {
g.knockout = crate::Knockout::Off;
}
g.knockout_shape = n.knockout_shape;
g.extras_mut().mask = n.extras().mask.clone();
g
}
/// Recursively replace every live object in `n` by plain geometry (for exporters).
pub fn expand_deep(n: &Node, outline: Outliner) -> Node {
expand_deep_hooks(n, outline.into())
}
/// [`expand_deep`] with [`Hooks`]. The objects live art makes here count against one
/// [`MAX_GENERATED`] ([`Generating`]): blends of blends kept their inner blends live, and each
/// copy expanded on its own, so step counts capped one by one multiplied. Past it, live art
/// expands to nothing.
pub fn expand_deep_hooks(n: &Node, hooks: Hooks) -> Node {
let _generating = Generating::enter();
let mut out = if is_live(n) {
let g = expanded_group_hooks(n, hooks);
if !Generating::charge(g.count()) {
return Node::new(n.id, NodeKind::Group { children: vec![], clip: false });
}
g
} else {
n.clone()
};
if let Some(ch) = out.children_mut() {
for c in ch.iter_mut() {
if subtree_has_live(c) {
*c = Arc::new(expand_deep_hooks(c, hooks));
}
}
}
out
}
/// Most objects one evaluation of live art (an expansion, an envelope's mapping and everything
/// inside them) may make.
pub const MAX_GENERATED: usize = 4_000_000;
std::thread_local! {
/// What is left of [`MAX_GENERATED`] for the evaluation running on this thread; `None` outside
/// one.
static GENERATED_LEFT: std::cell::Cell<Option<usize>> = const { std::cell::Cell::new(None) };
}
/// The outermost live evaluation on this thread: it starts [`GENERATED_LEFT`] and ends it.
struct Generating(bool);
impl Generating {
fn enter() -> Self {
let outermost = GENERATED_LEFT.get().is_none();
if outermost {
GENERATED_LEFT.set(Some(MAX_GENERATED));
}
Self(outermost)
}
/// Spend `n` objects; `false` (and nothing left) when there aren't that many.
fn charge(n: usize) -> bool {
match GENERATED_LEFT.get() {
None => true,
Some(left) => {
GENERATED_LEFT.set(Some(left.saturating_sub(n)));
n <= left
}
}
}
}
impl Drop for Generating {
fn drop(&mut self) {
if self.0 {
GENERATED_LEFT.set(None);
}
}
}
/// Does `n` or any descendant need live evaluation?
pub fn subtree_has_live(n: &Node) -> bool {
let mut any = false;
n.walk(&mut |c| any |= is_live(c));
any
}
/// Largest stroke outset in a subtree (for visual bounds of envelopes). With `symbols`, the
/// outsets in the art of the symbols its instances show count too
/// ([`crate::symbols::ArtBox::reach`]), grown by [`crate::SymbolBoxes::brush_pad`]. In boxes made
/// by [`crate::SymbolBoxes::without_art_strokes`], the outsets in the art do not count, and the pad
/// does.
pub(crate) fn max_outset(nodes: &[Arc<Node>], symbols: Option<&crate::SymbolBoxes>) -> f64 {
let mut o = 0.0f64;
for n in nodes {
n.walk(&mut |c| {
o = o.max(c.appearance.outset());
if let (Some(s), NodeKind::SymbolInstance { symbol, .. }) = (symbols, &c.kind)
&& let Some(art) = s.art(symbol)
{
let reach = if s.art_strokes() { art.reach } else { 0.0 };
o = o.max(reach + s.brush_pad(symbol));
}
});
}
o
}
+761
View File
@@ -0,0 +1,761 @@
use super::envelope::{EnvelopeKind, H_DOWN, H_LEFT, H_RIGHT, H_UP, envelope_grid, mesh_at};
use super::warp::{WarpStyle, flatten_subpath, resample, warp_point};
use super::*;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MeshPoint {
pub p: Point,
pub color: Color,
#[serde(default = "one")]
pub opacity: f32,
/// Tangent handles as offsets from `p` (see [`H_RIGHT`] …).
#[serde(default)]
pub handles: [Vec2; 4],
}
/// A gradient mesh: `(rows+1)×(cols+1)` points, row-major.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GradientMesh {
pub rows: u32,
pub cols: u32,
pub points: Vec<MeshPoint>,
}
/// One tessellated piece of a mesh: a small quad, its colour at the centre, and its colour and
/// opacity at each corner (in `pts` order), which a renderer shades between so neighbouring quads
/// meet without a step.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MeshQuad {
pub pts: [Point; 4],
pub color: Color,
pub opacity: f32,
pub corners: [(Color, f32); 4],
}
/// Create Gradient Mesh → Appearance.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum MeshAppearance {
#[default]
Flat,
ToCenter,
ToEdge,
}
impl MeshAppearance {
pub fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"flat" => Some(Self::Flat),
"center" | "tocenter" | "to center" => Some(Self::ToCenter),
"edge" | "toedge" | "to edge" => Some(Self::ToEdge),
_ => None,
}
}
}
/// Default envelope fidelity.
pub fn default_fidelity() -> f64 {
fifty()
}
const SIDE: usize = 65;
/// Four boundary curves (top TL→TR, right TR→BR, bottom BL→BR, left TL→BL), each resampled to
/// [`SIDE`] points, from the outline of a closed path split at the points nearest its bounding
/// box corners.
#[derive(Clone, Debug)]
pub struct Coons {
top: Vec<Point>,
right: Vec<Point>,
bottom: Vec<Point>,
left: Vec<Point>,
}
impl Coons {
/// A rectangle.
pub fn rect(r: Rect) -> Self {
let line = |a: Point, b: Point| (0..SIDE).map(|i| a.lerp(b, i as f64 / (SIDE - 1) as f64)).collect::<Vec<_>>();
let (tl, tr, br, bl) = (Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1));
Self { top: line(tl, tr), right: line(tr, br), bottom: line(bl, br), left: line(tl, bl) }
}
pub fn from_path(path: &PathData) -> Option<Self> {
// The largest subpath is the outline.
let sp = path.subpaths.iter().filter(|s| s.anchors.len() >= 2).max_by(|a, b| a.area().abs().total_cmp(&b.area().abs()))?;
let r = path.bounds()?;
let tol = (r.width() + r.height()).max(1e-6) / 2000.0;
let mut poly = flatten_subpath(sp, tol);
if poly.len() > 1
&& let (Some(a), Some(b)) = (poly.first(), poly.last())
&& a.distance(*b) < 1e-9
{
poly.pop();
}
if poly.len() < 3 {
return Some(Self::rect(r));
}
// Orient clockwise in y-down (TL → TR → BR → BL).
let area: f64 = (0..poly.len())
.map(|i| {
let (p, q) = (poly[i], poly[(i + 1) % poly.len()]);
p.x * q.y - q.x * p.y
})
.sum();
if area < 0.0 {
poly.reverse();
}
let nearest = |c: Point| (0..poly.len()).min_by(|i, j| poly[*i].distance_squared(c).total_cmp(&poly[*j].distance_squared(c))).unwrap_or(0);
let tl = nearest(Point::new(r.x0, r.y0));
let tr = nearest(Point::new(r.x1, r.y0));
let br = nearest(Point::new(r.x1, r.y1));
let bl = nearest(Point::new(r.x0, r.y1));
let n = poly.len();
let arc = |i: usize, j: usize| -> Vec<Point> {
let mut v = vec![poly[i]];
let mut k = i;
while k != j {
k = (k + 1) % n;
v.push(poly[k]);
}
v
};
let top = resample(&arc(tl, tr), SIDE);
let right = resample(&arc(tr, br), SIDE);
let mut bottom = resample(&arc(br, bl), SIDE);
bottom.reverse();
let mut left = resample(&arc(bl, tl), SIDE);
left.reverse();
Some(Self { top, right, bottom, left })
}
/// The point at `u` along a side; past its ends the end segments go on straight (art an
/// envelope bends can reach past the content's box: outlined strokes).
fn sample(side: &[Point], u: f64) -> Point {
let s = u * (side.len() - 1) as f64;
let i = (s.floor().max(0.0) as usize).min(side.len() - 2);
side[i].lerp(side[i + 1], s - i as f64)
}
/// The surface point at (u, v) ∈ [0,1]².
pub fn eval(&self, u: f64, v: f64) -> Point {
let (t, b, l, r) = (Self::sample(&self.top, u), Self::sample(&self.bottom, u), Self::sample(&self.left, v), Self::sample(&self.right, v));
let (p00, p10, p01, p11) = (self.top[0], self.top[SIDE - 1], self.bottom[0], self.bottom[SIDE - 1]);
let c = (1.0 - v) * t.to_vec2() + v * b.to_vec2() + (1.0 - u) * l.to_vec2() + u * r.to_vec2()
- ((1.0 - u) * (1.0 - v) * p00.to_vec2() + u * (1.0 - v) * p10.to_vec2() + (1.0 - u) * v * p01.to_vec2() + u * v * p11.to_vec2());
c.to_point()
}
/// The four corners TL, TR, BR, BL.
pub fn corners(&self) -> [Point; 4] {
[self.top[0], self.top[SIDE - 1], self.bottom[SIDE - 1], self.bottom[0]]
}
}
// =====================================================================================
// Gradient meshes
// =====================================================================================
/// `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);
(0..=n).map(|i| i as f64 / n as f64).collect()
}
/// The number of points of a `(rows+1)×(cols+1)` grid, `None` past `usize`: counts come from
/// files, and u32::MAX rows and columns (65,535 on 32-bit targets) would wrap to an empty grid.
pub(super) fn grid_len(rows: u32, cols: u32) -> Option<usize> {
(rows as usize).checked_add(1)?.checked_mul((cols as usize).checked_add(1)?)
}
impl GradientMesh {
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())
}
/// A 1×1 mesh from its four corners (bottom-left, top-left, top-right, bottom-right in u/v
/// order: row 0 is v = 0, columns follow u), each with its handles ([`H_RIGHT`] …).
fn patch(corners: [(Point, [Vec2; 4], Color); 4]) -> Self {
let point = |(p, handles, color): (Point, [Vec2; 4], Color)| MeshPoint { p, color, opacity: 1.0, handles };
let [a, b, c, d] = corners;
Self { rows: 1, cols: 1, points: vec![point(a), point(d), point(b), point(c)] }
}
/// A 1×1 mesh from a Coons patch's twelve boundary points (in the order PDF and PostScript
/// shadings list them: v runs cp0 → cp3 along cp1 and cp2, u runs cp0 → cp9 along cp11 and
/// cp10) and its corner colours (at cp0, cp3, cp6, cp9); `None` if a point isn't finite.
pub fn coons(cp: &[Point; 12], colors: [Color; 4]) -> Option<Self> {
if !cp.iter().all(|p| p.is_finite()) {
return None;
}
let h = |a: usize, b: usize| cp[b] - cp[a];
let handles = |right: Vec2, left: Vec2, down: Vec2, up: Vec2| {
let mut h = [Vec2::ZERO; 4];
(h[H_RIGHT], h[H_LEFT], h[H_DOWN], h[H_UP]) = (right, left, down, up);
h
};
let [c0, c1, c2, c3] = colors;
Some(Self::patch([
(cp[0], handles(h(0, 11), Vec2::ZERO, h(0, 1), Vec2::ZERO), c0),
(cp[3], handles(h(3, 4), Vec2::ZERO, Vec2::ZERO, h(3, 2)), c1),
(cp[6], handles(Vec2::ZERO, h(6, 5), Vec2::ZERO, h(6, 7)), c2),
(cp[9], handles(Vec2::ZERO, h(9, 10), h(9, 8), Vec2::ZERO), c3),
]))
}
/// A 1×1 mesh of a Gouraud-shaded triangle (a patch with two corners together); `None` if a
/// point isn't finite.
pub fn triangle(corners: [(Point, Color); 3]) -> Option<Self> {
if !corners.iter().all(|(p, _)| p.is_finite()) {
return None;
}
let [a, b, c] = corners.map(|(p, color)| (p, [Vec2::ZERO; 4], color));
Some(Self::patch([a, b, c, c]))
}
/// A mesh over surface `s` (u, v ∈ [0,1]) with handles from its partial derivatives.
pub fn from_surface(rows: u32, cols: u32, s: &dyn Fn(f64, f64) -> Point, color: &dyn Fn(f64, f64) -> Color) -> Self {
let (us, vs) = (even_steps(cols), even_steps(rows));
Self::from_grid(&us, &vs, s, &|r, c, _| (color(us[c], vs[r]), 1.0))
}
/// A mesh over surface `s` (u, v ∈ [0,1]) whose column lines sit at `us` and row lines at `vs`
/// (ascending, at least two each; equal neighbours make a patch of no width, a sharp colour
/// change), with handles from its partial derivatives. `color(row, col, point)` gives each
/// point its colour and opacity.
pub fn from_grid(us: &[f64], vs: &[f64], s: &dyn Fn(f64, f64) -> Point, color: &dyn Fn(usize, usize, Point) -> (Color, f32)) -> Self {
let (rows, cols) = (vs.len().saturating_sub(1), us.len().saturating_sub(1));
let eps = 1e-3;
let mut points = Vec::with_capacity(us.len() * vs.len());
for (r, &v) in vs.iter().enumerate() {
for (c, &u) in us.iter().enumerate() {
let p = s(u, v);
let d_u = (s((u + eps).min(1.0), v) - s((u - eps).max(0.0), v)) / ((u + eps).min(1.0) - (u - eps).max(0.0));
let d_v = (s(u, (v + eps).min(1.0)) - s(u, (v - eps).max(0.0))) / ((v + eps).min(1.0) - (v - eps).max(0.0));
// A third of the way to the neighbouring line along the tangent; outward handles on
// the border are unused, so they stay at the point.
let h = |d: Vec2, from: f64, to: Option<&f64>| to.map_or(Vec2::ZERO, |to| d * ((to - from) / 3.0));
let handles = [
h(d_u, u, us.get(c + 1)),
h(d_u, u, c.checked_sub(1).map(|i| &us[i])),
h(d_v, v, vs.get(r + 1)),
h(d_v, v, r.checked_sub(1).map(|i| &vs[i])),
];
let (color, opacity) = color(r, c, p);
points.push(MeshPoint { p, color, opacity, handles });
}
}
Self { rows: rows as u32, cols: cols as u32, points }
}
/// Create Gradient Mesh for a path: the outline split at its corners, coloured per `appearance`.
pub fn for_path(path: &PathData, rows: u32, cols: u32, base: Color, appearance: MeshAppearance, highlight: f64) -> Option<Self> {
let mut m = Self::for_path_with(path, rows, cols, &|_| (base, 1.0))?;
m.highlight(appearance, highlight);
Some(m)
}
/// Create Gradient Mesh for a path: the outline split at its corners, each point taking the
/// 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)))
}
/// Create Gradient Mesh → Appearance: lighten the points towards white, to the centre or to
/// the edges, by at most `highlight` %.
pub fn highlight(&mut self, appearance: MeshAppearance, highlight: f64) {
let h = (highlight / 100.0).clamp(0.0, 1.0) as f32;
let (rows, cols) = (self.rows as usize, self.cols as usize);
for r in 0..=rows {
for c in 0..=cols {
let (u, v) = (c as f64 / cols as f64, r as f64 / rows as f64);
let d = ((u - 0.5).abs().max((v - 0.5).abs()) * 2.0) as f32; // 0 centre … 1 edge
let w = match appearance {
MeshAppearance::Flat => 0.0,
MeshAppearance::ToCenter => (1.0 - d) * h,
MeshAppearance::ToEdge => d * h,
};
if w > 0.0 {
let i = self.idx(r, c);
self.points[i].color = lerp_color(&self.points[i].color, &Color::WHITE, w);
}
}
}
}
/// Horizontal edge from (r, c) to (r, c+1).
pub fn edge_u(&self, r: usize, c: usize) -> CubicBez {
let (a, b) = (&self.points[self.idx(r, c)], &self.points[self.idx(r, c + 1)]);
CubicBez::new(a.p, a.p + a.handles[H_RIGHT], b.p + b.handles[H_LEFT], b.p)
}
/// Vertical edge from (r, c) to (r+1, c).
pub fn edge_v(&self, r: usize, c: usize) -> CubicBez {
let (a, b) = (&self.points[self.idx(r, c)], &self.points[self.idx(r + 1, c)]);
CubicBez::new(a.p, a.p + a.handles[H_DOWN], b.p + b.handles[H_UP], b.p)
}
/// Coons patch point of patch (r, c) at local (u, v).
pub fn eval(&self, r: usize, c: usize, u: f64, v: f64) -> Point {
let (t, b) = (self.edge_u(r, c).eval(u), self.edge_u(r + 1, c).eval(u));
let (l, rr) = (self.edge_v(r, c).eval(v), self.edge_v(r, c + 1).eval(v));
let p00 = self.points[self.idx(r, c)].p.to_vec2();
let p10 = self.points[self.idx(r, c + 1)].p.to_vec2();
let p01 = self.points[self.idx(r + 1, c)].p.to_vec2();
let p11 = self.points[self.idx(r + 1, c + 1)].p.to_vec2();
((1.0 - v) * t.to_vec2() + v * b.to_vec2() + (1.0 - u) * l.to_vec2() + u * rr.to_vec2()
- ((1.0 - u) * (1.0 - v) * p00 + u * (1.0 - v) * p10 + (1.0 - u) * v * p01 + u * v * p11))
.to_point()
}
/// Bilinear colour and opacity of patch (r, c) at local (u, v). Corners all in CMYK (or all
/// grey) mix in that space, as a PDF shading mixes in its own colour space: the inks come out
/// exact, and a CMYK document separates no colour to draw them. Other corners mix in RGB.
pub fn color_at(&self, r: usize, c: usize, u: f64, v: f64) -> (Color, f32) {
let q =
[&self.points[self.idx(r, c)], &self.points[self.idx(r, c + 1)], &self.points[self.idx(r + 1, c)], &self.points[self.idx(r + 1, c + 1)]];
let w = [(1.0 - u) * (1.0 - v), u * (1.0 - v), (1.0 - u) * v, u * v];
let op = q.iter().zip(w).map(|(p, w)| p.opacity * w as f32).sum();
let cmyk: Option<Vec<[f32; 4]>> =
q.iter().map(|p| if let Color::Cmyk { c, m, y, k } = p.color { Some([c, m, y, k]) } else { None }).collect();
if let Some(inks) = cmyk {
let mut mix = [0.0f32; 4];
for (ink, w) in inks.iter().zip(w) {
for (m, x) in mix.iter_mut().zip(ink) {
*m += x * w as f32;
}
}
let [c, m, y, k] = mix;
return (Color::Cmyk { c, m, y, k }, op);
}
let grey: Option<Vec<f32>> = q.iter().map(|p| if let Color::Gray { k } = p.color { Some(k) } else { None }).collect();
if let Some(ks) = grey {
return (Color::Gray { k: ks.iter().zip(w).map(|(k, w)| k * w as f32).sum() }, op);
}
let mut rgb = [0.0f32; 3];
for (p, w) in q.iter().zip(w) {
for (ch, x) in rgb.iter_mut().zip(p.color.to_rgb()) {
*ch += x * w as f32;
}
}
(Color::rgb(rgb[0], rgb[1], rgb[2]), op)
}
/// Tessellate into `n`×`n` quads per patch, 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 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 {
for c in 0..self.cols as usize {
for j in 0..=n {
for i in 0..=n {
let (u, v) = (i as f64 / n as f64, j as f64 / n as f64);
grid[j * (n + 1) + i] = self.eval(r, c, u, v);
colors[j * (n + 1) + i] = self.color_at(r, c, u, v);
}
}
for j in 0..n {
for i in 0..n {
let (color, opacity) = self.color_at(r, c, (i as f64 + 0.5) / n as f64, (j as f64 + 0.5) / n as f64);
let g = |a: usize, b: usize| grid[b * (n + 1) + a];
let k = |a: usize, b: usize| colors[b * (n + 1) + a];
out.push(MeshQuad {
pts: [g(i, j), g(i + 1, j), g(i + 1, j + 1), g(i, j + 1)],
color,
opacity,
corners: [k(i, j), k(i + 1, j), k(i + 1, j + 1), k(i, j + 1)],
});
}
}
}
}
out
}
/// The mesh's outer boundary as a closed path.
pub fn outline(&self) -> PathData {
if !self.is_valid() {
return PathData::default();
}
let (rows, cols) = (self.rows as usize, self.cols as usize);
let mut segs: Vec<CubicBez> = vec![];
for c in 0..cols {
segs.push(self.edge_u(0, c));
}
for r in 0..rows {
segs.push(self.edge_v(r, cols));
}
for c in (0..cols).rev() {
let e = self.edge_u(rows, c);
segs.push(CubicBez::new(e.p3, e.p2, e.p1, e.p0));
}
for r in (0..rows).rev() {
let e = self.edge_v(r, 0);
segs.push(CubicBez::new(e.p3, e.p2, e.p1, e.p0));
}
let mut anchors: Vec<Anchor> = Vec::with_capacity(segs.len());
for (i, s) in segs.iter().enumerate() {
let prev = &segs[(i + segs.len() - 1) % segs.len()];
anchors.push(Anchor::with_handles(s.p0, prev.p2, s.p1));
}
PathData::single(SubPath::new(anchors, true))
}
/// Mesh lines (all row and column edges) as open paths, for outline view and editing overlays.
pub fn lines(&self) -> PathData {
if !self.is_valid() {
return PathData::default();
}
let (rows, cols) = (self.rows as usize, self.cols as usize);
let mut subs = vec![];
let cub = |e: CubicBez| SubPath::new(vec![Anchor::with_handles(e.p0, e.p0, e.p1), Anchor::with_handles(e.p3, e.p2, e.p3)], false);
for r in 0..=rows {
for c in 0..cols {
subs.push(cub(self.edge_u(r, c)));
}
}
for c in 0..=cols {
for r in 0..rows {
subs.push(cub(self.edge_v(r, c)));
}
}
PathData::new(subs)
}
/// Control bounds (points and handles).
pub fn bounds(&self) -> Option<Rect> {
let mut it = self.points.iter().flat_map(|p| std::iter::once(p.p).chain(p.handles.iter().map(move |h| p.p + *h)));
let first = it.next()?;
Some(it.fold(Rect::from_points(first, first), |r, q| r.union_pt(q)))
}
pub fn transform(&mut self, a: Affine) {
let lin = Affine::new({
let c = a.as_coeffs();
[c[0], c[1], c[2], c[3], 0.0, 0.0]
});
for p in &mut self.points {
p.p = a * p.p;
for h in &mut p.handles {
*h = (lin * h.to_point()).to_vec2();
}
}
}
/// Map points (and handle ends) through a non-linear function.
pub fn map(&mut self, f: &dyn Fn(Point) -> Point) {
for p in &mut self.points {
let q = f(p.p);
for h in &mut p.handles {
*h = f(p.p + *h) - q;
}
p.p = q;
}
}
/// 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)> {
if !self.is_valid() {
return None;
}
let bp = self.outline().to_bezpath();
if bp.winding(p) == 0 {
return None;
}
const N: usize = 16;
let mut best = (f64::INFINITY, 0, 0, 0.0, 0.0);
for r in 0..self.rows as usize {
for c in 0..self.cols as usize {
for j in 0..=N {
for i in 0..=N {
let (u, v) = (i as f64 / N as f64, j as f64 / N as f64);
let d = self.eval(r, c, u, v).distance_squared(p);
if d < best.0 {
best = (d, r, c, u, v);
}
}
}
}
}
// Refine with a few Newton-free local searches.
let (_, r, c, mut u, mut v) = best;
let mut step = 0.5 / N as f64;
for _ in 0..12 {
let mut improved = false;
for (du, dv) in [(step, 0.0), (-step, 0.0), (0.0, step), (0.0, -step)] {
let (nu, nv) = ((u + du).clamp(0.0, 1.0), (v + dv).clamp(0.0, 1.0));
if self.eval(r, c, nu, nv).distance_squared(p) < self.eval(r, c, u, v).distance_squared(p) {
u = nu;
v = nv;
improved = true;
}
}
if !improved {
step /= 2.0;
}
}
Some((r, c, u, v))
}
/// Index of the mesh point within `tol` of `p`.
pub fn point_near(&self, p: Point, tol: f64) -> Option<usize> {
self.points
.iter()
.enumerate()
.filter(|(_, q)| q.p.distance(p) <= tol)
.min_by(|a, b| a.1.p.distance(p).total_cmp(&b.1.p.distance(p)))
.map(|(i, _)| i)
}
fn surface_du(&self, r: usize, c: usize, u: f64, v: f64) -> Vec2 {
let e = 1e-3;
let (a, b) = ((u - e).max(0.0), (u + e).min(1.0));
(self.eval(r, c, b, v) - self.eval(r, c, a, v)) / (b - a)
}
fn surface_dv(&self, r: usize, c: usize, u: f64, v: f64) -> Vec2 {
let e = 1e-3;
let (a, b) = ((v - e).max(0.0), (v + e).min(1.0));
(self.eval(r, c, u, b) - self.eval(r, c, u, a)) / (b - a)
}
/// Insert a mesh row through patch row `r` at local `v`.
pub fn insert_row(&mut self, r: usize, v: f64) {
let cols = self.cols as usize;
let v = v.clamp(0.01, 0.99);
let mut row = Vec::with_capacity(cols + 1);
// Split the vertical edges.
let mut ups = vec![];
for c in 0..=cols {
let e = self.edge_v(r, c);
let (l, rr) = (e.subsegment(0.0..v), e.subsegment(v..1.0));
ups.push((l.p1 - l.p0, rr.p2 - rr.p3));
let pc = c.min(cols - 1);
let u = if c == cols { 1.0 } else { 0.0 };
let (color, opacity) = self.color_at(r, pc, u, v);
// Horizontal handles: isocurve derivatives on either side.
let right = if c < cols { self.surface_du(r, c, 0.0, v) / (3.0) } else { Vec2::ZERO };
let left = if c > 0 { -self.surface_du(r, c - 1, 1.0, v) / 3.0 } else { Vec2::ZERO };
row.push(MeshPoint { p: l.p3, color, opacity, handles: [right, left, rr.p1 - rr.p0, l.p2 - l.p3] });
}
for (c, (down, up)) in ups.into_iter().enumerate() {
let i0 = self.idx(r, c);
let i1 = self.idx(r + 1, c);
self.points[i0].handles[H_DOWN] = down;
self.points[i1].handles[H_UP] = up;
}
let at = self.idx(r + 1, 0);
self.points.splice(at..at, row);
self.rows += 1;
}
/// Insert a mesh column through patch column `c` at local `u`.
pub fn insert_col(&mut self, c: usize, u: f64) {
let rows = self.rows as usize;
let u = u.clamp(0.01, 0.99);
let mut col = Vec::with_capacity(rows + 1);
for r in 0..=rows {
let e = self.edge_u(r, c);
let (l, rr) = (e.subsegment(0.0..u), e.subsegment(u..1.0));
let pr = r.min(rows - 1);
let v = if r == rows { 1.0 } else { 0.0 };
let (color, opacity) = self.color_at(pr, c, u, v);
let down = if r < rows { self.surface_dv(r, c, u, 0.0) / 3.0 } else { Vec2::ZERO };
let up = if r > 0 { -self.surface_dv(r - 1, c, u, 1.0) / 3.0 } else { Vec2::ZERO };
col.push((MeshPoint { p: l.p3, color, opacity, handles: [rr.p1 - rr.p0, l.p2 - l.p3, down, up] }, l.p1 - l.p0, rr.p2 - rr.p3));
}
let cols = self.cols as usize;
let mut pts = Vec::with_capacity((rows + 1) * (cols + 2));
for (r, (mp, right, left)) in col.into_iter().enumerate() {
for cc in 0..=cols {
let mut q = self.points[r * (cols + 1) + cc].clone();
if cc == c {
q.handles[H_RIGHT] = right;
}
if cc == c + 1 {
q.handles[H_LEFT] = left;
}
pts.push(q);
if cc == c {
pts.push(mp.clone());
}
}
}
self.points = pts;
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)?;
let mut rr = r;
let mut cc = c;
let row_new = v > 0.02 && v < 0.98;
let col_new = u > 0.02 && u < 0.98;
if row_new {
self.insert_row(r, v);
rr = r + 1;
} else if v >= 0.98 {
rr = r + 1;
}
if col_new {
self.insert_col(c, u);
cc = c + 1;
} else if u >= 0.98 {
cc = c + 1;
}
Some(self.idx(rr, cc))
}
/// Mesh tool Alt-click: delete the mesh lines through point `index` (interior lines only).
pub fn remove_point_lines(&mut self, index: usize) -> bool {
// A mesh from a file may not have the points its rows and columns need: it is indexed below.
if !self.is_valid() || index >= self.points.len() {
return false;
}
let w = self.cols as usize + 1;
let (r, c) = (index / w, index % w);
let mut changed = false;
if r > 0 && r < self.rows as usize {
// Merge the edges above and below: keep the outer handles.
for cc in 0..w {
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;
}
self.points.drain(r * w..(r + 1) * w);
self.rows -= 1;
changed = true;
}
let w = self.cols as usize + 1;
if c > 0 && c < self.cols as usize {
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 mut k = 0;
self.points.retain(|_| {
let keep = k % w != c;
k += 1;
keep
});
self.cols -= 1;
changed = true;
}
changed
}
}
// =====================================================================================
// Envelopes
// =====================================================================================
/// Union of the geometric bounds of `nodes`.
pub fn nodes_bounds(nodes: &[Arc<Node>]) -> Option<Rect> {
nodes.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric_bounds()))
}
/// A `(rows+1)×(cols+1)` grid over `r` (row-major).
pub fn grid_points(r: Rect, rows: u32, cols: u32) -> Vec<Point> {
let mut v = Vec::with_capacity(((rows + 1) * (cols + 1)) as usize);
for i in 0..=rows {
for j in 0..=cols {
v.push(Point::new(r.x0 + r.width() * j as f64 / cols.max(1) as f64, r.y0 + r.height() * i as f64 / rows.max(1) as f64));
}
}
v
}
fn catmull(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2, t: f64) -> Vec2 {
let t2 = t * t;
let t3 = t2 * t;
0.5 * (2.0 * p1 + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (3.0 * p1 - p0 - 3.0 * p2 + p3) * t3)
}
/// Point map from the unit square onto a point grid (tensor-product Catmull-Rom; linear
/// extrapolation at the borders so an undistorted grid is the identity, also past the square).
pub(super) fn grid_eval(rows: usize, cols: usize, pts: &[Point], u: f64, v: f64) -> Point {
let at = |r: i64, c: i64| -> Vec2 {
let rr = r.clamp(0, rows as i64);
let cc = c.clamp(0, cols as i64);
let base = pts[rr as usize * (cols + 1) + cc as usize].to_vec2();
// Linear extrapolation outside the grid.
let er = r - rr;
let ec = c - cc;
let mut p = base;
if er != 0 && rows >= 1 {
let inner = (rr - er.signum()).clamp(0, rows as i64);
p += (base - pts[inner as usize * (cols + 1) + cc as usize].to_vec2()) * er.abs() as f64;
}
if ec != 0 && cols >= 1 {
let inner = (cc - ec.signum()).clamp(0, cols as i64);
p += (base - pts[rr as usize * (cols + 1) + inner as usize].to_vec2()) * ec.abs() as f64;
}
p
};
// Past the grid's edges the outer patches go on (art can reach past the content's box).
let (su, sv) = (u * cols as f64, v * rows as f64);
let (ci, ri) = ((su.floor() as i64).clamp(0, cols as i64 - 1), (sv.floor() as i64).clamp(0, rows as i64 - 1));
let (fu, fv) = (su - ci as f64, sv - ri as f64);
let mut rowsv = [Vec2::ZERO; 4];
for (k, rv) in rowsv.iter_mut().enumerate() {
let r = ri - 1 + k as i64;
*rv = catmull(at(r, ci - 1), at(r, ci), at(r, ci + 1), at(r, ci + 2), fu);
}
catmull(rowsv[0], rowsv[1], rowsv[2], rowsv[3], fv).to_point()
}
/// The surface (u, v ∈ [0,1]) an envelope of `kind` maps content onto, for content whose bounds in
/// the envelope's own `frame` (its axes → the document) are `src`: a warp bends along the frame's
/// axes; a mesh's points and a top object's path are in the document already.
pub(super) fn surface_of<'a>(kind: &'a EnvelopeKind, src: Rect, frame: Affine) -> Box<dyn Fn(f64, f64) -> Point + 'a> {
let flat = move |u: f64, v: f64| frame * Point::new(src.x0 + u * src.width(), src.y0 + v * src.height());
match kind {
EnvelopeKind::Warp { style, bend, h: dh, v: dv, horizontal } => {
let st = WarpStyle::from_id(style).unwrap_or(WarpStyle::Arc);
let b = bend.clamp(-100.0, 100.0) / 100.0;
let (dh, dv) = (dh.clamp(-100.0, 100.0) / 100.0, dv.clamp(-100.0, 100.0) / 100.0);
let c = src.center();
let (hw, hh) = (src.width() / 2.0, src.height() / 2.0);
let horizontal = *horizontal;
Box::new(move |u: f64, v: f64| {
let (x, y) = (2.0 * u - 1.0, 2.0 * v - 1.0);
let (x2, y2) = if horizontal {
warp_point(st, b, dh, dv, hh / hw.max(1e-9), x, y)
} else {
let (a, b2) = warp_point(st, b, dh, dv, hw / hh.max(1e-9), y, x);
(b2, a)
};
frame * Point::new(c.x + x2 * hw, c.y + y2 * hh)
})
}
EnvelopeKind::Mesh { rows, cols, points, handles } => {
if !handles.is_empty()
&& let Some(m) = envelope_grid(*rows, *cols, points, handles)
{
return Box::new(move |u: f64, v: f64| mesh_at(&m, u, v));
}
if *rows == 0 || *cols == 0 || grid_len(*rows, *cols) != Some(points.len()) {
return Box::new(flat);
}
let (rows, cols) = (*rows as usize, *cols as usize);
Box::new(move |u: f64, v: f64| grid_eval(rows, cols, points, u, v))
}
EnvelopeKind::TopObject { path } => match Coons::from_path(path) {
Some(co) => Box::new(move |u: f64, v: f64| co.eval(u, v)),
None => Box::new(flat),
},
}
}
+253
View File
@@ -0,0 +1,253 @@
use super::*;
// =====================================================================================
// Warp maps (shared with vectorcraft-effects' Warp effects)
// =====================================================================================
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WarpStyle {
Arc,
ArcLower,
ArcUpper,
Arch,
Bulge,
ShellLower,
ShellUpper,
Flag,
Wave,
Fish,
Rise,
Fisheye,
Inflate,
Squeeze,
Twist,
}
impl WarpStyle {
/// From the id suffix (`arc`, `arcLower`, …).
pub fn from_id(s: &str) -> Option<Self> {
use WarpStyle::*;
Some(match s {
"arc" => Arc,
"arcLower" => ArcLower,
"arcUpper" => ArcUpper,
"arch" => Arch,
"bulge" => Bulge,
"shellLower" => ShellLower,
"shellUpper" => ShellUpper,
"flag" => Flag,
"wave" => Wave,
"fish" => Fish,
"rise" => Rise,
"fisheye" => Fisheye,
"inflate" => Inflate,
"squeeze" => Squeeze,
"twist" => Twist,
_ => return None,
})
}
}
/// The warp map on normalised coordinates: `b` = bend (-1..1), `dh`/`dv` = distortion (-1..1);
/// `aspect` is the box's half-height over its half-width along the warp (Arc bends in real
/// proportions).
pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, aspect: f64, x: f64, y: f64) -> (f64, f64) {
use WarpStyle::*;
let t = (y + 1.0) / 2.0; // 0 at top, 1 at bottom
let par = 1.0 - x * x; // parabola: 1 at centre, 0 at sides
let (mut x2, mut y2) = match style {
Arc => {
if b.abs() < 1e-6 {
(x, y)
} else {
// Bend the centre line into a circular arc of equal length (sweep = |b|·180°), in
// the box's real proportions (lengths in half-widths, so y counts `aspect` each):
// the edges are concentric arcs, and one that would pass the centre stops there
// instead of folding over (#1578).
let k = if aspect.is_finite() && aspect > 0.0 { aspect } else { 1.0 };
let sweep = b.abs() * PI;
let r0 = 2.0 / sweep;
let yy = if b > 0.0 { y } else { -y } * k;
let a = x / r0;
let r = (r0 - yy).max(0.0);
let (s, c) = a.sin_cos();
let (nx, ny) = (r * s, (r0 - r * c) / k);
(nx, if b > 0.0 { ny } else { -ny })
}
}
ArcLower => (x, y + b * par * t),
ArcUpper => (x, y - b * par * (1.0 - t)),
Arch => (x, y - b * par),
Bulge => (x, y + b * par * y),
ShellLower => (x * (1.0 - 0.5 * b * (1.0 - t)), y + b * par * t),
ShellUpper => (x * (1.0 - 0.5 * b * t), y - b * par * (1.0 - t)),
Flag => (x, y + 0.5 * b * (PI * x).sin()),
Wave => (x + 0.1 * b * (PI * y).sin(), y + 0.3 * b * (2.0 * PI * x).sin() * (0.5 + 0.5 * t)),
Fish => (x, y * (1.0 + 0.5 * b * (0.75 * PI * (x + 1.0)).sin())),
Rise => (x, y - b * ((x + 1.0) / 2.0).powi(2) * 2.0 + b),
Fisheye => {
let r2 = x * x + y * y;
let s = if r2 < 1.0 { 1.0 + 0.5 * b * (1.0 - r2) } else { 1.0 };
(x * s, y * s)
}
Inflate => (x * (1.0 + 0.5 * b * (1.0 - y * y)), y * (1.0 + 0.5 * b * par)),
Squeeze => (x * (1.0 - 0.5 * b * (1.0 - y * y)), y * (1.0 + 0.3 * b * par)),
Twist => {
let r = (x * x + y * y).sqrt();
let a = -b * PI * 0.5 * (1.0 - r / std::f64::consts::SQRT_2).max(0.0);
let (s, c) = a.sin_cos();
(x * c - y * s, x * s + y * c)
}
};
// Perspective-like distortion: horizontal narrows one side, vertical one end.
if dh != 0.0 {
y2 *= (1.0 + dh * x2 * 0.5).max(0.0);
}
if dv != 0.0 {
x2 *= (1.0 + dv * y2 * 0.5).max(0.0);
}
(x2, y2)
}
/// Segment as a cubic; straight lines get handles at 1/3 and 2/3 so a non-linear map bends them.
fn seg_cubic(sp: &SubPath, i: usize) -> CubicBez {
let c = sp.segment(i);
if sp.segment_is_line(i) { CubicBez::new(c.p0, c.p0.lerp(c.p3, 1.0 / 3.0), c.p0.lerp(c.p3, 2.0 / 3.0), c.p3) } else { c }
}
fn poly_len(c: &CubicBez) -> f64 {
c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3)
}
/// Running length along a polyline: `0, |p0p1|, |p0p1| + |p1p2|, …` (one entry per point).
fn cumulative_lengths(pts: &[Point]) -> Vec<f64> {
let mut total = 0.0;
let mut cum = Vec::with_capacity(pts.len());
cum.push(0.0);
for w in pts.windows(2) {
total += w[0].distance(w[1]);
cum.push(total);
}
cum
}
/// Most pieces [`map_nonlinear`] splits one segment into.
const MAX_PIECES: usize = 64;
/// How many pieces of about `piece` a segment of length `len` is split into: one to `most`.
pub fn pieces(len: f64, piece: f64, most: usize) -> usize {
((len / piece).ceil() as usize).clamp(1, most.max(1))
}
/// The piece length to split segments of lengths `lens` with ([`pieces`], at most `most` each) so
/// that the pieces past each segment's first number at most `room`: `piece` when they already do,
/// longer pieces otherwise. A segment of `n` pieces adds `n - 1 ≤ len / piece` points to a path,
/// so pieces of the total length over `room` fit; with no room every segment stays one piece.
pub fn fit_piece(lens: &[f64], piece: f64, most: usize, room: usize) -> f64 {
let added = lens.iter().map(|&len| pieces(len, piece, most) - 1).fold(0, usize::saturating_add);
if added <= room {
return piece;
}
if room == 0 {
return f64::INFINITY;
}
// A NaN length is always one piece.
piece.max(lens.iter().filter(|len| !len.is_nan()).sum::<f64>() / room as f64)
}
/// Map every point of `path` through the non-linear function `f`. Segments are split into pieces
/// no longer than about `max_piece` (up to 64 per segment) and their control points mapped, which
/// approximates the image curve to O(h²). Closedness and subpath structure are preserved.
pub fn map_nonlinear(path: &PathData, max_piece: f64, f: impl Fn(Point) -> Point) -> PathData {
map_nonlinear_within(path, max_piece, usize::MAX, f)
}
/// [`map_nonlinear`] whose result has at most `max` anchors, or `path`'s count when that is more:
/// where pieces of `max_piece` would make more, the pieces grow until they don't ([`fit_piece`]),
/// a coarser approximation of the image curve. Geometry effects map within their anchor budget.
pub fn map_nonlinear_within(path: &PathData, max_piece: f64, max: usize, f: impl Fn(Point) -> Point) -> PathData {
let mut max_piece = max_piece.max(1e-3);
// Each segment adds one anchor per piece past its first.
let room = max.saturating_sub(path.anchor_count());
let segs = path.subpaths.iter().map(SubPath::segment_count).fold(0, usize::saturating_add);
if segs.saturating_mul(MAX_PIECES - 1) > room {
let lens: Vec<f64> = path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| poly_len(&seg_cubic(sp, i)))).collect();
max_piece = fit_piece(&lens, max_piece, MAX_PIECES, room);
}
let mut subs = Vec::with_capacity(path.subpaths.len());
for sp in &path.subpaths {
let n = sp.anchors.len();
if n == 0 {
continue;
}
let segs = sp.segment_count();
let mut res: Vec<Anchor> = Vec::new();
let mut pending_in: Option<Point> = None;
for i in 0..n {
let a = &sp.anchors[i];
let h_in = pending_in.take().unwrap_or_else(|| f(a.h_in));
res.push(Anchor { p: f(a.p), h_in, h_out: f(a.h_out), kind: a.kind });
if i < segs {
let c = seg_cubic(sp, i);
let k = pieces(poly_len(&c), max_piece, MAX_PIECES);
for j in 0..k {
let sub = c.subsegment((j as f64 / k as f64)..((j + 1) as f64 / k as f64));
if let Some(last) = res.last_mut() {
last.h_out = f(sub.p1);
}
if j + 1 < k {
res.push(Anchor { p: f(sub.p3), h_in: f(sub.p2), h_out: f(sub.p3), kind: Default::default() });
} else {
pending_in = Some(f(sub.p2));
}
}
}
}
if let Some(h) = pending_in
&& sp.closed
&& let Some(first) = res.first_mut()
{
first.h_in = h;
}
let anchors = res.into_iter().map(|a| Anchor::with_handles(a.p, a.h_in, a.h_out)).collect();
subs.push(SubPath::new(anchors, sp.closed));
}
PathData::new(subs)
}
// =====================================================================================
// Coons patches from outlines
// =====================================================================================
pub(super) fn flatten_subpath(sp: &SubPath, tol: f64) -> Vec<Point> {
let mut bp = BezPath::new();
sp.to_bezpath_into(&mut bp);
let mut pts: Vec<Point> = Vec::new();
vectorcraft_geom::kurbo::flatten(bp.iter(), tol, |el| match el {
vectorcraft_geom::PathEl::MoveTo(p) | vectorcraft_geom::PathEl::LineTo(p) if pts.last().is_none_or(|q| q.distance(p) > 1e-9) => pts.push(p),
_ => {}
});
pts
}
pub(super) fn resample(poly: &[Point], m: usize) -> Vec<Point> {
if poly.is_empty() {
return vec![Point::ZERO; m];
}
if poly.len() == 1 {
return vec![poly[0]; m];
}
let cum = cumulative_lengths(poly);
let total = cum.last().copied().unwrap_or(0.0);
let mut out = Vec::with_capacity(m);
let mut i = 0;
for k in 0..m {
let s = if m > 1 { total * k as f64 / (m - 1) as f64 } else { 0.0 };
while i + 2 < cum.len() && cum[i + 1] < s {
i += 1;
}
let seg = (cum[i + 1] - cum[i]).max(1e-12);
out.push(poly[i].lerp(poly[i + 1], ((s - cum[i]) / seg).clamp(0.0, 1.0)));
}
out
}
+18 -2548
View File
File diff suppressed because it is too large Load Diff
+133
View File
@@ -0,0 +1,133 @@
use super::kind::NodeKind;
use super::live_shape::LiveShape;
use super::*;
/// Window → Attributes: an object's settings that don't change how it prints.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ObjectAttributes {
/// Show Center / Don't Show Center (`None`: the object kind's default, [`Node::shows_center`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub show_center: Option<bool>,
/// Image Map: the clickable area the URL covers in web output.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub image_map: ImageMap,
/// URL the object links to (SVG `<a href>`; empty: none).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub url: String,
/// The note shown in the Attributes panel.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub note: String,
/// The object's own data, in order: SVG's `data-*` attributes (`data-pivot="100,180"` is
/// `("pivot", "100,180")`), kept from import to export (`object.setProps {data}`).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub data: Vec<(String, String)>,
/// Window › SVG Interactivity: the JavaScript each event runs, in the panel's order
/// (`("onclick", "alert(1)")`, one entry per event of [`SVG_EVENTS`]). SVG export writes them as
/// the element's event attributes; nothing in VectorCraft runs them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub events: Vec<(String, String)>,
/// Attributes in other applications' XML namespaces, in order, kept from SVG import to SVG
/// export: what another tool notes on an object (a plotter's pen, a cutter's passes) survives
/// a round trip through VectorCraft. Nothing in VectorCraft reads them. They are the object's,
/// as `data` is, so copies, duplicates and pastes keep them and a deleted object takes them
/// with it.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub foreign: Vec<ForeignAttribute>,
}
/// An attribute in another application's XML namespace: `plotter:pen="2"` with `xmlns:plotter=
/// "https://example.org/plotter"` is `{ns: "https://example.org/plotter", prefix: "plotter", name:
/// "pen", value: "2"}`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ForeignAttribute {
/// The namespace's URI, which names it.
pub ns: String,
/// The prefix the file gave the namespace, which export reuses where it can.
pub prefix: String,
/// The attribute's local name.
pub name: String,
/// The attribute's value, as the file had it.
pub value: String,
}
/// The events the SVG Interactivity panel attaches JavaScript to, by the names of the SVG event
/// attributes that run it.
pub const SVG_EVENTS: [&str; 19] = [
"onfocusin",
"onfocusout",
"onactivate",
"onmousedown",
"onmouseup",
"onclick",
"onmouseover",
"onmousemove",
"onmouseout",
"onkeydown",
"onkeypress",
"onkeyup",
"onload",
"onerror",
"onabort",
"onunload",
"onzoom",
"onresize",
"onscroll",
];
/// The most JavaScript files a document links (`Document::svg_scripts`): SVG import keeps the
/// first ones and warns about the rest, and `svgInteractivity.addFile` refuses another.
pub const MAX_SVG_SCRIPTS: usize = 64;
/// The Attributes panel's Image Map shapes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ImageMap {
#[default]
None,
Rectangle,
Polygon,
}
impl ImageMap {
pub const ALL: [ImageMap; 3] = [ImageMap::None, ImageMap::Rectangle, ImageMap::Polygon];
pub fn label(self) -> &'static str {
match self {
ImageMap::None => "None",
ImageMap::Rectangle => "Rectangle",
ImageMap::Polygon => "Polygon",
}
}
/// Parse a label or serialized name, ignoring case.
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|m| m.label().eq_ignore_ascii_case(s))
}
}
impl Node {
/// The URL this object links to, if any.
pub fn url(&self) -> Option<&str> {
self.extras().attrs.as_deref().map(|a| a.url.as_str()).filter(|u| !u.is_empty())
}
/// Whether the canvas shows this object's centre point when it is selected: as set in the
/// Attributes panel, else [`Self::shows_center_by_default`].
pub fn shows_center(&self) -> bool {
self.extras().attrs.as_deref().and_then(|a| a.show_center).unwrap_or_else(|| self.shows_center_by_default())
}
/// Shapes drawn with the shape tools (live rectangles, ellipses and polygons) show their centre.
pub fn shows_center_by_default(&self) -> bool {
matches!(&self.kind, NodeKind::Path { live: Some(l), .. } if !matches!(**l, LiveShape::Line { .. } | LiveShape::Path { .. }))
}
/// Change this object's attributes with `f`; all-default attributes are dropped.
pub fn edit_attrs<R>(&mut self, f: impl FnOnce(&mut ObjectAttributes) -> R) -> R {
let mut a = self.extras_mut().attrs.take().unwrap_or_default();
let r = f(&mut a);
self.extras_mut().attrs = (*a != ObjectAttributes::default()).then_some(a);
r
}
}
+120
View File
@@ -0,0 +1,120 @@
use super::kind::NodeKind;
use super::*;
/// Unites filled regions (each under its own fill rule) into one path filled non-zero. Booleans
/// live above this crate (`vectorcraft-pathops`), so callers supply it.
pub type Uniter<'a> = &'a dyn Fn(&[(BezPath, FillRule)]) -> BezPath;
impl Node {
/// The filled regions this object clips to as the clipping path of a clip group, in document
/// space, each with its fill rule: a path, a compound path (with its holes), an image's frame,
/// text outlined by `text` (glyph outlines need the font engine, above this crate), the visible
/// members of a group (their union) and the clipping path of a nested clip group. Live objects
/// are evaluated first. Guides and symbol instances add nothing.
pub fn clip_shapes(&self, text: Outliner) -> Vec<(BezPath, FillRule)> {
let mut out = vec![];
self.push_clip_shapes(text, &mut out);
out
}
fn push_clip_shapes(&self, text: Outliner, out: &mut Vec<(BezPath, FillRule)>) {
match &self.kind {
NodeKind::Path { guide: true, .. } | NodeKind::SymbolInstance { .. } => {}
NodeKind::Path { path, rule, .. } => out.push((path.to_bezpath(), *rule)),
NodeKind::Compound { children, rule } => {
let mut bp = BezPath::new();
for p in children.iter().filter_map(|c| c.path_data()) {
bp.extend(p.to_bezpath());
}
out.push((bp, *rule));
}
NodeKind::Group { children, clip: true } => {
if let Some(c) = children.first() {
c.push_clip_shapes(text, out);
}
}
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
for c in children.iter().skip(usize::from(self.extras().shaper.is_some())).filter(|c| c.visible) {
c.push_clip_shapes(text, out);
}
}
NodeKind::Text(_) => {
if let Some(o) = text.and_then(|f| f(self)) {
o.push_clip_shapes(None, out);
}
}
NodeKind::Image(im) => {
let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf);
out.push((frame.to_bezpath(), FillRule::NonZero));
}
NodeKind::PlacedDocument(p) => {
let frame = shapes::rectangle(p.natural()).transformed(p.xf);
out.push((frame.to_bezpath(), FillRule::NonZero));
}
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => {
crate::live::expand_deep(self, text).push_clip_shapes(text, out);
}
}
}
/// The region this object clips to as one path and fill rule ([`Self::clip_shapes`]), shared
/// by the renderer and the SVG and PDF writers so every output clips alike. One shape keeps its
/// own rule; several are united by `unite` (filled non-zero). `None` when there is nothing to
/// clip by: the clipped art is then hidden.
pub fn clip_outline(&self, text: Outliner, unite: Uniter) -> Option<(BezPath, FillRule)> {
let mut shapes = self.clip_shapes(text);
match shapes.len() {
0 => None,
1 => shapes.pop(),
_ => Some((unite(&shapes), FillRule::NonZero)),
}
}
/// What this object paints as the clipping path of a clip group, shared by the renderer and
/// the SVG and PDF writers: its fills, painted behind the clipped art, and its strokes, painted
/// over it (unclipped). Each part is this object keeping only those appearance items (type:
/// also only its characters' fills or strokes); `None` where it paints nothing, as right after
/// Make Clipping Mask. Only paths, compound paths and type paint as clipping paths.
pub fn clip_paint(&self) -> ClipPaint {
use crate::appearance::AppearanceItem;
let runs = match &self.kind {
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => None,
NodeKind::Text(t) => Some(&t.runs),
_ => return ClipPaint::default(),
};
let paints = |fill: bool| {
let item = |i: &AppearanceItem| match i {
AppearanceItem::Fill(f) => fill && f.visible && !f.paint.is_none(),
AppearanceItem::Stroke(s) => !fill && s.visible && !s.paint.is_none() && s.width > 0.0,
};
let run = |r: &crate::text::TextRun| if fill { !r.style.fill.is_none() } else { !r.style.stroke.is_none() && r.style.stroke_width > 0.0 };
self.appearance.items.iter().any(item) || runs.is_some_and(|rs| rs.iter().any(run))
};
let part = |fill: bool| {
paints(fill).then(|| {
let mut n = self.clone();
for i in (0..n.appearance.items.len()).rev() {
if matches!(n.appearance.items[i], AppearanceItem::Fill(_)) != fill {
n.appearance.remove_item(i);
}
}
if let NodeKind::Text(t) = &mut n.kind {
for r in &mut t.runs {
*(if fill { &mut r.style.stroke } else { &mut r.style.fill }) = vectorcraft_color::Paint::None;
}
}
n
})
};
ClipPaint { fill: part(true), stroke: part(false) }
}
}
/// What a clipping path paints ([`Node::clip_paint`]).
#[derive(Clone, Debug, Default)]
pub struct ClipPaint {
/// The clipping path with only its fills: painted behind the clipped art (inside the clip).
pub fill: Option<Node>,
/// The clipping path with only its strokes: painted over the clipped art, not clipped.
pub stroke: Option<Node>,
}
+171
View File
@@ -0,0 +1,171 @@
use super::attributes::ObjectAttributes;
use super::kind::NodeKind;
use super::live_shape::LiveShape;
use super::opacity::OpacityMask;
use super::*;
/// The parts of a [`Node`] that few objects have: kept out of line, so that the nodes without
/// them stay small ([`Node::extras`]).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct NodeExtras {
/// Opacity mask (Transparency panel). Its art lives here, outside the layer tree.
pub mask: Option<Box<OpacityMask>>,
/// Image Trace object: `{preset, params, view?}` it was traced with (the Image Trace panel shows
/// them; `view` is its [`crate::TraceView`] id, absent for the tracing result).
pub trace: Option<Box<serde_json::Value>>,
/// Object → Text Wrap: area type below this object (in the same layer) flows around it.
pub wrap: Option<crate::text::TextWrap>,
/// Graph object: the group's children are generated from this spec (Object → Graph).
pub graph: Option<Box<crate::graph::GraphSpec>>,
/// Index of the graph series this generated group belongs to. Axes, the legend and other
/// children leave it unset, so a series named "Legend" or "Axes" is not mistaken for them.
pub series_index: Option<u32>,
/// The group holds the glyph outlines of type. Geometry effects that reshape it move and scale
/// the placed gradients in it with the outlines.
pub type_outlines: bool,
/// Editable Shaper composition; the original art is retained in its source child.
pub shaper: Option<Box<crate::shaper::ShaperSpec>>,
/// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps
/// that style's look: editing its appearance or transparency breaks the link.
pub graphic_style: Option<u32>,
/// Attributes panel: centre point display, image map, URL and note (`None`: all defaults).
pub attrs: Option<Box<ObjectAttributes>>,
/// Object → Slice → Make: the object is an object slice (its slice follows its bounds) with
/// these options.
pub slice: Option<Box<crate::SliceOptions>>,
/// Object › Perspective: the perspective grid plane the object is attached to.
pub perspective: Option<Box<crate::PerspectiveAttachment>>,
/// Dimension object: the live measurement it draws; its ordinary group children hold its
/// exportable appearance (Object › Dimensions, the Dimension tool).
pub dimension: Option<Box<crate::dimension::Dimension>>,
/// 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>>,
/// 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
/// [`crate::objects_on_path`]).
pub objects_on_path: Option<Box<crate::objects_on_path::ObjectsOnPath>>,
}
/// No extras: what [`Node::extras`] gives for a node without any.
pub(super) static NO_EXTRAS: NodeExtras = NodeExtras {
mask: None,
trace: None,
wrap: None,
graph: None,
series_index: None,
type_outlines: false,
shaper: None,
graphic_style: None,
attrs: None,
slice: None,
perspective: None,
dimension: None,
intertwine: None,
live_paint_gaps: None,
objects_on_path: None,
};
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
/// none.
#[derive(Clone, Debug, Default)]
pub struct Extras(pub(crate) Option<Box<NodeExtras>>);
impl Extras {
fn get(&self) -> &NodeExtras {
self.0.as_deref().unwrap_or(&NO_EXTRAS)
}
}
impl PartialEq for Extras {
fn eq(&self, other: &Self) -> bool {
match (&self.0, &other.0) {
(Some(a), Some(b)) => a == b,
_ => self.get() == other.get(),
}
}
}
impl Node {
/// The rarely set parts of this node (all unset when it has none).
#[inline]
pub fn extras(&self) -> &NodeExtras {
self.extras.get()
}
/// The rarely set parts of this node, to change: when the change is done (the guard dropped),
/// extras that were set are allocated and extras left all unset are let go. Setting one to
/// `None` on a node without extras allocates nothing.
#[inline]
pub fn extras_mut(&mut self) -> ExtrasMut<'_> {
ExtrasMut { slot: &mut self.extras.0, local: NodeExtras::default() }
}
/// The rarely set parts of this node, if it has any: to change what is there in place.
#[inline]
pub fn extras_if_any(&mut self) -> Option<&mut NodeExtras> {
self.extras.0.as_deref_mut()
}
/// The live shape of a path object, if it is one.
pub fn live_shape(&self) -> Option<&LiveShape> {
match &self.kind {
NodeKind::Path { live, .. } => live.as_deref(),
_ => None,
}
}
/// The live shape of a path object, if it is one, to change.
pub fn live_shape_mut(&mut self) -> Option<&mut LiveShape> {
match &mut self.kind {
NodeKind::Path { live, .. } => live.as_deref_mut(),
_ => None,
}
}
/// This node with opacity mask `mask` (or none).
pub fn with_mask(mut self, mask: Option<Box<OpacityMask>>) -> Self {
self.extras_mut().mask = mask;
self
}
/// The extras, if this node has them allocated (for [`crate::mem`]'s estimate).
pub(crate) fn extras_box(&self) -> Option<&NodeExtras> {
self.extras.0.as_deref()
}
}
/// [`Node::extras_mut`]: the node's extras while they change.
pub struct ExtrasMut<'a> {
slot: &'a mut Option<Box<NodeExtras>>,
/// Stands for the extras of a node without any, boxed into it when dropped if one was set.
local: NodeExtras,
}
impl std::ops::Deref for ExtrasMut<'_> {
type Target = NodeExtras;
fn deref(&self) -> &NodeExtras {
self.slot.as_deref().unwrap_or(&self.local)
}
}
impl std::ops::DerefMut for ExtrasMut<'_> {
fn deref_mut(&mut self) -> &mut NodeExtras {
match self.slot.as_deref_mut() {
Some(e) => e,
None => &mut self.local,
}
}
}
impl Drop for ExtrasMut<'_> {
fn drop(&mut self) {
match self.slot.as_deref() {
Some(e) if *e == NO_EXTRAS => *self.slot = None,
Some(_) => {}
None if self.local != NO_EXTRAS => *self.slot = Some(Box::new(std::mem::take(&mut self.local))),
None => {}
}
}
}
+59
View File
@@ -0,0 +1,59 @@
use super::*;
/// Stable per-document object id. Never reused.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct NodeId(pub u64);
impl std::fmt::Display for NodeId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "#{}", self.0)
}
}
/// Layer colours used for selection highlighting (index into [`LAYER_COLORS`]) or a custom RGB.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum LayerColor {
Preset(u8),
Custom([u8; 3]),
}
/// Layer highlight colours, assigned in sequence to new layers. First is Illustrator-like light blue.
pub const LAYER_COLORS: [(&str, [u8; 3]); 27] = [
("Light Blue", [0x4f, 0x80, 0xff]),
("Red", [0xff, 0x4f, 0x4f]),
("Green", [0x4f, 0xff, 0x4f]),
("Blue", [0x4f, 0x4f, 0xff]),
("Yellow", [0xff, 0xff, 0x4f]),
("Magenta", [0xff, 0x4f, 0xff]),
("Cyan", [0x4f, 0xff, 0xff]),
("Gray", [0x80, 0x80, 0x80]),
("Black", [0x00, 0x00, 0x00]),
("Orange", [0xff, 0x66, 0x00]),
("Dark Green", [0x00, 0x80, 0x00]),
("Teal", [0x00, 0x80, 0x80]),
("Tan", [0xcc, 0x99, 0x66]),
("Brown", [0x99, 0x33, 0x00]),
("Violet", [0x99, 0x33, 0xff]),
("Gold", [0xff, 0x99, 0x00]),
("Dark Blue", [0x00, 0x00, 0x80]),
("Pink", [0xff, 0x99, 0xcc]),
("Lavender", [0x99, 0x99, 0xff]),
("Brick Red", [0x99, 0x00, 0x00]),
("Olive Green", [0x66, 0x66, 0x00]),
("Peach", [0xff, 0x99, 0x99]),
("Burgundy", [0x99, 0x00, 0x33]),
("Grass Green", [0x99, 0xcc, 0x00]),
("Ochre", [0x99, 0x66, 0x00]),
("Purple", [0x66, 0x00, 0x99]),
("Light Gray", [0xbb, 0xbb, 0xbb]),
];
impl LayerColor {
pub fn rgb(self) -> [u8; 3] {
match self {
LayerColor::Preset(i) => LAYER_COLORS[i as usize % LAYER_COLORS.len()].1,
LayerColor::Custom(c) => c,
}
}
}
+326
View File
@@ -0,0 +1,326 @@
use super::ids::LayerColor;
use super::live_shape::LiveShape;
use super::*;
/// Embedded or linked raster image.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ImageObject {
/// Key into [`crate::Document::images`].
pub key: String,
pub width: u32,
pub height: u32,
/// Maps pixel space (0..w, 0..h) into the document.
pub xf: Affine,
/// The file a linked image shows (File → Place with Link); `None`: embedded. Files saved
/// before links had details hold just the path.
#[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::links::de_link")]
pub link: Option<crate::LinkInfo>,
/// Links panel → Placement Options: how a file read again takes this image's place.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub placement: crate::links::PlacementOptions,
/// The pixels of an image in art made outside a document (type's outlines, a glyph's art),
/// which has no [`crate::Document::images`] to keep them in. A document takes them in
/// ([`crate::Document::adopt_pixels`]): no image in one holds its own.
#[serde(skip)]
pub pixels: Option<Arc<crate::ImageBlob>>,
}
/// Written with its `type` first, then its fields (an internally tagged enum); read by
/// [`kind_de`], which takes the fields straight from the file when `type` comes first.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum NodeKind {
/// A top-level layer or sublayer.
Layer {
color: LayerColor,
#[serde(default)]
template: bool,
#[serde(default = "yes")]
printable: bool,
children: Vec<Arc<Node>>,
/// Layer clipping mask: the first (bottom-most) child clips the others, as in a clip group.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
clip: bool,
/// Layer Options → Preview: off, the layer's art is drawn (and clicked) in outline on screen
/// (Ctrl-click its eye in the Layers panel).
#[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")]
preview: bool,
/// Layer Options → Dim Images to: on screen, images on the layer show faded to this
/// percentage (0–100); `None`: not dimmed.
#[serde(default, skip_serializing_if = "Option::is_none")]
dim_images: Option<u8>,
},
/// A group. With `clip`, the first (bottom-most) child is the clipping path.
Group {
children: Vec<Arc<Node>>,
#[serde(default)]
clip: bool,
},
Path {
path: PathData,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
rule: FillRule,
/// Boxed: few paths are live shapes, and the shape is larger than the rest of the variant.
#[serde(default, skip_serializing_if = "Option::is_none")]
live: Option<Box<LiveShape>>,
/// This path acts as a clipping path inside a clip group.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
clipping: bool,
/// Guide (non-printing) path.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
guide: bool,
},
/// Compound path: children are paths painted as one with the compound's appearance.
Compound {
children: Vec<Arc<Node>>,
#[serde(default)]
rule: FillRule,
},
Text(Box<TextObject>),
/// Boxed, as the other large kinds: a node is as large as its largest kind.
Image(Box<ImageObject>),
SymbolInstance {
symbol: String,
xf: Affine,
},
/// Live blend: the key objects (paint order) plus spacing/orientation/spine; the intermediate
/// steps are evaluated on demand (`live::blend_expand`).
Blend {
children: Vec<Arc<Node>>,
#[serde(default)]
spec: Box<BlendSpec>,
},
/// Live envelope distortion of `content`.
Envelope {
content: Vec<Arc<Node>>,
kind: Box<EnvelopeKind>,
/// Envelope Options → Fidelity (0–100).
#[serde(default = "crate::live::default_fidelity")]
fidelity: f64,
/// Edit Contents mode (the content, not the envelope, is edited).
#[serde(default)]
editing: bool,
/// Envelope Options besides Fidelity.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
options: crate::live::EnvelopeOptions,
/// The envelope's own axes (→ the document): the transforms it took since it was made that
/// the page axes can't stand for (rotation, shear, reflection). The content maps from its
/// bounds in this frame, and a warp bends along it.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
frame: Box<Affine>,
},
/// Gradient mesh.
Mesh(GradientMesh),
/// Live Repeat (radial / grid / mirror) of source art.
Repeat(RepeatSpec),
/// A placed document: an artboard of another VectorCraft file, or a page of a PDF or `.ai`
/// file, linked and locked (see [`crate::placed_document`]).
PlacedDocument(Box<crate::placed_document::PlacedDocument>),
/// Object › Intertwine: the objects keep their own stacking order, except where one of them
/// is drawn over the others: the area [`NodeExtras::intertwine`] of that member (see
/// [`crate::intertwine`]).
Intertwine {
children: Vec<Arc<Node>>,
},
}
/// Reading a [`NodeKind`]. Derived, an internally tagged enum buffers each kind (with every
/// object inside a group, again at each level of nesting) before reading it, since `type` may come
/// anywhere among its fields. Files have it first (it is written first): then the kind is read
/// straight from the file. Otherwise (hand-written files, values whose keys are sorted) the
/// fields before `type` are buffered, and only those.
mod kind_de {
use std::fmt;
use std::sync::Arc;
use serde::de::value::{MapAccessDeserializer, StringDeserializer};
use serde::de::{self, DeserializeSeed, Deserializer, EnumAccess, IntoDeserializer, MapAccess, VariantAccess, Visitor};
use serde::{Deserialize, forward_to_deserialize_any};
use vectorcraft_geom::{Affine, FillRule, PathData};
use super::{LayerColor, LiveShape, Node, NodeKind, yes};
use crate::live::{BlendSpec, EnvelopeKind, GradientMesh};
use crate::pattern::RepeatSpec;
use crate::text::TextObject;
/// The kinds and their fields as [`NodeKind`] has them, with the defaults its field attributes
/// note (the compiler checks that every field is here), read externally tagged: the kind from
/// `type`, its fields from the rest of the object ([`Tagged`]).
#[derive(Deserialize)]
#[serde(remote = "NodeKind", rename_all = "lowercase")]
enum KindDef {
Layer {
color: LayerColor,
#[serde(default)]
template: bool,
#[serde(default = "yes")]
printable: bool,
children: Vec<Arc<Node>>,
#[serde(default)]
clip: bool,
#[serde(default = "yes")]
preview: bool,
#[serde(default)]
dim_images: Option<u8>,
},
Group {
children: Vec<Arc<Node>>,
#[serde(default)]
clip: bool,
},
Path {
path: PathData,
#[serde(default)]
rule: FillRule,
#[serde(default)]
live: Option<Box<LiveShape>>,
#[serde(default)]
clipping: bool,
#[serde(default)]
guide: bool,
},
Compound {
children: Vec<Arc<Node>>,
#[serde(default)]
rule: FillRule,
},
Text(Box<TextObject>),
Image(Box<super::ImageObject>),
SymbolInstance {
symbol: String,
xf: Affine,
},
Blend {
children: Vec<Arc<Node>>,
#[serde(default)]
spec: Box<BlendSpec>,
},
Envelope {
content: Vec<Arc<Node>>,
kind: Box<EnvelopeKind>,
#[serde(default = "crate::live::default_fidelity")]
fidelity: f64,
#[serde(default)]
editing: bool,
#[serde(default)]
options: crate::live::EnvelopeOptions,
#[serde(default)]
frame: Box<Affine>,
},
Mesh(GradientMesh),
Repeat(RepeatSpec),
PlacedDocument(Box<crate::placed_document::PlacedDocument>),
Intertwine {
children: Vec<Arc<Node>>,
},
}
impl<'de> Deserialize<'de> for NodeKind {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
d.deserialize_map(KindVisitor)
}
}
struct KindVisitor;
impl<'de> Visitor<'de> for KindVisitor {
type Value = NodeKind;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("an object kind (a map with `type`)")
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<NodeKind, A::Error> {
let mut before = vec![];
while let Some(key) = map.next_key::<String>()? {
if key == "type" {
let tag: String = map.next_value()?;
return KindDef::deserialize(Tagged { tag, map: Buffered { before: before.into_iter(), value: None, map } });
}
before.push((key, map.next_value::<serde_json::Value>()?));
}
Err(de::Error::missing_field("type"))
}
}
/// The fields read before `type` (buffered), then the rest of the object.
struct Buffered<A> {
before: std::vec::IntoIter<(String, serde_json::Value)>,
value: Option<serde_json::Value>,
map: A,
}
impl<'de, A: MapAccess<'de>> MapAccess<'de> for Buffered<A> {
type Error = A::Error;
fn next_key_seed<K: DeserializeSeed<'de>>(&mut self, seed: K) -> Result<Option<K::Value>, A::Error> {
match self.before.next() {
Some((key, value)) => {
self.value = Some(value);
seed.deserialize(StringDeserializer::<A::Error>::new(key)).map(Some)
}
None => self.map.next_key_seed(seed),
}
}
fn next_value_seed<V: DeserializeSeed<'de>>(&mut self, seed: V) -> Result<V::Value, A::Error> {
match self.value.take() {
Some(value) => seed.deserialize(value).map_err(de::Error::custom),
None => self.map.next_value_seed(seed),
}
}
}
/// An object whose `type` was read: the kind is `tag`, its fields are the rest of `map`.
struct Tagged<A> {
tag: String,
map: A,
}
impl<'de, A: MapAccess<'de>> Deserializer<'de> for Tagged<A> {
type Error = A::Error;
fn deserialize_any<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, A::Error> {
visitor.visit_enum(self)
}
forward_to_deserialize_any! {
bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string bytes byte_buf
option unit unit_struct newtype_struct seq tuple tuple_struct map struct enum
identifier ignored_any
}
}
impl<'de, A: MapAccess<'de>> EnumAccess<'de> for Tagged<A> {
type Error = A::Error;
type Variant = Fields<A>;
fn variant_seed<V: DeserializeSeed<'de>>(self, seed: V) -> Result<(V::Value, Fields<A>), A::Error> {
let kind = seed.deserialize(self.tag.into_deserializer())?;
Ok((kind, Fields(self.map)))
}
}
/// A kind's fields: the rest of the object.
struct Fields<A>(A);
impl<'de, A: MapAccess<'de>> VariantAccess<'de> for Fields<A> {
type Error = A::Error;
fn unit_variant(self) -> Result<(), A::Error> {
Err(de::Error::invalid_type(de::Unexpected::UnitVariant, &"an object kind with fields"))
}
fn newtype_variant_seed<T: DeserializeSeed<'de>>(self, seed: T) -> Result<T::Value, A::Error> {
seed.deserialize(MapAccessDeserializer::new(self.0))
}
fn tuple_variant<V: Visitor<'de>>(self, _: usize, _: V) -> Result<V::Value, A::Error> {
Err(de::Error::invalid_type(de::Unexpected::TupleVariant, &"an object kind with fields"))
}
fn struct_variant<V: Visitor<'de>>(self, _: &'static [&'static str], visitor: V) -> Result<V::Value, A::Error> {
visitor.visit_map(self.0)
}
}
}
+346
View File
@@ -0,0 +1,346 @@
use super::*;
/// Live shape parameters (Illustrator's "Live Shapes"). The node's path is regenerated from these
/// while the shape stays live; editing anchors directly converts it to a plain path.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "shape", rename_all = "lowercase")]
pub enum LiveShape {
Rectangle {
/// Untransformed width/height. Transforms fold their scale into these (see
/// [`LiveShape::transform`]), so they are document lengths unless `xf` shears.
w: f64,
h: f64,
/// Corner radii: top-left, top-right, bottom-right, bottom-left. In the same units as
/// `w`/`h`: the corners are circular arcs in the document whenever `xf` doesn't scale.
radii: [f64; 4],
/// Corner kinds, in the same order (Live Corners' corner types).
#[serde(default, skip_serializing_if = "all_round")]
kinds: [CornerKind; 4],
/// When true, `radii` are fractions in 0..1 of each corner's largest possible radius.
#[serde(default, skip_serializing_if = "is_false")]
radii_relative: bool,
/// Maps the untransformed shape (origin at its top-left) into the document. A rotation
/// or reflection plus a move, unless a shear (or a file saved before transforms folded
/// their scale into `w`/`h`) put more in it.
xf: Affine,
},
Ellipse {
w: f64,
h: f64,
/// Pie start/end angles in degrees (0/360 = full ellipse).
pie: (f64, f64),
xf: Affine,
},
Polygon {
radius: f64,
/// 3–1000, as [`LiveShape::set_sides`] gives it; a file's count is clamped when read.
#[serde(deserialize_with = "de_sides")]
sides: u32,
xf: Affine,
/// Corner radii in document units, by vertex from the first one clockwise (none: sharp).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
radii: Vec<f64>,
/// Corner kinds, in the same order (none: round).
#[serde(default, skip_serializing_if = "all_round")]
kinds: Vec<CornerKind>,
#[serde(default, skip_serializing_if = "is_false")]
radii_relative: bool,
},
Line {
a: Point,
b: Point,
},
/// Any other path whose corners Live Corners cut (a star, a pen path): it keeps its uncut
/// outline so the corners stay editable. Without a cut corner it is a plain path again.
Path {
/// The path with its corners uncut, in the document.
base: PathData,
/// Corner radii in document units, by anchor index of `base` (counting every subpath's
/// anchors in order; missing: sharp).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
radii: Vec<f64>,
/// Corner kinds, in the same order (missing: round).
#[serde(default, skip_serializing_if = "all_round")]
kinds: Vec<CornerKind>,
#[serde(default, skip_serializing_if = "is_false")]
radii_relative: bool,
},
}
fn is_false(b: &bool) -> bool {
!*b
}
impl LiveShape {
pub fn radii_relative(&self) -> bool {
match self {
LiveShape::Rectangle { radii_relative, .. } | LiveShape::Polygon { radii_relative, .. } | LiveShape::Path { radii_relative, .. } => {
*radii_relative
}
LiveShape::Ellipse { .. } | LiveShape::Line { .. } => false,
}
}
pub fn label(&self) -> &'static str {
match self {
LiveShape::Rectangle { radii, .. } if radii.iter().any(|r| *r > 0.0) => "Rounded Rectangle",
LiveShape::Rectangle { .. } => "Rectangle",
LiveShape::Ellipse { .. } => "Ellipse",
LiveShape::Polygon { .. } => "Polygon",
LiveShape::Line { .. } => "Line",
LiveShape::Path { .. } => "Path",
}
}
/// Regenerate the path.
pub fn to_path(&self) -> PathData {
match self {
LiveShape::Rectangle { w, h, radii, kinds, radii_relative, xf } => {
let base = shapes::rectangle(Rect::new(0.0, 0.0, *w, *h));
let resolved = crate::corners::resolve_stored_radii(&base, radii, *radii_relative);
let radii = std::array::from_fn(|i| resolved.get(i).copied().unwrap_or(0.0));
shapes::rectangle_with_corners(Rect::new(0.0, 0.0, *w, *h), radii, *kinds).transformed(*xf)
}
LiveShape::Ellipse { w, h, pie, xf } => shapes::ellipse_pie(Rect::new(0.0, 0.0, *w, *h), pie.0, pie.1).transformed(*xf),
LiveShape::Polygon { radii, kinds, radii_relative, .. } => {
let outline = self.polygon_outline();
let radii = crate::corners::resolve_stored_radii(&outline, radii, *radii_relative);
cut_corners(&outline, &radii, kinds).0
}
LiveShape::Line { a, b } => shapes::line(*a, *b),
LiveShape::Path { base, radii, kinds, radii_relative } => {
let radii = crate::corners::resolve_stored_radii(base, radii, *radii_relative);
cut_corners(base, &radii, kinds).0
}
}
}
/// A polygon's outline with its corners uncut, in the document (empty for other shapes).
pub fn polygon_outline(&self) -> PathData {
match self {
LiveShape::Polygon { radius, sides, xf, .. } => shapes::polygon(Point::ZERO, *radius, *sides, 0.0).transformed(*xf),
_ => PathData::default(),
}
}
/// Give a polygon `n` sides (3–1000). Its corners keep the radius and kind they shared; else
/// the corners past the old last one are sharp.
pub fn set_sides(&mut self, n: u64) {
let LiveShape::Polygon { sides, radii, kinds, .. } = self else { return };
*sides = n.clamp(3, 1000) as u32;
fn spread<T: Copy + PartialEq + Default>(v: &mut Vec<T>, n: usize) {
match v.first().copied() {
Some(x) if v.iter().all(|y| *y == x) => *v = vec![x; n],
Some(_) => v.resize(n, T::default()),
None => {}
}
}
spread(radii, *sides as usize);
spread(kinds, *sides as usize);
}
/// A polygon's radius in the document, centre to vertex: its own radius by the mean scale of
/// `xf` (exact while its sides are equal). None for other shapes.
pub fn polygon_radius(&self) -> Option<f64> {
let LiveShape::Polygon { radius, xf, .. } = self else { return None };
Some(radius * xf.determinant().abs().sqrt())
}
/// A polygon's angle: the counterclockwise degrees in [0, 360) its first vertex is turned by
/// from straight up (the Rotate field's sense). None for other shapes.
pub fn polygon_angle(&self) -> Option<f64> {
let LiveShape::Polygon { .. } = self else { return None };
// No −0° or 360° from rounding: a polygon drawn upright reads 0°.
let a = (-self.angle_deg()).rem_euclid(360.0);
Some(if a > 360.0 - 1e-9 { 0.0 } else { a + 0.0 })
}
/// Whether a polygon's sides are all as long: `xf` scales it evenly and doesn't shear it. True
/// for other shapes.
pub fn polygon_sides_equal(&self) -> bool {
let LiveShape::Polygon { xf, .. } = self else { return true };
let [a, b, c, d, _, _] = xf.as_coeffs();
let (sx, sy) = (a.hypot(b), c.hypot(d));
(sx - sy).abs() <= 1e-9 * sx.max(sy) && (a * c + b * d).abs() <= 1e-9 * sx * sy
}
/// Give a polygon the document radius `r` (centre to vertex, see [`LiveShape::polygon_radius`]).
pub fn set_polygon_radius(&mut self, r: f64) {
let LiveShape::Polygon { radius, xf, .. } = self else { return };
let k = xf.determinant().abs().sqrt();
if k > 1e-12 && r.is_finite() {
*radius = r.max(0.0) / k;
}
}
/// The document radius that makes a polygon's sides `side` long (with its sides equal).
pub fn polygon_radius_for_side(&self, side: f64) -> Option<f64> {
let LiveShape::Polygon { sides, .. } = self else { return None };
Some(side / (2.0 * (std::f64::consts::PI / f64::from((*sides).max(3))).sin()))
}
/// Turn a polygon about its centre to `angle` (degrees, as [`LiveShape::polygon_angle`]).
pub fn set_polygon_angle(&mut self, angle: f64) {
let Some(now) = self.polygon_angle() else { return };
if let LiveShape::Polygon { xf, .. } = self
&& angle.is_finite()
{
let centre = *xf * Point::ZERO;
*xf = Affine::rotate_about((now - angle).to_radians(), centre) * *xf;
}
}
/// Make a polygon's sides equal (Make Sides Equal): drop the uneven scale and the shear from
/// `xf`, keeping its centre, angle, reflection and [`LiveShape::polygon_radius`].
pub fn make_sides_equal(&mut self) {
let Some(r) = self.polygon_radius() else { return };
let LiveShape::Polygon { radius, xf, .. } = self else { return };
let [a, b, _, _, e, f] = xf.as_coeffs();
let flip = if xf.determinant() < 0.0 { Affine::FLIP_Y } else { Affine::IDENTITY };
*xf = Affine::translate((e, f)) * Affine::rotate(b.atan2(a)) * flip;
*radius = r;
}
/// Apply `a` to the shape. True when the shape is no longer `a` applied to the old path, so
/// the caller must regenerate the path with [`LiveShape::to_path`].
///
/// A rectangle keeps round corners whatever its proportions: when `a` scales it along its own
/// sides (any rotation or reflection, no shear), the scale goes into `w`/`h` and `xf` keeps
/// only the rotation, reflection and position. The radii scale by the mean scale (the square
/// root of the determinant), which [`LiveShape::keep_corners`] undoes for Scale Corners off.
/// A shear can't keep circular corners: `xf` takes it, as do moves (so files saved with a
/// scale in `xf` keep their exact geometry until they're next transformed). A polygon's or a
/// path's corner radii are document lengths: they scale by the mean scale and the corners are
/// cut again, circular whatever `a` does.
pub fn transform(&mut self, a: Affine) -> bool {
match self {
LiveShape::Rectangle { xf, .. } => {
*xf = a * *xf;
!moves_only(a) && self.fold_scale()
}
LiveShape::Ellipse { xf, .. } => {
*xf = a * *xf;
false
}
LiveShape::Polygon { xf, radii, radii_relative, .. } => {
*xf = a * *xf;
scale_radii(radii, a, *radii_relative)
}
LiveShape::Path { base, radii, radii_relative, .. } => {
base.transform(a);
scale_radii(radii, a, *radii_relative)
}
LiveShape::Line { a: p, b } => {
*p = a * *p;
*b = a * *b;
false
}
}
}
/// Move the scale of a rectangle's `xf` into `w`/`h` (and its radii, by the mean scale), so
/// `xf` keeps only a rotation or reflection plus a move and the corners are circular in the
/// document. False (and nothing changes) when there is no scale to move, when `xf` shears or
/// isn't finite, and for other shapes. Files saved before transforms did this (#291) carry an
/// uneven scale in `xf`: their corners are elliptical until this runs.
pub fn fold_scale(&mut self) -> bool {
let LiveShape::Rectangle { w, h, radii, radii_relative, xf, .. } = self else { return false };
let [m0, m1, m2, m3, m4, m5] = xf.as_coeffs();
let (sx, sy) = (m0.hypot(m1), m2.hypot(m3));
let orthogonal = sx > 1e-12 && sy > 1e-12 && (m0 * m2 + m1 * m3).abs() <= 1e-9 * sx * sy;
let finite = xf.as_coeffs().iter().all(|c| c.is_finite()) && w.is_finite() && h.is_finite();
let unscaled = (sx - 1.0).abs() < 1e-12 && (sy - 1.0).abs() < 1e-12;
if !orthogonal || !finite || unscaled {
return false;
}
let k = (sx * sy).sqrt();
if !*radii_relative {
radii.iter_mut().for_each(|r| *r *= k);
}
*w *= sx;
*h *= sy;
*xf = Affine::new([m0 / sx, m1 / sx, m2 / sy, m3 / sy, m4, m5]);
true
}
/// This shape with [`LiveShape::fold_scale`] applied: a rectangle's size and radii in document
/// units (what Live Corners show and edit).
pub fn folded(&self) -> Self {
let mut s = self.clone();
s.fold_scale();
s
}
/// Divide live corner radii by `k`, the mean scale of a transform just applied, so the corners
/// keep their size (Scale Corners off). False when nothing changed.
pub fn keep_corners(&mut self, k: f64) -> bool {
let relative = self.radii_relative();
let radii: &mut [f64] = match self {
LiveShape::Rectangle { radii, .. } => radii,
LiveShape::Polygon { radii, .. } | LiveShape::Path { radii, .. } => radii,
LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return false,
};
if relative || !radii.iter().any(|r| *r > 0.0) || k <= 1e-12 {
return false;
}
radii.iter_mut().for_each(|r| *r /= k);
true
}
/// Rotation angle of the live shape in degrees, clockwise on the page (y points down).
pub fn angle_deg(&self) -> f64 {
match self {
LiveShape::Rectangle { xf, .. } | LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => {
let c = xf.as_coeffs();
c[1].atan2(c[0]).to_degrees()
}
LiveShape::Line { a, b } => (b.y - a.y).atan2(b.x - a.x).to_degrees(),
LiveShape::Path { .. } => 0.0,
}
}
}
fn all_round(kinds: &[CornerKind]) -> bool {
kinds.iter().all(|k| *k == CornerKind::Round)
}
/// A polygon's side count read from a file, clamped as the commands clamp it: the outline and its
/// corner lists hold one entry per side, so a huge count would allocate gigabytes.
fn de_sides<'de, D: serde::Deserializer<'de>>(d: D) -> Result<u32, D::Error> {
u32::deserialize(d).map(|n| n.clamp(3, 1000))
}
/// Whether `a` only moves (no rotation, scale, shear or reflection).
pub(super) fn moves_only(a: Affine) -> bool {
let [a0, a1, a2, a3, _, _] = a.as_coeffs();
(a0 - 1.0).abs() < 1e-12 && a1.abs() < 1e-12 && a2.abs() < 1e-12 && (a3 - 1.0).abs() < 1e-12
}
/// Scale corner radii in document units by the mean scale of `a`, applied to their path. True
/// when the path must be cut again: some corner is cut and `a` does more than move it (an
/// uneven scale would otherwise make the cuts elliptical).
fn scale_radii(radii: &mut [f64], a: Affine, relative: bool) -> bool {
if relative || moves_only(a) || !radii.iter().any(|r| *r > 0.0) {
return false;
}
let k = a.determinant().abs().sqrt();
radii.iter_mut().for_each(|r| *r *= k);
true
}
/// What a transform scales besides geometry, by its mean scale (the square root of its
/// determinant). `Scaling::default()` (and `false`) scales nothing else; `true` scales stroke
/// weights and dashes only. Transforms the user asks for take theirs from Scale Strokes & Effects
/// and Scale Corners.
#[derive(Clone, Copy, Debug, Default)]
pub struct Scaling {
/// Stroke weights, dash lengths and dash offsets scale.
pub strokes: bool,
/// With `strokes`: scales an effect's distance parameters (the effects catalogue's).
pub effects: Option<fn(&mut crate::Effect, f64)>,
/// Type keeps its character strokes' weight (they otherwise scale with the type).
pub keep_type_strokes: bool,
/// Live corner radii keep their size.
pub keep_corners: bool,
/// Pattern fills and strokes move with the art (Transform Patterns; General › Transform
/// Pattern Tiles is its default): their tiles transform too, instead of staying put.
pub patterns: bool,
}
impl Scaling {
/// The mean scale of `a` when it scales (isn't 1): what strokes, effects and corners scale by.
pub fn factor(a: Affine) -> Option<f64> {
let k = a.determinant().abs().sqrt();
((k - 1.0).abs() > 1e-9).then_some(k)
}
}
impl From<bool> for Scaling {
fn from(strokes: bool) -> Self {
Self { strokes, ..Self::default() }
}
}
+129
View File
@@ -0,0 +1,129 @@
use super::kind::NodeKind;
use super::*;
/// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct OpacityMask {
/// Mask art in document coordinates.
pub art: Arc<Node>,
/// Clip: outside the mask art is hidden. Off: outside the mask art stays visible.
#[serde(default = "yes")]
pub clip: bool,
/// Invert the mask's luminance.
#[serde(default)]
pub invert: bool,
/// Disabled masks are kept but not applied.
#[serde(default)]
pub disabled: bool,
/// Linked masks move with the object.
#[serde(default = "yes")]
pub linked: bool,
}
impl OpacityMask {
pub fn new(art: Node, clip: bool) -> Self {
Self { art: Arc::new(art), clip, invert: false, disabled: false, linked: true }
}
}
/// Every paint of `n` (and its children) white, opacities kept: an alpha mask's art as an opacity
/// mask's, whose luminance over the black outside the art is then the art's alpha.
pub fn whiten(n: &mut Node) {
let white = |p: &mut Paint| match p {
Paint::Solid { color, swatch, tint } => {
(*color, *swatch, *tint) = (Color::WHITE, None, 1.0);
}
Paint::Gradient(g) => {
for s in &mut g.gradient.stops {
(s.color, s.swatch, s.tint) = (Color::WHITE, None, 1.0);
}
}
Paint::None | Paint::Pattern { .. } => {}
};
for item in &mut n.appearance.items {
white(item.paint_mut());
}
match &mut n.kind {
NodeKind::Mesh(m) => m.points.iter_mut().for_each(|p| p.color = Color::WHITE),
NodeKind::Text(t) => t.runs.iter_mut().for_each(|r| {
white(&mut r.style.fill);
white(&mut r.style.stroke);
}),
_ => {}
}
if let Some(ch) = n.children_mut() {
for c in ch {
whiten(Arc::make_mut(c));
}
}
}
/// Knockout Group state of a container (the Transparency panel's three-state checkbox). In a
/// knockout group each child composites against the group's backdrop, so it hides the children
/// below it instead of showing them through its transparency. Neutral passes the enclosing
/// group's setting through to the children. Files from before the three states stored a bool:
/// `true` loads as On, `false` as Neutral.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum Knockout {
#[default]
Neutral,
Off,
On,
}
impl Knockout {
pub const ALL: [Knockout; 3] = [Knockout::Neutral, Knockout::Off, Knockout::On];
/// Name in files and command parameters.
pub fn label(self) -> &'static str {
match self {
Knockout::Neutral => "neutral",
Knockout::Off => "off",
Knockout::On => "on",
}
}
/// A name ([`Self::label`], any case) or a bool as older files and commands wrote it.
pub fn from_value(v: &serde_json::Value) -> Option<Self> {
match v {
serde_json::Value::Bool(b) => Some(Self::from(*b)),
serde_json::Value::String(s) => Self::ALL.into_iter().find(|k| k.label().eq_ignore_ascii_case(s.trim())),
_ => None,
}
}
/// The state a click on the checkbox moves to: on → neutral → off → on.
pub fn cycle(self) -> Self {
match self {
Knockout::On => Knockout::Neutral,
Knockout::Neutral => Knockout::Off,
Knockout::Off => Knockout::On,
}
}
/// Whether children of a group with this state knock each other out, inside a group (or page)
/// where that is `enclosing`.
pub fn resolve(self, enclosing: bool) -> bool {
match self {
Knockout::Neutral => enclosing,
Knockout::Off => false,
Knockout::On => true,
}
}
}
impl From<bool> for Knockout {
fn from(b: bool) -> Self {
if b { Knockout::On } else { Knockout::Neutral }
}
}
impl Serialize for Knockout {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.label())
}
}
impl<'de> Deserialize<'de> for Knockout {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = serde_json::Value::deserialize(d)?;
Self::from_value(&v).ok_or_else(|| serde::de::Error::custom(format!("invalid knockout {v}")))
}
}
+712
View File
@@ -0,0 +1,712 @@
use super::extras::Extras;
use super::ids::{LayerColor, NodeId};
use super::kind::NodeKind;
use super::live_shape::{Scaling, moves_only};
use super::opacity::Knockout;
use super::*;
impl Node {
pub fn new(id: NodeId, kind: NodeKind) -> Self {
Self {
id,
name: None,
visible: true,
locked: false,
opacity: 1.0,
blend: BlendMode::Normal,
isolate: false,
knockout: Knockout::Neutral,
knockout_shape: false,
appearance: Appearance::default().into(),
kind,
bbox_angle: 0.0,
extras: Extras::default(),
}
}
pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self {
let mut n = Self::new(id, NodeKind::Path { path, rule: FillRule::NonZero, live: None, clipping: false, guide: false });
n.appearance = appearance.into();
n
}
pub fn group(id: NodeId, children: Vec<Arc<Node>>) -> Self {
Self::new(id, NodeKind::Group { children, clip: false })
}
pub fn layer(id: NodeId, name: &str, color: LayerColor) -> Self {
let mut n = Self::new(
id,
NodeKind::Layer { color, template: false, printable: true, children: vec![], clip: false, preview: true, dim_images: None },
);
n.name = Some(name.to_string());
n
}
pub fn children(&self) -> Option<&Vec<Arc<Node>>> {
match &self.kind {
NodeKind::Layer { children, .. }
| NodeKind::Group { children, .. }
| NodeKind::Compound { children, .. }
| NodeKind::Blend { children, .. }
| NodeKind::Envelope { content: children, .. }
| NodeKind::Repeat(RepeatSpec { source: children, .. })
| NodeKind::Intertwine { children } => Some(children),
_ => None,
}
}
pub fn children_mut(&mut self) -> Option<&mut Vec<Arc<Node>>> {
match &mut self.kind {
NodeKind::Layer { children, .. }
| NodeKind::Group { children, .. }
| NodeKind::Compound { children, .. }
| NodeKind::Blend { children, .. }
| NodeKind::Envelope { content: children, .. }
| NodeKind::Repeat(RepeatSpec { source: children, .. })
| NodeKind::Intertwine { children } => Some(children),
_ => None,
}
}
/// Does this object or layer move with its artboard? Locked and hidden ones only with
/// `locked_and_hidden` (Selection & Anchor Display › Move Locked and Hidden Artwork with
/// Artboard).
pub fn rides_with_artboard(&self, locked_and_hidden: bool) -> bool {
locked_and_hidden || (!self.locked && self.visible)
}
pub fn is_layer(&self) -> bool {
matches!(self.kind, NodeKind::Layer { .. })
}
/// A template layer (Layer Options → Template).
pub fn is_template(&self) -> bool {
matches!(self.kind, NodeKind::Layer { template: true, .. })
}
/// The art objects of this container: its children, looking through sublayers, which are not
/// objects (bottom first). With `editable`, only the visible, unlocked objects in visible,
/// unlocked, non-template sublayers.
pub fn layer_art(&self, editable: bool) -> Vec<NodeId> {
fn visit(n: &Node, editable: bool, out: &mut Vec<NodeId>) {
for c in n.children().into_iter().flatten() {
if editable && (!c.visible || c.locked || c.is_template()) {
continue;
}
if c.is_layer() {
visit(c, editable, out);
} else {
out.push(c.id);
}
}
}
let mut out = vec![];
visit(self, editable, &mut out);
out
}
pub fn is_container(&self) -> bool {
self.children().is_some()
}
/// Full opacity, Normal blending, no isolation, knockout or opacity mask (the Layers panel
/// fills an object's target circle otherwise).
pub fn has_default_transparency(&self) -> bool {
self.opacity >= 1.0
&& self.blend == BlendMode::Normal
&& !self.isolate
&& self.knockout == Knockout::Neutral
&& !self.knockout_shape
&& self.extras().mask.is_none()
}
/// Kind name as the Layers panel / Properties panel shows it.
pub fn kind_label(&self) -> &'static str {
match &self.kind {
NodeKind::Layer { .. } => "Layer",
NodeKind::Group { clip: true, .. } => "Clip Group",
NodeKind::Group { .. } if self.extras().objects_on_path.is_some() => "Objects on Path",
NodeKind::Group { .. } => "Group",
NodeKind::Path { live: Some(l), .. } => l.label(),
NodeKind::Path { guide: true, .. } => "Guide",
NodeKind::Path { .. } => "Path",
NodeKind::Compound { .. } => "Compound Path",
NodeKind::Text(_) => "Type",
NodeKind::Image(_) => "Image",
NodeKind::SymbolInstance { .. } => "Symbol",
NodeKind::Blend { .. } => "Blend",
NodeKind::Envelope { .. } => "Envelope",
NodeKind::Mesh(_) => "Mesh",
NodeKind::Repeat(r) => r.kind.label(),
NodeKind::PlacedDocument(_) => "Placed Document",
NodeKind::Intertwine { .. } => "Intertwine",
}
}
/// Name shown in the Layers panel: explicit name or `<Kind>`.
pub fn display_name(&self) -> String {
if let Some(n) = &self.name {
return n.clone();
}
match &self.kind {
NodeKind::Text(t) => {
let s: String = t.plain_text().chars().take(32).collect();
if s.is_empty() { "<Text>".into() } else { s }
}
_ => format!("<{}>", self.kind_label()),
}
}
/// [`Self::display_name`], or its XML id when Preferences › Units › Identify Objects By is XML ID.
pub fn identify_label(&self, xml_id: bool) -> String {
let name = self.display_name();
if xml_id { crate::xml_id::from_object_name(&name) } else { name }
}
/// Path data for path nodes.
pub fn path_data(&self) -> Option<&PathData> {
match &self.kind {
NodeKind::Path { path, .. } => Some(path),
_ => None,
}
}
pub fn path_data_mut(&mut self) -> Option<&mut PathData> {
match &mut self.kind {
NodeKind::Path { path, .. } => Some(path),
_ => None,
}
}
/// The path a path's or compound path's own strokes follow (a compound's members' together).
pub fn stroke_path(&self) -> Option<vectorcraft_geom::BezPath> {
match &self.kind {
NodeKind::Path { path, .. } => Some(path.to_bezpath()),
NodeKind::Compound { children, .. } => {
let mut bp = vectorcraft_geom::BezPath::new();
children.iter().filter_map(|c| c.path_data()).for_each(|pd| bp.extend(pd.to_bezpath()));
Some(bp)
}
_ => None,
}
}
/// A quick box round everything the object can paint, for culling clicks: paths and compound
/// paths grow their geometric bounds by the farthest their strokes can reach from any path
/// ([`Appearance::outset`]) instead of measuring along their shape. An image's box takes in
/// the shapes its own fills and strokes paint ([`crate::hit::set_image_paint`]). Groups, layers
/// without a clipping mask and Intertwine groups take in their members' boxes, grown by their
/// own strokes. Symbol instances are measured as in [`Node::visual_bounds`].
pub fn reach_bounds(&self) -> Option<Rect> {
self.reach(None)
}
/// [`Node::reach_bounds`] with symbol instances measured by their symbol's art box from
/// `symbols`. An instance that only moves its art is measured as the art's visual box moved,
/// and any other instance is measured as its geometric box ([`Node::geometric_bounds_with`])
/// grown by the farthest a stroke in the art can reach.
pub fn reach_bounds_with(&self, symbols: &SymbolBoxes) -> Option<Rect> {
symbols.quickly(|| self.reach(Some(symbols)))
}
/// The box of [`Node::reach_bounds`], or of [`Node::reach_bounds_with`] when `symbols` is given.
pub(crate) fn reach(&self, symbols: Option<&SymbolBoxes>) -> Option<Rect> {
match &self.kind {
NodeKind::Path { .. } | NodeKind::Compound { .. } => {
let o = self.appearance.outset();
self.geometric(symbols).map(|b| b.inflate(o, o))
}
// The members' reach, grown by the container's own strokes.
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
let o = self.appearance.outset();
children
.iter()
.skip(usize::from(self.extras().shaper.is_some()))
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.reach(symbols)))
.map(|b| b.inflate(o, o))
}
NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.reach(symbols))),
// The frame and each shape the image's own fills and strokes paint, a stroke's grown by
// the farthest it can reach from its shape.
NodeKind::Image(_) => crate::hit::image_paint(self).iter().fold(self.geometric(symbols), |acc, (bp, _, item)| {
let o = if let crate::AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 };
vectorcraft_geom::union_opt(acc, (!bp.elements().is_empty()).then(|| bp.bounding_box().inflate(o, o)))
}),
_ => self.visual(symbols),
}
}
/// Geometric bounds (no stroke), recursively. Clip groups are bounded by their clip path. A
/// symbol instance is measured as its transform applied to the 20 × 20 square around the origin
/// that symbols made in the app keep their art in ([`Node::geometric_bounds_with`] measures
/// its art).
pub fn geometric_bounds(&self) -> Option<Rect> {
self.geometric(None)
}
/// [`Node::geometric_bounds`] with each symbol instance measured as its transform applied to
/// its symbol's art box from `symbols`. An instance is measured as the square when its symbol
/// is missing, when its symbol or a symbol nested in its art holds an instance of itself, and
/// when symbols nest 32 deep in its art.
pub fn geometric_bounds_with(&self, symbols: &SymbolBoxes) -> Option<Rect> {
self.geometric(Some(symbols))
}
/// Children that contribute to a container's editing bounds and selection highlights. A live trace retains a
/// hidden bitmap for re-tracing/release; only source-image views measure that bitmap.
pub fn editing_children(&self) -> &[Arc<Node>] {
let children = self.children().map(Vec::as_slice).unwrap_or_default();
let trace_source = self.extras().trace.is_some()
&& !self.trace_view().shows_source()
&& children.first().is_some_and(|n| !n.visible && matches!(n.kind, NodeKind::Image(_)));
let skip = usize::from(self.extras().shaper.is_some() || trace_source);
children.get(skip..).unwrap_or_default()
}
/// The box of [`Node::geometric_bounds`], or of [`Node::geometric_bounds_with`] when `symbols`
/// is given.
pub(crate) fn geometric(&self, symbols: Option<&SymbolBoxes>) -> Option<Rect> {
let union = |nodes: &[Arc<Node>]| nodes.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric(symbols)));
match &self.kind {
NodeKind::Path { path, .. } => path.bounds(),
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
children.first().and_then(|c| c.geometric(symbols))
}
NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Compound { .. } => self
.editing_children()
.iter()
.filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. }))
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric(symbols))),
NodeKind::Text(t) => self.projected(t.bounds()),
NodeKind::Image(im) => Some(im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, im.width as f64, im.height as f64))),
NodeKind::SymbolInstance { symbol, xf } => {
let art = symbols.and_then(|s| s.art(symbol)).map_or(SYMBOL_SQUARE, |a| a.geometric);
self.projected(Some(xf.transform_rect_bbox(art)))
}
NodeKind::Blend { children, spec } => vectorcraft_geom::union_opt(union(children), spec.spine.as_ref().and_then(|s| s.bounds())),
NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, **frame),
NodeKind::Mesh(m) => m.bounds(),
// Each copy is measured where the repeat draws it: `RepeatSpec::transforms` lays the
// copies out by the source's bounds measured without the document.
NodeKind::Repeat(r) => r.copies_bounds(union(&r.source)?, symbols.map(|s| s.document())),
NodeKind::PlacedDocument(p) => Some(p.bounds()),
// The members drawn over the others are drawn where they are.
NodeKind::Intertwine { children } => union(children),
}
}
/// Visual bounds: what the object paints, its strokes included (paths and compound paths
/// measure theirs along their own shape, see [`Appearance::stroked_bounds`]). An image is
/// measured by its frame. A symbol instance is measured as in [`Node::geometric_bounds`],
/// grown by its own strokes' reach ([`Node::visual_bounds_with`] measures its art).
pub fn visual_bounds(&self) -> Option<Rect> {
self.visual(None)
}
/// [`Node::visual_bounds`] with symbol instances measured by their symbol's art from
/// `symbols`, the strokes in it included. An instance that only moves its art is measured as
/// the art's visual box moved. Any other instance is measured as its art transformed by it with
/// the strokes at their own weight, as the renderer draws it, and never smaller than its
/// geometric box ([`Node::geometric_bounds_with`]). Instances inside symbol art are measured as
/// in [`Node::reach_bounds_with`]. In boxes made by [`SymbolBoxes::with_brush_art`], the box of
/// an instance whose art holds brushed strokes grows by as far as their brush art can paint. In
/// boxes made by [`SymbolBoxes::without_art_strokes`], an instance is measured as its art's
/// geometric box placed by its transform, grown by its own strokes.
pub fn visual_bounds_with(&self, symbols: &SymbolBoxes) -> Option<Rect> {
self.visual(Some(symbols))
}
/// The box of [`Node::visual_bounds`], or of [`Node::visual_bounds_with`] when `symbols` is
/// given.
pub(crate) fn visual(&self, symbols: Option<&SymbolBoxes>) -> Option<Rect> {
match &self.kind {
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
// A clipping path that paints (see `clip_paint`) strokes over the clip edge.
let clip = children.first()?;
if matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)) {
clip.visual(symbols)
} else {
clip.geometric(symbols)
}
}
NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))),
NodeKind::Layer { .. } | NodeKind::Group { .. } => {
// The container's own strokes paint around its members.
let o = self.appearance.outset();
self.editing_children().iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))).map(|b| b.inflate(o, o))
}
NodeKind::Blend { children, spec } => {
let b = children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols)));
let o = crate::live::max_outset(children, symbols);
vectorcraft_geom::union_opt(b, spec.spine.as_ref().and_then(|s| s.bounds()).map(|r| r.inflate(o, o)))
}
NodeKind::Envelope { content, .. } | NodeKind::Repeat(RepeatSpec { source: content, .. }) => {
let o = crate::live::max_outset(content, symbols);
self.geometric(symbols).map(|b| b.inflate(o, o))
}
NodeKind::Path { .. } | NodeKind::Compound { .. } => {
let b = self.geometric(symbols)?;
Some(self.appearance.stroked_bounds(b, || self.stroke_path().unwrap_or_default()))
}
NodeKind::SymbolInstance { symbol, xf } => {
let b = match symbols.and_then(|s| Some((s, s.art(symbol)?))) {
Some((s, art)) if !s.art_strokes() => xf.transform_rect_bbox(art.geometric),
Some((_, art)) if moves_only(*xf) => xf.transform_rect_bbox(art.visual),
Some((s, art)) => {
let placed = xf.transform_rect_bbox(art.geometric);
match s.placed_visual(symbol, *xf) {
Some(v) => v.union(placed),
None => placed.inflate(art.reach, art.reach),
}
}
None => xf.transform_rect_bbox(SYMBOL_SQUARE),
};
let o = self.appearance.outset() + symbols.map_or(0.0, |s| s.brush_pad(symbol));
self.projected(Some(b)).map(|b| b.inflate(o, o))
}
NodeKind::Text(t) => {
// The characters' strokes paint around the glyphs.
let o = self.appearance.outset().max(t.char_stroke_outset());
self.geometric(symbols).map(|b| b.inflate(o, o))
}
// An image is measured by its frame, without its own fills and strokes.
NodeKind::Image(_) => self.geometric(symbols),
_ => {
let o = self.appearance.outset();
self.geometric(symbols).map(|b| b.inflate(o, o))
}
}
}
/// Apply an affine transform to the geometry (and gradients) of this node and its descendants.
/// `scaling` says what else scales by the transform's mean scale (a bool: stroke weights
/// only, see [`Scaling`]).
pub fn transform(&mut self, a: Affine, scaling: impl Into<Scaling>) {
self.transform_scaled(a, &scaling.into());
}
fn transform_scaled(&mut self, a: Affine, sc: &Scaling) {
// A dimension's measurement moves with it.
if let Some(e) = self.extras_if_any()
&& let Some(dimension) = &mut e.dimension
{
dimension.transform(a);
}
// The frames of the objects attached to a path move with them.
if let Some(e) = self.extras_if_any()
&& let Some(on_path) = &mut e.objects_on_path
{
on_path.transform(a);
}
if !self.is_layer() {
self.bbox_angle = crate::orient::transformed_angle(self.bbox_angle, a);
}
// Type and symbols in perspective keep looking the same, moved by `a`.
self.transform_projection(a);
// Refitting an unplaced gradient only reproduces moves and uniform scales.
if !keeps_gradient_fit(a) {
self.pin_gradients();
}
let k = Scaling::factor(a);
if let Some(k) = k
&& sc.strokes
{
self.appearance.scale_strokes(k);
if let Some(f) = sc.effects {
self.appearance.scale_effects(k, f);
}
}
// Only through `DerefMut` when there is something to change: the appearance is shared
// copy-on-write (`Shared`), and a move must not copy what it leaves as it is.
if self.appearance.gradients().next().is_some() {
self.appearance.transform_gradients(a);
}
if sc.patterns {
crate::pattern::transform_pattern_paints(self, a);
}
match &mut self.kind {
NodeKind::Path { path, live, .. } => {
path.transform(a);
if let Some(l) = live {
// A rectangle scaled unevenly keeps circular corners: its path is regenerated.
let reshaped = l.transform(a);
let kept = sc.keep_corners && k.is_some_and(|k| l.keep_corners(k));
if reshaped || kept {
*path = l.to_path();
}
}
}
NodeKind::Layer { children, .. }
| NodeKind::Group { children, .. }
| NodeKind::Compound { children, .. }
| NodeKind::Intertwine { children } => {
for c in children.iter_mut() {
Arc::make_mut(c).transform_scaled(a, sc);
}
}
NodeKind::Text(t) => {
t.transform(a);
// Character strokes scale with the type: undo that when strokes keep their weight.
if sc.keep_type_strokes
&& let Some(k) = k.filter(|k| *k > 1e-12)
{
t.scale_char_strokes(1.0 / k);
}
}
NodeKind::Image(im) => im.xf = a * im.xf,
NodeKind::PlacedDocument(p) => p.xf = a * p.xf,
NodeKind::SymbolInstance { xf, .. } => *xf = a * *xf,
NodeKind::Blend { children, spec } => {
for c in children.iter_mut() {
Arc::make_mut(c).transform_scaled(a, sc);
}
if let Some(s) = &mut spec.spine {
s.transform(a);
}
}
NodeKind::Envelope { content, kind, frame, .. } => {
for c in content.iter_mut() {
Arc::make_mut(c).transform_scaled(a, sc);
}
**frame = crate::live::envelope_frame(a * **frame);
match &mut **kind {
EnvelopeKind::Mesh { points, handles, .. } => {
for p in points.iter_mut() {
*p = a * *p;
}
// Handles are offsets: they take the linear part.
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
let lin = Affine::new([m0, m1, m2, m3, 0.0, 0.0]);
for h in handles.iter_mut().flatten() {
*h = (lin * h.to_point()).to_vec2();
}
}
EnvelopeKind::TopObject { path } => path.transform(a),
EnvelopeKind::Warp { .. } => {}
}
}
NodeKind::Mesh(m) => m.transform(a),
NodeKind::Repeat(r) => r.transform(a, *sc),
}
if let Some(m) = self.extras_if_any().and_then(|e| e.mask.as_mut())
&& m.linked
{
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);
}
}
/// Visit this node and all descendants depth first (paint order).
pub fn walk<'a>(&'a self, f: &mut impl FnMut(&'a Node)) {
f(self);
if let Some(ch) = self.children() {
for c in ch {
c.walk(f);
}
}
}
/// Count of nodes in this subtree.
pub fn count(&self) -> usize {
let mut n = 0;
self.walk(&mut |_| n += 1);
n
}
/// [`Self::count`], with the art of the opacity masks in the subtree (and theirs): what copying
/// and transforming the subtree makes, as [`Self::walk`] doesn't go into masks.
pub fn count_with_masks(&self) -> usize {
let mut n = 0;
self.walk(&mut |c| {
n += 1;
if let Some(m) = &c.extras().mask {
n += m.art.count_with_masks();
}
});
n
}
/// Fix this node's unplaced gradients to their fit on its current bounds (before a transform
/// or warp that refitting wouldn't reproduce). Descendants pin their own when transformed.
pub fn pin_gradients(&mut self) {
if self.appearance.has_unplaced_gradient()
&& let Some(b) = self.geometric_bounds()
{
self.appearance.pin_gradients(b);
}
}
/// The paint behind the Fill (or Stroke) proxy, the map from its space to the document and
/// the box (in that space) an unplaced gradient fits. `item` (the Appearance panel's active
/// item) stands in for the topmost fill or stroke when it is of the proxy's kind. Otherwise
/// type objects paint their runs (the first run speaks for all) in text space, fitted to the
/// layout bounds; everything else paints its fill or stroke in the document, fitted to the
/// geometric bounds. Strokes fit the box grown by half their weight.
pub fn proxy_paint(&self, stroke: bool, item: Option<usize>) -> Option<(&vectorcraft_color::Paint, Affine, Rect)> {
let item = self.appearance.item_of_kind(item, !stroke);
if item.is_none()
&& let NodeKind::Text(t) = &self.kind
{
let st = &t.runs.first()?.style;
let b = t.local_bounds();
return Some(if stroke { (&st.stroke, t.xf, crate::appearance::stroke_paint_bounds(b, st.stroke_width)) } else { (&st.fill, t.xf, b) });
}
// An object without bounds (an empty path) still shows its paints, fitted to an empty box.
let b = self.geometric_bounds().unwrap_or(Rect::ZERO);
if stroke {
self.appearance.stroke_at(item).map(|s| (&s.paint, Affine::IDENTITY, s.paint_bounds(b)))
} else {
self.appearance.fill_at(item).map(|f| (&f.paint, Affine::IDENTITY, b))
}
}
/// The gradient behind the Fill (or Stroke) proxy and its placement in document coordinates
/// (see [`Node::proxy_paint`]): what the gradient annotator shows and edits.
pub fn proxy_gradient(&self, stroke: bool, item: Option<usize>) -> Option<(&vectorcraft_color::GradientPaint, vectorcraft_color::GradientGeom)> {
let (paint, to_doc, b) = self.proxy_paint(stroke, item)?;
let vectorcraft_color::Paint::Gradient(g) = paint else { return None };
let mut geom = g.resolve(b);
if to_doc != Affine::IDENTITY {
geom.transform(to_doc, g.gradient.kind);
}
Some((g, geom))
}
/// The freeform gradient behind the Fill (or Stroke) proxy, its points in document coordinates
/// (the automatic ones while unplaced), and the length its spreads are fractions of there
/// (see [`Node::proxy_paint`]): what the Gradient tool's freeform annotator shows and edits.
pub fn proxy_freeform(&self, stroke: bool, item: Option<usize>) -> Option<(vectorcraft_color::Freeform, f64)> {
let (paint, to_doc, b) = self.proxy_paint(stroke, item)?;
let vectorcraft_color::Paint::Gradient(g) = paint else { return None };
if g.gradient.kind != vectorcraft_color::GradientKind::Freeform {
return None;
}
let mut f = g.freeform_on(b).into_owned();
let mut scale = vectorcraft_color::freeform::spread_scale(b);
if to_doc != Affine::IDENTITY {
f.transform(to_doc);
scale *= to_doc.determinant().abs().sqrt();
}
Some((f, scale))
}
}
impl Node {
/// Whether the children of this group or layer knock each other out, inside a group (or page)
/// where that is `enclosing`.
pub fn knocks_out(&self, enclosing: bool) -> bool {
matches!(self.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && self.knockout.resolve(enclosing)
}
/// A plain group or layer that is neutral to knockout and has no transparency or appearance
/// of its own: inside a knockout group its children composite as the group's own children.
pub fn passes_knockout_through(&self) -> bool {
matches!(self.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { template: false, clip: false, .. })
&& self.has_default_transparency()
&& self.appearance.items.is_empty()
&& self.appearance.effects.is_empty()
}
/// The visible elements of a knockout group made of `children` (bottom first): neutral plain
/// groups ([`Self::passes_knockout_through`]) contribute their children instead of themselves.
pub fn knockout_elements(children: &[Arc<Node>]) -> Vec<&Arc<Node>> {
fn push<'a>(children: &'a [Arc<Node>], out: &mut Vec<&'a Arc<Node>>) {
for c in children.iter().filter(|c| c.visible) {
match c.children() {
Some(ch) if c.passes_knockout_through() => push(ch, out),
_ => out.push(c),
}
}
}
let mut out = Vec::with_capacity(children.len());
push(children, &mut out);
out
}
/// Whether this is a clip group or a layer with a clipping mask: its first child clips the rest.
pub fn clips(&self) -> bool {
matches!(self.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. })
}
/// Make this group or layer clip its children by its first child, or stop it.
pub fn set_clips(&mut self, on: bool) {
if let NodeKind::Group { clip, .. } | NodeKind::Layer { clip, .. } = &mut self.kind {
*clip = on;
}
}
/// Whether blending inside this object reaches the art below it unless the object isolates
/// it: a blend mode other than Normal on a child (or, in a leaf, on a fill or stroke),
/// directly or through children that don't isolate their own blending.
pub fn blends_through(&self) -> bool {
self.blends_through_with(&mut |c| c.blends_through())
}
/// [`Self::blends_through`], with `inner` answering it for the children (e.g. from a cache).
pub fn blends_through_with(&self, inner: &mut dyn FnMut(&Arc<Node>) -> bool) -> bool {
match self.children() {
Some(ch) if !matches!(self.kind, NodeKind::Compound { .. }) => Self::children_blend(ch, inner),
_ => self.appearance.items.iter().any(|i| i.visible() && i.blend() != BlendMode::Normal),
}
}
/// [`Self::blends_through`] of a group made of `children` (`inner` answers it for each child).
pub fn children_blend(children: &[Arc<Node>], inner: &mut dyn FnMut(&Arc<Node>) -> bool) -> bool {
children.iter().any(|c| c.visible && (c.blend != BlendMode::Normal || (!c.isolate && inner(c))))
}
}
impl Node {
/// The Appearance panel's row for what this object's own fills and strokes paint around:
/// "Contents" for a group or layer (its members) and for an image (its pixels), "Characters"
/// for type; `None` for objects whose fills and strokes paint their own geometry.
pub fn contents_label(&self) -> Option<&'static str> {
match self.kind {
NodeKind::Group { .. } | NodeKind::Layer { .. } | NodeKind::Image(_) => Some("Contents"),
NodeKind::Text(_) => Some("Characters"),
_ => None,
}
}
}
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.
pub fn shows_transparency(&self) -> 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);
self.visible
&& (self.opacity < 1.0
|| self.blend != BlendMode::Normal
|| self.extras().mask.as_ref().is_some_and(|m| !m.disabled)
|| 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())))
}
}
/// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)?
fn keeps_gradient_fit(a: Affine) -> bool {
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
let eps = 1e-12 * m0.abs().max(1.0);
m0 > 0.0 && m1.abs() <= eps && m2.abs() <= eps && (m0 - m3).abs() <= eps
}
impl Node {
/// Whether a document point lies in this object's filled regions ([`Node::clip_shapes`]; true
/// everywhere for objects without them, such as type, whose boxes stand in).
pub fn contains_fn(&self) -> impl Fn(Point) -> bool + use<> {
let shapes = self.clip_shapes(None);
move |p| shapes.is_empty() || shapes.iter().any(|(bp, rule)| vectorcraft_geom::hit::fill_contains(bp, *rule, p))
}
}
+435
View File
@@ -0,0 +1,435 @@
use super::*;
/// A node writes its rarely set parts among the others, in the file's field order, and reads
/// them back into its extras; a node without any keeps none.
#[test]
fn extras_are_written_flat_in_order() {
let mut n = Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art());
n.name = Some("n".into());
n.knockout_shape = true;
n.bbox_angle = 30.0;
{
let mut e = n.extras_mut();
e.mask = Some(Box::new(OpacityMask::new(Node::group(NodeId(8), vec![]), true)));
e.trace = Some(Box::new(serde_json::json!({"preset": "p"})));
e.series_index = Some(2);
e.type_outlines = true;
e.graphic_style = Some(3);
e.attrs = Some(Box::default());
e.slice = Some(Box::default());
e.perspective = Some(Box::new(crate::PerspectiveAttachment::new("left", 1.0)));
e.live_paint_gaps = Some(Box::default());
}
/// The top-level keys of a JSON object, in the order written.
struct Keys(Vec<String>);
impl<'de> Deserialize<'de> for Keys {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct V;
impl<'de> serde::de::Visitor<'de> for V {
type Value = Keys;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("an object")
}
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut a: A) -> Result<Keys, A::Error> {
let mut out = vec![];
while let Some(k) = a.next_key::<String>()? {
a.next_value::<serde::de::IgnoredAny>()?;
out.push(k);
}
Ok(Keys(out))
}
}
d.deserialize_map(V)
}
}
let text = serde_json::to_string(&n).unwrap();
let keys = serde_json::from_str::<Keys>(&text).unwrap().0;
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
let order = [
"id",
"name",
"knockout_shape",
"appearance",
"mask",
"trace",
"series_index",
"type_outlines",
"kind",
"graphic_style",
"attrs",
"slice",
"bbox_angle",
"perspective",
"live_paint_gaps",
];
assert_eq!(keys, order);
assert_eq!(serde_json::from_value::<Node>(v).unwrap(), n);
let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art());
let back: Node = serde_json::from_value(serde_json::to_value(&plain).unwrap()).unwrap();
assert!(back.extras_box().is_none());
// Clearing the last extra lets them go; clearing one a node doesn't have allocates none.
n.extras_mut().mask = None;
assert!(n.extras_box().is_some());
*n.extras_mut() = NodeExtras::default();
assert!(n.extras_box().is_none());
n.extras_mut().mask = None;
assert!(n.extras_box().is_none());
assert_eq!(n.extras(), &NodeExtras::default());
}
#[test]
fn rect_live_shape_roundtrip() {
let l = LiveShape::Rectangle {
w: 10.0,
h: 20.0,
radii: [0.0; 4],
kinds: Default::default(),
radii_relative: false,
xf: Affine::translate((5.0, 5.0)),
};
assert_eq!(l.to_path().bounds(), Some(Rect::new(5.0, 5.0, 15.0, 25.0)));
assert_eq!(l.label(), "Rectangle");
}
#[test]
fn corner_kinds_save_only_when_some_corner_isnt_round() {
let mut l = LiveShape::Rectangle {
w: 100.0,
h: 50.0,
radii: [0.0, 10.0, 0.0, 0.0],
kinds: Default::default(),
radii_relative: false,
xf: Affine::IDENTITY,
};
// Older files read as round.
let json = serde_json::to_value(&l).unwrap();
assert!(json.get("kinds").is_none(), "{json}");
assert_eq!(serde_json::from_value::<LiveShape>(json).unwrap(), l);
if let LiveShape::Rectangle { kinds, .. } = &mut l {
kinds[1] = CornerKind::Chamfer;
}
let json = serde_json::to_value(&l).unwrap();
assert_eq!(json["kinds"], serde_json::json!(["round", "chamfer", "round", "round"]));
assert_eq!(serde_json::from_value::<LiveShape>(json).unwrap(), l);
// Polygons saved before Live Corners cut theirs read as sharp; a live path round-trips.
let p: LiveShape = serde_json::from_value(serde_json::json!({"shape": "polygon", "radius": 10.0, "sides": 5, "xf": [1, 0, 0, 1, 0, 0]})).unwrap();
assert_eq!(p, LiveShape::Polygon { radius: 10.0, sides: 5, xf: Affine::IDENTITY, radii: vec![], kinds: vec![], radii_relative: false });
let star = shapes::star(Point::new(0.0, 0.0), 20.0, 10.0, 5, 0.0);
let path = LiveShape::Path { base: star, radii: vec![2.0; 10], kinds: vec![], radii_relative: false };
let json = serde_json::to_value(&path).unwrap();
assert_eq!((json["shape"].as_str(), json.get("kinds")), (Some("path"), None));
assert_eq!(serde_json::from_value::<LiveShape>(json).unwrap(), path);
assert_eq!(path.label(), "Path");
}
/// A polygon read from a file has 3 to 1000 sides, as the commands give it: selecting one
/// with four billion made its outline and corner lists allocate tens of gigabytes.
#[test]
fn a_polygon_from_a_file_has_3_to_1000_sides() {
let read = |sides: u32| {
let json = serde_json::json!({"shape": "polygon", "radius": 10.0, "sides": sides, "xf": [1, 0, 0, 1, 0, 0]});
serde_json::from_value::<LiveShape>(json).unwrap()
};
let sides_of = |l: &LiveShape| match l {
LiveShape::Polygon { sides, .. } => *sides,
_ => panic!("not a polygon: {l:?}"),
};
let huge = read(4_000_000_000);
assert_eq!([0, 5, 1000].map(|n| sides_of(&read(n))), [3, 5, 1000]);
assert_eq!(sides_of(&huge), 1000);
assert_eq!(huge.polygon_outline().anchor_count(), 1000);
}
#[test]
fn a_polygon_reports_and_takes_its_radius_angle_and_equal_sides() {
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
// A hexagon of radius 10 at (50, 50), drawn 2× and turned 30° clockwise on the page.
let xf = Affine::translate((50.0, 50.0)) * Affine::rotate(30f64.to_radians()) * Affine::scale(2.0);
let mut p = LiveShape::Polygon { radius: 10.0, sides: 6, xf, radii: vec![], kinds: vec![], radii_relative: false };
assert!(close(p.polygon_radius().unwrap(), 20.0) && close(p.polygon_angle().unwrap(), 330.0) && p.polygon_sides_equal());
p.set_polygon_angle(45.0);
assert!(close(p.polygon_angle().unwrap(), 45.0));
let centre = p.to_path().bounds().unwrap().center();
assert!(close(centre.x, 50.0) && close(centre.y, 50.0), "turned about its centre: {centre:?}");
p.set_polygon_radius(30.0);
assert!(close(p.polygon_radius().unwrap(), 30.0));
// A hexagon's side is as long as its radius.
assert!(close(p.polygon_radius_for_side(12.0).unwrap(), 12.0));
// Stretched, its sides differ; Make Sides Equal keeps its centre, angle and mean radius.
p.transform(Affine::scale_non_uniform(2.0, 1.0));
assert!(!p.polygon_sides_equal());
let (r, a) = (p.polygon_radius().unwrap(), p.polygon_angle().unwrap());
p.make_sides_equal();
assert!(p.polygon_sides_equal() && close(p.polygon_radius().unwrap(), r) && close(p.polygon_angle().unwrap(), a));
let LiveShape::Polygon { xf, .. } = &p else { panic!("polygon") };
assert_eq!(*xf * Point::ZERO, Point::new(100.0, 50.0));
// Other shapes have none of it.
let e = LiveShape::Ellipse { w: 1.0, h: 1.0, pie: (0.0, 360.0), xf: Affine::IDENTITY };
assert_eq!((e.polygon_radius(), e.polygon_angle()), (None, None));
}
/// A node holding a live rectangle (`w` × `h`, all radii `r`, placed by `xf`) and its path.
fn live_rect(w: f64, h: f64, r: f64, xf: Affine) -> Node {
let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), radii_relative: false, xf };
let mut n = Node::path(NodeId(1), live.to_path(), Appearance::default_art());
if let NodeKind::Path { live: slot, .. } = &mut n.kind {
*slot = Some(Box::new(live));
}
n
}
fn live_of(n: &Node) -> (f64, f64, [f64; 4], Affine) {
let NodeKind::Path { live: Some(l), .. } = &n.kind else { panic!("not a live shape") };
let LiveShape::Rectangle { w, h, radii, xf, .. } = &**l else { panic!("not a live rectangle") };
(*w, *h, *radii, *xf)
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
fn near_xf(a: Affine, b: Affine) -> bool {
a.as_coeffs().iter().zip(b.as_coeffs()).all(|(x, y)| near(*x, y))
}
/// Size and radius of a live rectangle, within rounding.
fn assert_live(n: &Node, w: f64, h: f64, r: f64) {
let (lw, lh, radii, _) = live_of(n);
assert!(near(lw, w) && near(lh, h) && radii.iter().all(|x| near(*x, r)), "{lw} × {lh} r {radii:?}, want {w} × {h} r {r}");
}
/// Each corner arc of a rounded rectangle's path, mapped by `back` (to undo a rotation),
/// spans `r` along x and y: the corner is a circular arc of radius `r`.
fn assert_round_corners(n: &Node, back: Affine, r: f64) {
let NodeKind::Path { path, .. } = &n.kind else { panic!("not a path") };
let a: Vec<Point> = path.subpaths[0].anchors.iter().map(|a| back * a.p).collect();
assert_eq!(a.len(), 8, "a rounded rectangle");
// Anchors 1→2, 3→4, 5→6 and 7→0 are the arcs.
for (i, j) in [(1, 2), (3, 4), (5, 6), (7, 0)] {
let (x, y) = ((a[j].x - a[i].x).abs(), (a[j].y - a[i].y).abs());
assert!(near(x, r) && near(y, r), "corner spans {x} × {y}, want {r} × {r}");
}
}
const KEEP_CORNERS: Scaling = Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: true, patterns: false };
#[test]
fn uneven_scales_keep_live_corners_circular() {
// A 100 pt rounded square at (10, 10) stretched to 300 × 100 (#291).
let mut n = live_rect(100.0, 100.0, 20.0, Affine::translate((10.0, 10.0)));
let stretch = Affine::translate((10.0, 10.0)) * Affine::scale_non_uniform(3.0, 1.0) * Affine::translate((-10.0, -10.0));
n.transform(stretch, KEEP_CORNERS);
assert_live(&n, 300.0, 100.0, 20.0);
assert!(near_xf(live_of(&n).3, Affine::translate((10.0, 10.0))), "the scale went into the size");
let b = n.geometric_bounds().unwrap();
assert!(near(b.x0, 10.0) && near(b.y0, 10.0) && near(b.x1, 310.0) && near(b.y1, 110.0), "{b:?}");
assert_round_corners(&n, Affine::IDENTITY, 20.0);
// Scale Corners on: the radius scales by the mean scale, still circular.
let mut n = live_rect(100.0, 100.0, 20.0, Affine::IDENTITY);
n.transform(Affine::scale_non_uniform(4.0, 1.0), Scaling::default());
assert_live(&n, 400.0, 100.0, 40.0);
assert_round_corners(&n, Affine::IDENTITY, 40.0);
// Squashed below the radius, the short sides are fully round; stretched back, it returns.
n.transform(Affine::scale_non_uniform(1.0, 0.5), KEEP_CORNERS);
assert_round_corners(&n, Affine::IDENTITY, 25.0);
n.transform(Affine::scale_non_uniform(1.0, 2.0), KEEP_CORNERS);
assert_round_corners(&n, Affine::IDENTITY, 40.0);
}
#[test]
fn rotated_and_reflected_live_rectangles_scale_along_their_own_sides() {
let place = Affine::translate((200.0, 100.0)) * Affine::rotate(30f64.to_radians());
let mut n = live_rect(100.0, 50.0, 10.0, place);
// Stretched along its own width, as a bounding-box drag of a rotated shape does.
n.transform(place * Affine::scale_non_uniform(2.0, 1.0) * place.inverse(), KEEP_CORNERS);
assert_live(&n, 200.0, 50.0, 10.0);
assert!(near_xf(live_of(&n).3, place));
assert_round_corners(&n, place.inverse(), 10.0);
// A reflection stays in `xf`; the corners stay as they were.
n.transform(Affine::scale_non_uniform(-1.0, 1.0), KEEP_CORNERS);
assert_live(&n, 200.0, 50.0, 10.0);
let xf = live_of(&n).3;
assert!(near(xf.determinant(), -1.0));
assert_round_corners(&n, xf.inverse(), 10.0);
}
#[test]
fn shears_and_moves_keep_the_old_transform() {
// Scaling a rotated rectangle along the page axes shears it: `xf` takes it all, as before.
let rot = Affine::rotate(30f64.to_radians());
let mut n = live_rect(100.0, 50.0, 10.0, rot);
let before = n.clone();
let a = Affine::scale_non_uniform(2.0, 1.0);
n.transform(a, Scaling::default());
assert_eq!(live_of(&n), (100.0, 50.0, [10.0; 4], a * rot));
let (NodeKind::Path { path, .. }, NodeKind::Path { path: old, .. }) = (&n.kind, &before.kind) else { panic!() };
assert_eq!(*path, old.transformed(a));
// A file saved with an uneven scale in `xf` keeps its exact geometry when moved...
let legacy = Affine::translate((5.0, 5.0)) * Affine::scale_non_uniform(2.0, 1.0);
let mut n = live_rect(100.0, 100.0, 20.0, legacy);
n.transform(Affine::translate((10.0, 0.0)), KEEP_CORNERS);
assert_eq!(live_of(&n), (100.0, 100.0, [20.0; 4], Affine::translate((10.0, 0.0)) * legacy));
// ...and once scaled, has document units and circular corners of its mean radius.
n.transform(Affine::scale(2.0), Scaling::default());
assert_live(&n, 400.0, 200.0, 20.0 * 2f64.sqrt() * 2.0);
assert!(near(live_of(&n).3.determinant(), 1.0));
assert_round_corners(&n, Affine::IDENTITY, 20.0 * 2f64.sqrt() * 2.0);
}
/// A rectangle saved with an uneven scale in `xf` (before #291) has elliptical corners; Live
/// Corners see and edit it in document units, with circular corners (#442).
#[test]
fn a_scale_left_in_the_transform_folds_into_the_size() {
let legacy = Affine::translate((5.0, 5.0)) * Affine::scale_non_uniform(4.0, 1.0);
let n = live_rect(50.0, 80.0, 20.0, legacy);
let NodeKind::Path { live: Some(live), .. } = &n.kind else { panic!("not live") };
let folded = live.folded();
assert_eq!(live_of(&n).3, legacy, "folded() leaves the shape alone");
let LiveShape::Rectangle { w, h, radii, xf, .. } = &folded else { panic!("not a rectangle") };
// Drawn 200 × 80 with 80 × 20 corners; folded, the corners are circles of the mean radius.
assert!(near(*w, 200.0) && near(*h, 80.0) && radii.iter().all(|r| near(*r, 40.0)), "{w} × {h} r {radii:?}");
assert!(near_xf(*xf, Affine::translate((5.0, 5.0))));
let (a, b) = (folded.to_path().bounds().unwrap(), live.to_path().bounds().unwrap());
assert!(near(a.x0, b.x0) && near(a.y0, b.y0) && near(a.x1, b.x1) && near(a.y1, b.y1), "same place and size: {a:?} {b:?}");
let corners = crate::LiveCorners::of(&n).unwrap();
assert_eq!(corners.style(&corners.all()).0, Some(40.0), "the radius in document units");
// Nothing to fold: a moved, rotated or reflected rectangle and a sheared one stay as they are.
let place = Affine::translate((5.0, 5.0)) * Affine::rotate(0.5) * Affine::scale_non_uniform(-1.0, 1.0);
let shear = Affine::new([1.0, 0.0, 0.5, 1.0, 0.0, 0.0]);
for xf in [place, shear] {
let mut l = live_rect(100.0, 160.0, 20.0, xf);
let NodeKind::Path { live: Some(live), .. } = &mut l.kind else { panic!("not live") };
assert!(!live.fold_scale());
assert_eq!(live_of(&l), (100.0, 160.0, [20.0; 4], xf));
}
}
#[test]
fn default_transparency() {
let p = || Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
assert!(p().has_default_transparency());
let tweaks: [fn(&mut Node); 7] = [
|n| n.opacity = 0.5,
|n| n.blend = BlendMode::Multiply,
|n| n.isolate = true,
|n| n.knockout = Knockout::On,
|n| n.knockout = Knockout::Off,
|n| n.knockout_shape = true,
|n| n.extras_mut().mask = Some(Box::new(OpacityMask::new(n.clone(), true))),
];
for tweak in tweaks {
let mut n = p();
tweak(&mut n);
assert!(!n.has_default_transparency(), "{n:?}");
}
}
#[test]
fn transform_group_recurses() {
let p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
let mut g = Node::group(NodeId(1), vec![Arc::new(p)]);
g.transform(Affine::scale(2.0), true);
assert_eq!(g.geometric_bounds(), Some(Rect::new(0.0, 0.0, 20.0, 20.0)));
let child = &g.children().unwrap()[0];
assert_eq!(child.appearance.stroke_width(), 2.0);
}
#[test]
fn unplaced_gradients_pin_only_when_a_refit_would_differ() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint};
let grad = || Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
let fresh = || Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::basic(grad(), grad(), 2.0));
let geoms = |n: &Node| match (n.appearance.fill_paint(), n.appearance.stroke_paint()) {
(Paint::Gradient(f), Paint::Gradient(s)) => (f.geom, s.geom),
_ => panic!("gradient fill and stroke"),
};
let mut n = fresh();
n.transform(Affine::translate((10.0, 5.0)) * Affine::scale(2.0), true);
assert_eq!(geoms(&n), (None, None), "moves and uniform scales keep refitting");
let mut n = fresh();
n.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), false);
let (f, s) = geoms(&n);
// Fitted on the old bounds (strokes on the stroke-inflated box), then rotated: vertical.
let mut want = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 0.0);
want.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), GradientKind::Linear);
let f = f.unwrap();
assert!(f.start.distance(want.start) < 1e-9 && f.end.distance(want.end) < 1e-9, "{f:?}");
assert!((s.unwrap().length() - 102.0).abs() < 1e-9);
}
#[test]
fn display_names() {
let p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
assert_eq!(p.display_name(), "<Path>");
let l = Node::layer(NodeId(1), "Layer 1", LayerColor::Preset(0));
assert_eq!(l.display_name(), "Layer 1");
}
#[test]
fn visual_bounds_include_stroke() {
let mut p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
p.appearance.stroke_mut().unwrap().join = crate::appearance::LineJoin::Round;
assert_eq!(p.visual_bounds(), Some(Rect::new(-0.5, -0.5, 10.5, 10.5)));
}
/// Type's visual bounds grow by how far its character strokes paint past the glyphs: half the
/// weight centred, the whole weight outside, nothing inside; scaled with the type.
#[test]
fn type_visual_bounds_grow_with_the_character_stroke() {
use crate::appearance::StrokeAlign;
use crate::text::{CharStyle, TextObject};
let style = CharStyle { stroke: vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), stroke_width: 4.0, ..Default::default() };
let mut t = TextObject::point(vectorcraft_geom::Point::new(0.0, 0.0), "Ab", style);
t.xf = Affine::scale(2.0) * t.xf;
let node = |t: &TextObject| Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone())));
let g = node(&t).geometric_bounds().unwrap();
for (align, grow) in [(StrokeAlign::Center, 4.0), (StrokeAlign::Outside, 8.0), (StrokeAlign::Inside, 0.0)] {
t.runs[0].style.stroke_align = align;
assert_eq!(node(&t).visual_bounds(), Some(g.inflate(grow, grow)), "{align:?}");
}
t.runs[0].style.stroke = vectorcraft_color::Paint::None;
assert_eq!(node(&t).visual_bounds(), Some(g), "no stroke, no growth");
}
/// A group holding a path, a compound, an image and a nested group, as a file writes it.
fn nested_group() -> Node {
let path = |id| Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
let image = ImageObject { key: "k".into(), width: 2, height: 3, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
let inner = Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(path(6))], clip: true });
let compound = Node::new(NodeId(3), NodeKind::Compound { children: vec![Arc::new(path(4))], rule: FillRule::EvenOdd });
let kids = vec![Arc::new(path(2)), Arc::new(compound), Arc::new(Node::new(NodeId(7), NodeKind::Image(Box::new(image)))), Arc::new(inner)];
Node::new(NodeId(1), NodeKind::Group { children: kids, clip: false })
}
#[test]
fn kinds_read_back_with_type_first_or_anywhere() {
let n = nested_group();
let json = serde_json::to_string(&n).unwrap();
assert!(json.contains(r#""kind":{"type":"group","children":["#), "written with `type` first: {json}");
assert_eq!(serde_json::from_str::<Node>(&json).unwrap(), n);
// Through a JSON value, whose keys are sorted: `type` comes after `children`.
let v = serde_json::to_value(&n).unwrap();
assert_eq!(serde_json::from_value::<Node>(v.clone()).unwrap(), n);
assert_eq!(serde_json::from_str::<Node>(&v.to_string()).unwrap(), n);
// `type` last, in the middle, and keys a newer version added (ignored, as before).
let kind = r#"{"children":[],"x":{"deep":[1,2]},"clip":true,"type":"group"}"#;
assert_eq!(serde_json::from_str::<NodeKind>(kind).unwrap(), NodeKind::Group { children: vec![], clip: true });
let kind = r#"{"symbol":"S","type":"symbolinstance","later":1,"xf":[1,0,0,1,5,6]}"#;
assert_eq!(serde_json::from_str::<NodeKind>(kind).unwrap(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((5.0, 6.0)) });
let kind = r#"{"width":2,"type":"image","key":"k","height":3,"xf":[1,0,0,1,0,0]}"#;
assert!(matches!(serde_json::from_str::<NodeKind>(kind).unwrap(), NodeKind::Image(i) if i.key == "k" && i.height == 3));
// Defaults as before.
let NodeKind::Layer { printable, preview, template, .. } =
serde_json::from_str(r#"{"type":"layer","color":{"Preset":0},"children":[]}"#).unwrap()
else {
panic!("a layer")
};
assert!(printable && preview && !template);
}
#[test]
fn bad_kinds_are_errors() {
for bad in [r#"{"children":[]}"#, r#"{"type":"nothing"}"#, r#"{"type":"group"}"#, r#"{"type":3,"children":[]}"#, r#"[]"#, r#""group""#] {
assert!(serde_json::from_str::<NodeKind>(bad).is_err(), "{bad}");
}
}
+181
View File
@@ -0,0 +1,181 @@
use super::attributes::ObjectAttributes;
use super::extras::{Extras, NO_EXTRAS, NodeExtras};
use super::ids::NodeId;
use super::kind::NodeKind;
use super::opacity::{Knockout, OpacityMask};
use super::*;
/// How a [`Node`] is written: the flat field list of the file format, in its order, borrowed from
/// the node and its extras (nothing is copied). The node reads back through `NodeWire`.
impl Serialize for Node {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use crate::skip::{is_default, is_one, is_true};
use serde::ser::SerializeStruct;
let e = self.extras();
// As the derive did: the fields written, then each field written or skipped in order.
let skips = [
false,
self.name.is_none(),
is_true(&self.visible),
is_default(&self.locked),
is_one(&self.opacity),
is_default(&self.blend),
is_default(&self.isolate),
is_default(&self.knockout),
is_default(&self.knockout_shape),
is_default(&self.appearance),
e.mask.is_none(),
e.trace.is_none(),
e.wrap.is_none(),
e.graph.is_none(),
e.series_index.is_none(),
is_default(&e.type_outlines),
e.shaper.is_none(),
false,
e.graphic_style.is_none(),
e.attrs.is_none(),
e.slice.is_none(),
is_default(&self.bbox_angle),
e.perspective.is_none(),
e.dimension.is_none(),
e.intertwine.is_none(),
e.live_paint_gaps.is_none(),
e.objects_on_path.is_none(),
];
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
let mut skip = skips.into_iter();
macro_rules! field {
($name:literal, $v:expr) => {
if skip.next().unwrap_or(false) {
st.skip_field($name)?;
} else {
st.serialize_field($name, $v)?;
}
};
}
field!("id", &self.id);
field!("name", &self.name);
field!("visible", &self.visible);
field!("locked", &self.locked);
field!("opacity", &self.opacity);
field!("blend", &self.blend);
field!("isolate", &self.isolate);
field!("knockout", &self.knockout);
field!("knockout_shape", &self.knockout_shape);
field!("appearance", &self.appearance);
field!("mask", &e.mask);
field!("trace", &e.trace);
field!("wrap", &e.wrap);
field!("graph", &e.graph);
field!("series_index", &e.series_index);
field!("type_outlines", &e.type_outlines);
field!("shaper", &e.shaper);
field!("kind", &self.kind);
field!("graphic_style", &e.graphic_style);
field!("attrs", &e.attrs);
field!("slice", &e.slice);
field!("bbox_angle", &self.bbox_angle);
field!("perspective", &e.perspective);
field!("dimension", &e.dimension);
field!("intertwine", &e.intertwine);
field!("live_paint_gaps", &e.live_paint_gaps);
field!("objects_on_path", &e.objects_on_path);
st.end()
}
}
/// How a [`Node`] is read: the flat field list of the file format. Fields move into the node
/// (and its extras, allocated only when one is set) without a copy of what they own.
#[derive(Deserialize)]
#[serde(rename = "Node")]
pub(super) struct NodeWire {
id: NodeId,
#[serde(default)]
name: Option<String>,
#[serde(default = "yes")]
visible: bool,
#[serde(default)]
locked: bool,
#[serde(default = "one")]
opacity: f32,
#[serde(default)]
blend: BlendMode,
#[serde(default)]
isolate: bool,
#[serde(default)]
knockout: Knockout,
#[serde(default)]
knockout_shape: bool,
#[serde(default)]
appearance: crate::Shared<Appearance>,
#[serde(default)]
mask: Option<Box<OpacityMask>>,
#[serde(default)]
trace: Option<Box<serde_json::Value>>,
#[serde(default)]
wrap: Option<crate::text::TextWrap>,
#[serde(default)]
graph: Option<Box<crate::graph::GraphSpec>>,
#[serde(default)]
series_index: Option<u32>,
#[serde(default)]
type_outlines: bool,
#[serde(default)]
shaper: Option<Box<crate::shaper::ShaperSpec>>,
kind: NodeKind,
#[serde(default)]
graphic_style: Option<u32>,
#[serde(default)]
attrs: Option<Box<ObjectAttributes>>,
#[serde(default)]
slice: Option<Box<crate::SliceOptions>>,
#[serde(default)]
bbox_angle: f64,
#[serde(default)]
perspective: Option<Box<crate::PerspectiveAttachment>>,
#[serde(default)]
dimension: Option<Box<crate::dimension::Dimension>>,
#[serde(default)]
intertwine: Option<Box<PathData>>,
#[serde(default)]
live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
#[serde(default)]
objects_on_path: Option<Box<crate::objects_on_path::ObjectsOnPath>>,
}
impl From<NodeWire> for Node {
fn from(w: NodeWire) -> Self {
let extras = NodeExtras {
mask: w.mask,
trace: w.trace,
wrap: w.wrap,
graph: w.graph,
series_index: w.series_index,
type_outlines: w.type_outlines,
shaper: w.shaper,
graphic_style: w.graphic_style,
attrs: w.attrs,
slice: w.slice,
perspective: w.perspective,
dimension: w.dimension,
intertwine: w.intertwine,
live_paint_gaps: w.live_paint_gaps,
objects_on_path: w.objects_on_path,
};
Node {
id: w.id,
name: w.name,
visible: w.visible,
locked: w.locked,
opacity: w.opacity,
blend: w.blend,
isolate: w.isolate,
knockout: w.knockout,
knockout_shape: w.knockout_shape,
appearance: w.appearance,
kind: w.kind,
bbox_angle: w.bbox_angle,
extras: Extras((extras != NO_EXTRAS).then(|| Box::new(extras))),
}
}
}
+4 -2
View File
@@ -142,8 +142,10 @@ impl Document {
let ids: Vec<NodeId> = list.as_array().into_iter().flatten().filter_map(|v| v.as_u64()).map(NodeId).collect();
// The old list marked spot-linked blacks too (so the spot names don't matter).
let op = OverprintBlack { spot: true, ..Default::default() };
for id in ids {
if let Some(n) = self.node_mut(id) {
// One walk of the tree finds them all (a missing id costs a walk each, and a list can repeat it).
let paths = self.index_paths(ids);
for path in paths.values() {
if let Some(n) = self.node_mut_at(path) {
op.apply(n, &[]);
}
}
+9 -2
View File
@@ -632,7 +632,12 @@ impl RepeatSpec {
/// Where the instances go, counting toward the budget the Repeats in the art of the symbols
/// of `doc` that instances in the source show (none without a document).
pub fn transforms(&self, doc: Option<&Document>) -> Vec<Affine> {
let mut v = repeat_transforms(&self.kind, self.source_bounds());
self.transforms_from(doc, self.source_bounds())
}
/// [`Self::transforms`] with the source's bounds (as [`Self::source_bounds`] measures them)
/// already at hand: measuring them again, at every level of nested Repeats, doubles the work.
fn transforms_from(&self, doc: Option<&Document>, bounds: Option<Rect>) -> Vec<Affine> {
let mut v = repeat_transforms(&self.kind, bounds);
// Each instance draws the Repeats in the source again, so nested ones multiply (a 500 × 500
// grid of one is 62.5 billion instances): keep the first that fit the budget.
v.truncate(self.kept(NestedCount::new(doc).nodes(&self.source)));
@@ -681,7 +686,9 @@ impl RepeatSpec {
/// The bounds of all instances of a source measuring `source`, each placed by its transform
/// ([`Self::transforms`] for `doc`).
pub(crate) fn copies_bounds(&self, source: Rect, doc: Option<&Document>) -> Option<Rect> {
self.transforms(doc).into_iter().fold(None, |acc, m| vectorcraft_geom::union_opt(acc, Some(m.transform_rect_bbox(source))))
// Without a document the source's bounds are the ones `source` is: not measured twice.
let transforms = if doc.is_none() { self.transforms_from(None, Some(source)) } else { self.transforms(doc) };
transforms.into_iter().fold(None, |acc, m| vectorcraft_geom::union_opt(acc, Some(m.transform_rect_bbox(source))))
}
/// Apply a document transform (source + arrangement parameters).
pub fn transform(&mut self, a: Affine, scaling: impl Into<crate::node::Scaling>) {
+2 -1
View File
@@ -17,6 +17,7 @@ pub fn parse_range(s: &str, count: usize) -> Result<Vec<usize>, String> {
.ok_or_else(|| format!("range `{s}`: `{t}` is not a number from 1 to {count}"))
};
let mut out = vec![];
let mut seen = std::collections::HashSet::new();
for part in s.split(',').map(str::trim).filter(|p| !p.is_empty()) {
let (a, b) = match part.replace('\u{2013}', "-").split_once('-') {
Some((a, b)) if a.trim().is_empty() && b.trim().is_empty() => return Err(format!("range `{s}`: `{part}` names no number")),
@@ -30,7 +31,7 @@ pub fn parse_range(s: &str, count: usize) -> Result<Vec<usize>, String> {
return Err(format!("range `{s}`: `{part}` runs backwards"));
}
for i in a - 1..b {
if !out.contains(&i) {
if seen.insert(i) {
out.push(i);
}
}
+123
View File
@@ -0,0 +1,123 @@
use super::*;
/// New art on a locked artboard ([`Document::new_art`]). Setting it changes the document in
/// place, so a selection change that sets it doesn't replace the document.
#[derive(Default)]
pub struct NewArtIds(pub(crate) std::sync::RwLock<Arc<std::collections::HashSet<NodeId>>>);
impl NewArtIds {
/// The objects.
pub fn get(&self) -> Arc<std::collections::HashSet<NodeId>> {
self.0.read().map(|g| g.clone()).unwrap_or_default()
}
/// Make `ids` the objects.
pub fn set(&self, ids: Arc<std::collections::HashSet<NodeId>>) {
if let Ok(mut g) = self.0.write() {
*g = ids;
}
}
}
impl Clone for NewArtIds {
fn clone(&self) -> Self {
NewArtIds(std::sync::RwLock::new(self.get()))
}
}
/// Editing state, not document content: two documents are equal whatever new art they name.
impl PartialEq for NewArtIds {
fn eq(&self, _: &Self) -> bool {
true
}
}
impl std::fmt::Debug for NewArtIds {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("NewArtIds").field(&self.get()).finish()
}
}
/// Opacity-mask editing mode: `object`'s mask art lives on the temporary `layer` while editing.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct MaskEdit {
pub object: NodeId,
pub layer: NodeId,
}
/// A saved view (View → New View…): zoom, centre and rotation, listed at the bottom of the View menu.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SavedView {
pub name: String,
pub center: Point,
pub zoom: f64,
#[serde(default)]
pub rotation: f64,
}
impl SavedView {
/// Most views a document keeps (the View menu lists every one).
pub const MAX: usize = 25;
/// Longest name, in characters.
pub const MAX_NAME: usize = 255;
/// `name` trimmed and cut to [`Self::MAX_NAME`] characters (`None` when blank).
pub fn clean_name(name: &str) -> Option<String> {
let name: String = name.trim().chars().take(Self::MAX_NAME).collect();
(!name.is_empty()).then_some(name)
}
/// The first "View N" none of `saved` is named.
pub fn default_name(saved: &[SavedView]) -> String {
(1..=saved.len() + 1).map(|i| format!("View {i}")).find(|n| !saved.iter().any(|x| &x.name == n)).unwrap_or_default()
}
/// Can the canvas show this view: a finite centre and a finite, positive zoom.
pub fn is_usable(&self) -> bool {
self.center.x.is_finite() && self.center.y.is_finite() && self.zoom.is_finite() && self.zoom > 0.0
}
}
/// A saved selection (Select → Save Selection…): the objects that were selected, listed by name at
/// the bottom of the Select menu.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SavedSelection {
pub name: String,
pub objects: Vec<NodeId>,
}
impl SavedSelection {
/// Most saved selections a document keeps (the Select menu lists every one).
pub const MAX: usize = 25;
/// Longest name, in characters.
pub const MAX_NAME: usize = 255;
/// `name` trimmed and cut to [`Self::MAX_NAME`] characters (`None` when blank).
pub fn clean_name(name: &str) -> Option<String> {
let name: String = name.trim().chars().take(Self::MAX_NAME).collect();
(!name.is_empty()).then_some(name)
}
/// The first "Selection N" none of `saved` is named.
pub fn default_name(saved: &[SavedSelection]) -> String {
(1..=saved.len() + 1).map(|i| format!("Selection {i}")).find(|n| !saved.iter().any(|x| &x.name == n)).unwrap_or_default()
}
}
/// A symbol definition.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Symbol {
pub name: String,
pub art: Arc<Node>,
}
/// Encoded image bytes (PNG/JPEG/WebP…) shared by image objects.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ImageBlob {
pub mime: String,
#[serde(skip)]
pub bytes: Arc<Vec<u8>>,
/// A linked image's low-resolution preview (PNG, see [`links`]): what a save writes when only
/// linked images show this blob, and what `bytes` hold while the linked file can't be read.
#[serde(skip)]
pub proxy: Option<Arc<Vec<u8>>>,
}
+2 -1
View File
@@ -212,7 +212,8 @@ pub struct DocSetup {
pub bleed: [f64; 4],
/// Show Images in Outline Mode.
pub outline_images: bool,
/// Highlight text whose font is missing (drawn in a substitute font).
/// The Highlight Substituted Fonts value older builds kept in Document Setup. Files keep it as
/// they were saved; the screen follows Preferences › Type › Highlight Substituted Fonts.
pub highlight_substituted_fonts: bool,
/// Highlight glyphs the font lacks (drawn from a fallback font).
pub highlight_substituted_glyphs: bool,
+15
View File
@@ -0,0 +1,15 @@
pub fn is_default<T: Default + PartialEq>(v: &T) -> bool {
*v == T::default()
}
pub fn is_true(v: &bool) -> bool {
*v
}
pub fn is_one(v: &f32) -> bool {
*v == 1.0
}
pub fn is_empty_vec<T>(v: &[T]) -> bool {
v.is_empty()
}
pub fn is_empty_map(v: &serde_json::Map<String, serde_json::Value>) -> bool {
v.is_empty()
}
+18
View File
@@ -274,6 +274,10 @@ pub fn auto_slices(region: Rect, cut: &[Rect]) -> Vec<Rect> {
return vec![];
}
let inside: Vec<Rect> = cut.iter().map(|r| region.intersect(*r)).filter(|r| valid_rect(*r)).collect();
// Every band scans every cut: past a bound the layout is refused, not truncated.
if inside.len() > MAX_AUTO_CUTS {
return vec![];
}
let mut ys: Vec<f64> = inside.iter().flat_map(|r| [r.y0, r.y1]).chain([region.y0, region.y1]).collect();
ys.sort_by(f64::total_cmp);
ys.dedup_by(|a, b| (*a - *b).abs() <= EPS);
@@ -298,6 +302,9 @@ pub fn auto_slices(region: Rect, cut: &[Rect]) -> Vec<Rect> {
// Spans continuing an open slice extend it; the others close.
let mut next = Vec::with_capacity(free.len());
for (a, b) in free {
if done.len() + open.len() + next.len() >= MAX_AUTO_SLICES {
break;
}
match open.iter().position(|r: &Rect| (r.x0 - a).abs() <= EPS && (r.x1 - b).abs() <= EPS) {
Some(i) => {
let mut r = open.swap_remove(i);
@@ -309,7 +316,18 @@ pub fn auto_slices(region: Rect, cut: &[Rect]) -> Vec<Rect> {
}
done.append(&mut open);
open = next;
// Crossing strips make a cell of every crossing: the layout stops, short, past a bound.
if done.len() + open.len() >= MAX_AUTO_SLICES {
break;
}
}
done.append(&mut open);
done.truncate(MAX_AUTO_SLICES);
done
}
/// The most slices automatic layout makes.
const MAX_AUTO_SLICES: usize = 10_000;
/// The most cut rectangles automatic layout works through.
const MAX_AUTO_CUTS: usize = 2_000;
+418
View File
@@ -0,0 +1,418 @@
use super::*;
use vectorcraft_geom::shapes;
mod lookups;
mod slices;
mod units;
/// Zooming in on an inch ruler divided the inch into tenths (#2827). Its steps below an inch
/// are binary fractions now, and the minor ticks between them stay binary too; every other
/// unit is still read in tenths.
#[test]
fn an_inch_ruler_steps_in_binary_fractions() {
let step = |u: Unit, zoom: f64| u.ruler_step(zoom);
// Every inch step at or below an inch is 1/2^k, never a tenth or a fifth.
for zoom in [1.0, 2.0, 5.0, 7.5, 10.0, 20.0, 40.0, 100.0, 400.0] {
let s = step(Unit::Inches, zoom);
if s <= 1.0 {
let k = s.recip();
assert_eq!(k, k.round(), "{s} at zoom {zoom} is 1/k for a whole k");
assert!((k.round() as u64).is_power_of_two(), "{s} at zoom {zoom} is 1/2^k");
}
// A label step always leaves at least 50 px, which is what the ladder is picked for.
assert!(s * Unit::Inches.points() * zoom >= 50.0, "zoom {zoom}");
}
// The steps the report named: tenths became quarters, eighths and sixteenths.
assert_eq!(step(Unit::Inches, 5.0), 0.25);
assert_eq!(step(Unit::Inches, 10.0), 0.125);
assert_eq!(step(Unit::Inches, 20.0), 0.0625);
// An inch and wider is unchanged.
assert_eq!(step(Unit::Inches, 1.0), 1.0);
// Labelling stops at a sixteenth, as the report says Illustrator's does, so zooming in
// further only adds ticks. A label therefore never needs more than the six characters of
// "0.0625", which the left ruler has room for when it spells one out a character per line.
for zoom in [20.0, 50.0, 100.0, 400.0, 1600.0] {
assert_eq!(step(Unit::Inches, zoom), 0.0625, "zoom {zoom}");
}
// The unlabelled ticks go on down to a hundred and twenty-eighth: the 32nds, 64ths and
// 128ths the report expects to appear unlabelled when zoomed in close.
assert_eq!(Unit::Inches.ruler_minor(0.0625), 1.0 / 128.0);
// The minor ticks: an eighth of the step on an inch ruler, a tenth elsewhere.
assert_eq!(Unit::Inches.ruler_minor(0.25), 0.03125);
assert_eq!(Unit::Millimeters.ruler_minor(25.0), 2.5);
assert_eq!(Unit::Points.ruler_minor(100.0), 10.0);
// Other units keep the decimal ladder, so a tenth and a fifth still appear.
assert!([0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0].contains(&step(Unit::Millimeters, 5.0)), "{}", step(Unit::Millimeters, 5.0));
}
fn doc_with_rects() -> (Document, NodeId, NodeId) {
let mut d = Document::new(612.0, 792.0);
let layer = d.layers[0].id;
let a = d.alloc_id();
d.insert(Some(layer), usize::MAX, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())).unwrap();
let b = d.alloc_id();
d.insert(Some(layer), usize::MAX, Node::path(b, shapes::rectangle(Rect::new(20.0, 0.0, 30.0, 10.0)), Appearance::default_art())).unwrap();
(d, a, b)
}
#[test]
fn new_document_has_layer_and_artboard() {
let d = Document::new(612.0, 792.0);
assert_eq!(d.layers.len(), 1);
assert_eq!(d.layers[0].display_name(), "Layer 1");
assert_eq!(d.artboards[0].rect, Rect::new(0.0, 0.0, 612.0, 792.0));
}
#[test]
fn art_on_artboard_takes_unlocked_objects_wholly_inside() {
let (mut d, a, b) = doc_with_rects();
let wide = Rect::new(-1.0, -1.0, 40.0, 15.0);
assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 15.0, 15.0), false), vec![a]);
assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 25.0, 15.0), false), vec![a], "b only half inside");
// Flush with the artboard's edges counts as inside: the right and bottom ones too (#890).
for board in [Rect::new(0.0, 0.0, 10.0, 10.0), Rect::new(-5.0, 0.0, 10.0, 15.0), Rect::new(0.0, -5.0, 15.0, 10.0)] {
assert_eq!(d.art_on_artboard(board, false), vec![a], "{board:?}");
}
d.node_mut(a).unwrap().locked = true;
assert_eq!(d.art_on_artboard(wide, false), vec![b]);
// Move Locked and Hidden Artwork with Artboard (#394): hidden art stays too, unless on.
d.node_mut(b).unwrap().visible = false;
assert!(d.art_on_artboard(wide, false).is_empty());
assert_eq!(d.art_on_artboard(wide, true), vec![a, b]);
Arc::make_mut(&mut d.layers[0]).locked = true;
assert!(d.art_on_artboard(wide, false).is_empty());
assert_eq!(d.art_on_artboard(wide, true), vec![a, b], "a locked layer's art too");
}
#[test]
fn art_on_artboard_tolerates_roundoff_at_each_edge() {
let (d, a, _) = doc_with_rects();
let roundoff = 1e-12;
for board in [
Rect::new(roundoff, 0.0, 10.0, 10.0),
Rect::new(0.0, roundoff, 10.0, 10.0),
Rect::new(0.0, 0.0, 10.0 - roundoff, 10.0),
Rect::new(0.0, 0.0, 10.0, 10.0 - roundoff),
] {
assert_eq!(d.art_on_artboard(board, false), vec![a], "roundoff at {board:?}");
}
// A real overhang remains outside.
for board in
[Rect::new(0.001, 0.0, 10.0, 10.0), Rect::new(0.0, 0.001, 10.0, 10.0), Rect::new(0.0, 0.0, 9.999, 10.0), Rect::new(0.0, 0.0, 10.0, 9.999)]
{
assert!(d.art_on_artboard(board, false).is_empty(), "overhang at {board:?}");
}
}
/// The art on a locked artboard (each object of a layer whose bounds overlap it, a group as one
/// object) can't be edited, and Select All, a click and a marquee pass over it; art flush with
/// the artboard from outside isn't on it, a layer is on no artboard, and new art isn't on one.
/// Unlocking the artboard gives the art back.
#[test]
fn art_on_a_locked_artboard_cannot_be_selected_or_edited() {
use crate::hit::{HitOptions, hit_test, marquee, objects_at};
let (mut d, a, b) = doc_with_rects();
let layer = d.layers[0].id;
d.artboards.push(Artboard {
id: 2,
name: "Artboard 2".into(),
rect: Rect::new(700.0, 0.0, 900.0, 200.0),
locked: false,
show_center_mark: false,
show_cross_hairs: false,
show_video_safe_areas: false,
video_ruler_pixel_aspect_ratio: 1.0,
color: None,
});
let rect = |d: &mut Document, r: Rect, appearance: Appearance| {
let id = d.alloc_id();
d.insert(Some(layer), usize::MAX, Node::path(id, shapes::rectangle(r), appearance)).unwrap();
id
};
let c = rect(&mut d, Rect::new(710.0, 10.0, 730.0, 30.0), Appearance::default_art());
// A group with one member on the artboard and one right of it.
let (g, m1, m2) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
let members = [(m1, 750.0), (m2, 950.0)]
.map(|(id, x)| Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, 50.0, x + 10.0, 60.0)), Appearance::default_art())));
d.insert(Some(layer), usize::MAX, Node::group(g, members.to_vec())).unwrap();
// Unstroked and flush with the artboard's left edge, from outside.
let e = rect(&mut d, Rect::new(680.0, 10.0, 700.0, 30.0), Appearance::default());
let opt = HitOptions::default();
let top = |d: &Document, x, y| hit_test(d, Point::new(x, y), opt).map(|h| h.top_object(None));
let all = Rect::new(-10.0, -10.0, 1000.0, 800.0);
assert_eq!(top(&d, 720.0, 20.0), Some(c));
d.artboards[1].locked = true;
for id in [c, g, m1, m2] {
assert!(d.on_locked_artboard(id) && !d.is_editable(id), "{id:?} is on the locked artboard");
}
for id in [a, b, e, layer] {
assert!(!d.on_locked_artboard(id) && d.is_editable(id), "{id:?} isn't");
}
assert_eq!(d.selectable_art(), [a, b, e]);
assert_eq!(top(&d, 720.0, 20.0), None, "a click passes over c");
assert_eq!(top(&d, 955.0, 55.0), None, "and over the group's member off the artboard");
let isolated = HitOptions { scope: Some(g), ..opt };
assert!(hit_test(&d, Point::new(755.0, 55.0), isolated).is_none(), "and inside the group isolated before the lock");
assert!(objects_at(&d, Point::new(720.0, 20.0), opt, None).is_empty());
assert_eq!(marquee(&d, all, None, false), [a, b, e]);
assert_eq!(marquee(&d, all, None, true), [a, b, e], "the Direct Selection tool's marquee too");
// Deselected in one pass, with the key object and the anchors.
let mut sel = Selection::default();
sel.set([a, c]);
sel.key = Some(c);
sel.anchors.insert(c, [(0, 0)].into());
sel.deselect_on_locked_artboards(&d);
assert_eq!((sel.objects.clone(), sel.key, sel.anchors.is_empty()), (vec![a], None, true));
// A targeted layer stays targeted, with its art off the locked artboard selected.
sel.set_target(&d, layer);
sel.deselect_on_locked_artboards(&d);
assert_eq!((sel.objects.clone(), sel.target), (vec![a, b, e], Some(layer)));
// Shift-clicking the layer's target circle removes its art off the locked artboard, and
// a second Shift-click adds it again.
sel.toggle_target(&d, layer);
sel.deselect_on_locked_artboards(&d);
assert_eq!((sel.objects.clone(), sel.target), (vec![], None));
sel.toggle_target(&d, layer);
sel.deselect_on_locked_artboards(&d);
assert_eq!(sel.objects, [a, b, e]);
// A sublayer with art on both artboards: its art off the locked artboard can still be clicked.
let (sub, s1, s2) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(2))).unwrap();
for (id, x, y) in [(s1, 300.0, 300.0), (s2, 800.0, 100.0)] {
d.insert(Some(sub), usize::MAX, Node::path(id, shapes::rectangle(Rect::new(x, y, x + 10.0, y + 10.0)), Appearance::default_art())).unwrap();
}
assert_eq!((top(&d, 305.0, 305.0), top(&d, 805.0, 105.0)), (Some(s1), None));
assert!(!d.on_locked_artboard(sub));
let mut both = Selection::default();
both.set([s1, s2]);
both.deselect_on_locked_artboards(&d);
assert_eq!(both.objects, [s1]);
// New art on it is editable and can be clicked while it is new art.
d.new_art.set(Arc::new([c].into()));
assert!(!d.on_locked_artboard(c) && d.is_editable(c), "new art");
assert_eq!(top(&d, 720.0, 20.0), Some(c));
d.new_art.set(Default::default());
d.artboards[1].locked = false;
assert_eq!(d.selectable_art(), [a, b, c, g, e, s1, s2]);
assert_eq!(top(&d, 955.0, 55.0), Some(g));
assert_eq!(marquee(&d, all, None, false), [a, b, c, g, e, s1, s2]);
}
#[test]
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) });
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.
assert_eq!(d.art_on_artboard(Rect::new(0.0, 0.0, 15.0, 15.0), false), vec![id]);
}
#[test]
fn artboard_at_counts_the_right_and_bottom_edges() {
// Artboard 0 is (0, 0) to (100, 100), artboard 1 is to its right and artboard 2 below it.
let mut d = Document::new(100.0, 100.0);
for rect in [Rect::new(100.0, 0.0, 200.0, 100.0), Rect::new(0.0, 100.0, 100.0, 200.0)] {
let mut a = d.artboards[0].clone();
a.rect = rect;
d.artboards.push(a);
}
let held = |d: &Document, x: f64, y: f64| (d.artboard_at(Point::new(x, y)), d.artboard_holding(Point::new(x, y), false));
// Each artboard's own right and bottom edges, edges with roundoff, and points beyond them.
assert_eq!(held(&d, 200.0, 50.0), (Some(1), Some(1)), "right edge");
assert_eq!(held(&d, 150.0, 100.0), (Some(1), Some(1)), "bottom edge");
assert_eq!(held(&d, 200.0, 100.0), (Some(1), Some(1)), "bottom right corner");
assert_eq!(held(&d, 100.0, 150.0), (Some(2), Some(2)), "right edge");
assert_eq!(held(&d, 50.0, 200.0 + 1e-9), (Some(2), Some(2)), "roundoff past the bottom edge");
assert_eq!(held(&d, -1e-9, 50.0), (Some(0), Some(0)), "roundoff past the left edge");
assert_eq!(held(&d, 200.001, 50.0), (None, None));
assert_eq!(held(&d, 50.0, 200.001), (None, None));
// On an edge two artboards share, the artboard to the right of it or below it holds the
// point, in either list order, except at the end of the edge that lies on that artboard's
// bottom or right edge.
assert_eq!(held(&d, 100.0, 50.0), (Some(1), Some(1)));
assert_eq!(held(&d, 50.0, 100.0), (Some(2), Some(2)));
// The bottom edges of artboards 0 and 1 meet at (100, 100), which is also artboard 2's top
// right corner: the first holds the point, or the last when `topmost`.
assert_eq!(held(&d, 100.0, 100.0), (Some(2), Some(0)), "shared corner");
d.artboards.swap(0, 1);
assert_eq!(held(&d, 100.0, 50.0), (Some(0), Some(0)));
// Where two artboards overlap, the topmost holds the point, or the first one when not
// `topmost`.
let mut inner = d.artboards[1].clone();
inner.rect = Rect::new(10.0, 10.0, 90.0, 90.0);
d.artboards.push(inner);
assert_eq!(held(&d, 50.0, 50.0), (Some(3), Some(1)));
assert_eq!(held(&d, 90.0, 50.0), (Some(1), Some(1)), "inside artboard 1, on the right edge of artboard 3");
}
#[test]
fn find_and_paths() {
let (d, a, b) = doc_with_rects();
assert_eq!(d.index_path(b), Some(vec![0, 1]));
assert_eq!(d.parent_of(a), Some(d.layers[0].id));
assert_eq!(d.layer_of(b), Some(d.layers[0].id));
assert!(d.node(NodeId(999)).is_none());
assert_eq!(d.node_count(), 3);
}
#[test]
fn copy_on_write_shares_untouched() {
let (d, a, b) = doc_with_rects();
let mut e = d.clone();
e.node_mut(a).unwrap().name = Some("A".into());
// b's Arc is shared between both versions; a's is not.
let lb = &d.layers[0].children().unwrap()[1];
let eb = &e.layers[0].children().unwrap()[1];
assert!(Arc::ptr_eq(lb, eb));
assert_eq!(d.node(a).unwrap().name, None);
assert_eq!(e.node(a).unwrap().name.as_deref(), Some("A"));
let _ = b;
}
#[test]
fn move_and_remove() {
let (mut d, a, b) = doc_with_rects();
let layer = d.layers[0].id;
d.move_node(a, Some(layer), usize::MAX).unwrap();
assert_eq!(d.index_path(a), Some(vec![0, 1]));
assert_eq!(d.index_path(b), Some(vec![0, 0]));
let r = d.remove(a).unwrap();
assert_eq!(r.id, a);
assert!(d.node(a).is_none());
assert!(d.move_node(layer, Some(layer), 0).is_err());
}
#[test]
fn layers_and_names() {
let mut d = Document::new(100.0, 100.0);
let l2 = d.add_layer(None);
assert_eq!(d.node(l2).unwrap().display_name(), "Layer 2");
assert_eq!(d.default_layer(), Some(l2));
assert_eq!(d.layer_color(l2), LAYER_COLORS[1].1);
}
/// #1718: a file whose ids or counters are at their type's largest value still hands out ids
/// nothing in it uses (and never 0), instead of overflowing.
#[test]
fn exhausted_ids_in_a_file_dont_run_out() {
let (d, a, b) = doc_with_rects();
let mut v = serde_json::to_value(&d).unwrap();
v["next_id"] = serde_json::json!(u64::MAX);
// Object `a` gets the largest id there is.
fn renumber(v: &mut serde_json::Value, from: u64) {
match v {
serde_json::Value::Object(o) => {
if o.get("id").and_then(serde_json::Value::as_u64) == Some(from) {
o.insert("id".into(), serde_json::json!(u64::MAX));
}
o.values_mut().for_each(|c| renumber(c, from));
}
serde_json::Value::Array(a) => a.iter_mut().for_each(|c| renumber(c, from)),
_ => {}
}
}
renumber(&mut v["layers"], a.0);
let mut d: Document = serde_json::from_value(v).unwrap();
assert!(d.node(NodeId(u64::MAX)).is_some(), "the file's largest id is kept");
d.fix_next_id();
let fresh: Vec<NodeId> = (0..3).map(|_| d.alloc_id()).collect();
assert!(fresh.iter().all(|id| d.node(*id).is_none() && id.0 > b.0 && id.0 < MAX_NEXT_ID), "{fresh:?}");
assert!(fresh[0] != fresh[1] && fresh[1] != fresh[2]);
// A counter left at the end by other means stays there rather than wrapping to 0.
d.next_id = u64::MAX;
assert_eq!((d.alloc_id(), d.peek_next_id()), (NodeId(u64::MAX), u64::MAX));
d.reserve_ids(u64::MAX);
// Artboards and graphic styles: one past the largest, else the smallest id free.
assert_eq!(free_id([1, 2, 7].into_iter()), 8);
assert_eq!(free_id([u32::MAX, 0, 1, 2, 4].into_iter()), 3);
assert_eq!(free_id([u32::MAX].into_iter()), 1);
d.artboards[0].id = u32::MAX;
assert_eq!(d.next_artboard_id(), 1);
}
#[test]
fn serde_roundtrip() {
let (d, _, _) = doc_with_rects();
let s = serde_json::to_string(&d).unwrap();
let back: Document = serde_json::from_str(&s).unwrap();
assert_eq!(back.node_count(), d.node_count());
assert_eq!(back.peek_next_id(), d.peek_next_id());
}
#[test]
fn reid_gives_fresh_ids() {
let (mut d, a, b) = doc_with_rects();
let g = d.alloc_id();
let na = (*d.remove(a).unwrap()).clone();
let nb = (*d.remove(b).unwrap()).clone();
let group = Node::group(g, vec![Arc::new(na), Arc::new(nb)]);
let copy = d.reid(&group);
assert_ne!(copy.id, g);
let ids: Vec<NodeId> = copy.children().unwrap().iter().map(|c| c.id).collect();
assert!(!ids.contains(&a) && !ids.contains(&b));
}
#[test]
fn setup_round_trips_and_old_files_load() {
let (mut d, _, _) = doc_with_rects();
// A document without a setup writes no `setup` key (old readers and files stay unchanged).
assert!(!serde_json::to_string(&d).unwrap().contains("\"setup\""));
d.setup.bleed = [9.0, 9.0, 0.0, 18.0];
d.setup.grid_size = GridSize::Large;
d.setup.typographers_quotes = false;
d.setup.quotes = setup::language_quotes("German").unwrap();
d.setup.flattener_preset = Some("High Resolution".into());
d.setup.background = Background::White;
d.setup.export_text = ExportText::Appearance;
let back: Document = serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap();
assert_eq!(back.setup, d.setup);
// Files written before Document Setup existed load with the defaults.
let mut v = serde_json::to_value(&d).unwrap();
v.as_object_mut().unwrap().remove("setup");
let old: Document = serde_json::from_value(v).unwrap();
assert_eq!(old.setup, DocSetup::default());
}
#[test]
fn char_position_round_trips() {
let st =
CharStyle { position: CharPosition::Subscript(ScriptMetrics { size: 50.0, position: 20.0 }), small_caps: Some(75.0), ..CharStyle::default() };
let v = serde_json::to_value(&st).unwrap();
assert_eq!(v["position"], serde_json::json!({"kind": "subscript", "size": 50.0, "position": 20.0}));
assert_eq!(serde_json::from_value::<CharStyle>(v).unwrap(), st);
let plain = serde_json::to_value(CharStyle::default()).unwrap();
assert!(plain.get("position").is_none() && plain.get("small_caps").is_none());
let (scale, shift) = CharPosition::Subscript(ScriptMetrics::DEFAULT).scale_shift(10.0);
assert!((scale - 0.583).abs() < 1e-9 && (shift + 3.33).abs() < 1e-9);
}
#[test]
fn units() {
assert_eq!(Unit::Inches.parse("1"), Some(72.0));
assert_eq!(Unit::Points.parse("1in"), Some(72.0));
assert_eq!(Unit::Points.parse("10 mm").map(|v| (v * 1000.0).round()), Some(28346.0));
assert_eq!(Unit::Points.parse("2p6"), Some(30.0));
assert_eq!(Unit::Points.parse("10+5"), Some(15.0));
assert_eq!(Unit::Points.parse("100/4"), Some(25.0));
assert_eq!(Unit::Points.parse("-5"), Some(-5.0));
assert_eq!(Unit::Points.parse("abc"), None);
assert_eq!(Unit::Points.format(12.5), "12.5 pt");
assert_eq!(Unit::Inches.format(144.0), "2 in");
}
#[test]
fn negative_picas_and_points_sign_applies_to_whole_value() {
assert_eq!(Unit::Points.parse("-2p6"), Some(-30.0));
assert_eq!(Unit::Points.parse("-0p6"), Some(-6.0));
assert_eq!(Unit::Points.parse("-2p"), Some(-24.0));
assert_eq!(Unit::Points.parse("2p6"), Some(30.0));
}
#[test]
fn pica_form_rejects_exponent_notation() {
for s in ["1e5p", "infp", "nanp", "infp3", "1p1e2"] {
assert_eq!(Unit::Points.parse(s), None, "{s}");
}
}
@@ -2,7 +2,7 @@
//! with the one-id ones on nested trees with missing and duplicated ids, and the edits of many nodes
//! ([`Document::remove_many`], `wrap`, `insert_beside`) leave the tree that `remove`, `insert` and
//! `move_node` leave one node at a time.
use super::*;
use crate::*;
use vectorcraft_geom::shapes;
/// Layers with paths and groups nested `depth` deep; every 7th id is reused (a damaged file), so
@@ -3,7 +3,7 @@
use proptest::prelude::*;
use super::Unit;
use crate::Unit;
#[test]
fn every_unit_round_trips_through_its_field_text() {
@@ -133,7 +133,7 @@ fn a_negative_operand_after_an_operator_is_a_sign() {
assert_eq!(Unit::Points.parse("5 - -3"), Some(8.0));
assert_eq!(Unit::Points.parse("5+-3"), Some(2.0));
assert_eq!(Unit::Points.parse("10-2"), Some(8.0));
assert_eq!(super::parse_number("90*-1"), Some(-90.0));
assert_eq!(crate::parse_number("90*-1"), Some(-90.0));
}
#[test]
+17 -2389
View File
File diff suppressed because it is too large Load Diff
+228
View File
@@ -0,0 +1,228 @@
use super::para_style::FirstBaseline;
use super::*;
/// Area Type Options "Align" (vertical): where the lines of each row/column sit in it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum VerticalAlign {
/// Lines start at the top of the cell (the default).
#[default]
Top,
/// The block of lines is centred in the cell.
Center,
/// The last line's descent touches the cell bottom.
Bottom,
/// The first line stays at the top, the last one moves to the bottom and the space left over
/// is shared equally between the lines (no paragraph spacing limit).
Justify,
}
impl VerticalAlign {
pub const ALL: [VerticalAlign; 4] = [VerticalAlign::Top, VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify];
pub fn id(self) -> &'static str {
match self {
VerticalAlign::Top => "top",
VerticalAlign::Center => "center",
VerticalAlign::Bottom => "bottom",
VerticalAlign::Justify => "justify",
}
}
}
/// Text Wrap Options of a wrap object (Object → Text Wrap).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct TextWrap {
/// Distance kept between the text and the object, in points (Illustrator's default 6 pt).
pub offset: f64,
/// Invert Wrap: text flows inside the object instead of around it.
pub invert: bool,
}
impl Default for TextWrap {
fn default() -> Self {
Self { offset: 6.0, invert: false }
}
}
/// A resolved wrap shape on an area text object: the outline of a wrap object in text space.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct WrapShape {
pub path: PathData,
#[serde(flatten)]
pub wrap: TextWrap,
}
/// Type on a Path effect: how each glyph is oriented on the path (Type → Type on a Path).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PathEffect {
/// Glyphs rotate with the path (the default).
#[default]
Rainbow,
/// Vertical edges stay vertical; the baseline follows the path.
Skew,
/// Horizontal edges stay horizontal; vertical edges are perpendicular to the path.
#[serde(rename = "3dRibbon")]
Ribbon3d,
/// No rotation: the left end of each glyph's baseline sits on the path.
StairStep,
/// The baseline centre sits on the path and glyphs point away from the path's centre.
Gravity,
}
impl PathEffect {
pub const ALL: [PathEffect; 5] = [PathEffect::Rainbow, PathEffect::Skew, PathEffect::Ribbon3d, PathEffect::StairStep, PathEffect::Gravity];
pub fn id(self) -> &'static str {
match self {
PathEffect::Rainbow => "rainbow",
PathEffect::Skew => "skew",
PathEffect::Ribbon3d => "3dRibbon",
PathEffect::StairStep => "stairStep",
PathEffect::Gravity => "gravity",
}
}
pub fn parse(s: &str) -> Option<Self> {
let k = s.to_ascii_lowercase().replace([' ', '-', '_'], "");
Self::ALL.into_iter().find(|e| e.id().to_ascii_lowercase() == k || (k == "ribbon3d" && *e == PathEffect::Ribbon3d))
}
}
/// Type on a Path Options › Align to Path: which height of the type runs along the path.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PathAlign {
/// The font's top edge: the type hangs below the path.
Ascender,
/// The font's bottom edge: the type stands above the path.
Descender,
/// Halfway between the ascender and the descender.
Center,
/// The baseline (the default).
#[default]
Baseline,
}
impl PathAlign {
pub const ALL: [PathAlign; 4] = [PathAlign::Ascender, PathAlign::Descender, PathAlign::Center, PathAlign::Baseline];
pub fn id(self) -> &'static str {
match self {
PathAlign::Ascender => "ascender",
PathAlign::Descender => "descender",
PathAlign::Center => "center",
PathAlign::Baseline => "baseline",
}
}
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|a| a.id().eq_ignore_ascii_case(s.trim()))
}
}
/// Area Type Options: rows and columns, gutters, inset and first baseline of area type.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct AreaOptions {
#[serde(deserialize_with = "de_rows_or_columns")]
pub rows: usize,
#[serde(deserialize_with = "de_rows_or_columns")]
pub columns: usize,
/// Gutter between rows/columns in points.
pub gutter: f64,
/// Inset from the frame edges in points.
pub inset: f64,
pub first_baseline: FirstBaseline,
/// Minimum first-baseline offset in points.
pub first_baseline_min: f64,
/// Vertical alignment of the lines in each row/column.
pub vertical_align: VerticalAlign,
/// 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,
}
impl Default for AreaOptions {
fn default() -> Self {
Self {
rows: 1,
columns: 1,
gutter: 18.0,
inset: 0.0,
first_baseline: FirstBaseline::Ascent,
first_baseline_min: 0.0,
vertical_align: VerticalAlign::Top,
fit: AreaFit::None,
}
}
}
impl AreaOptions {
/// The most rows, and the most columns, area type can have.
pub const MAX_ROWS_COLUMNS: usize = 100;
}
/// [`AreaOptions::rows`] or [`AreaOptions::columns`], read as a `u64` and clamped to
/// 1..=[`AreaOptions::MAX_ROWS_COLUMNS`].
fn de_rows_or_columns<'de, D: serde::Deserializer<'de>>(d: D) -> Result<usize, D::Error> {
let n = usize::try_from(u64::deserialize(d)?).unwrap_or(usize::MAX);
Ok(n.clamp(1, AreaOptions::MAX_ROWS_COLUMNS))
}
/// How area type and its frame fit each other. Serialized as `"none"`, `"autoHeight"` or
/// `{"shrinkText": {"minPercent": 50}}`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum AreaFit {
/// The frame keeps its size; text that doesn't fit overflows.
#[default]
None,
/// The frame's height follows the text (Illustrator's Auto Size): resolved by the engine after
/// each edit, for rectangular frames of horizontal type in one row.
AutoHeight,
/// Text that overflows is scaled down (size, leading and baseline shift; not paragraph
/// spacing) by the largest factor down to `min_percent` % that makes it fit, at layout time.
ShrinkText {
#[serde(rename = "minPercent", default = "AreaFit::default_min_percent")]
min_percent: f64,
},
}
impl AreaFit {
/// The smallest Shrink Text to Fit percentage allowed.
pub const MIN_PERCENT: f64 = 10.0;
/// The Shrink Text to Fit percentage new settings start with.
pub const DEFAULT_MIN_PERCENT: f64 = 50.0;
fn default_min_percent() -> f64 {
Self::DEFAULT_MIN_PERCENT
}
/// Its id: `none`, `autoHeight` or `shrinkText`.
pub fn id(self) -> &'static str {
match self {
AreaFit::None => "none",
AreaFit::AutoHeight => "autoHeight",
AreaFit::ShrinkText { .. } => "shrinkText",
}
}
/// The fit an id names (case, spaces, dashes and underscores ignored), with `min_percent`
/// for Shrink Text (clamped to 10..100; a non-finite value gives the default).
pub fn parse(id: &str, min_percent: Option<f64>) -> Option<Self> {
match id.to_ascii_lowercase().replace([' ', '-', '_'], "").as_str() {
"none" | "off" => Some(AreaFit::None),
"autoheight" | "autosize" => Some(AreaFit::AutoHeight),
"shrinktext" | "shrinktexttofit" | "shrink" => {
Some(AreaFit::ShrinkText { min_percent: Self::clamp_percent(min_percent.unwrap_or(Self::DEFAULT_MIN_PERCENT)) })
}
_ => None,
}
}
/// A Shrink Text minimum percentage within 10..100 (the default when not finite).
pub fn clamp_percent(p: f64) -> f64 {
if p.is_finite() { p.clamp(Self::MIN_PERCENT, 100.0) } else { Self::DEFAULT_MIN_PERCENT }
}
/// Shrink Text's minimum scale factor (0.1..1), if this is Shrink Text.
pub fn min_scale(self) -> Option<f64> {
match self {
AreaFit::ShrinkText { min_percent } => Some(Self::clamp_percent(min_percent) / 100.0),
_ => None,
}
}
}
+432
View File
@@ -0,0 +1,432 @@
use super::para_style::ParaDirection;
use super::*;
/// Character attributes (the Character panel).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CharStyle {
pub font_family: String,
#[serde(default = "regular")]
pub font_style: String,
/// Which installed version of the font (its version string) when several versions of the
/// family and style are installed and the text was set in one the family and style alone
/// wouldn't pick (an imported file's type matched to it); None = the one they pick.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font_version: Option<String>,
/// Variation axis settings of a variable font (Character panel › Variable Font), by axis tag
/// (`wght`, `wdth`), in the axis's own units. They replace the named instance's setting of
/// each axis they name; the other axes keep the style's settings. Empty: the style as it is.
#[serde(default, rename = "fontAxes", skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub font_axes: std::collections::BTreeMap<String, f64>,
/// Size in points.
pub size: f64,
/// Leading in points; None = Auto (120% of size).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub leading: Option<f64>,
/// Tracking in 1/1000 em.
#[serde(default)]
pub tracking: f64,
/// Kerning: None = Auto (metrics), Some(v) = manual in 1/1000 em.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kerning: Option<f64>,
#[serde(default)]
pub baseline_shift: f64,
#[serde(default = "hundred")]
pub h_scale: f64,
#[serde(default = "hundred")]
pub v_scale: f64,
#[serde(default)]
pub rotation: f64,
pub fill: Paint,
#[serde(default)]
pub stroke: Paint,
#[serde(default)]
pub stroke_width: f64,
#[serde(default)]
pub underline: bool,
#[serde(default)]
pub strikethrough: bool,
#[serde(default)]
pub all_caps: bool,
/// OpenType features that differ from the defaults (OpenType panel), as tags: `"dlig"` turns
/// a feature on, `"-liga"` off.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub features: Vec<String>,
/// Character style (Character Styles panel) these attributes come from; None = Normal.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub style_name: Option<String>,
/// Overprint Fill / Overprint Stroke of these characters (see [`crate::FillLayer::overprint`]).
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub overprint_fill: bool,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub overprint_stroke: bool,
/// Cap, join, miter limit and dashes of the character stroke (Stroke panel, with type
/// selected); defaults as for object strokes.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub stroke_cap: LineCap,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub stroke_join: LineJoin,
#[serde(default = "ten", skip_serializing_if = "is_ten")]
pub stroke_miter_limit: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stroke_dash: Option<Dash>,
/// Align Stroke of the character stroke (Stroke panel, with type selected): centred on the
/// glyph outlines, inside or outside them, as for object strokes; the type stays live.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub stroke_align: StrokeAlign,
/// Superscript or subscript (Character panel).
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub position: CharPosition,
/// Small Caps (Character panel): lowercase letters drawn as capitals at this percentage of the
/// size (Document Setup → Type → Small Caps); None = off.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub small_caps: Option<f64>,
/// Character Alignment (Character panel menu): where characters smaller than the largest on
/// their line line up with it.
#[serde(default, rename = "charAlign", skip_serializing_if = "crate::skip::is_default")]
pub char_align: CharAlign,
/// Character Direction (Character panel menu, Middle Eastern options): these characters are
/// set left to right or right to left inside their paragraph, each stretch of them a Unicode
/// bidi embedding (UAX #9 LRE or RLE); None = Default, the direction the bidi algorithm gives
/// them.
#[serde(default, rename = "charDirection", skip_serializing_if = "Option::is_none")]
pub char_direction: Option<ParaDirection>,
/// Proportional Metrics (Character panel menu, East Asian options): full-width glyphs take the
/// font's own proportional widths, OpenType `palt` (#966); in vertical type upright glyphs take
/// its proportional heights, `vpal`.
#[serde(default, rename = "proportionalMetrics", skip_serializing_if = "crate::skip::is_default")]
pub proportional_metrics: bool,
/// Tate-chu-yoko (Character panel menu, East Asian options): in vertical type these characters
/// stand side by side across one em of the column. Horizontal type keeps the mark and does not
/// move them. Two or three half-width digits do this without the mark.
/// <https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html>
#[serde(default, rename = "tateChuYoko", skip_serializing_if = "crate::skip::is_default")]
pub tate_chu_yoko: bool,
/// Digits (Character panel menu, Middle Eastern options): the numeral system the digits of
/// these characters are drawn in, whatever digits were typed.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub digits: Digits,
/// No Break (Character panel menu): these characters are not broken across lines, including
/// at a space or a hyphenation point inside the run. A run longer than the line stays together
/// and can overflow it.
/// <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,
}
/// 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).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Digits {
/// Each digit as typed.
#[default]
Default,
/// Western digits 0–9 (the digits Arabic typography calls Arabic).
Arabic,
/// Arabic-Indic digits ٠–٩ (U+0660–U+0669), used in Arabic.
Hindi,
/// Eastern Arabic-Indic digits ۰–۹ (U+06F0–U+06F9), used in Persian and Urdu.
Farsi,
}
impl Digits {
/// 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 {
let value = match c {
'0'..='9' => c as u32 - '0' as u32,
'\u{660}'..='\u{669}' => c as u32 - 0x660,
'\u{6F0}'..='\u{6F9}' => c as u32 - 0x6F0,
_ => return c,
};
let base = match self {
Digits::Default => return c,
Digits::Arabic => '0' as u32,
Digits::Hindi => 0x660,
Digits::Farsi => 0x6F0,
};
char::from_u32(base + value).unwrap_or(c)
}
}
/// Where a character smaller than the largest on its line lines up with it: on the Roman
/// baseline, at the top (right, in vertical type), centre or bottom (left) of the ideographic
/// em boxes, or at the top (right) or bottom (left) of the ideographic character faces (ICF, the
/// average box of the ideographs inside the em box: OpenType's `icft` and `icfb` baselines).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum CharAlign {
#[default]
RomanBaseline,
EmBoxTop,
EmBoxCenter,
EmBoxBottom,
IcfTop,
IcfBottom,
}
/// Superscript or subscript proportions in percent of the font size (Document Setup → Type).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct ScriptMetrics {
/// Glyph size.
pub size: f64,
/// Baseline offset: up for superscript, down for subscript.
pub position: f64,
}
impl ScriptMetrics {
pub const DEFAULT: Self = Self { size: 58.3, position: 33.3 };
/// Returns these proportions with the size kept to 1 to 500% and the position kept to -500 to
/// 500%, the ranges `document.setup` takes for `superscript` and `subscript`. A value outside
/// its range becomes the nearer end.
pub fn in_range(self) -> Self {
Self { size: self.size.clamp(1.0, 500.0), position: self.position.clamp(-500.0, 500.0) }
}
}
/// Returns a small caps size kept to 1 to 200%, the range `document.setup` takes for
/// `smallCapsSize`. A size outside it becomes the nearer end.
fn small_caps_in_range(size: f64) -> f64 {
size.clamp(1.0, 200.0)
}
impl Default for ScriptMetrics {
fn default() -> Self {
Self::DEFAULT
}
}
/// Character position (Character panel Superscript / Subscript). The proportions are the
/// document's when the position is applied, and follow later Document Setup changes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum CharPosition {
#[default]
Normal,
Superscript(ScriptMetrics),
Subscript(ScriptMetrics),
}
impl CharPosition {
/// Glyph scale and baseline shift (points, positive = up) for text of `size` points.
pub fn scale_shift(self, size: f64) -> (f64, f64) {
match self {
CharPosition::Normal => (1.0, 0.0),
CharPosition::Superscript(m) => (m.size / 100.0, m.position / 100.0 * size),
CharPosition::Subscript(m) => (m.size / 100.0, -m.position / 100.0 * size),
}
}
/// `normal`, `superscript` or `subscript`.
pub fn id(self) -> &'static str {
match self {
CharPosition::Normal => "normal",
CharPosition::Superscript(_) => "superscript",
CharPosition::Subscript(_) => "subscript",
}
}
/// Returns this position with its proportions kept by [`ScriptMetrics::in_range`].
pub fn in_range(self) -> Self {
match self {
CharPosition::Normal => CharPosition::Normal,
CharPosition::Superscript(m) => CharPosition::Superscript(m.in_range()),
CharPosition::Subscript(m) => CharPosition::Subscript(m.in_range()),
}
}
}
fn ten() -> f64 {
10.0
}
fn is_ten(v: &f64) -> bool {
*v == 10.0
}
fn regular() -> String {
"Regular".into()
}
fn hundred() -> f64 {
100.0
}
pub(super) fn yes() -> bool {
true
}
impl Default for CharStyle {
fn default() -> Self {
Self {
font_family: "Source Sans 3".into(),
font_style: "Regular".into(),
font_version: None,
font_axes: std::collections::BTreeMap::new(),
size: 12.0,
leading: None,
tracking: 0.0,
kerning: None,
baseline_shift: 0.0,
h_scale: 100.0,
v_scale: 100.0,
rotation: 0.0,
fill: Paint::solid(Color::BLACK),
stroke: Paint::None,
stroke_width: 0.0,
underline: false,
strikethrough: false,
all_caps: false,
features: vec![],
style_name: None,
overprint_fill: false,
overprint_stroke: false,
stroke_cap: LineCap::Butt,
stroke_join: LineJoin::Miter,
stroke_miter_limit: 10.0,
stroke_dash: None,
stroke_align: StrokeAlign::Center,
position: CharPosition::Normal,
char_align: CharAlign::RomanBaseline,
char_direction: None,
proportional_metrics: false,
tate_chu_yoko: false,
small_caps: None,
digits: Digits::Default,
no_break: false,
}
}
}
impl CharStyle {
pub fn effective_leading(&self) -> f64 {
self.leading.unwrap_or(self.size * 1.2)
}
/// Keeps the size, leading, baseline shift and horizontal and vertical scales in range: a size
/// or scale of 0 or less becomes the default new type takes (12 pt, 100%), a negative leading
/// becomes Auto, and a value past [`crate::MAX_COORD`] (a baseline shift past it either way)
/// becomes the nearer end. A leading of 0, which legacy `.ai` import stores, stays, and a
/// baseline shift that is not a number becomes 0.
pub fn keep_dimensions_in_range(&mut self) {
let default = Self::default();
let within = |v: f64, or: f64| if v > 0.0 { v.min(crate::MAX_COORD) } else { or };
self.size = within(self.size, default.size);
self.h_scale = within(self.h_scale, default.h_scale);
self.v_scale = within(self.v_scale, default.v_scale);
self.leading = self.leading.filter(|l| *l >= 0.0).map(|l| l.min(crate::MAX_COORD));
self.baseline_shift = if self.baseline_shift.is_nan() { 0.0 } else { self.baseline_shift.clamp(-crate::MAX_COORD, crate::MAX_COORD) };
}
/// Do the characters draw a stroke (a paint and a positive weight)?
pub fn has_stroke(&self) -> bool {
!self.stroke.is_none() && self.stroke_width > 0.0
}
/// The character stroke as a stroke layer (paint, weight, cap, join, miter limit, dashes,
/// alignment and overprint), so type strokes share the object strokes' geometry, rendering
/// and export.
pub fn stroke_layer(&self) -> StrokeLayer {
StrokeLayer {
overprint: self.overprint_stroke,
cap: self.stroke_cap,
join: self.stroke_join,
miter_limit: self.stroke_miter_limit,
dash: self.stroke_dash.clone(),
align: self.stroke_align,
..StrokeLayer::new(self.stroke.clone(), self.stroke_width)
}
}
/// Take the paint, weight, cap, join, miter limit, dashes and alignment of `st` as the
/// character stroke (the options characters have no use for, such as arrowheads and width
/// profiles, are dropped).
pub fn set_stroke_layer(&mut self, st: &StrokeLayer) {
self.stroke = st.paint.clone();
self.stroke_width = st.width;
self.stroke_cap = st.cap;
self.stroke_join = st.join;
self.stroke_miter_limit = st.miter_limit;
self.stroke_dash = st.dash.clone();
self.stroke_align = st.align;
}
/// How far the character stroke paints past the glyph outlines (text space): half the weight
/// centred, the whole weight outside, nothing inside (miter spikes aside).
pub fn stroke_outset(&self) -> f64 {
if !self.has_stroke() {
return 0.0;
}
match self.stroke_align {
StrokeAlign::Center => self.stroke_width / 2.0,
StrokeAlign::Outside => self.stroke_width,
StrokeAlign::Inside => 0.0,
}
}
/// The character fill as a fill layer, overprinting as the characters do.
fn fill_layer(&self) -> FillLayer {
FillLayer { overprint: self.overprint_fill, ..FillLayer::new(self.fill.clone()) }
}
/// The characters' paint as an object appearance (their outlines'): the fill, plus the
/// stroke when it draws one, overprinting as the characters do.
pub fn appearance(&self) -> Appearance {
let mut a = Appearance { items: vec![AppearanceItem::Fill(self.fill_layer())], ..Default::default() };
if self.has_stroke() {
a.items.push(AppearanceItem::Stroke(self.stroke_layer()));
}
a
}
/// The characters' paint as the basic fill and stroke rows (the stroke row even when it has
/// no paint): the appearance the Eyedropper and graphic styles take from type.
pub fn basic_appearance(&self) -> Appearance {
Appearance { items: vec![AppearanceItem::Fill(self.fill_layer()), AppearanceItem::Stroke(self.stroke_layer())], ..Default::default() }
}
/// Keeps the superscript or subscript proportions in range with [`CharPosition::in_range`] and
/// keeps the small caps size to 1 to 200%, the range `document.setup` takes for `smallCapsSize`.
pub fn keep_scripts_in_range(&mut self) {
self.position = self.position.in_range();
self.small_caps = self.small_caps.map(small_caps_in_range);
}
}
impl crate::Document {
/// Keeps the superscript, subscript and small caps proportions to the ranges `document.setup`
/// takes ([`CharStyle::keep_scripts_in_range`]) in the type of the layers, symbols, graph
/// designs, pattern swatches and the pattern definition that pattern editing's Cancel puts
/// back (opacity-mask art included), in graphs' label styles and in the character styles. It
/// recurses through the art: call it on a document no deeper than [`crate::depth::MAX_DEPTH`].
pub fn keep_scripts_in_range(&mut self) {
let mut fix = |n: &mut crate::Node| {
if let crate::NodeKind::Text(t) = &mut n.kind {
for r in &mut t.runs {
r.style.keep_scripts_in_range();
}
}
if let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) {
let l = &mut g.label_styles;
for st in [&mut l.values, &mut l.categories, &mut l.legend].into_iter().flatten() {
st.keep_scripts_in_range();
}
}
};
let roots = self
.layers
.iter_mut()
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
.chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut()))
.chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut()));
for root in roots {
crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix);
}
// Character styles store the attributes they set by their serialized names.
for def in self.char_styles.iter_mut() {
if let Some(v) = def.attrs.get_mut("position")
&& let Ok(p) = serde_json::from_value::<CharPosition>(v.clone())
&& p.in_range() != p
{
*v = serde_json::to_value(p.in_range()).unwrap_or(serde_json::Value::Null);
}
if let Some(v) = def.attrs.get_mut("small_caps")
&& let Some(size) = v.as_f64()
&& small_caps_in_range(size) != size
{
*v = small_caps_in_range(size).into();
}
}
}
}
+493
View File
@@ -0,0 +1,493 @@
use super::area::{AreaOptions, PathAlign, PathEffect};
use super::char_style::CharStyle;
use super::para_style::ParaStyle;
use super::runs::{RubySpan, TextKind, TextRun, move_anchors, reverse_from_end};
use super::tabs_lists::ParaList;
use super::*;
impl TextObject {
/// None for collapsed or unrepresentable transforms, whose type dimensions cannot
/// be edited by compensating the stored attributes.
pub fn style_scale(&self) -> Option<TextStyleScale> {
let [a, b, c, d, e, f] = self.xf.as_coeffs();
if ![a, b, c, d, e, f].iter().all(|v| v.is_finite()) {
return None;
}
let (sx, sy) = (a.hypot(b), c.hypot(d));
let determinant = (a / sx) * (d / sy) - (b / sx) * (c / sy);
let horizontal = sx / sy;
if !sy.is_finite() || sy <= 0.0 || !horizontal.is_finite() || horizontal <= 0.0 || !determinant.is_finite() || determinant.abs() <= 1e-12 {
return None;
}
// Avoid a pure rotation turning otherwise identical fields into mixed values.
let unit = |v: f64| if (v - 1.0).abs() <= 1e-12 { 1.0 } else { v };
Some(TextStyleScale { points: unit(sy), horizontal: unit(horizontal) })
}
/// Character attributes as presented by controls (stored runs stay local for layout
/// and rich-text clipboard operations). Explicit vertical character scale stays separate.
pub fn effective_char_style(&self, style: &CharStyle) -> Option<CharStyle> {
let scale = self.style_scale()?;
let mut style = style.clone();
style.size *= scale.points;
style.leading = style.leading.map(|v| v * scale.points);
style.baseline_shift *= scale.points;
style.h_scale *= scale.horizontal;
if ![style.size, style.leading.unwrap_or(0.0), style.baseline_shift, style.h_scale].iter().all(|v| v.is_finite()) {
return None;
}
Some(style)
}
/// Bakes the object's scale ([`style_scale`]) into character attributes and removes that
/// scale from `xf`, so panels show document-point sizes without a separate transform factor.
/// Returns `false` when there is nothing to bake (identity scale or an unrepresentable
/// transform).
pub fn scale_text_attributes(&mut self) -> bool {
let Some(scale) = self.style_scale() else { return false };
if (scale.points - 1.0).abs() <= 1e-12 && (scale.horizontal - 1.0).abs() <= 1e-12 {
return false;
}
let [a, b, c, d, _, _] = self.xf.as_coeffs();
let (sx, sy) = (a.hypot(b), c.hypot(d));
if sx <= 1e-12 || sy <= 1e-12 {
return false;
}
let mut baked = Vec::with_capacity(self.runs.len());
for run in &self.runs {
let Some(eff) = self.effective_char_style(&run.style) else { return false };
baked.push(eff);
}
for (run, style) in self.runs.iter_mut().zip(baked) {
run.style = style;
}
// Character strokes are drawn in text space: widen them by the vertical scale we remove.
self.scale_char_strokes(1.0 / sy);
self.xf *= Affine::scale_non_uniform(1.0 / sx, 1.0 / sy);
self.cached_bounds = None;
self.cached_baselines.clear();
true
}
pub fn point(origin: Point, text: &str, style: CharStyle) -> Self {
Self {
vertical: false,
kind: TextKind::Point,
xf: Affine::translate(origin.to_vec2()),
runs: vec![TextRun::new(text, style)],
ruby: Vec::new(),
para: ParaStyle::default(),
paras: Vec::new(),
first_para_continues: false,
area: AreaOptions::default(),
path_effect: PathEffect::default(),
path_align: PathAlign::default(),
path_spacing: 0.0,
wrap: Vec::new(),
optical_margin: false,
system_layout: false,
fractional_widths: true,
list_count: 0,
cached_bounds: None,
cached_baselines: Vec::new(),
}
}
pub fn plain_text(&self) -> String {
self.runs.iter().map(|r| r.text.as_str()).collect()
}
/// Clamp the ruby spans to the plain text, drop empty/empty-reading ones and sort them.
///
/// Called after loading and editing text: ranges from a file or an edit are untrusted, and a
/// range past the text or an inverted one would lay out nothing (or panic in a silly place).
pub fn normalize_ruby(&mut self) {
let len: usize = self.runs.iter().map(|r| r.text.len()).sum();
for r in &mut self.ruby {
r.start = r.start.min(len);
r.end = r.end.min(len);
if r.start > r.end {
std::mem::swap(&mut r.start, &mut r.end);
}
r.text = r.text.trim().to_string();
}
self.ruby.retain(|r| r.start < r.end && !r.text.is_empty());
self.ruby.sort_by_key(|r| (r.start, r.end));
}
/// The ruby span covering byte `byte` of the plain text, if any.
pub fn ruby_at(&self, byte: usize) -> Option<&RubySpan> {
self.ruby.iter().find(|r| r.start <= byte && byte < r.end)
}
/// Ruby spans touching the plain-text byte range `start..end`, with their ranges ordered.
pub fn ruby_in(&self, start: usize, end: usize) -> impl Iterator<Item = &RubySpan> {
let (a, b) = (start.min(end), start.max(end));
self.ruby.iter().filter(move |r| r.start < b && a < r.end)
}
/// Keep the ruby spans over the text after replacing plain-text bytes `start..end` with
/// `inserted` bytes of text: spans after the edit move with the text, ones the edit runs into
/// keep their start and end up where the replaced part ended. Call it after every edit of the
/// runs (`vectorcraft_text::edit`), then [`Self::normalize_ruby`] has the last word.
pub fn ruby_after_replace(&mut self, start: usize, end: usize, inserted: usize) {
let (start, end) = (start.min(end), start.max(end));
let delta = inserted as i64 - (end.saturating_sub(start)) as i64;
let shift = |v: usize| -> usize { (v as i64 + delta).max(0) as usize };
for r in &mut self.ruby {
if r.end <= start {
continue;
}
if r.start >= end {
r.start = shift(r.start);
r.end = shift(r.end);
} else {
r.start = r.start.min(start);
r.end = shift(r.end).max(r.start);
}
}
self.normalize_ruby();
}
/// Number of paragraphs (`\n`-separated; empty text has one).
pub fn paragraph_count(&self) -> usize {
1 + self.runs.iter().map(|r| r.text.bytes().filter(|&b| b == b'\n').count()).sum::<usize>()
}
/// Attributes of paragraph `i` (the last paragraph's past the end).
pub fn para_at(&self, i: usize) -> &ParaStyle {
self.paras.get(i).or(self.paras.last()).unwrap_or(&self.para)
}
/// Every paragraph's attributes, one per paragraph.
pub fn paragraph_styles(&self) -> Vec<ParaStyle> {
(0..self.paragraph_count()).map(|i| self.para_at(i).clone()).collect()
}
/// Set every paragraph's attributes from `styles` (one per paragraph; a short list repeats its
/// last entry, a long one is cut).
pub fn set_paragraph_styles(&mut self, styles: Vec<ParaStyle>) {
if let Some(first) = styles.first() {
self.para = first.clone();
}
self.paras = styles;
self.normalize_paras();
}
/// The same attributes for every paragraph.
pub fn set_all_paras(&mut self, style: ParaStyle) {
self.para = style;
self.paras.clear();
}
/// Re-establish the `paras` invariant: one entry per paragraph (cut, or extended with the
/// last), `paras[0] == para`, and empty when every paragraph is alike.
pub fn normalize_paras(&mut self) {
if self.paras.is_empty() {
return;
}
let n = self.paragraph_count();
let last = self.paras.last().cloned().unwrap_or_default();
self.paras.resize(n, last);
if let Some(first) = self.paras.first_mut() {
first.clone_from(&self.para);
}
if self.paras.iter().all(|p| *p == self.para) {
self.paras.clear();
}
}
/// Indices of the paragraphs that byte range `start..end` of the plain text touches (a caret
/// touches its paragraph).
pub fn paragraphs_in(&self, start: usize, end: usize) -> std::ops::Range<usize> {
let text = self.plain_text();
let (a, b) = (start.min(end).min(text.len()), start.max(end).min(text.len()));
let index = |byte: usize| text.as_bytes().get(..byte).map_or(0, |s| s.iter().filter(|&&c| c == b'\n').count());
index(a)..index(b) + 1
}
/// The list that the characters before byte `at` start ([`ParaList::typed`]) with the byte
/// where their paragraph starts: when the plain text from the start of the paragraph up to
/// `at` is a list's start followed by a space, and the paragraph is in no list. None otherwise.
pub fn typed_list_at(&self, at: usize) -> Option<(usize, ParaList)> {
let text = self.plain_text();
let before = text.get(..at)?;
let start = before.rfind('\n').map_or(0, |i| i + 1);
let list = ParaList::typed(before[start..].strip_suffix(' ')?)?;
let index = before.bytes().filter(|&b| b == b'\n').count();
self.para_at(index).list.is_none().then_some((start, list))
}
/// Apply `f` to the attributes of paragraphs `range` (indices; None = every paragraph).
pub fn edit_paras(&mut self, range: Option<std::ops::Range<usize>>, mut f: impl FnMut(&mut ParaStyle)) {
match range {
None => {
f(&mut self.para);
for p in &mut self.paras {
f(p);
}
}
Some(r) => {
let mut v = self.paragraph_styles();
for p in v.iter_mut().take(r.end).skip(r.start) {
f(p);
}
self.set_paragraph_styles(v);
}
}
self.normalize_paras();
}
/// Every stored paragraph style (`para` and the per-paragraph ones), for scans and renames.
pub fn para_styles_mut(&mut self) -> impl Iterator<Item = &mut ParaStyle> {
std::iter::once(&mut self.para).chain(self.paras.iter_mut())
}
/// Every stored paragraph style (`para` and the per-paragraph ones).
pub fn para_styles(&self) -> impl Iterator<Item = &ParaStyle> {
std::iter::once(&self.para).chain(self.paras.iter())
}
/// Update the paragraph styles for replacing bytes `start..end` of the current plain text by
/// `insert` (call before changing the runs). The paragraph the range starts in keeps its
/// style (merging paragraphs: the first one's wins); paragraphs the insertion starts take that
/// style too (Return continues the paragraph it splits); later paragraphs keep theirs.
pub fn splice_paras(&mut self, start: usize, end: usize, insert: &str) {
if self.paras.is_empty() {
return;
}
let r = self.paragraphs_in(start, end);
let v = self.paragraph_styles();
let Some(keep) = v.get(r.start).cloned() else { return };
let added = insert.bytes().filter(|&b| b == b'\n').count();
let mut out: Vec<ParaStyle> = v.iter().take(r.start + 1).cloned().collect();
out.extend(std::iter::repeat_n(keep, added));
out.extend(v.iter().skip(r.end).cloned());
self.paras = out;
if let Some(first) = self.paras.first() {
self.para = first.clone();
}
}
pub fn first_style(&self) -> CharStyle {
self.runs.first().map(|r| r.style.clone()).unwrap_or_default()
}
/// Approximate bounds when no layout cache is available (0.55 em average advance).
pub fn estimate_bounds(&self) -> Rect {
let st = self.first_style();
let text = self.plain_text();
let lines: Vec<&str> = text.split('\n').collect();
let w = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0) as f64 * st.size * 0.55;
let lead = st.effective_leading();
Rect::new(0.0, -st.size * 0.8, w.max(1.0), -st.size * 0.8 + lead * lines.len().max(1) as f64)
}
pub fn bounds(&self) -> Option<Rect> {
match &self.kind {
TextKind::Area { frame } => frame.bounds().map(|b| self.xf.transform_rect_bbox(b)),
TextKind::OnPath { path, .. } => path.bounds().map(|b| self.xf.transform_rect_bbox(b)),
TextKind::Point => Some(self.xf.transform_rect_bbox(self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()))),
}
}
/// How far the characters' strokes paint past their glyphs, in document space: they scale
/// with the type.
pub fn char_stroke_outset(&self) -> f64 {
let k = crate::node::Scaling::factor(self.xf).unwrap_or(1.0);
self.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k
}
/// How far the characters' strokes can paint past their glyphs, their miter spikes and square
/// caps included ([`StrokeLayer::reach`]), in document space: what a change of the type must
/// redraw. They stretch with the type, so this is as far as its transform takes them on either
/// axis. The visual bounds take [`Self::char_stroke_outset`].
pub fn char_stroke_reach(&self) -> f64 {
let reach = self.runs.iter().filter(|r| r.style.has_stroke()).map(|r| r.style.stroke_layer().reach()).fold(0.0, f64::max);
let [a, b, c, d, ..] = self.xf.as_coeffs();
reach * (a.abs() + c.abs()).max(b.abs() + d.abs())
}
pub fn transform(&mut self, a: Affine) {
self.xf = a * self.xf;
}
/// Type on a path's path in document space; None for other type.
pub fn type_path(&self) -> Option<PathData> {
match &self.kind {
TextKind::OnPath { path, .. } => Some(path.transformed(self.xf)),
_ => None,
}
}
/// Flip type on a path to the other side of its path (Type on a Path Options › Flip, or its
/// centre bracket dragged across the path): the path runs the other way and the brackets swap
/// ends, so the type keeps its stretch of the path. False, changing nothing, for other type.
pub fn flip_on_path(&mut self) -> bool {
let Some((s, e)) = self.kind.path_span() else { return false };
let TextKind::OnPath { path, start, end } = &mut self.kind else { return false };
reverse_from_end(path);
if path.is_closed() {
*start = (1.0 - e).rem_euclid(1.0);
*end = (e - s < 1.0 - 1e-9).then(|| (1.0 - s).rem_euclid(1.0));
} else {
*start = 1.0 - e;
*end = (s > 1e-9).then_some(1.0 - s);
}
true
}
/// Area type's frame (the type area) in document space; None for other type.
pub fn area_frame(&self) -> Option<PathData> {
match &self.kind {
TextKind::Area { frame } => Some(frame.transformed(self.xf)),
_ => None,
}
}
/// Reshape area type's frame by `a`, a document-space transform, keeping the type at its size
/// (a bounding-box resize: the text reflows, it isn't scaled). False, changing nothing, for
/// other type, a degenerate `xf` or a frame that would not be finite.
pub fn transform_area(&mut self, a: Affine) -> bool {
let xf = self.xf;
self.reshape_area(|frame, to_text| {
frame.transform(to_text * a * xf);
true
})
}
/// Move anchors `refs` (subpath, anchor) of area type's frame by `d` in document space, with
/// their handles (a Direct Selection drag of a frame corner or edge). False, changing
/// nothing, when an anchor doesn't exist, or as [`Self::transform_area`].
pub fn move_area_anchors(&mut self, refs: &[(usize, usize)], d: Vec2) -> bool {
self.reshape_area(|frame, to_text| move_anchors(frame, to_text, refs, d))
}
/// Move anchors `refs` (subpath, anchor) of type on a path's path by `d` in document space,
/// with their handles (a Direct Selection drag of an anchor); the brackets keep their
/// fractions of the path. False, changing nothing, for other type, when an anchor doesn't
/// exist, a degenerate `xf` or a path that would not be finite.
pub fn move_path_anchors(&mut self, refs: &[(usize, usize)], d: Vec2) -> bool {
matches!(self.kind, TextKind::OnPath { .. }) && self.reshape_local(|path, to_text| move_anchors(path, to_text, refs, d))
}
/// Edit a copy of the area frame with `f` (given the document → text space map) and keep it
/// when `f` succeeds and the result is finite.
fn reshape_area(&mut self, f: impl FnOnce(&mut PathData, Affine) -> bool) -> bool {
matches!(self.kind, TextKind::Area { .. }) && self.reshape_local(f)
}
/// [`Self::reshape_area`] for the area frame or type on a path's path.
fn reshape_local(&mut self, f: impl FnOnce(&mut PathData, Affine) -> bool) -> bool {
let det = self.xf.determinant();
if !det.is_finite() || det.abs() < 1e-12 {
return false;
}
let to_text = self.xf.inverse();
let (TextKind::Area { frame } | TextKind::OnPath { path: frame, .. }) = &mut self.kind else { return false };
let mut next = frame.clone();
if !f(&mut next, to_text) || !next.anchors().all(|(_, _, a)| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()) {
return false;
}
*frame = next;
true
}
/// Scale the character strokes' weights and dashes by `s` (they are drawn in text space, so
/// `1 / scale` keeps their weight through a transform that scales the type).
pub fn scale_char_strokes(&mut self, s: f64) {
for r in &mut self.runs {
r.style.stroke_width *= s;
if let Some(d) = &mut r.style.stroke_dash {
d.scale(s);
}
}
}
/// Layout bounds in text space (the layout cache, else the estimate): the box an unplaced run
/// gradient fits, as the renderers lay it out.
pub fn local_bounds(&self) -> Rect {
self.cached_bounds.unwrap_or_else(|| self.estimate_bounds())
}
/// The baselines in text space (the layout cache): for point type with none cached, the first
/// one across [`Self::local_bounds`] (down the first column's centre line for vertical type).
pub fn baselines(&self) -> impl Iterator<Item = (Point, Point)> + '_ {
let first = (self.cached_baselines.is_empty() && matches!(self.kind, TextKind::Point)).then(|| {
let b = self.local_bounds();
if self.vertical { (Point::new(0.0, b.y0), Point::new(0.0, b.y1)) } else { (Point::new(b.x0, 0.0), Point::new(b.x1, 0.0)) }
});
self.cached_baselines.iter().copied().chain(first)
}
/// Keeps every run's size, leading, baseline shift and scales in range
/// ([`CharStyle::keep_dimensions_in_range`]) and every paragraph's indents, spacing and tab
/// stops ([`ParaStyle::keep_spacing_in_range`]).
pub fn keep_type_values_in_range(&mut self) {
for r in &mut self.runs {
r.style.keep_dimensions_in_range();
}
for p in self.para_styles_mut() {
p.keep_spacing_in_range();
}
}
}
impl crate::Document {
/// Keeps the character dimensions, indents, paragraph spacing and tab stops in range
/// ([`TextObject::keep_type_values_in_range`]) in the type of the layers, symbols, graph
/// designs, pattern swatches and the pattern definition that pattern editing's Cancel puts
/// back (opacity-mask art included), and the same attributes of the character and paragraph
/// styles. It recurses through the art: call it on a document no deeper than
/// [`crate::depth::MAX_DEPTH`].
pub fn keep_type_values_in_range(&mut self) {
let mut fix = |n: &mut crate::Node| {
if let crate::NodeKind::Text(t) = &mut n.kind {
t.keep_type_values_in_range();
}
};
let roots = self
.layers
.iter_mut()
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
.chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut()))
.chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut()));
for root in roots {
crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix);
}
// Styles store the attributes they set by their serialized names (a number, or null for
// Auto leading); a number is rewritten only when it changes.
for def in self.char_styles.iter_mut() {
let num = |k: &str| def.attrs.get(k).and_then(serde_json::Value::as_f64);
let mut st = CharStyle { leading: num("leading"), ..CharStyle::default() };
st.size = num("size").unwrap_or(st.size);
st.h_scale = num("h_scale").unwrap_or(st.h_scale);
st.v_scale = num("v_scale").unwrap_or(st.v_scale);
st.baseline_shift = num("baseline_shift").unwrap_or(st.baseline_shift);
st.keep_dimensions_in_range();
let kept = [
("size", Some(st.size)),
("h_scale", Some(st.h_scale)),
("v_scale", Some(st.v_scale)),
("baseline_shift", Some(st.baseline_shift)),
("leading", st.leading),
];
for (k, v) in kept {
if let Some(old) = def.attrs.get_mut(k)
&& old.is_number()
&& old.as_f64() != v
{
*old = v.into();
}
}
}
for def in self.para_styles.iter_mut() {
for k in ["left_indent", "right_indent", "first_line_indent", "space_before", "space_after"] {
if let Some(v) = def.attrs.get_mut(k)
&& let Some(x) = v.as_f64()
&& x.abs() > crate::MAX_COORD
{
*v = x.clamp(-crate::MAX_COORD, crate::MAX_COORD).into();
}
}
for t in def.attrs.get_mut("tabs").and_then(serde_json::Value::as_array_mut).into_iter().flatten() {
if let Some(v) = t.get_mut("position")
&& let Some(x) = v.as_f64()
&& !(0.0..=100_000.0).contains(&x)
{
*v = x.clamp(0.0, 100_000.0).into();
}
}
}
}
/// Clamps the ruby spans of the type in the layers, symbols, graph designs, pattern swatches
/// and the pattern definition that pattern editing's Cancel puts back (opacity-mask art
/// included) to their text ([`TextObject::normalize_ruby`]). Loaded files and edits carry
/// ranges from outside: a range past the text, an inverted one or an empty reading would lay
/// out nothing. It recurses through the art: call it on a document no deeper than
/// [`crate::depth::MAX_DEPTH`].
pub fn fix_ruby(&mut self) {
let mut fix = |n: &mut crate::Node| {
if let crate::NodeKind::Text(t) = &mut n.kind {
t.normalize_ruby();
}
};
let roots = self
.layers
.iter_mut()
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
.chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut()))
.chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut()));
for root in roots {
crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix);
}
}
}
+517
View File
@@ -0,0 +1,517 @@
use super::char_style::CharStyle;
use super::tabs_lists::{ParaList, TabStop};
use super::*;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Justify {
/// Align to the start of each paragraph's direction ([`ParaStyle::direction`]): left for
/// left-to-right paragraphs, right for right-to-left ones. New type's alignment.
Auto,
#[default]
Left,
Center,
Right,
JustifyLeft,
JustifyCenter,
JustifyRight,
JustifyAll,
}
/// Paragraph composer (Paragraph panel menu): how lines are broken.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Composer {
/// Break each line as soon as it is full.
SingleLine,
/// Total fit over the whole paragraph (Illustrator's default): justified lines get even word
/// spacing, ragged lines an even rag.
#[default]
EveryLine,
}
impl Composer {
fn is_default(&self) -> bool {
*self == Self::default()
}
}
/// A paragraph's hyphenation options (Paragraph panel menu › Hyphenation…): which words automatic
/// hyphenation may break, where, and on how many lines in a row. Saved only when they differ from
/// the defaults: words longer than 5 letters, 3 letters on each side of the hyphen, no hyphen
/// limit, no hyphenation zone, and capitalized words hyphenated.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct HyphenationOptions {
/// Words Longer Than: only words with more letters than this are hyphenated.
pub words_longer_than: u32,
/// After First: the fewest letters a word keeps before the hyphen.
pub after_first: u32,
/// Before Last: the fewest letters a word keeps after the hyphen.
pub before_last: u32,
/// Hyphen Limit: the most consecutive lines that automatic hyphenation may end with a hyphen;
/// 0 sets no limit.
pub hyphen_limit: u32,
/// Hyphenation Zone (pt): with the Single-line Composer, a line whose text before the next
/// word already ends within this distance of its right edge breaks before that word instead
/// of hyphenating it. 0 allows every hyphenation.
pub zone: f64,
/// Hyphenate Capitalized Words: when it is off, words that start with a capital letter are not
/// hyphenated. Words in capitals only are never hyphenated.
pub capitalized: bool,
}
impl Default for HyphenationOptions {
fn default() -> Self {
Self { words_longer_than: 5, after_first: 3, before_last: 3, hyphen_limit: 0, zone: 0.0, capitalized: true }
}
}
/// How a justified line holding a single word is set ([`JustificationOptions::single_word`]).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SingleWordJustify {
/// Spread across the line, between its letters.
#[default]
FullJustify,
AlignLeft,
AlignCenter,
AlignRight,
}
/// A paragraph's justification options (Paragraph panel menu › Justification…), in percent.
///
/// Each spacing has a Minimum, a Desired and a Maximum value (`[min, desired, max]`). Desired
/// applies to every line of the paragraph, justified or not: word spaces are that share of the
/// font's word space, each letter is followed by that share of a word space, and glyphs are that
/// wide. Justified lines may vary them between Minimum and Maximum to fill the measure. Saved only
/// when they differ from the defaults: word spacing 80/100/133, letter spacing 0/0/0, glyph
/// scaling 100/100/100, Auto Leading 120 and Full Justify.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct JustificationOptions {
/// Word Spacing, percent of the font's word space (0..1000).
pub word_spacing: [f64; 3],
/// Letter Spacing, percent of the font's word space added after each letter (-100..500).
pub letter_spacing: [f64; 3],
/// Glyph Scaling, percent of the glyphs' width (50..200).
pub glyph_scaling: [f64; 3],
/// Auto Leading: the leading of characters whose leading is Auto, in percent of their size
/// (0..500).
pub auto_leading: f64,
/// Single Word Justification: how a justified line holding one word is set.
pub single_word: SingleWordJustify,
}
impl JustificationOptions {
/// Word Spacing's range (percent).
pub const WORD_SPACING: (f64, f64) = (0.0, 1000.0);
/// Letter Spacing's range (percent).
pub const LETTER_SPACING: (f64, f64) = (-100.0, 500.0);
/// Glyph Scaling's range (percent).
pub const GLYPH_SCALING: (f64, f64) = (50.0, 200.0);
/// Auto Leading's range (percent).
pub const AUTO_LEADING: (f64, f64) = (0.0, 500.0);
/// Are Desired word spacing, letter spacing and glyph scaling the defaults (nothing to change
/// in a line's natural widths)?
pub fn desired_is_default(&self) -> bool {
let d = Self::default();
self.word_spacing[1] == d.word_spacing[1] && self.letter_spacing[1] == d.letter_spacing[1] && self.glyph_scaling[1] == d.glyph_scaling[1]
}
/// Keeps options read from a file to what `text.setFormat` can set: a value that is not a
/// finite number becomes its default, every value is clamped to its range, and a Minimum,
/// Desired and Maximum out of order go back to their defaults together.
pub fn keep_in_range(&mut self) {
let d = Self::default();
for (v, dv, (lo, hi)) in [
(&mut self.word_spacing, d.word_spacing, Self::WORD_SPACING),
(&mut self.letter_spacing, d.letter_spacing, Self::LETTER_SPACING),
(&mut self.glyph_scaling, d.glyph_scaling, Self::GLYPH_SCALING),
] {
for (x, dx) in v.iter_mut().zip(dv) {
*x = if x.is_finite() { x.clamp(lo, hi) } else { dx };
}
if !(v[0] <= v[1] && v[1] <= v[2]) {
*v = dv;
}
}
let (lo, hi) = Self::AUTO_LEADING;
self.auto_leading = if self.auto_leading.is_finite() { self.auto_leading.clamp(lo, hi) } else { d.auto_leading };
}
/// A paragraph's leading for characters of `size` whose leading is Auto.
pub fn auto_leading_for(&self, size: f64) -> f64 {
size * self.auto_leading / 100.0
}
}
impl Default for JustificationOptions {
fn default() -> Self {
Self {
word_spacing: [80.0, 100.0, 133.0],
letter_spacing: [0.0, 0.0, 0.0],
glyph_scaling: [100.0, 100.0, 100.0],
auto_leading: 120.0,
single_word: SingleWordJustify::FullJustify,
}
}
}
/// Insert Kashidas (Paragraph panel): how long the kashidas are that lengthen the Arabic words of
/// a justified paragraph's lines. A kashida is a tatweel (U+0640) drawn between a word's last pair
/// of joined letters, at most one in each word. Short, Medium and Long kashidas are at most a quarter
/// of an em, half an em and an em long; a Stylistic kashida is a row of whole tatweels as the font
/// draws them, at most an em long.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Kashida {
/// No kashidas: justified lines widen their word spaces.
#[default]
None,
Short,
Medium,
Long,
Stylistic,
}
impl Kashida {
/// The longest kashida, in ems of the letter that follows it in the text; 0 for None.
pub fn max_em(self) -> f64 {
match self {
Kashida::None => 0.0,
Kashida::Short => 0.25,
Kashida::Medium => 0.5,
Kashida::Long | Kashida::Stylistic => 1.0,
}
}
}
/// Paragraph attributes (the Paragraph panel).
///
/// Saved through [`ParaStyleFile`], which keeps files with text openable by builds from before
/// [`Justify::Auto`].
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(from = "ParaStyleFile", into = "ParaStyleFile")]
pub struct ParaStyle {
pub justify: Justify,
pub left_indent: f64,
pub right_indent: f64,
pub first_line_indent: f64,
pub space_before: f64,
pub space_after: f64,
pub hyphenate: bool,
/// The limits automatic hyphenation keeps to (Paragraph panel menu › Hyphenation…).
pub hyphenation: HyphenationOptions,
/// Word and letter spacing, glyph scaling, Auto Leading and Single Word Justification
/// (Paragraph panel menu › Justification…).
pub justification: JustificationOptions,
/// Line breaking: Single-line or Every-line Composer.
pub composer: Composer,
/// Tab stops (Tabs panel), sorted by position.
pub tabs: Vec<TabStop>,
/// Bullets (Paragraph panel): the list the paragraph is in; None for a paragraph that is not
/// in a list.
pub list: Option<ParaList>,
/// Paragraph style (Paragraph Styles panel) these attributes come from; None = Normal.
pub style_name: Option<String>,
/// Japanese composition: the spacing of punctuation (Paragraph panel › Mojikumi). Type made
/// with the Type tools and `text.create` takes [`Mojikumi::LineEndHalf`]; documents from before
/// it and imported text (already set) keep [`Mojikumi::None`].
pub mojikumi: Mojikumi,
/// Paragraph direction (Paragraph panel): the base direction of each paragraph for
/// bidirectional text (UAX #9). None: from each paragraph's first strong character (Hebrew or
/// Arabic: right to left).
pub direction: Option<ParaDirection>,
/// How leading is measured (Paragraph panel menu): from baseline to baseline, or from the top
/// of one line's ideographic em box to the next.
pub leading_model: LeadingModel,
/// Hanging punctuation (Paragraph panel menu › Burasagari): a comma or full stop ending a line
/// may stand outside the frame. New type takes [`Burasagari::Standard`]; documents from before
/// it and imported text keep [`Burasagari::None`].
pub burasagari: Burasagari,
/// Kinsoku (Paragraph panel › Kinsoku Set): which Japanese characters may not start or end a
/// line.
pub kinsoku: Kinsoku,
/// Roman Hanging Punctuation (Paragraph panel menu): quotation marks, hyphens, full stops and
/// commas starting or ending a line hang outside the margin, and asterisks, tildes, ellipses,
/// dashes, colons and semicolons hang by half. East Asian punctuation hangs by burasagari.
pub roman_hanging: bool,
/// Insert Kashidas (Paragraph panel): justified lines lengthen their Arabic words with
/// kashidas before they widen their word spaces.
pub kashida: Kashida,
}
impl ParaStyle {
/// Keeps the indents and the space before and after to -[`crate::MAX_COORD`] to
/// [`crate::MAX_COORD`], and the tab stop positions to 0 to 100,000 pt, the range
/// `text.tabs.set` takes. A value outside its range becomes the nearer end, and one that is
/// not a number becomes 0.
pub fn keep_spacing_in_range(&mut self) {
let within = |v: f64, lo: f64, hi: f64| if v.is_nan() { 0.0 } else { v.clamp(lo, hi) };
for v in [&mut self.left_indent, &mut self.right_indent, &mut self.first_line_indent, &mut self.space_before, &mut self.space_after] {
*v = within(*v, -crate::MAX_COORD, crate::MAX_COORD);
}
for t in &mut self.tabs {
t.position = within(t.position, 0.0, 100_000.0);
}
}
/// The leading of characters styled `st` in this paragraph, as the layout sets it: their own
/// leading, or for Auto leading the paragraph's Justification › Auto Leading share of their
/// size (exactly [`CharStyle::effective_leading`] at the default 120%, or when the share is
/// not a finite number).
pub fn effective_leading(&self, st: &CharStyle) -> f64 {
let auto = self.justification.auto_leading;
if st.leading.is_some() || auto == JustificationOptions::default().auto_leading {
return st.effective_leading();
}
let l = self.justification.auto_leading_for(st.size);
if l.is_finite() { l } else { st.effective_leading() }
}
}
/// [`ParaStyle`] as saved. [`Justify::Auto`] is written as the alignment it has in the paragraph
/// direction (`Right` for right to left, else `Left`) with `justify_auto` set: builds without Auto
/// read the alignment and ignore the flag, and builds with it read Auto back.
#[derive(Serialize, Deserialize)]
struct ParaStyleFile {
#[serde(default)]
justify: Justify,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
justify_auto: bool,
#[serde(default)]
left_indent: f64,
#[serde(default)]
right_indent: f64,
#[serde(default)]
first_line_indent: f64,
#[serde(default)]
space_before: f64,
#[serde(default)]
space_after: f64,
#[serde(default)]
hyphenate: bool,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
hyphenation: HyphenationOptions,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
justification: JustificationOptions,
#[serde(default, skip_serializing_if = "Composer::is_default")]
composer: Composer,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
tabs: Vec<TabStop>,
#[serde(default, skip_serializing_if = "Option::is_none")]
list: Option<ParaList>,
#[serde(default, skip_serializing_if = "Option::is_none")]
style_name: Option<String>,
#[serde(default, skip_serializing_if = "Mojikumi::is_none")]
mojikumi: Mojikumi,
#[serde(default, skip_serializing_if = "Option::is_none")]
direction: Option<ParaDirection>,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
leading_model: LeadingModel,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
burasagari: Burasagari,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
kinsoku: Kinsoku,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
roman_hanging: bool,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
kashida: Kashida,
}
impl From<ParaStyle> for ParaStyleFile {
fn from(p: ParaStyle) -> Self {
let justify_auto = p.justify == Justify::Auto;
let justify = match p.justify {
Justify::Auto if p.direction == Some(ParaDirection::RightToLeft) => Justify::Right,
Justify::Auto => Justify::Left,
j => j,
};
let ParaStyle {
left_indent,
right_indent,
first_line_indent,
space_before,
space_after,
hyphenate,
hyphenation,
justification,
composer,
tabs,
list,
style_name,
mojikumi,
direction,
leading_model,
burasagari,
kinsoku,
roman_hanging,
kashida,
..
} = p;
Self {
justify,
justify_auto,
left_indent,
right_indent,
first_line_indent,
space_before,
space_after,
hyphenate,
hyphenation,
justification,
composer,
tabs,
list,
style_name,
mojikumi,
direction,
leading_model,
burasagari,
kinsoku,
roman_hanging,
kashida,
}
}
}
impl From<ParaStyleFile> for ParaStyle {
fn from(f: ParaStyleFile) -> Self {
let justify = if f.justify_auto { Justify::Auto } else { f.justify };
let mut justification = f.justification;
justification.keep_in_range();
let ParaStyleFile {
left_indent,
right_indent,
first_line_indent,
space_before,
space_after,
hyphenate,
hyphenation,
composer,
tabs,
list,
style_name,
mojikumi,
direction,
leading_model,
burasagari,
kinsoku,
roman_hanging,
kashida,
..
} = f;
Self {
justify,
left_indent,
right_indent,
first_line_indent,
space_before,
space_after,
hyphenate,
hyphenation,
justification,
composer,
tabs,
list,
style_name,
mojikumi,
direction,
leading_model,
burasagari,
kinsoku,
roman_hanging,
kashida,
}
}
}
/// A paragraph's base direction ([`ParaStyle::direction`]), or the direction characters are set
/// in ([`CharStyle::char_direction`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ParaDirection {
LeftToRight,
RightToLeft,
}
/// How a paragraph's leading is measured.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LeadingModel {
/// From one line's baseline to the next's: a line's leading is the space above it, and area
/// type's first baseline follows Area Type Options › First Baseline.
#[default]
RomanBaseline,
/// From the top of one line's ideographic em box (the right side, in vertical type) to the
/// next's: a line's leading is the space below it, and area type's first line touches the top
/// of the frame. Japanese layout's usual model.
EmBoxTop,
}
/// How Japanese punctuation is spaced (JLREQ 3.1). Full-width punctuation is half a glyph and half
/// a space: an opening bracket's space before it, a closing bracket's, a comma's or a full stop's
/// after it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Mojikumi {
/// Every character takes its full advance.
#[default]
None,
/// Consecutive punctuation shares one half-em space (JLREQ 3.1.4), and a closing bracket,
/// comma or full stop ending a line is set half width.
LineEndHalf,
}
impl Mojikumi {
pub fn is_none(&self) -> bool {
*self == Mojikumi::None
}
}
/// Which Japanese characters may not start or end a line (kinsoku shori).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Kinsoku {
/// No kinsoku: a line may break between any two characters that allow a break.
None,
/// Closing brackets, commas, full stops, iteration marks, the prolonged sound mark, small
/// kana and the like don't start a line; opening brackets don't end one.
#[default]
Hard,
/// As Hard, except that 々, the prolonged sound mark ー and small kana may start a line
/// (JLREQ's level 3 line-breaking rules, Appendix C.3).
Soft,
}
/// Hanging punctuation (burasagari): an East Asian comma or full stop ending a line (、。,.、。)
/// stands outside the line's measure, in the space its punctuation spacing gives it (half width
/// with Line-end Punctuation Half Width). Closing brackets and Latin punctuation don't hang. Shown
/// as None / Regular / Force.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Burasagari {
/// A comma or full stop that doesn't fit goes to the next line with the character before it.
#[default]
None,
/// A comma or full stop that doesn't fit hangs outside the line; one that fits stays inside.
Standard,
/// A comma or full stop ending a line always hangs, and the rest of the line fills the measure.
Forced,
}
/// Area Type Options "First Baseline" offset.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum FirstBaseline {
/// The tallest glyph ascent touches the frame top (Illustrator's default).
#[default]
Ascent,
CapHeight,
XHeight,
/// The first line's leading.
Leading,
/// Exactly `first_baseline_min` below the top.
Fixed,
}
+207
View File
@@ -0,0 +1,207 @@
use super::char_style::CharStyle;
use super::*;
/// A named character or paragraph style: the attributes it sets (a subset of [`CharStyle`] or
/// [`ParaStyle`] fields, by their serialized names). Text using it records the name.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TextStyleDef {
pub name: String,
#[serde(default)]
pub attrs: serde_json::Map<String, serde_json::Value>,
}
/// A run of text sharing one character style.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TextRun {
pub text: String,
pub style: CharStyle,
/// An inline graphic: the run is one [`INLINE_CHAR`] drawn as a document symbol that flows
/// with the text like a glyph (InDesign-style inline anchored object). Its `style` still sets
/// the size and tracking; fills and strokes don't apply to the art.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inline: Option<InlineArt>,
}
impl TextRun {
/// A plain text run.
pub fn new(text: impl Into<String>, style: CharStyle) -> Self {
Self { text: text.into(), style, inline: None }
}
/// An inline graphic run showing `art`.
pub fn inline(art: InlineArt, style: CharStyle) -> Self {
Self { text: INLINE_CHAR.to_string(), style, inline: Some(art) }
}
}
/// The object replacement character: the plain text of an inline graphic run.
pub const INLINE_CHAR: char = '\u{FFFC}';
/// Largest inline graphic scale (× the run's size) accepted from input.
pub const INLINE_MAX_SCALE: f64 = 100.0;
/// A document symbol placed inline in text ([`TextRun::inline`]).
///
/// Placement: the art is scaled uniformly so its height (visual bounds) is `scale` × the run's
/// font size, its left edge at the pen position, and its vertical centre on the middle of the
/// run's cap height, raised by `baseline_shift` (points, positive = up). Centred on the cap
/// height the art lines up with capitals and figures (like a mana symbol in rules text) and, at
/// the default scale, stays inside the font's ascent and descent so the leading doesn't change.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct InlineArt {
/// Name of the document symbol ([`crate::Symbol::name`]).
pub symbol: String,
/// Art height as a multiple of the run's font size.
#[serde(default = "one")]
pub scale: f64,
/// Extra raise in points (positive = up).
#[serde(default)]
pub baseline_shift: f64,
/// The symbol art's visual bounds at its natural size, resolved from the document's symbols by
/// [`crate::Document::resolve_inline_art`] (not saved). `None`: not resolved or missing; the
/// layout then reserves a one-em square and nothing is drawn.
#[serde(skip)]
pub bounds: Option<Rect>,
}
fn one() -> f64 {
1.0
}
fn fifty() -> f64 {
50.0
}
impl InlineArt {
pub fn new(symbol: impl Into<String>) -> Self {
Self { symbol: symbol.into(), scale: 1.0, baseline_shift: 0.0, bounds: None }
}
/// `scale`, made finite and positive (untrusted input).
pub fn safe_scale(&self) -> f64 {
if self.scale.is_finite() && self.scale > 0.0 { self.scale.min(INLINE_MAX_SCALE) } else { 1.0 }
}
/// `baseline_shift`, made finite (untrusted input).
pub fn safe_shift(&self) -> f64 {
if self.baseline_shift.is_finite() { self.baseline_shift.clamp(-1e5, 1e5) } else { 0.0 }
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum TextKind {
/// Point type: anchored at the baseline origin of the first line.
Point,
/// Area type flowed inside `frame` (document coordinates, untransformed by `xf`).
Area { frame: PathData },
/// Type on a path, flowing from its start bracket `start` to its end bracket `end` (0..1 of the
/// path length; no `end`: the end of the path, or once round a closed path). See
/// [`TextKind::path_span`].
OnPath {
path: PathData,
start: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
end: Option<f64>,
},
}
impl TextKind {
/// Type on a path's span: from its start to its end bracket, as fractions of its path's length.
/// Round a closed path the end may be past 1 (the span runs on past the path's start), a full
/// turn without an end or with the end at the start. None for other type.
pub fn path_span(&self) -> Option<(f64, f64)> {
let TextKind::OnPath { path, start, end } = self else { return None };
let s = fraction(*start);
Some(if path.is_closed() {
let run = end.map_or(0.0, |e| (fraction(e) - s).rem_euclid(1.0));
(s, s + if run < 1e-9 { 1.0 } else { run })
} else {
(s, end.map_or(1.0, |e| fraction(e).max(s)))
})
}
}
/// Move anchors `refs` of `path`, in text space, by `d` in document space (`to_text`: the
/// document → text map). False when an anchor doesn't exist.
pub(super) fn move_anchors(path: &mut PathData, to_text: Affine, refs: &[(usize, usize)], d: Vec2) -> bool {
// A distance: only the linear part of the document → text map applies.
let local = to_text * d.to_point() - to_text * Point::ZERO;
refs.iter().all(|&(si, ai)| path.anchor_mut(si, ai).map(|a| a.translate(local)).is_some())
}
/// `x` as a fraction of a path's length (0..1; 0 when not finite).
fn fraction(x: f64) -> f64 {
if x.is_finite() { x.clamp(0.0, 1.0) } else { 0.0 }
}
/// `path` run the other way, each point as far from its new start as it was from its old end
/// (the subpaths in reverse order, a closed one keeping its first anchor first).
pub(super) fn reverse_from_end(path: &mut PathData) {
path.subpaths.reverse();
for sp in &mut path.subpaths {
sp.reverse();
if sp.closed && !sp.anchors.is_empty() {
sp.anchors.rotate_right(1);
}
}
}
/// Where ruby (furigana) text sits: above its base text in horizontal type, right of it in
/// vertical type (the Japanese convention), or below (left in vertical type).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RubyPosition {
#[default]
Above,
Below,
}
/// How ruby text lines up over its base text when the two are different widths.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RubyAlign {
/// Centred over the base text.
#[default]
Center,
/// Starting at the base text's start (left in horizontal type, top in vertical).
Start,
/// Ending at the base text's end (right in horizontal type, bottom in vertical).
End,
}
/// Ruby (furigana, phonetic guide): a reading set over a range of the text.
///
/// `start`/`end` are byte offsets into [`TextObject::plain_text`] (the same space as
/// [`crate::TextObject`] commands' `start`/`end`). The base text itself stays the text;
/// emptying the reading removes the ruby. New ruby is half the base text's size, centred, and
/// allowed to overhang neighbouring characters when the reading is longer than its base.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RubySpan {
/// Byte offset of the first base character in the plain text.
pub start: usize,
/// Byte offset just past the last base character.
pub end: usize,
/// The reading, set in the base characters' font (its size comes from `size_percent`).
pub text: String,
#[serde(default)]
pub position: RubyPosition,
#[serde(default)]
pub align: RubyAlign,
/// Ruby size as a percentage of the base text's size, 1–100 (50 = half, the usual).
#[serde(default = "fifty")]
pub size_percent: f64,
/// Let a reading wider than its base hang over the neighbouring characters instead of being
/// squeezed to the base's width.
#[serde(default = "yes")]
pub overhang: bool,
}
impl RubySpan {
/// Ruby over `start..end` with reading `text`, at the defaults (half size, centred, overhanging).
pub fn new(start: usize, end: usize, text: impl Into<String>) -> Self {
Self { start, end, text: text.into(), position: RubyPosition::Above, align: RubyAlign::Center, size_percent: 50.0, overhang: true }
}
/// `size_percent` made finite and usable: 1–100% (untrusted input).
pub fn safe_size_percent(&self) -> f64 {
if self.size_percent.is_finite() { self.size_percent.clamp(1.0, 100.0) } else { 50.0 }
}
}
+249
View File
@@ -0,0 +1,249 @@
use super::char_style::{CharPosition, CharStyle};
use super::*;
/// Tab stop alignment (Tabs panel).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TabAlign {
#[default]
Left,
Center,
Right,
/// Aligns on the first `align_on` character (a decimal point by default).
Decimal,
}
/// A tab stop, measured from the left edge of the text (area type: the frame's left edge).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TabStop {
pub position: f64,
#[serde(default)]
pub align: TabAlign,
/// Leader characters repeated across the tab's gap (e.g. ". ").
#[serde(default, skip_serializing_if = "String::is_empty")]
pub leader: String,
/// Decimal tabs align on this character.
#[serde(default = "default_align_on")]
pub align_on: char,
}
fn default_align_on() -> char {
'.'
}
/// Distance between default tab stops when no explicit stop applies (½ inch).
pub const DEFAULT_TAB_INTERVAL: f64 = 36.0;
/// The bullet of a bulleted list ([`ParaList::bullet`]): the Paragraph panel's Bullets presets.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Bullet {
/// A filled dot (•).
#[default]
Dot,
/// A dash (–).
Dash,
/// A hollow circle (◦).
Circle,
/// A filled square (▪).
Square,
}
impl Bullet {
/// The character the layout draws for the bullet.
pub fn character(self) -> char {
match self {
Bullet::Dot => '\u{2022}',
Bullet::Dash => '\u{2013}',
Bullet::Circle => '\u{25E6}',
Bullet::Square => '\u{25AA}',
}
}
}
/// How a numbered list writes its numbers ([`ParaList::number`]): the Paragraph panel's Numbered
/// List presets.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum NumberStyle {
/// 1, 2, 3…
#[default]
Arabic,
/// A, B, C… Z, AA, BB…
UpperLetters,
/// a, b, c… z, aa, bb…
LowerLetters,
/// I, II, III, IV…
UpperRoman,
/// i, ii, iii, iv…
LowerRoman,
/// 01, 02… 99, 100…
TwoDigits,
/// 001, 002… 999, 1000…
ThreeDigits,
}
/// Past this number, roman numerals and letters are written in Arabic digits.
const MAX_LETTERS_OR_ROMAN: u32 = 3999;
impl NumberStyle {
/// Every style, in the order the Numbered List presets list them.
pub const ALL: [NumberStyle; 7] = [
NumberStyle::Arabic,
NumberStyle::UpperLetters,
NumberStyle::LowerLetters,
NumberStyle::UpperRoman,
NumberStyle::LowerRoman,
NumberStyle::TwoDigits,
NumberStyle::ThreeDigits,
];
/// Number `n` (1 or more) written in this style, without a separator. Letters run A to Z, then
/// AA to ZZ, then AAA and so on; roman numerals use the subtractive forms (IV, IX, XL, XC, CD,
/// CM); both are written in Arabic digits past 3999. Two and three digits pad with zeros and
/// keep every digit of a longer number.
pub fn format(self, n: u32) -> String {
let letters_or_roman =
matches!(self, NumberStyle::UpperLetters | NumberStyle::LowerLetters | NumberStyle::UpperRoman | NumberStyle::LowerRoman);
if n == 0 || (letters_or_roman && n > MAX_LETTERS_OR_ROMAN) {
return n.to_string();
}
match self {
NumberStyle::Arabic => n.to_string(),
NumberStyle::TwoDigits => format!("{n:02}"),
NumberStyle::ThreeDigits => format!("{n:03}"),
NumberStyle::UpperLetters => letters(n),
NumberStyle::LowerLetters => letters(n).to_lowercase(),
NumberStyle::UpperRoman => roman(n),
NumberStyle::LowerRoman => roman(n).to_lowercase(),
}
}
}
/// `n` (1 or more) in capital letters: A to Z, then each letter twice, three times and so on.
fn letters(n: u32) -> String {
let n = n - 1;
let letter = char::from(b'A' + (n % 26) as u8);
std::iter::repeat_n(letter, (n / 26 + 1) as usize).collect()
}
/// `n` (1 to 3999) in capital roman numerals.
fn roman(mut n: u32) -> String {
const DIGITS: [(u32, &str); 13] = [
(1000, "M"),
(900, "CM"),
(500, "D"),
(400, "CD"),
(100, "C"),
(90, "XC"),
(50, "L"),
(40, "XL"),
(10, "X"),
(9, "IX"),
(5, "V"),
(4, "IV"),
(1, "I"),
];
let mut s = String::new();
for (value, digits) in DIGITS {
while n >= value {
s.push_str(digits);
n -= value;
}
}
s
}
/// The list a paragraph is in (Paragraph panel › Bullets and Numbered List). The layout draws the
/// list's marker before the paragraph's first line, in the font, size and color of the paragraph's
/// first character without its baseline shift, rotation, superscript or subscript, tracking, manual
/// kerning, tate-chu-yoko, underline and strikethrough ([`Self::marker_style`]), and the text after
/// it starts at the next tab stop, the left indent counting as a stop. The marker is not a
/// character of the text, and an empty paragraph has none. A saved list reads the fields it leaves
/// out as their defaults, and a build ignores the fields it doesn't know.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(default)]
pub struct ParaList {
/// The bullet each paragraph of a bulleted list starts with. A numbered list keeps it for
/// when the list is made bulleted again.
pub bullet: Bullet,
/// A numbered list's number style. A bulleted list has none, and the file saves it only for a
/// numbered list.
#[serde(skip_serializing_if = "Option::is_none")]
pub number: Option<NumberStyle>,
}
impl ParaList {
/// A bulleted list with `bullet`.
pub fn bulleted(bullet: Bullet) -> Self {
Self { bullet, number: None }
}
/// A numbered list with `style`.
pub fn numbered(style: NumberStyle) -> Self {
Self { bullet: Bullet::default(), number: Some(style) }
}
/// The characters of the marker the layout draws before the paragraph: the bullet, or for a
/// numbered list number `n` in its style followed by a period.
pub fn marker(&self, n: u32) -> String {
match self.number {
Some(style) => format!("{}.", style.format(n)),
None => self.bullet.character().to_string(),
}
}
/// The marker of the next paragraph of a story, whose list is `list` (None: in no list) and
/// which holds no text when `empty`, with `count` the numbered paragraphs counted before it.
/// A paragraph in no list sets the count back to 0 and has no marker; an empty paragraph has
/// none and leaves the count as it is; a bulleted paragraph has its bullet and leaves the
/// count as it is; a numbered paragraph adds one to the count and is numbered with it.
pub fn next_marker(list: Option<ParaList>, empty: bool, count: &mut u32) -> Option<String> {
let Some(list) = list else {
*count = 0;
return None;
};
if empty {
return None;
}
if list.number.is_some() {
*count = count.saturating_add(1);
}
Some(list.marker(*count))
}
/// The style the marker is set in: `first` (the style of the paragraph's first character)
/// without its baseline shift, rotation, superscript or subscript, tracking, manual kerning,
/// tate-chu-yoko, underline and strikethrough.
pub fn marker_style(first: &CharStyle) -> CharStyle {
CharStyle {
baseline_shift: 0.0,
rotation: 0.0,
position: CharPosition::Normal,
tracking: 0.0,
kerning: None,
tate_chu_yoko: false,
underline: false,
strikethrough: false,
..first.clone()
}
}
/// The list that the Type tool starts when `typed` and a space are typed at the start of a
/// paragraph: "1." or "1)" starts a numbered list in 1, 2, 3, "A." or "A)" starts one in A, B,
/// C, and "*", "#", ">" or "+" starts a bulleted list with the filled dot. Any other text starts
/// none.
pub fn typed(typed: &str) -> Option<ParaList> {
match typed {
"1." | "1)" => Some(Self::numbered(NumberStyle::Arabic)),
"A." | "A)" => Some(Self::numbered(NumberStyle::UpperLetters)),
"*" | "#" | ">" | "+" => Some(Self::bulleted(Bullet::Dot)),
_ => None,
}
}
}
/// The left and first-line indents (pt) a list gives a paragraph whose indents are both 0: the
/// marker hangs in the first line's 18 pt before the text.
pub const LIST_HANGING_INDENT: f64 = 18.0;
+335
View File
@@ -0,0 +1,335 @@
use super::*;
/// Character Direction is saved only when set, and styles written before it existed read as
/// Default.
#[test]
fn character_direction_is_saved_only_when_set() {
let st = CharStyle::default();
assert_eq!(st.char_direction, None);
assert!(serde_json::to_value(&st).unwrap().get("charDirection").is_none());
for (d, key) in [(ParaDirection::LeftToRight, "leftToRight"), (ParaDirection::RightToLeft, "rightToLeft")] {
let set = CharStyle { char_direction: Some(d), ..CharStyle::default() };
assert_eq!(serde_json::to_value(&set).unwrap()["charDirection"], key);
assert_eq!(serde_json::from_value::<CharStyle>(serde_json::to_value(&set).unwrap()).unwrap(), set);
}
let old = serde_json::to_value(&st).unwrap();
assert_eq!(serde_json::from_value::<CharStyle>(old).unwrap().char_direction, None);
}
/// Ruby saves with the type and a file saved before it (no `ruby` key) reads with none.
#[test]
fn ruby_is_saved_and_missing_ruby_reads_as_none() {
let mut t = TextObject::point(Point::ZERO, "漢字", CharStyle::default());
t.ruby = vec![RubySpan { size_percent: 40.0, overhang: false, ..RubySpan::new(0, 6, "かんじ") }];
let json = serde_json::to_string(&t).unwrap();
assert!(json.contains("かんじ"));
let back: TextObject = serde_json::from_str(&json).unwrap();
assert_eq!(back.ruby, t.ruby);
assert!((back.ruby[0].size_percent - 40.0).abs() < 1e-9 && !back.ruby[0].overhang);
// Old files have no `ruby` key: none.
let mut old: serde_json::Value = serde_json::from_str(&json).unwrap();
old.as_object_mut().unwrap().remove("ruby");
assert!(serde_json::from_value::<TextObject>(old).unwrap().ruby.is_empty());
// A damaged file's range past the text and its empty reading are clamped away.
let mut damaged = t.clone();
damaged.ruby = vec![RubySpan::new(0, 10_000, "かんじ"), RubySpan::new(3, 2, " "), RubySpan::new(0, 3, "")];
damaged.normalize_ruby();
assert_eq!((damaged.ruby[0].start, damaged.ruby[0].end), (0, 6));
}
/// Digits map every digit of the three sets into the chosen one, leave the rest alone, and
/// are saved only when set.
#[test]
fn digits_map_between_the_numeral_systems() {
let map = |d: Digits, s: &str| -> String { s.chars().map(|c| d.map(c)).collect() };
for s in ["0123456789", "٠١٢٣٤٥٦٧٨٩", "۰۱۲۳۴۵۶۷۸۹"] {
assert_eq!(map(Digits::Default, s), s);
assert_eq!(map(Digits::Arabic, s), "0123456789");
assert_eq!(map(Digits::Hindi, s), "٠١٢٣٤٥٦٧٨٩");
assert_eq!(map(Digits::Farsi, s), "۰۱۲۳۴۵۶۷۸۹");
}
// Letters, punctuation and other scripts' digits (Devanagari ३) are not digits of these sets.
assert_eq!(map(Digits::Hindi, "a-Z ३ ٪ ۔"), "a-Z ३ ٪ ۔");
let st = CharStyle::default();
assert!(serde_json::to_value(&st).unwrap().get("digits").is_none());
let farsi = CharStyle { digits: Digits::Farsi, ..CharStyle::default() };
assert_eq!(serde_json::to_value(&farsi).unwrap()["digits"], "farsi");
// Styles written before the attribute existed (no `digits` key) read as Default.
let old = serde_json::to_value(&st).unwrap();
assert_eq!(serde_json::from_value::<CharStyle>(old).unwrap().digits, Digits::Default);
assert_eq!(serde_json::from_value::<CharStyle>(serde_json::to_value(&farsi).unwrap()).unwrap(), farsi);
}
/// Auto leading follows the paragraph's Auto Leading; a set leading doesn't.
#[test]
fn paragraph_effective_leading_follows_auto_leading() {
let st = CharStyle { size: 10.0, leading: None, ..CharStyle::default() };
let mut para = ParaStyle::default();
assert_eq!(para.effective_leading(&st), st.effective_leading(), "default 120%");
para.justification.auto_leading = 200.0;
assert_eq!(para.effective_leading(&st), 20.0);
para.justification.auto_leading = 0.0;
assert_eq!(para.effective_leading(&st), 0.0);
let set = CharStyle { leading: Some(14.0), ..st.clone() };
assert_eq!(para.effective_leading(&set), 14.0);
para.justification.auto_leading = f64::NAN;
assert_eq!(para.effective_leading(&st), st.effective_leading(), "not a number");
}
/// A file can't feed layout justification values `text.setFormat` would refuse: each is
/// clamped to its range, one that isn't finite takes its default, and a Minimum, Desired and
/// Maximum out of order go back to their defaults.
#[test]
fn justification_options_read_from_a_file_stay_in_range() {
let d = JustificationOptions::default();
let read: ParaStyle = serde_json::from_value(serde_json::json!({
"justification": {
"word_spacing": [-5.0, 1e300, 1e308], "letter_spacing": [10.0, 5.0, 20.0],
"glyph_scaling": [1.0, 100.0, 9e9], "auto_leading": -1e12
}
}))
.unwrap();
let j = read.justification;
assert_eq!(j.word_spacing, [0.0, 1000.0, 1000.0]);
assert_eq!(j.letter_spacing, d.letter_spacing, "out of order");
assert_eq!(j.glyph_scaling, [50.0, 100.0, 200.0]);
assert_eq!(j.auto_leading, 0.0);
let mut j = JustificationOptions {
word_spacing: [f64::NAN, 100.0, f64::INFINITY],
letter_spacing: [-1.0, 0.0, f64::NEG_INFINITY],
auto_leading: f64::NAN,
..d.clone()
};
j.keep_in_range();
assert_eq!((j.word_spacing, j.letter_spacing, j.auto_leading), (d.word_spacing, [-1.0, 0.0, 0.0], d.auto_leading));
let mut same = d.clone();
same.keep_in_range();
assert_eq!(same, d);
}
/// Numbered lists: each number style writes its numbers and rolls over (letters after Z,
/// roman numerals in subtractive forms, zero-padded numbers past their digits), the marker adds
/// a period, and the count runs through list paragraphs, skips empty and bulleted ones and
/// starts again after a paragraph in no list. A list from before number styles reads as
/// bulleted, and a bulleted list saves no number style.
#[test]
fn number_styles_write_their_numbers_and_roll_over() {
let numbers = |style: NumberStyle, ns: &[u32]| ns.iter().map(|&n| style.format(n)).collect::<Vec<_>>();
assert_eq!(numbers(NumberStyle::Arabic, &[1, 9, 10, 123]), ["1", "9", "10", "123"]);
assert_eq!(
numbers(NumberStyle::UpperLetters, &[1, 2, 26, 27, 28, 52, 53, 3999, 4000]),
["A", "B", "Z", "AA", "BB", "ZZ", "AAA", &"U".repeat(154), "4000"]
);
assert_eq!(numbers(NumberStyle::LowerLetters, &[1, 26, 27]), ["a", "z", "aa"]);
assert_eq!(
numbers(NumberStyle::UpperRoman, &[1, 2, 3, 4, 5, 9, 14, 40, 90, 400, 1994, 3999, 4000]),
["I", "II", "III", "IV", "V", "IX", "XIV", "XL", "XC", "CD", "MCMXCIV", "MMMCMXCIX", "4000"]
);
assert_eq!(numbers(NumberStyle::LowerRoman, &[1, 4, 9, 12]), ["i", "iv", "ix", "xii"]);
assert_eq!(numbers(NumberStyle::TwoDigits, &[1, 9, 10, 99, 100]), ["01", "09", "10", "99", "100"]);
assert_eq!(numbers(NumberStyle::ThreeDigits, &[1, 10, 999, 1000]), ["001", "010", "999", "1000"]);
assert_eq!(ParaList::numbered(NumberStyle::UpperRoman).marker(4), "IV.");
assert_eq!(ParaList::bulleted(Bullet::Square).marker(4), "\u{25AA}");
let (num, dot) = (Some(ParaList::numbered(NumberStyle::Arabic)), Some(ParaList::bulleted(Bullet::Dot)));
let mut count = 0;
let markers: Vec<Option<String>> = [(num, false), (num, true), (dot, false), (num, false), (None, false), (num, false)]
.iter()
.map(|&(l, empty)| ParaList::next_marker(l, empty, &mut count))
.collect();
assert_eq!(markers, [Some("1.".into()), None, Some("\u{2022}".into()), Some("2.".into()), None, Some("1.".into())]);
assert_eq!(count, 1);
let read = |v: serde_json::Value| serde_json::from_value::<ParaList>(v).unwrap();
assert_eq!(read(serde_json::json!({"bullet": "dash"})), ParaList::bulleted(Bullet::Dash));
assert_eq!(read(serde_json::json!({"number": "threeDigits"})), ParaList::numbered(NumberStyle::ThreeDigits));
assert_eq!(serde_json::to_value(ParaList::bulleted(Bullet::Dash)).unwrap(), serde_json::json!({"bullet": "dash"}));
assert_eq!(
serde_json::to_value(ParaList::numbered(NumberStyle::LowerRoman)).unwrap(),
serde_json::json!({"bullet": "dot", "number": "lowerRoman"})
);
}
/// Typed list detection: "1.", "1)", "A." and "A)" and a space at the start of a paragraph
/// start a numbered list, and "*", "#", ">" and "+" and a space start a bulleted list with the
/// filled dot. Other text, the same characters later in a paragraph or without the space, and
/// a paragraph already in a list start nothing.
#[test]
fn typed_list_starts_are_found_at_a_paragraphs_start() {
let (arabic, letters, dot) =
(ParaList::numbered(NumberStyle::Arabic), ParaList::numbered(NumberStyle::UpperLetters), ParaList::bulleted(Bullet::Dot));
let typed = ["1.", "1)", "A.", "A)", "*", "#", ">", "+"].map(ParaList::typed);
assert_eq!(typed, [arabic, arabic, letters, letters, dot, dot, dot, dot].map(Some));
for other in ["", "2.", "a.", "a)", "I.", "1", "A", "1:", "-", "•", "1.)", " 1.", "*1", "1."] {
assert_eq!(ParaList::typed(other), None, "{other:?}");
}
let mut t = TextObject::point(Point::ZERO, "Fruit\n1. Apples\nA) x * y\n+ ", CharStyle::default());
assert_eq!(t.typed_list_at(9), Some((6, arabic)));
assert_eq!(t.typed_list_at(19), Some((16, letters)));
assert_eq!(t.typed_list_at(27), Some((25, dot)));
// Not at the paragraph's start, not right after the space, or past the end.
for at in [0, 5, 8, 10, 15, 23, 25, 26, 99] {
assert_eq!(t.typed_list_at(at), None, "{at}");
}
let mut styles = t.paragraph_styles();
styles[1].list = Some(ParaList::bulleted(Bullet::Square));
t.set_paragraph_styles(styles);
assert_eq!(t.typed_list_at(9), None, "a list paragraph stays as it is");
assert_eq!(t.typed_list_at(19), Some((16, letters)));
}
#[test]
fn character_controls_reject_collapsed_and_nonfinite_transforms() {
let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default());
for matrix in [[0.0; 6], [1.0, 1.0, 2.0, 2.0, 0.0, 0.0], [f64::INFINITY, 0.0, 0.0, 1.0, 0.0, 0.0], [1.0, 0.0, 0.0, 1.0, f64::NAN, 0.0]] {
t.xf = Affine::new(matrix);
assert!(t.effective_char_style(&t.first_style()).is_none());
}
t.xf = Affine::rotate(0.83);
assert_eq!(t.effective_char_style(&t.first_style()).unwrap().size, 12.0);
t.xf = Affine::scale(1e308);
assert!(t.effective_char_style(&t.first_style()).is_none(), "overflowing point size");
}
#[test]
fn point_text_basics() {
let t = TextObject::point(Point::new(10.0, 20.0), "Hello", CharStyle::default());
assert_eq!(t.plain_text(), "Hello");
let b = t.bounds().unwrap();
assert!(b.x0 >= 10.0 - 1e-9 && b.y1 > 20.0);
assert_eq!(CharStyle::default().effective_leading(), 14.399999999999999);
}
fn justified(j: Justify) -> ParaStyle {
ParaStyle { justify: j, ..ParaStyle::default() }
}
#[test]
fn paragraph_styles_split_merge_and_normalize() {
let mut t = TextObject::point(Point::ZERO, "one\ntwo\nthree", CharStyle::default());
assert_eq!((t.paragraph_count(), t.paras.len()), (3, 0), "one style for all: nothing stored");
assert_eq!(t.paragraphs_in(0, 0), 0..1);
assert_eq!(t.paragraphs_in(4, 4), 1..2);
assert_eq!(t.paragraphs_in(2, 9), 0..3);
assert_eq!(t.paragraphs_in(99, 99), 2..3, "clamped to the text");
t.edit_paras(Some(1..2), |p| p.justify = Justify::Center);
let js = |t: &TextObject| t.paragraph_styles().iter().map(|p| p.justify).collect::<Vec<_>>();
assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left]);
// Return inside "two": the new paragraph continues its style.
t.splice_paras(5, 5, "\n");
t.runs[0].text.insert(5, '\n');
assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Center, Justify::Left]);
// Deleting the break between "one" and "t": the first paragraph's style wins.
t.splice_paras(3, 4, "");
t.runs[0].text.remove(3);
assert_eq!(t.plain_text(), "onet\nwo\nthree");
assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left]);
// An edit that skips the splice is repaired by normalizing (the last style continues).
t.runs[0].text.push_str("\nfour");
t.normalize_paras();
assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left, Justify::Left]);
// `para` is paragraph 0's; all alike collapses to `para` alone.
t.edit_paras(Some(0..1), |p| p.justify = Justify::Right);
assert_eq!(t.para.justify, Justify::Right);
t.edit_paras(None, |p| p.justify = Justify::Right);
assert!(t.paras.is_empty() && t.para.justify == Justify::Right);
// A short list repeats its last entry, a long one is cut.
t.set_paragraph_styles(vec![justified(Justify::Left), justified(Justify::Center)]);
assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Center, Justify::Center]);
t.set_paragraph_styles(vec![justified(Justify::Center); 9]);
assert!(t.paras.is_empty() && t.para.justify == Justify::Center);
// Old documents (no `paras`) load; new ones keep paragraph 0 in `para`.
t.edit_paras(Some(3..4), |p| p.space_before = 4.0);
let json = serde_json::to_value(&t).unwrap();
assert_eq!(json["para"]["justify"], json["paras"][0]["justify"]);
let mut old = json.clone();
old.as_object_mut().unwrap().remove("paras");
let back: TextObject = serde_json::from_value(old).unwrap();
assert!(back.paras.is_empty() && back.para_at(3).justify == Justify::Center);
let back: TextObject = serde_json::from_value(json).unwrap();
assert_eq!(back.para_at(3).space_before, 4.0);
}
/// 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());
t.kind = TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 20.0)) };
t.xf = Affine::translate((40.0, 40.0)) * Affine::scale(2.0);
t
}
#[test]
fn area_resizes_its_frame_and_keeps_its_type() {
let mut t = area_type();
assert_eq!(t.bounds(), Some(Rect::new(40.0, 40.0, 160.0, 80.0)));
// Bottom-right handle to (200, 140), about the top-left corner.
let o = Affine::translate((40.0, 40.0));
assert!(t.transform_area(o * Affine::scale_non_uniform(160.0 / 120.0, 100.0 / 40.0) * o.inverse()));
assert_eq!(t.xf, Affine::translate((40.0, 40.0)) * Affine::scale(2.0), "the type keeps its size");
let b = t.bounds().unwrap();
assert!((b.x1 - 200.0).abs() < 1e-9 && (b.y1 - 140.0).abs() < 1e-9 && b.x0 == 40.0, "{b:?}");
assert_eq!(t.area_frame().unwrap().bounds(), Some(b));
// Point type has no area; a degenerate transform or a non-finite frame changes nothing.
let mut p = TextObject::point(Point::ZERO, "x", CharStyle::default());
assert!(!p.transform_area(Affine::scale(2.0)) && p.area_frame().is_none());
let before = t.clone();
assert!(!t.transform_area(Affine::scale(f64::INFINITY)));
t.xf = Affine::scale(0.0);
assert!(!t.transform_area(Affine::scale(2.0)));
t.xf = before.xf;
assert_eq!(t, before);
}
#[test]
fn area_anchors_move_in_document_space() {
let mut t = area_type();
// The bottom-right corner (anchor 2) 20 pt right: 10 pt in text space at 2×.
assert!(t.move_area_anchors(&[(0, 2)], Vec2::new(20.0, 0.0)));
let TextKind::Area { frame } = &t.kind else { panic!() };
assert_eq!(frame.subpaths[0].anchors[2].p, Point::new(70.0, 20.0));
assert_eq!(frame.subpaths[0].anchors[1].p, Point::new(60.0, 0.0), "the others stay");
// A missing anchor changes nothing.
let before = t.clone();
assert!(!t.move_area_anchors(&[(0, 1), (0, 9)], Vec2::new(5.0, 5.0)));
assert!(!t.move_area_anchors(&[(3, 0)], Vec2::new(5.0, 5.0)));
assert_eq!(t, before);
}
/// Builds from before [`Justify::Auto`] read new type's alignment as Left or Right (and ignore
/// `justify_auto`); this build reads Auto back.
#[test]
fn auto_alignment_saves_readable_by_older_builds() {
#[derive(Deserialize, Debug, PartialEq)]
enum OldJustify {
Left,
Center,
Right,
JustifyLeft,
JustifyCenter,
JustifyRight,
JustifyAll,
}
#[derive(Deserialize)]
struct OldPara {
justify: OldJustify,
}
#[derive(Deserialize)]
struct OldText {
para: OldPara,
}
for (direction, physical) in
[(Some(ParaDirection::RightToLeft), OldJustify::Right), (Some(ParaDirection::LeftToRight), OldJustify::Left), (None, OldJustify::Left)]
{
let mut t = TextObject::point(Point::ZERO, "שלום", CharStyle::default());
t.para = ParaStyle { justify: Justify::Auto, direction, ..Default::default() };
let json = serde_json::to_string(&t).unwrap();
let old: OldText = serde_json::from_str(&json).unwrap();
assert_eq!(old.para.justify, physical, "{json}");
assert_eq!(serde_json::from_str::<TextObject>(&json).unwrap(), t);
}
// Other alignments save as before, without the flag.
let p = ParaStyle { justify: Justify::Right, ..Default::default() };
let json = serde_json::to_string(&p).unwrap();
assert!(!json.contains("justify_auto"), "{json}");
assert_eq!(serde_json::from_str::<ParaStyle>(&json).unwrap(), p);
// Files from before the flag keep their alignment.
assert_eq!(serde_json::from_str::<ParaStyle>(r#"{"justify":"Center"}"#).unwrap().justify, Justify::Center);
}
+293
View File
@@ -0,0 +1,293 @@
use super::*;
/// Measurement units (display only; the model is always in points).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Unit {
#[default]
Points,
Picas,
Inches,
Millimeters,
Centimeters,
Pixels,
FeetInches,
Meters,
Yards,
Feet,
}
impl Unit {
pub const ALL: [Unit; 10] = [
Unit::Points,
Unit::Picas,
Unit::Inches,
Unit::Millimeters,
Unit::Centimeters,
Unit::Pixels,
Unit::FeetInches,
Unit::Meters,
Unit::Yards,
Unit::Feet,
];
/// Points per unit.
pub fn points(self) -> f64 {
match self {
Unit::Points | Unit::Pixels => 1.0,
Unit::Picas => 12.0,
Unit::Inches => 72.0,
Unit::Millimeters => 72.0 / 25.4,
Unit::Centimeters => 72.0 / 2.54,
Unit::Meters => 72.0 / 0.0254,
Unit::Yards => 72.0 * 36.0,
Unit::Feet | Unit::FeetInches => 72.0 * 12.0,
}
}
pub fn suffix(self) -> &'static str {
match self {
Unit::Points => "pt",
Unit::Picas => "p",
Unit::Inches => "in",
Unit::Millimeters => "mm",
Unit::Centimeters => "cm",
Unit::Pixels => "px",
Unit::FeetInches | Unit::Feet => "ft",
Unit::Meters => "m",
Unit::Yards => "yd",
}
}
pub fn label(self) -> &'static str {
match self {
Unit::Points => "Points",
Unit::Picas => "Picas",
Unit::Inches => "Inches",
Unit::Millimeters => "Millimeters",
Unit::Centimeters => "Centimeters",
Unit::Pixels => "Pixels",
Unit::FeetInches => "Feet & Inches",
Unit::Meters => "Meters",
Unit::Yards => "Yards",
Unit::Feet => "Feet",
}
}
/// The rulers' label step at `zoom` (screen pixels per point), in this unit: the first of
/// 1, 2, 5 × 10ⁿ that puts labels at least 50 pixels apart. Ticks mark every tenth of it.
pub fn ruler_step(self, zoom: f64) -> f64 {
const DECIMAL: [f64; 19] =
[0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0, 5000.0, 10000.0];
// An inch is divided in half, not in ten: a tenth of an inch is not a mark an inch ruler
// carries, so below an inch the steps are binary fractions (#2827). Labelling stops at a
// sixteenth; zooming in past that leaves the labels where they are and only the ticks
// between them grow denser, down to a hundred and twenty-eighth
// ([`Self::ruler_minor`]). A label is at most six characters that way ("0.0625"), which
// the left ruler has room for when it spells one down the ruler a character at a time.
// From an inch up the ladder is the shared one.
const BINARY: [f64; 15] = [1.0 / 16.0, 0.125, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 10000.0];
let steps: &[f64] = if self.ruler_halves() { &BINARY } else { &DECIMAL };
steps.iter().copied().find(|s| s * self.points() * zoom >= 50.0).unwrap_or(10000.0)
}
/// Does this unit's ruler divide a step in half rather than in ten? Inches do; the metric
/// units, points, pixels and picas are read in tenths.
fn ruler_halves(self) -> bool {
matches!(self, Unit::Inches)
}
/// The unlabelled ticks between two labels, `step` apart ([`Self::ruler_step`]): an eighth of
/// the step on an inch ruler, so every tick is a binary fraction of an inch and they stay about
/// as dense as the tenths elsewhere; a tenth of it otherwise.
pub fn ruler_minor(self, step: f64) -> f64 {
if self.ruler_halves() { step / 8.0 } else { step / 10.0 }
}
/// `v` (points) on the nearest ruler tick at `zoom` ([`Self::ruler_step`]).
pub fn snap_to_ruler_tick(self, v: f64, zoom: f64) -> f64 {
let tick = self.ruler_minor(self.ruler_step(zoom)) * self.points();
(v / tick).round() * tick
}
pub fn from_pt(self, v: f64) -> f64 {
v / self.points()
}
pub fn to_pt(self, v: f64) -> f64 {
v * self.points()
}
/// Format a point value in this unit the way fields show it (e.g. `12.5 pt`, `3 in`).
pub fn format(self, pt: f64) -> String {
format!("{} {}", self.number(pt), self.suffix())
}
/// [`Unit::format`] without the suffix (`12.5`), for narrow fields.
pub fn number(self, pt: f64) -> String {
// Three decimals for the small units (`595.276 pt`), four for the large ones (`8.2677 in`,
// `35.2778 mm`, the `0.0078 in` stroke preset); trailing zeros are trimmed (`1 pt` is `1`).
let decimals = if matches!(self, Unit::Points | Unit::Pixels | Unit::Picas) { 3 } else { 4 };
let s = format!("{:.*}", decimals, self.from_pt(pt));
let s = s.trim_end_matches('0').trim_end_matches('.');
if s == "-0" { "0".into() } else { s.into() }
}
/// Parse `12`, `12pt`, `1in`, `3 mm`, `2p6` (picas+points), simple `+ - * /` arithmetic.
///
/// A unit after a `*` or `/` operand measures the whole expression (`1080/2 px` is 540 px), so
/// math typed before a field's unit suffix works. After `*` or `/`, a percentage scales the term
/// before it (`3cm*50%` is 1.5 cm, `3cm/50%` is 6 cm). After `+` or `-`, it adds or takes away
/// that share of everything before it (`50pt+25%` is 62.5 pt, `100pt+20pt-50%` is 60 pt). A
/// percentage on its own (`50%`) reads as nothing.
pub fn parse(self, s: &str) -> Option<f64> {
parse_measure(s, self).filter(|v| v.is_finite())
}
/// The value naming this unit in the Units preferences (`points`, `millimeters`,
/// `feetInches`).
pub fn key(self) -> &'static str {
match self {
Unit::Points => "points",
Unit::Picas => "picas",
Unit::Inches => "inches",
Unit::Millimeters => "millimeters",
Unit::Centimeters => "centimeters",
Unit::Pixels => "pixels",
Unit::FeetInches => "feetInches",
Unit::Meters => "meters",
Unit::Yards => "yards",
Unit::Feet => "feet",
}
}
/// The unit `name` names: its label, suffix or [`Unit::key`] in any case, ignoring spaces and
/// punctuation (`Millimeters`, `mm`, `Feet & Inches`, `feetInches`).
pub fn named(name: &str) -> Option<Unit> {
let letters = |s: &'static str| s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase());
let given = || name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase());
// Feet and Feet & Inches share the suffix `ft`: it names the plain unit, so Feet & Inches
// is left to its label and key.
Unit::ALL
.into_iter()
.find(|u| letters(u.label()).eq(given()))
.or_else(|| Unit::ALL.into_iter().filter(|u| *u != Unit::FeetInches).find(|u| u.suffix().chars().eq(given())))
}
/// 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())
}
}
fn parse_measure(s: &str, default: Unit) -> Option<f64> {
let s = s.trim();
// Simple arithmetic on the right (Illustrator fields accept "10+5", "100/2", "3in*2").
// `+` and `-` share one precedence level, as do `*` and `/`, so each level splits at the last
// of its operators (left to right).
for ops in [&['+', '-'][..], &['*', '/'][..]] {
// Past the first character, so a leading sign isn't an operator.
let skip = s.chars().next().map_or(0, char::len_utf8);
// A `+` or `-` right after another operator is the sign of its operand (`10*-2`, `5--3`).
let is_sign = |i: usize| {
s.get(i..).and_then(|r| r.chars().next()).is_some_and(|c| matches!(c, '+' | '-'))
&& s.get(..i).and_then(|l| l.trim_end().chars().last()).is_some_and(|c| "+-*/".contains(c))
};
let found = s.char_indices().rev().find(|&(i, c)| i >= skip && ops.contains(&c) && !is_sign(i));
if let Some(i) = found.map(|(i, _)| i) {
let (l, r) = (&s[..i], &s[i + 1..]);
if l.trim().is_empty() {
continue;
}
let Some(op) = s[i..].chars().next() else { continue };
return match op {
'+' | '-' => {
let a = parse_measure(l, default)?;
// A percentage added or taken away is a share of everything before it:
// `50pt+25%` is 62.5 pt, `100pt+20%-10pt` is 110 pt.
let b = match percent(r) {
Some(k) => a * k,
None => parse_measure(r, default)?,
};
Some(if op == '+' { a + b } else { a - b })
}
_ => {
// A factor or divisor is a plain number or a percentage (`3cm*50%` is 1.5 cm);
// a unit after it is the expression's.
let (k, unit) = match percent(r) {
Some(k) => (k, None),
None => number_unit(r)?,
};
let a = parse_measure(l, unit.unwrap_or(default))?;
if op == '*' {
Some(a * k)
} else if k == 0.0 {
None
} else {
Some(a / k)
}
}
};
}
}
// Plain decimals only, as in `number_unit` (`str::parse` would accept `1e5`, `inf`, `nan`).
let plain = |t: &str| t.trim().chars().all(|c| c.is_ascii_digit() || c == '.' || c == '-') && t.trim().parse::<f64>().is_ok();
if let Some((p, pt)) = s.split_once('p')
&& !p.is_empty()
&& plain(p)
&& (pt.is_empty() || plain(pt))
&& !s.ends_with("pt")
&& !s.ends_with("px")
{
// The sign belongs to the whole value: `-2p6` is -(2 picas + 6 points).
let sign = if p.trim_start().starts_with('-') { -1.0 } else { 1.0 };
let picas = p.trim().parse::<f64>().ok()?.abs();
return Some(sign * (picas * 12.0 + pt.trim().parse::<f64>().unwrap_or(0.0)));
}
let (v, unit) = number_unit(s)?;
Some(unit.unwrap_or(default).to_pt(v))
}
/// A number with an optional unit suffix (`12`, `3 mm`, `2in`) → (the number, its unit).
fn number_unit(s: &str) -> Option<(f64, Option<Unit>)> {
let s = s.trim();
// A leading `+` is a sign (`+5`), like `-`.
let sign = usize::from(s.starts_with('+'));
let num_end = s.get(sign..)?.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-')).map_or(s.len(), |i| i + sign);
let v: f64 = s.get(..num_end)?.trim().parse().ok()?;
let unit = match s.get(num_end..)?.trim() {
"" => None,
"pt" => Some(Unit::Points),
"px" => Some(Unit::Pixels),
"in" | "\"" => Some(Unit::Inches),
"mm" => Some(Unit::Millimeters),
"cm" => Some(Unit::Centimeters),
"m" => Some(Unit::Meters),
"ft" | "'" => Some(Unit::Feet),
"yd" => Some(Unit::Yards),
"p" | "pc" => Some(Unit::Picas),
_ => return None,
};
Some((v, unit))
}
/// A number followed by `%` (`50%`, `12.5 %`) → its fraction (`0.5`, `0.125`).
fn percent(s: &str) -> Option<f64> {
match number_unit(s.trim().strip_suffix('%')?)? {
(p, None) => Some(p / 100.0),
_ => None,
}
}
/// A unitless field value (percent, degrees, counts) with the same `+ - * /` arithmetic as
/// [`Unit::parse`] (`45*2`, `100/3`); text other than digits, `.` and operators reads as nothing.
pub fn parse_number(s: &str) -> Option<f64> {
if !s.chars().all(|c| c.is_ascii_digit() || " .+-*/".contains(c)) {
return None;
}
// A leading `+` is a sign (`+5`), as a plain number read it.
let s = s.trim();
Unit::Points.parse(s.strip_prefix('+').unwrap_or(s))
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ColorMode {
#[default]
Rgb,
Cmyk,
}
impl ColorMode {
/// The colour model new colours take in a document of this mode.
pub fn model(self) -> vectorcraft_color::cms::Model {
match self {
ColorMode::Rgb => vectorcraft_color::cms::Model::Rgb,
ColorMode::Cmyk => vectorcraft_color::cms::Model::Cmyk,
}
}
}