mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 01:50:22 -06:00
Merge main into feat/native-save-precision
This commit is contained in:
+93
-951
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)) }
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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 => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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}")))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, &[]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>>>,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user