Merge remote-tracking branch 'upstream/main' into fix/ai-type-pattern-paint

# Conflicts:
#	docs/file-format-parity.md
This commit is contained in:
jmverges
2026-11-12 18:02:58 +00:00
164 changed files with 10435 additions and 781 deletions
+4 -7
View File
@@ -35,7 +35,6 @@ trait Backend {
fn suspend(&mut self, window: &Self::Window);
fn restore(&mut self, window: &Self::Window);
fn build(&mut self, request: &Request) -> Option<Self::Cursor>;
fn current(&self) -> Option<Self::Cursor>;
fn set(&mut self, cursor: &Self::Cursor);
}
@@ -73,9 +72,10 @@ impl<B: Backend> State<B> {
&& *key == request.key
&& *owned == window
{
if self.backend.current().as_ref() != Some(cursor) {
self.backend.set(cursor);
}
// AppKit can show the system arrow when the pointer moves while
// `NSCursor.currentCursor` (the app's last set cursor) still reads as ours, so a
// comparison would keep the arrow. Setting the cursor again costs nothing.
self.backend.set(cursor);
return;
}
self.release();
@@ -135,9 +135,6 @@ impl Backend for AppKit {
fn build(&mut self, request: &Request) -> Option<Self::Cursor> {
build(&request.bitmap)
}
fn current(&self) -> Option<Self::Cursor> {
Some(NSCursor::currentCursor())
}
fn set(&mut self, cursor: &Self::Cursor) {
cursor.set();
}
+5 -14
View File
@@ -6,7 +6,6 @@ use std::cell::Cell;
struct Fake {
window: Option<u8>,
suspended: Vec<u8>,
current: Option<Key>,
sets: usize,
builds: usize,
restores: Vec<u8>,
@@ -27,17 +26,12 @@ impl Backend for Fake {
fn restore(&mut self, w: &u8) {
self.suspended.retain(|v| v != w);
self.restores.push(*w);
self.current = None;
}
fn build(&mut self, r: &Request) -> Option<Key> {
self.builds += 1;
(!self.fail).then_some(r.key)
}
fn current(&self) -> Option<Key> {
self.current
}
fn set(&mut self, cursor: &Key) {
self.current = Some(*cursor);
fn set(&mut self, _: &Key) {
self.sets += 1;
}
}
@@ -66,19 +60,16 @@ fn deliver(state: &mut State<Fake>, request: Option<Request>) {
}
#[test]
fn mac_cursor_ownership_deduplicates_reasserts_and_restores() {
fn mac_cursor_ownership_reasserts_every_pass_and_restores() {
let req = request(1.0);
let mut state = fake_state();
deliver(&mut state, Some(req.clone()));
assert_eq!(state.backend.suspended, [1]);
assert_eq!((state.backend.builds, state.backend.sets), (1, 1));
// The pointer moves and AppKit shows the system arrow, but the app's current cursor
// still reads as ours. The next pass sets it again without rebuilding or re-suspending.
deliver(&mut state, Some(req.clone()));
assert_eq!(state.backend.sets, 1);
// Simulate a platform fallback replacing the cursor after finalization. The next pass
// reasserts; this proves the protocol, not AppKit timing or absence of transient flicker.
state.backend.current = None;
deliver(&mut state, Some(req.clone()));
assert_eq!(state.backend.sets, 2);
assert_eq!((state.backend.builds, state.backend.sets, state.backend.suspended.len()), (1, 2, 1));
deliver(&mut state, None);
assert_eq!(state.backend.restores, [1]);
deliver(&mut state, Some(req.clone()));
+12 -9
View File
@@ -370,11 +370,14 @@ pub struct ArtBrush {
pub scale: ArtScale,
/// Width in percent.
pub width: f64,
/// How the width varies along a stroke: `None` (Fixed) always `width`; Pressure goes from
/// `width` at the lightest pen pressure to [`Self::width_max`] at the heaviest.
/// How the width varies along a stroke: `None` (Fixed) always `width`; a mode that follows the
/// pen goes from `width` to [`Self::width_max`] over the ranges [`Variation`] gives: Pressure
/// from the lightest pressure to the heaviest, Tilt from the pen lying flat to upright, Bearing
/// and Rotation from −180° to 180° (half way at 0°).
#[serde(skip_serializing_if = "Option::is_none")]
pub width_mode: Option<Variation>,
/// The width at the heaviest pen pressure (%), for Pressure (`width` without one).
/// The second width (%) of a mode that follows the pen: at the heaviest pressure, upright, or
/// at 180° (`width` without one).
#[serde(skip_serializing_if = "Option::is_none")]
pub width_max: Option<f64>,
pub flip_along: bool,
@@ -388,11 +391,11 @@ impl ArtBrush {
/// The range of the width (%).
pub const WIDTH_RANGE: (f64, f64) = (1.0, 1000.0);
/// The widths (%) at the lightest and the heaviest pen pressure: `width` for both unless the
/// width follows the pressure.
/// The widths (%) where the pen reads 0 and 1 for the width's mode (the lightest and the
/// heaviest pressure, for Pressure): `width` for both unless the width follows the pen.
pub fn width_range(&self) -> (f64, f64) {
match self.width_mode {
Some(Variation::Pressure) => (self.width, self.width_max.unwrap_or(self.width)),
Some(m) if m.follows_pen() => (self.width, self.width_max.unwrap_or(self.width)),
_ => (self.width, self.width),
}
}
@@ -713,8 +716,8 @@ impl Brush {
}
BrushKind::Art(a) => {
a.width = within(a.width, ArtBrush::WIDTH_RANGE, 100.0);
// The width follows the pen's pressure or stays fixed.
if a.width_mode != Some(Variation::Pressure) {
// The width follows the pen (its pressure, tilt, bearing or rotation) or stays fixed.
if !a.width_mode.is_some_and(Variation::follows_pen) {
a.width_mode = None;
}
a.width_max = a.width_max.map(|w| within(w, ArtBrush::WIDTH_RANGE, a.width));
@@ -806,7 +809,7 @@ pub fn stroke_pieces(doc: &Document, brush: &Brush, bp: &BezPath, stroke: &Strok
let mut out = match &brush.kind {
BrushKind::Calligraphic(c) => calli::calligraphic(c, bp, w, stroke, &brush.name),
BrushKind::Bristle(b) => calli::bristle(b, bp, w, &stroke.paint, &brush.name),
BrushKind::Art(a) => warp::art(a, bp, w, stroke.pressure.as_ref()),
BrushKind::Art(a) => warp::art(a, bp, w, stroke),
BrushKind::Scatter(s) => warp::scatter(s, bp, w, stroke, &brush.name),
BrushKind::Pattern(p) => warp::pattern(p, bp, w),
};
+56
View File
@@ -1371,6 +1371,62 @@ fn art_brush_width_follows_the_pressure() {
assert!(plain.get("width_mode").is_none() && plain.get("width_max").is_none(), "{plain}");
}
/// Art Brush Options: Width on Tilt goes from Width with the pen lying flat to the second width
/// upright, and on Bearing or Rotation from Width at −180° to the second width at 180°, along the
/// stroke as the pen recorded it; each mode reads only its own input.
#[test]
fn art_brush_width_follows_tilt_bearing_and_rotation() {
let brush = |mode: Variation| {
let mut b = art_brush(rect_art(10.0, 4.0), ArtScale::Stretch);
if let BrushKind::Art(a) = &mut b.kind {
(a.width, a.width_mode, a.width_max) = (50.0, Some(mode), Some(300.0));
}
b
};
let bp = line(0.0, 0.0, 100.0, 0.0);
let draw = |b: &Brush, st: &StrokeLayer| stroke_pieces(&Document::new(1.0, 1.0), b, &bp, st);
// Tilt, upright at the start (300 %: 6 pt either side of 4 pt art) to flat at the end (50 %: 1 pt).
let upright_to_flat = StrokeLayer { tilt: Some(vectorcraft_doc::PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] }), ..stroke(1.0) };
let out = draw(&brush(Variation::Tilt), &upright_to_flat);
assert!(near(half_width(&out, 0.0, 1.0), 6.0, 0.1), "{}", half_width(&out, 0.0, 1.0));
assert!(near(half_width(&out, 99.0, 100.0), 1.0, 0.1), "{}", half_width(&out, 99.0, 100.0));
assert!(near(half_width(&out, 49.0, 51.0), 3.5, 0.15), "{}", half_width(&out, 49.0, 51.0));
// Bearing and Rotation: half way at 0°, a quarter of the way at −90°, three quarters at 90°.
for (mode, angle, half) in [
(Variation::Bearing, 0.0, 3.5),
(Variation::Bearing, -90.0, 2.25),
(Variation::Bearing, 90.0, 4.75),
(Variation::Rotation, -90.0, 2.25),
(Variation::Rotation, 90.0, 4.75),
] {
let st = if mode == Variation::Bearing { posed(Some(0.5), Some(angle), None) } else { posed(None, None, Some(angle)) };
let h = bounds(&draw(&brush(mode), &st)).height();
assert!(near(h, 2.0 * half, 1e-6), "{mode:?} {angle}: {h}");
}
// Each mode reads its own input: without it (the pressure, or another of the pen's values
// recorded instead), the art is half way between the two widths.
let others = [
(Variation::Tilt, posed(None, Some(90.0), Some(90.0))),
(Variation::Tilt, pressed(vec![(0.0, 0.0), (1.0, 1.0)])),
(Variation::Bearing, posed(Some(0.0), None, Some(90.0))),
(Variation::Rotation, posed(Some(0.0), Some(90.0), None)),
(Variation::Pressure, posed(Some(0.0), Some(90.0), Some(90.0))),
];
for (mode, st) in others {
let h = bounds(&draw(&brush(mode), &st)).height();
assert!(near(h, 7.0, 1e-6), "{mode:?}: {h}");
}
// The pen's modes are kept when a definition is read, and Random is read as Fixed.
let lib = parse_library(&serde_json::json!([
{"name": "t", "type": "art", "art": rect_art(10.0, 4.0), "width_mode": "tilt", "width_max": 300.0},
{"name": "b", "type": "art", "art": rect_art(10.0, 4.0), "width_mode": "bearing", "width_max": 300.0},
{"name": "r", "type": "art", "art": rect_art(10.0, 4.0), "width_mode": "rotation", "width_max": 300.0},
{"name": "x", "type": "art", "art": rect_art(10.0, 4.0), "width_mode": "random", "width_max": 300.0},
]));
let modes: Vec<_> = lib.iter().map(|b| if let BrushKind::Art(a) = &b.kind { a.width_mode } else { None }).collect();
assert_eq!(modes, [Some(Variation::Tilt), Some(Variation::Bearing), Some(Variation::Rotation), None]);
}
/// Definitions from files and commands are kept within their ranges.
#[test]
fn sanitize_keeps_values_within_their_ranges() {
+6 -5
View File
@@ -8,7 +8,7 @@
use std::sync::Arc;
use kurbo::ParamCurveNearest;
use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind, PressureProfile, StrokeLayer};
use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_geom::{Affine, BezPath, CubicBez, ParamCurve, PathData, PathEl, Point, Rect, SubPath, Vec2};
use crate::track::{Rng, Track, normal, seed_of, tracks};
@@ -503,9 +503,10 @@ fn art_on_run(
bend_widened(t, art, b, along, cross, widen)
}
/// An art brush's art along `bp`. With a width that follows the pen's pressure, the art is as
/// wide as the pressure recorded at each point of the stroke gives (half way without a recording).
pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, pressure: Option<&PressureProfile>) -> Vec<Node> {
/// An art brush's art along `bp`. With a width that follows the pen (its pressure, tilt, bearing
/// or rotation), the art is as wide as what `stroke` recorded at each point gives (half way
/// without a recording).
pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, stroke: &StrokeLayer) -> Vec<Node> {
let Some((art, b)) = oriented(&a.art, a.direction, a.flip_along, a.flip_across) else { return vec![] };
let (light, heavy) = a.width_range();
let width = light.max(heavy);
@@ -522,7 +523,7 @@ pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, pressure: Option<&Pre
}
// A run past the start of a closed track wraps round to it.
let at = if s > l { s - l } else { s };
let p = pressure.map_or(PressureProfile::MID, |p| p.at(at / l)).clamp(0.0, 1.0);
let p = a.width_mode.and_then(|m| PenAt::of(stroke, at / l).control(m)).unwrap_or(0.5);
(light + (heavy - light) * p) / width
};
let runs = if a.overlap { art_runs(&t) } else { vec![(0.0, l)] };
+7
View File
@@ -202,6 +202,13 @@ impl AngleProfile {
if v.is_finite() { vectorcraft_geom::normalize_deg(v) } else { 0.0 }
}
/// The angle at `t` (0..1) as [`Self::at`] reads it, but in the samples' own running degrees,
/// so the whole turns the curve makes along the stroke are kept; 0 without any samples.
pub fn unwrapped_at(&self, t: f64) -> f64 {
let v = interpolated(&self.points, t).unwrap_or(0.0);
if v.is_finite() { v } else { 0.0 }
}
/// A profile from (position, degrees) samples in order along the stroke: non-finite ones are
/// dropped, each sample is joined to the one before it the shorter way round, and the curve is
/// simplified to within 1° and kept to [`PressureProfile::MAX_POINTS`]. `None` without a sample.
+159 -5
View File
@@ -5,7 +5,8 @@
//! winding and to the start points that keep them from twisting, or to the anchors the Blend
//! tool clicked.
//! - **Paint**: colours, gradients (stops resampled to a common set when their counts differ),
//! opacity, stroke weight, dashes, miter limit and width profiles interpolate; what can't
//! opacity, stroke weight, dashes, miter limit, width profiles and the pen pressure, tilt,
//! bearing and rotation that brushes read interpolate; what can't
//! (caps, joins, brushes, fonts, symbols, text content) switches halfway.
//! - **Groups** pair their children in stacking order; the children one group has more of grow
//! out of the other group's centre. Compound paths stay compound paths.
@@ -22,7 +23,7 @@ use vectorcraft_color::{Color, Gradient, GradientPaint, GradientStop, Paint};
use vectorcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveDeriv};
use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2};
use crate::appearance::{Appearance, AppearanceItem, Dash, StrokeLayer, WidthProfile};
use crate::appearance::{AngleProfile, Appearance, AppearanceItem, Dash, PressureProfile, StrokeLayer, WidthProfile};
use crate::live::{BlendOrientation, BlendSpacing, BlendSpec};
use crate::node::{Node, NodeId, NodeKind};
use crate::text::TextObject;
@@ -255,9 +256,101 @@ fn lerp_profile(x: Option<&WidthProfile>, y: Option<&WidthProfile>, t: f64) -> O
Some(WidthProfile { points })
}
/// Interpolate a stroke's paint, weight, opacity, miter limit, dashes, arrowhead scale and width
/// profile into `o` (a copy of the nearer stroke, so caps, joins, alignment, arrowheads and
/// brushes switch halfway).
/// The positions along the stroke where either of two pen recordings has a sample, in order.
fn sample_positions(x: Option<&[(f64, f64)]>, y: Option<&[(f64, f64)]>) -> Vec<f64> {
let mut at: Vec<f64> = x.unwrap_or_default().iter().chain(y.unwrap_or_default()).map(|p| p.0).filter(|p| p.is_finite()).collect();
at.sort_by(f64::total_cmp);
at.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
if at.is_empty() {
at = vec![0.0, 1.0];
}
at
}
/// The value pen recording `p` steps to at position `f`, where two or more of its samples share
/// that position (reading at `f` gives the first of them); None where it doesn't step.
fn step_after(p: Option<&[(f64, f64)]>, f: f64) -> Option<f64> {
let p = p?;
let i = p.partition_point(|q| q.0 < f - 1e-9);
let j = p.partition_point(|q| q.0 <= f + 1e-9).checked_sub(1)?;
(j > i && (p.get(i)?.0 - f).abs() <= 1e-9).then(|| p.get(j).map(|q| q.1)).flatten()
}
/// Two pen recordings read at every position either has a sample, as (position, x's value, y's
/// value): `read_x` and `read_y` read them, and `clean` makes a stepped sample's value one they
/// would read. A position where either steps gives two, before and after the step.
fn paired_samples(
x: Option<&[(f64, f64)]>,
y: Option<&[(f64, f64)]>,
read_x: impl Fn(f64) -> f64,
read_y: impl Fn(f64) -> f64,
clean: impl Fn(f64) -> f64,
) -> Vec<(f64, f64, f64)> {
let mut out = Vec::new();
for f in sample_positions(x, y) {
let (a, b) = (read_x(f), read_y(f));
out.push((f, a, b));
let (sa, sb) = (step_after(x, f), step_after(y, f));
if sa.is_some() || sb.is_some() {
out.push((f, sa.map_or(a, &clean), sb.map_or(b, &clean)));
}
}
out
}
/// Interpolate pen pressure or tilt recordings at every position either has a sample, keeping
/// their steps. A stroke without one reads as [`PressureProfile::MID`], as brushes read it; None
/// when neither has one.
fn lerp_pressure(x: Option<&PressureProfile>, y: Option<&PressureProfile>, t: f64) -> Option<PressureProfile> {
if x.is_none() && y.is_none() {
return None;
}
let read = |p: Option<&PressureProfile>, f: f64| p.map_or(PressureProfile::MID, |p| p.at(f));
let clean = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) } else { PressureProfile::MID };
let samples = paired_samples(x.map(|p| p.points.as_slice()), y.map(|p| p.points.as_slice()), |f| read(x, f), |f| read(y, f), clean);
PressureProfile::from_samples(samples.into_iter().map(|(f, a, b)| (f, lerp_f64(a, b, t))))
}
/// Interpolate pen angle recordings (bearing or barrel rotation) at every position either has a
/// sample: the steps turn from one key's angle to the other's the shorter way round at the
/// stroke's start, and keep the whole turns each recording makes along the stroke. A stroke
/// without one reads as 0°, as brushes read it; None when neither has one.
fn lerp_angle(x: Option<&AngleProfile>, y: Option<&AngleProfile>, t: f64) -> Option<AngleProfile> {
if x.is_none() && y.is_none() {
return None;
}
let read = |p: Option<&AngleProfile>, f: f64| p.map_or(0.0, |p| p.unwrapped_at(f));
let clean = |v: f64| if v.is_finite() { v } else { 0.0 };
let pairs = paired_samples(x.map(|p| p.points.as_slice()), y.map(|p| p.points.as_slice()), |f| read(x, f), |f| read(y, f), clean);
// Whole turns that bring the two angles within half a turn of each other at the first position.
let d0 = pairs.first().map_or(0.0, |&(_, a, b)| b - a);
let turns = vectorcraft_geom::normalize_deg(d0) - d0;
let value = |a: f64, b: f64| a + (b - a + turns) * t;
// Both curves are straight between two positions; samples are added where the steps turn
// through 90° or more between them, since `AngleProfile::from_samples` joins neighbours the
// shorter way round. A document's recordings can be long, so the added samples are limited.
let mut budget = 4 * PressureProfile::MAX_POINTS;
let mut samples = Vec::with_capacity(pairs.len());
let mut prev: Option<(f64, f64)> = None;
for (f, a, b) in pairs {
let v = value(a, b);
if let Some((pf, pv)) = prev.filter(|p| f - p.0 > 1e-9) {
let parts = ((v - pv).abs() / 90.0).ceil().min((budget + 1) as f64) as usize;
for k in 1..parts {
let g = pf + (f - pf) * k as f64 / parts as f64;
samples.push((g, value(read(x, g), read(y, g))));
}
budget -= parts.saturating_sub(1).min(budget);
}
samples.push((f, v));
prev = Some((f, v));
}
AngleProfile::from_samples(samples)
}
/// Interpolate a stroke's paint, weight, opacity, miter limit, dashes, arrowhead scale, width
/// profile and the pen pressure, tilt, bearing and rotation its brush reads into `o` (a copy of
/// the nearer stroke, so caps, joins, alignment, arrowheads and brushes switch halfway).
fn lerp_stroke(o: &mut StrokeLayer, x: &StrokeLayer, y: &StrokeLayer, t: f64) {
let tf = t as f32;
o.paint = lerp_paint(&x.paint, &y.paint, tf);
@@ -267,6 +360,10 @@ fn lerp_stroke(o: &mut StrokeLayer, x: &StrokeLayer, y: &StrokeLayer, t: f64) {
o.dash = lerp_dash(x.dash.as_ref(), y.dash.as_ref(), t);
o.arrow_scale = (lerp_f64(x.arrow_scale.0, y.arrow_scale.0, t), lerp_f64(x.arrow_scale.1, y.arrow_scale.1, t));
o.profile = lerp_profile(x.profile.as_ref(), y.profile.as_ref(), t);
o.pressure = lerp_pressure(x.pressure.as_ref(), y.pressure.as_ref(), t);
o.tilt = lerp_pressure(x.tilt.as_ref(), y.tilt.as_ref(), t);
o.bearing = lerp_angle(x.bearing.as_ref(), y.bearing.as_ref(), t);
o.rotation = lerp_angle(x.rotation.as_ref(), y.rotation.as_ref(), t);
}
/// Interpolate appearance stacks: item by item when their structure matches, else the top fill
@@ -1755,6 +1852,63 @@ mod tests {
assert_eq!(p.at(0.5), (1.0, 1.0));
}
#[test]
fn pen_pressure_tilt_and_angles_interpolate_for_brushes() {
let stroked = |pressure: Option<f64>, tilt: Option<f64>, bearing: Option<f64>| {
let mut ap = Appearance::default();
ap.set_stroke(Paint::solid(Color::BLACK));
if let Some(s) = ap.stroke_mut() {
s.brush = Some("Pressure Round".into());
s.pressure = pressure.map(|p| PressureProfile { points: vec![(0.0, p), (1.0, p)] });
s.tilt = tilt.map(|p| PressureProfile { points: vec![(0.0, p), (1.0, p)] });
s.bearing = bearing.map(|a| AngleProfile { points: vec![(0.0, a), (1.0, a)] });
}
ap
};
let at = |ap: &Appearance, f: f64| {
let s = ap.stroke().cloned().unwrap_or_else(|| panic!("a stroke"));
let read = |p: Option<&PressureProfile>| p.map_or(PressureProfile::MID, |p| p.at(f));
(read(s.pressure.as_ref()), read(s.tilt.as_ref()), s.bearing.as_ref().map_or(0.0, |p| p.at(f)))
};
let (light, heavy) = (stroked(Some(0.0), Some(0.2), Some(170.0)), stroked(Some(1.0), Some(0.6), Some(-170.0)));
let (p, tilt, bearing) = at(&lerp_appearance(&light, &heavy, 0.25), 0.5);
assert!((p - 0.25).abs() < 1e-9 && (tilt - 0.3).abs() < 1e-9, "a quarter of the way: {p} {tilt}");
assert!((bearing - 175.0).abs() < 1e-9, "the angle turns the shorter way round, through 180°: {bearing}");
assert!((at(&lerp_appearance(&light, &heavy, 0.5), 0.5).2.abs() - 180.0).abs() < 1e-9);
// A stroke drawn without a recording reads as half pressure, half tilt and 0°.
let plain = stroked(None, None, None);
let (p, tilt, bearing) = at(&lerp_appearance(&heavy, &plain, 0.5), 0.0);
assert!((p - 0.75).abs() < 1e-9 && (tilt - 0.55).abs() < 1e-9 && (bearing + 85.0).abs() < 1e-9, "{p} {tilt} {bearing}");
// Profiles that vary along the stroke interpolate at each position.
let mut ramp = stroked(None, None, None);
if let Some(s) = ramp.stroke_mut() {
s.pressure = Some(PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] });
}
let mid = lerp_appearance(&ramp, &heavy, 0.5);
assert!((at(&mid, 0.0).0 - 0.5).abs() < 1e-9 && (at(&mid, 0.5).0 - 0.75).abs() < 1e-9, "{:?}", mid.stroke().map(|s| &s.pressure));
// A step in a recording (two samples at one position) stays a step.
let mut stepped = stroked(None, None, None);
if let Some(s) = stepped.stroke_mut() {
s.pressure = Some(PressureProfile { points: vec![(0.0, 0.0), (0.5, 0.0), (0.5, 1.0), (1.0, 1.0)] });
}
let mid = lerp_appearance(&stepped, &stepped, 0.5);
assert!(at(&mid, 0.25).0.abs() < 1e-9 && (at(&mid, 0.75).0 - 1.0).abs() < 1e-9, "{:?}", mid.stroke().map(|s| &s.pressure));
// A whole turn recorded along a stroke stays a whole turn in the steps.
let mut turning = stroked(None, None, None);
if let Some(s) = turning.stroke_mut() {
s.rotation = Some(AngleProfile { points: vec![(0.0, 0.0), (1.0, 360.0)] });
}
let rot = |ap: &Appearance, f: f64| ap.stroke().and_then(|s| s.rotation.as_ref()).map_or(0.0, |p| p.at(f));
let same = lerp_appearance(&turning, &turning, 0.5);
assert!((rot(&same, 0.25) - 90.0).abs() < 1e-9 && (rot(&same, 0.5).abs() - 180.0).abs() < 1e-9, "{:?}", same.stroke().map(|s| &s.rotation));
let half = lerp_appearance(&turning, &plain, 0.5);
assert!((rot(&half, 0.5) - 90.0).abs() < 1e-9 && (rot(&half, 1.0).abs() - 180.0).abs() < 1e-9, "{:?}", half.stroke().map(|s| &s.rotation));
// Neither key has a recording: the steps have none either.
let none = lerp_appearance(&plain, &plain, 0.5);
let s = none.stroke().cloned().unwrap_or_else(|| panic!("a stroke"));
assert!(s.pressure.is_none() && s.tilt.is_none() && s.bearing.is_none() && s.rotation.is_none());
}
#[test]
fn odd_dash_blends_to_solid_with_closed_gaps() {
let d = lerp_dash(Some(&Dash { pattern: vec![2.0, 3.0, 4.0], ..Default::default() }), None, 1.0).unwrap();
+3 -1
View File
@@ -57,7 +57,9 @@ pub struct CharStyle {
#[serde(default)]
pub all_caps: bool,
/// OpenType features that differ from the defaults (OpenType panel), as tags: `"dlig"` turns
/// a feature on, `"-liga"` off.
/// a feature on, `"-liga"` off, and `"salt=2"` turns a feature with alternates on at the font's
/// alternate number 2 (the value the shaper gets; a plain `"salt"` is value 1). Runs that
/// differ only in this list are shaped together, each with its own features.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub features: Vec<String>,
/// Character style (Character Styles panel) these attributes come from; None = Normal.
+7 -2
View File
@@ -1,11 +1,16 @@
//! Cutting Tools (`addon.cutting.*`), a built-in add-on that prepares art for cutting. Stencil
//! Bridges cuts bridges out of the selected art so that a stencil cut from it holds together
//! ([`stencil`]). Its submenu (Object › Plug-ins › Cutting Tools) and dialog are the UI's.
//! ([`stencil`]), and Weed Box and Lines adds a weed box and weeding lines around the selected art
//! for vinyl cutting ([`weed`]). Its submenu (Object › Plug-ins › Cutting Tools) and dialogs are
//! the UI's.
mod stencil;
mod weed;
use super::super::CommandSpec;
pub fn specs() -> Vec<CommandSpec> {
stencil::specs()
let mut v = stencil::specs();
v.extend(weed::specs());
v
}
@@ -89,7 +89,12 @@ fn bridges(s: &mut Session, p: &Value) -> Result<Value> {
};
let refs: Vec<(&PathData, FillRule)> = parts.iter().map(|(p, r)| (p, *r)).collect();
let spec = BridgeSpec { width, count: count as usize, direction };
let bridged = stencil_bridges(&refs, spec).map_err(|e| EngineError::Other(e.to_string()))?;
let bridged = stencil_bridges(&refs, spec).map_err(|e| match e {
vectorcraft_pathops::PathOpsError::TooComplex => {
EngineError::Other("Stencil Bridges: the selected art is too large or too detailed to bridge".into())
}
e => EngineError::Other(e.to_string()),
})?;
(bridged, style_of(&top), roots)
};
let summary = |id: Option<NodeId>, original: Option<NodeId>| {
@@ -277,6 +277,34 @@ fn bad_params_are_refused() {
assert!(s.execute("addon.cutting.bridges", &json!({"width": 4})).is_ok(), "good params run");
}
#[test]
fn art_too_detailed_to_bridge_is_refused_with_a_message_that_says_so() {
let mut s = session();
let parent = s.doc().unwrap().target_parent().unwrap();
let id = s
.edit("dense", |d, sel| {
// More anchors than Stencil Bridges measures.
let mut zigzag = kurbo::BezPath::new();
zigzag.move_to((100.0, 100.0));
for i in 1..=vectorcraft_pathops::addons::stencil::MAX_STENCIL_SEGMENTS {
zigzag.line_to((100.0 + i as f64 * 0.001, if i % 2 == 0 { 100.0 } else { 101.0 }));
}
zigzag.close_path();
let huge = vectorcraft_geom::PathData::from_bezpath(&zigzag);
let n = vectorcraft_doc::Node::path(d.alloc_id(), huge, vectorcraft_doc::Appearance::default());
let id = n.id;
d.insert(parent, usize::MAX, n)?;
sel.set([id]);
Ok(id)
})
.unwrap();
select(&mut s, &[id]);
let steps = undo_steps(&s);
let e = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap_err().to_string();
assert!(e.contains("Stencil Bridges: the selected art is too large or too detailed to bridge"), "{e}");
assert_eq!(undo_steps(&s), steps);
}
#[test]
fn selected_objects_without_outlines_stay_where_they_are() {
for keep in [true, false] {
@@ -0,0 +1,160 @@
//! Weed Box and Lines (`addon.cutting.weed`), a command of the built-in Cutting Tools add-on for
//! vinyl cutting: around the selected art it adds a weed box (a rectangle at a margin around the
//! art) and weeding lines across it that stop at the art, so the waste vinyl around the art lifts
//! off in pieces (<https://help.arlon.com/what-is-a-weeding-border>). The art is measured by the
//! outlines a cutter cuts ([`geometry`]).
mod geometry;
#[cfg(test)]
mod tests;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_color::swatch::REGISTRATION;
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind};
use vectorcraft_geom::{Rect, shapes};
use super::super::super::edit::selected_roots;
use super::super::super::object::choice;
use super::super::super::paint::swatch_solid;
use super::super::super::pathops::{length_given, number_given};
use super::super::super::{CommandSpec, bad, cmd, has_selection, switch_or, text_param};
use crate::{EngineError, Result, Session};
/// Weed Box and Lines' command.
pub const WEED: &str = "addon.cutting.weed";
/// Which way the weeding lines run.
pub const DIRECTIONS: [&str; 3] = ["horizontal", "vertical", "both"];
/// The largest margin, gap or spacing, in points.
const MAX_DISTANCE: f64 = 100_000.0;
/// The most weeding lines each way.
const MAX_COUNT: usize = 1000;
/// The most pieces of weeding line one run adds.
const MAX_PIECES: usize = 100_000;
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"addon.cutting.weed",
"Weed Box and Lines…",
["Object", "Plug-ins", "Cutting Tools"],
None,
"{margin?: pt 0..100000 (9: from the art to the weed box), box?: true (false: the weeding lines only), direction?: \"horizontal\" | \"vertical\" | \"both\" (\"both\"), count?: whole number 0..1000 of lines each way (1), spacing?: pt (instead of count: as few lines as keep them at most this far apart), gap?: pt 0..100000 (0: the lines stop at the art's outlines), weight?: pt 0.01..1000 (0.3), swatch?: a color swatch the lines are stroked in (\"[Registration]\")} add a weed box around the selected art and weeding lines across it that divide it evenly, each running from one side of the box to the other and left out over the art and within `gap` of its outlines, so the waste lifts off in pieces. The art is measured by the outlines a cutter cuts: closed paths enclose their area by their fill rule and open paths are lines, compound paths by all their subpaths, type by its glyph outlines, a clip group by what shows inside its clipping path, images by their frames, placed files by the art they show (by their boxes when the file can't be read), symbol instances by their art and live objects by the art they draw; strokes, effects and hidden objects are left out. One group named Weeding, stroked with no fill, on top of the current layer (outside the clip groups around a group edited in isolation), selected, as one undo step → {id: the group, box: its weed box or null, lines (pieces of weeding line)}",
has_selection,
weed
)]
}
/// The distance `p` gives for `key` (0..[`MAX_DISTANCE`]), or `default`.
fn distance(p: &Value, key: &str, default: f64) -> Result<f64> {
match length_given(p, key, WEED)? {
None => Ok(default),
Some(v) if (0.0..=MAX_DISTANCE).contains(&v) => Ok(v),
Some(_) => Err(bad(WEED, format!("`{key}` must be from 0 to {MAX_DISTANCE} pt"))),
}
}
/// How many lines divide a side `length` long: `count`, or as few as keep them at most `spacing`
/// apart.
fn lines_across(length: f64, count: Option<usize>, spacing: Option<f64>) -> Result<usize> {
let n = match spacing {
// `length / spacing` strips, rounded up, need one line fewer.
Some(s) => ((length / s - 1e-9).ceil() - 1.0).max(0.0),
None => return Ok(count.unwrap_or(1)),
};
if n > MAX_COUNT as f64 {
return Err(bad(WEED, format!("`spacing` makes more than {MAX_COUNT} lines across the box")));
}
Ok(n as usize)
}
/// Where the Weeding group goes: `parent`, or, when `parent` is inside a clip group or clipping
/// sublayer (a group edited in isolation), the container around the outermost of them. A clipping
/// path would hide the lines and the box, which lie outside the art.
fn outside_clips(d: &Document, parent: Option<NodeId>) -> Option<NodeId> {
let clips = |id: NodeId| d.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. }));
let (mut at, mut out) = (parent, parent);
while let Some(p) = at {
let up = d.parent_of(p);
if clips(p) {
match up {
Some(up) if d.is_editable(up) => out = Some(up),
_ => break,
}
}
at = up;
}
out
}
fn weed(s: &mut Session, p: &Value) -> Result<Value> {
let margin = distance(p, "margin", 9.0)?;
let gap = distance(p, "gap", 0.0)?;
let with_box = switch_or(p, "box", true, WEED)?;
let direction = choice(p, WEED, "direction", &DIRECTIONS)?.unwrap_or("both");
let count = match number_given(p, "count", WEED)? {
None => None,
Some(n) if n.fract() == 0.0 && (0.0..=MAX_COUNT as f64).contains(&n) => Some(n as usize),
Some(_) => return Err(bad(WEED, format!("`count` must be a whole number from 0 to {MAX_COUNT}"))),
};
let spacing = match length_given(p, "spacing", WEED)? {
None => None,
Some(v) if v > 0.0 && v <= MAX_DISTANCE => Some(v),
Some(_) => return Err(bad(WEED, format!("`spacing` must be more than 0 and at most {MAX_DISTANCE} pt"))),
};
if count.is_some() && spacing.is_some() {
return Err(bad(WEED, "give `count` or `spacing`, not both"));
}
let weight = match length_given(p, "weight", WEED)? {
None => 0.3,
Some(v) if (0.01..=1000.0).contains(&v) => v,
Some(_) => return Err(bad(WEED, "`weight` must be from 0.01 to 1000 pt")),
};
let swatch = text_param(p, "swatch", WEED)?.unwrap_or(REGISTRATION);
let stroke = swatch_solid(&s.doc()?.doc, swatch, None).map_err(|e| bad(WEED, e))?;
let roots = selected_roots(s)?;
if roots.is_empty() {
return Err(bad(WEED, "select the art to weed"));
}
let d = &s.doc()?.doc;
let parent = outside_clips(d, s.doc()?.target_parent()?);
// Placed files' art uses their resources (symbols) under names of their own.
let placed = d.with_placed_art();
let region = geometry::Reader::new(&placed).read_ids(&roots).map_err(|e| bad(WEED, e))?;
let region = region.ok_or_else(|| bad(WEED, "the selection has no outlines to weed around"))?;
let outline = region.outline().map_err(|e| bad(WEED, e))?;
let art = outline.iter().fold(None, |r: Option<Rect>, &(a, b)| Some(r.map_or(Rect::from_points(a, b), |r| r.union_pt(a).union_pt(b))));
let frame = art.ok_or_else(|| bad(WEED, "the selection has no outlines to weed around"))?.inflate(margin, margin);
if !(frame.width().is_finite() && frame.height().is_finite()) {
return Err(bad(WEED, "the selected art is too large to measure"));
}
let each = |on: bool, length: f64| if on { lines_across(length, count, spacing) } else { Ok(0) };
let counts =
geometry::Counts { horizontal: each(direction != "vertical", frame.height())?, vertical: each(direction != "horizontal", frame.width())? };
let pieces = geometry::lines(&region, &outline, frame, counts, gap, MAX_PIECES).map_err(|e| bad(WEED, e))?;
if pieces.is_empty() && !with_box {
return Err(EngineError::Other("Weed Box and Lines: no weeding line fits outside the art, and the weed box is off".into()));
}
let ap = Appearance::basic(Paint::None, stroke, weight);
let (group, weed_box, lines) = s.edit("Weed Box and Lines", |d, sel| {
let mut children = vec![];
let mut weed_box = None;
if with_box {
let mut b = Node::path(d.alloc_id(), shapes::rectangle(frame), ap.clone());
b.name = Some("Weed Box".into());
weed_box = Some(b.id);
children.push(Arc::new(b));
}
for (a, b) in &pieces {
children.push(Arc::new(Node::path(d.alloc_id(), shapes::line(*a, *b), ap.clone())));
}
let mut g = Node::group(d.alloc_id(), children);
g.name = Some("Weeding".into());
let id = g.id;
d.insert(parent, usize::MAX, g)?;
sel.set([id]);
Ok((id, weed_box, pieces.len()))
})?;
Ok(json!({ "id": group.0, "box": weed_box.map(|b| b.0), "lines": lines }))
}
@@ -0,0 +1,769 @@
//! The geometry of Weed Box and Lines: the selected art as the outlines a cutter cuts ([`Region`]),
//! and the weeding lines across a box that stop at those outlines or at a gap from them
//! ([`lines`]).
//!
//! A region is measured along one straight line at a time. A closed outline encloses its area by
//! its fill rule, an open one is a line with no area, a group is the union of its members and a
//! clip group is what its members draw inside its clipping path. Along a weeding line the region
//! gives the stretches inside its area ([`Region::spans`]), and its outline pieces
//! ([`Region::outline`]) give the stretches within the gap of an outline.
use std::cell::Cell;
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use vectorcraft_geom::{BezPath, FillRule, PathEl, Point, Rect};
use super::super::super::super::pathops::node_path;
/// How closely curves are followed, in points.
const TOLERANCE: f64 = 0.02;
/// The most points the outlines of one selection are measured by, once curves are followed in
/// straight segments (each subpath's points, its first one included).
pub(super) const MAX_POINTS: usize = 200_000;
/// The most tests of a segment against another one measuring a clip group's outlines takes.
const MAX_PAIRS: usize = 40_000_000;
/// The deepest groups, symbol instances and live objects are opened inside one another: as deep
/// as any object is drawn.
pub(super) const MAX_DEPTH: usize = vectorcraft_doc::depth::MAX_DRAWN_DEPTH;
/// The most pairs of an outline segment and a weeding line it comes near that one run measures.
const MAX_NEAR: usize = 10_000_000;
/// Pieces of weeding line shorter than this, in points, are left out.
const MIN_PIECE: f64 = 0.01;
/// A straight segment.
pub(super) type Segment = (Point, Point);
/// One subpath followed in straight segments.
#[derive(Clone, Debug)]
pub(super) struct Poly {
points: Vec<Point>,
closed: bool,
/// The box around `points`.
bounds: Rect,
}
impl Poly {
/// The segments, with the closing one of a closed subpath.
fn segments(&self) -> impl Iterator<Item = Segment> + '_ {
let closing = self.closed.then(|| (self.points.last().copied(), self.points.first().copied()));
self.points.windows(2).map(|w| (w[0], w[1])).chain(closing.and_then(|(a, b)| Some((a?, b?))))
}
}
/// The selected art as the outlines a cutter cuts.
#[derive(Clone, Debug)]
pub(super) enum Region {
/// A path, compound path or type: its subpaths with the rule its closed ones fill by.
Shape { polys: Vec<Poly>, rule: FillRule },
/// Everything its members draw.
Union(Vec<Region>),
/// What `content` draws inside `clip`.
Clip { clip: Box<Region>, content: Box<Region> },
}
/// Reads the selected objects into a [`Region`], keeping count of the outline points.
pub(super) struct Reader<'a> {
doc: &'a Document,
points: usize,
}
impl<'a> Reader<'a> {
pub(super) fn new(doc: &'a Document) -> Self {
Self { doc, points: 0 }
}
/// The region of `nodes` together, `None` when they hold no outlines.
pub(super) fn read(&mut self, nodes: &[&Node]) -> Result<Option<Region>, String> {
let mut parts = vec![];
for n in nodes {
parts.extend(self.node(n, 0)?);
}
Ok(union(parts))
}
fn node(&mut self, n: &Node, depth: usize) -> Result<Option<Region>, String> {
if depth > MAX_DEPTH {
return Err(format!("the selected art is nested more than {MAX_DEPTH} levels deep"));
}
if !n.visible {
return Ok(None);
}
match &n.kind {
NodeKind::Path { guide: true, .. } | NodeKind::RulerGuide { .. } => Ok(None),
NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_) => self.shape(n),
NodeKind::Group { clip, .. } | NodeKind::Layer { clip, .. } => {
let members = n.editing_children();
if *clip {
let Some((first, rest)) = members.split_first() else { return Ok(None) };
let Some(clip) = self.clip_region(first)? else { return Ok(None) };
let mut parts = vec![];
for c in rest {
parts.extend(self.node(c, depth + 1)?);
}
Ok(union(parts).map(|content| Region::Clip { clip: Box::new(clip), content: Box::new(content) }))
} else {
let mut parts = vec![];
for c in members {
parts.extend(self.node(c, depth + 1)?);
}
Ok(union(parts))
}
}
NodeKind::Image(im) => {
let (w, h) = (f64::from(im.width), f64::from(im.height));
let corners = [Point::new(0.0, 0.0), Point::new(w, 0.0), Point::new(w, h), Point::new(0.0, h)].map(|p| im.xf * p);
self.polygon(&corners)
}
// An instance of a symbol the document doesn't have draws nothing.
NodeKind::SymbolInstance { .. } => match vectorcraft_render::effects::outline_object(Some(self.doc), n) {
Some(art) => self.node(&art, depth + 1),
None => Ok(None),
},
NodeKind::PlacedDocument(p) => {
// The art of the file it shows; a file that can't be read shows its preview in its
// box, which may be rotated.
if vectorcraft_doc::placed_document::read_cached(p).is_none() {
return self.polygon(&p.corners());
}
let art = vectorcraft_render::effects::expand_live_deep(Some(self.doc), n);
if std::mem::discriminant(&art.kind) == std::mem::discriminant(&n.kind) {
self.polygon(&p.corners())
} else {
self.node(&art, depth + 1)
}
}
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => {
// The art a live object draws, as the exporters write it; an object that stays
// as it is is measured by its box.
let art = vectorcraft_render::effects::expand_live_deep(Some(self.doc), n);
if std::mem::discriminant(&art.kind) == std::mem::discriminant(&n.kind) { self.frame(n) } else { self.node(&art, depth + 1) }
}
}
}
/// The region a clipping path `first` clips to, whatever its own visibility: the region the
/// renderer clips to (a path, a compound path, type by its glyphs, a group's visible members,
/// an image's frame). `None` when it clips to nothing, and the clipped art is hidden.
fn clip_region(&mut self, first: &Node) -> Result<Option<Region>, String> {
let Some((outline, rule)) = vectorcraft_render::effects::clip_outline(first) else { return Ok(None) };
let mut polys = self.polys(outline)?;
// A region is filled: an open subpath clips as if closed.
polys.iter_mut().for_each(|p| p.closed = true);
Ok((!polys.is_empty()).then_some(Region::Shape { polys, rule }))
}
/// The region of the objects `ids` together, each within the clipping paths of the clip groups
/// and layers around it (an object selected inside a clip group shows only within its clipping
/// path, and not at all inside a hidden group or layer; a clipping path is measured as its clip
/// group), `None` when they show no outlines.
pub(super) fn read_ids(&mut self, ids: &[NodeId]) -> Result<Option<Region>, String> {
let doc = self.doc;
let clips = |n: &Node| matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. });
// Every object's place in the tree, found in one walk.
let places = doc.index_paths(ids.iter().copied());
let mut paths = vec![];
for id in ids {
let Some(mut path) = places.get(id).cloned() else { continue };
// A clipping path selected on its own stands for the art it shows: its clip group.
while let (Some(n), Some(up)) = (doc.node_at(&path), path.split_last().map(|(_, up)| up).and_then(|up| doc.node_at(up))) {
if !(clips(up) && up.editing_children().first().is_some_and(|f| f.id == n.id)) {
break;
}
path.pop();
}
paths.push(path);
}
// An object selected twice, or inside another selected object, is measured once, with it.
paths.sort();
paths.dedup();
let mut kept: Vec<Vec<usize>> = vec![];
for path in paths {
if !kept.last().is_some_and(|k| path.starts_with(k)) {
kept.push(path);
}
}
let mut parts = vec![];
'ids: for path in &kept {
let Some(n) = doc.node_at(path) else { continue };
// Art in a hidden group or layer isn't shown, and isn't measured.
if (1..path.len()).any(|depth| path.get(..depth).and_then(|p| doc.node_at(p)).is_some_and(|c| !c.visible)) {
continue;
}
let Some(mut region) = self.read(&[n])? else { continue };
// The containers around the object, innermost first.
let mut child = n.id;
for depth in (1..path.len()).rev() {
let Some(container) = path.get(..depth).and_then(|p| doc.node_at(p)) else { break };
if clips(container)
&& let Some(first) = container.editing_children().first().filter(|f| f.id != child)
{
let Some(clip) = self.clip_region(first)? else { continue 'ids };
region = Region::Clip { clip: Box::new(clip), content: Box::new(region) };
}
child = container.id;
}
parts.push(region);
}
Ok(union(parts))
}
/// A path, compound path or type as its outlines.
fn shape(&mut self, n: &Node) -> Result<Option<Region>, String> {
// Each anchor is at least one point, so a path past the budget errs before it is copied.
if anchors(n) > MAX_POINTS.saturating_sub(self.points) {
return Err(too_many());
}
let Some((path, rule)) = node_path(n) else { return Ok(None) };
let polys = self.polys(path.to_bezpath())?;
Ok((!polys.is_empty()).then_some(Region::Shape { polys, rule }))
}
/// The box of an object whose outlines can't be read.
fn frame(&mut self, n: &Node) -> Result<Option<Region>, String> {
match n.geometric_bounds_with(&self.doc.symbol_boxes()) {
Some(r) => self.polygon(&[Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)]),
None => Ok(None),
}
}
/// A closed polygon through `points`.
fn polygon(&mut self, points: &[Point]) -> Result<Option<Region>, String> {
let poly = poly(points.to_vec(), true);
self.count(poly.iter().map(|p| p.points.len()).sum())?;
Ok(poly.map(|p| Region::Shape { polys: vec![p], rule: FillRule::NonZero }))
}
fn count(&mut self, n: usize) -> Result<(), String> {
self.points = self.points.saturating_add(n);
if self.points > MAX_POINTS { Err(too_many()) } else { Ok(()) }
}
/// The subpaths of `path` followed in straight segments.
fn polys(&mut self, path: BezPath) -> Result<Vec<Poly>, String> {
let (mut out, mut current, points) = (vec![], vec![], Cell::new(0usize));
let left = MAX_POINTS.saturating_sub(self.points);
// Past the budget no further curve is followed (one curve gives at most
// `MAX_FLATTEN_PER_CURVE` points), and the read fails below.
let elements = path.into_iter().take_while(|_| points.get() <= left);
vectorcraft_geom::flatten(elements, TOLERANCE, |el| {
points.set(points.get() + 1);
if points.get() > left {
return;
}
match el {
PathEl::MoveTo(p) => {
out.extend(poly(std::mem::take(&mut current), false));
current.push(p);
}
PathEl::LineTo(p) => current.push(p),
PathEl::ClosePath => out.extend(poly(std::mem::take(&mut current), true)),
PathEl::QuadTo(..) | PathEl::CurveTo(..) => {}
}
});
if points.get() > left {
return Err(too_many());
}
out.extend(poly(current, false));
self.count(out.iter().map(|p: &Poly| p.points.len()).sum())?;
Ok(out)
}
}
fn too_many() -> String {
format!("the selected art has too many outline points to measure (more than {MAX_POINTS})")
}
/// The anchors of a path or compound path node, read without copying its path; 0 for type.
fn anchors(n: &Node) -> usize {
match &n.kind {
NodeKind::Path { path, .. } => path.anchor_count(),
NodeKind::Compound { children, .. } => children.iter().fold(0, |sum, c| sum.saturating_add(anchors(c))),
_ => 0,
}
}
/// A subpath through `points`, `None` for fewer than two points or one that isn't finite.
fn poly(points: Vec<Point>, closed: bool) -> Option<Poly> {
if points.len() < 2 || points.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) {
return None;
}
let first = *points.first()?;
let bounds = points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p));
Some(Poly { points, closed, bounds })
}
/// One region of `parts`, `None` when there are none.
fn union(mut parts: Vec<Region>) -> Option<Region> {
match parts.len() {
0 => None,
1 => parts.pop(),
_ => Some(Region::Union(parts)),
}
}
/// `p` as (along, across) a weeding line: horizontal lines run along x, vertical ones along y.
fn uv(p: Point, vertical: bool) -> (f64, f64) {
if vertical { (p.y, p.x) } else { (p.x, p.y) }
}
/// Sorts `spans` and joins those that overlap or touch.
fn merged(mut spans: Vec<(f64, f64)>) -> Vec<(f64, f64)> {
spans.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut out: Vec<(f64, f64)> = Vec::with_capacity(spans.len());
for (a, b) in spans {
match out.last_mut() {
Some(last) if a <= last.1 => last.1 = last.1.max(b),
_ => out.push((a, b)),
}
}
out
}
/// Where sorted, joined spans `a` and `b` overlap.
fn intersected(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> {
let (mut i, mut j, mut out) = (0, 0, vec![]);
while let (Some(&(a0, a1)), Some(&(b0, b1))) = (a.get(i), b.get(j)) {
let (lo, hi) = (a0.max(b0), a1.min(b1));
if lo <= hi {
out.push((lo, hi));
}
if a1 < b1 {
i += 1;
} else {
j += 1;
}
}
out
}
/// Where segment a→b crosses the line `across` = `c`, along it, and which way (1 when `across`
/// grows from a to b), `None` when it doesn't. Each crossing counts once: an end exactly on the
/// line counts as past it.
fn crosses(a: Point, b: Point, vertical: bool, c: f64) -> Option<(f64, i32)> {
let ((u0, v0), (u1, v1)) = (uv(a, vertical), uv(b, vertical));
((v0 <= c && v1 > c) || (v1 <= c && v0 > c)).then(|| (u0 + (c - v0) * (u1 - u0) / (v1 - v0), if v1 > v0 { 1 } else { -1 }))
}
/// The stretches inside an area filled by `rule`, from where its outlines cross a line, sorted
/// along it.
fn filled(crossings: impl IntoIterator<Item = (f64, i32)>, rule: FillRule) -> Vec<(f64, f64)> {
let (mut winding, mut start, mut out) = (0, 0.0, vec![]);
let inside = |w: i32| match rule {
FillRule::NonZero => w != 0,
FillRule::EvenOdd => w % 2 != 0,
};
for (u, dir) in crossings {
let was = inside(winding);
winding += dir;
match (was, inside(winding)) {
(false, true) => start = u,
(true, false) => out.push((start, u)),
_ => {}
}
}
merged(out)
}
/// Where the closed outlines of a region's shapes cross a set of weeding lines: for each line, the
/// crossings as (shape, place along the line, direction), sorted by shape and then by place. Shapes
/// are numbered in the order [`Region::shapes`] lists them.
pub(super) type Crossings = Vec<Vec<(usize, f64, i32)>>;
/// Where the shapes of each part of a region are numbered among its [`Crossings`].
#[derive(Debug)]
pub(super) enum Layout {
Shape,
/// The number of each part's first shape, from the union's first, and the parts' layouts.
Union {
starts: Vec<usize>,
parts: Vec<Layout>,
},
/// The clipping path's shapes come first.
Clip {
clip_shapes: usize,
clip: Box<Layout>,
content: Box<Layout>,
},
}
impl Layout {
/// The layout of `region` and how many shapes it has.
pub(super) fn of(region: &Region) -> (Layout, usize) {
match region {
Region::Shape { .. } => (Layout::Shape, 1),
Region::Union(parts) => {
let (mut starts, mut layouts, mut n) = (vec![], vec![], 0);
for r in parts {
let (l, k) = Layout::of(r);
starts.push(n);
layouts.push(l);
n += k;
}
(Layout::Union { starts, parts: layouts }, n)
}
Region::Clip { clip, content } => {
let ((lc, a), (lt, b)) = (Layout::of(clip), Layout::of(content));
(Layout::Clip { clip_shapes: a, clip: Box::new(lc), content: Box::new(lt) }, a + b)
}
}
}
}
/// The [`Crossings`] of `region` with the lines `across` = v0 + (k + 1)·step, k < `count`. Errs
/// past [`MAX_NEAR`] crossings in all.
pub(super) fn crossings(region: &Region, vertical: bool, v0: f64, step: f64, count: usize) -> Result<Crossings, String> {
let mut shapes = vec![];
region.shapes(&mut shapes);
let at = |k: usize| v0 + step * (k as f64 + 1.0);
// The lines a segment may cross: those from just below its lower end to its higher end.
let candidates = |(a, b): Segment| {
let (va, vb) = (uv(a, vertical).1, uv(b, vertical).1);
if step <= 0.0 || va == vb {
return 0..0;
}
let first = (((va.min(vb) - v0) / step).floor() - 1.0).clamp(0.0, count as f64);
let end = ((va.max(vb) - v0) / step).ceil().clamp(0.0, count as f64);
(first as usize)..(end as usize)
};
let closed = |polys: &[Poly]| polys.iter().filter(|p| p.closed).flat_map(Poly::segments).collect::<Vec<_>>();
let total = shapes.iter().flat_map(|(polys, _)| closed(polys)).fold(0usize, |n, s| n.saturating_add(candidates(s).len()));
if total > MAX_NEAR {
return Err("the weeding lines cross the art's outlines too many times to measure; use fewer lines".into());
}
let mut out: Crossings = vec![vec![]; count];
for (i, (polys, _)) in shapes.iter().enumerate() {
for (a, b) in closed(polys) {
for k in candidates((a, b)) {
if let (Some((u, dir)), Some(line)) = (crosses(a, b, vertical, at(k)), out.get_mut(k)) {
line.push((i, u, dir));
}
}
}
}
for line in &mut out {
line.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)));
}
Ok(out)
}
impl Region {
/// The stretches of the line `across` = `c` (horizontal: y = c, along x; vertical: x = c,
/// along y) inside the region's area, sorted and joined.
pub(super) fn spans(&self, vertical: bool, c: f64) -> Vec<(f64, f64)> {
match self {
Region::Shape { polys, rule } => {
let mut crossings: Vec<(f64, i32)> = vec![];
for p in polys.iter().filter(|p| p.closed) {
let (lo, hi) = if vertical { (p.bounds.x0, p.bounds.x1) } else { (p.bounds.y0, p.bounds.y1) };
if c >= lo && c <= hi {
crossings.extend(p.segments().filter_map(|(a, b)| crosses(a, b, vertical, c)));
}
}
crossings.sort_by(|a, b| a.0.total_cmp(&b.0));
filled(crossings, *rule)
}
Region::Union(parts) => merged(parts.iter().flat_map(|r| r.spans(vertical, c)).collect()),
Region::Clip { clip, content } => intersected(&clip.spans(vertical, c), &content.spans(vertical, c)),
}
}
/// The region's shapes with their fill rules, in a fixed order.
fn shapes<'a>(&'a self, out: &mut Vec<(&'a [Poly], FillRule)>) {
match self {
Region::Shape { polys, rule } => out.push((polys, *rule)),
Region::Union(parts) => parts.iter().for_each(|r| r.shapes(out)),
Region::Clip { clip, content } => {
clip.shapes(out);
content.shapes(out);
}
}
}
/// [`Region::spans`] on a weeding line, from `line`, the line's [`Crossings`] with the
/// region's shapes, numbered from `base` (`layout` is the region's [`Layout`]). Only the parts
/// the line crosses are visited: a part it doesn't cross has no stretch inside its area.
pub(super) fn spans_on(&self, layout: &Layout, line: &[(usize, f64, i32)], base: usize) -> Vec<(f64, f64)> {
if line.is_empty() {
return vec![];
}
match (self, layout) {
(Region::Shape { rule, .. }, Layout::Shape) => filled(line.iter().map(|&(_, u, dir)| (u, dir)), *rule),
(Region::Union(parts), Layout::Union { starts, parts: layouts }) => {
let (mut out, mut rest) = (vec![], line);
while let Some(&(id, ..)) = rest.first() {
// The part whose shapes the next crossing is on, and its crossings.
let j = starts.partition_point(|&s| base + s <= id).saturating_sub(1);
let (Some(r), Some(l), Some(&start)) = (parts.get(j), layouts.get(j), starts.get(j)) else { break };
let end = starts.get(j + 1).map_or(usize::MAX, |&s| base + s);
let n = rest.partition_point(|c| c.0 < end);
if n == 0 {
break;
}
let (mine, after) = rest.split_at(n);
out.extend(r.spans_on(l, mine, base + start));
rest = after;
}
merged(out)
}
(Region::Clip { clip, content }, Layout::Clip { clip_shapes, clip: lc, content: lt }) => {
let (a, b) = line.split_at(line.partition_point(|c| c.0 < base + clip_shapes));
if a.is_empty() || b.is_empty() {
return vec![];
}
intersected(&clip.spans_on(lc, a, base), &content.spans_on(lt, b, base + clip_shapes))
}
_ => vec![],
}
}
/// Is `p` inside the region's area or on one of `segments`, the outlines it shows?
fn contains(&self, p: Point, segments: &[Segment]) -> bool {
const EPS: f64 = 1e-7;
self.spans(false, p.y).iter().any(|&(a, b)| p.x >= a - EPS && p.x <= b + EPS) || segments.iter().any(|&(a, b)| distance(p, a, b) <= EPS)
}
/// The pieces of outline the region shows: every outline of a shape and of a group's members,
/// and in a clip group the members' outlines inside the clipping path and the clipping path's
/// outline where the members' area or outlines are. Errs past [`MAX_PAIRS`], counted over
/// every clip group in the region together.
pub(super) fn outline(&self) -> Result<Vec<Segment>, String> {
self.outline_counted(&mut 0)
}
/// [`Region::outline`], adding the measure's work to `work`.
fn outline_counted(&self, work: &mut usize) -> Result<Vec<Segment>, String> {
match self {
Region::Shape { polys, .. } => Ok(polys.iter().flat_map(Poly::segments).collect()),
Region::Union(parts) => {
let mut out = vec![];
for r in parts {
out.extend(r.outline_counted(work)?);
}
Ok(out)
}
Region::Clip { clip, content } => {
// Each side's area is bounded by the outlines it shows, so the pieces are cut and
// tested against those, never against art a nested clip hides.
let (clip_shown, content_shown) = (clip.outline_counted(work)?, content.outline_counted(work)?);
let mut out = within(&content_shown, clip, &clip_shown, work)?;
out.extend(within(&clip_shown, content, &content_shown, work)?);
Ok(out)
}
}
}
/// The points of the region's closed outlines, those a nested clip hides included: what one
/// [`Region::spans`] looks at, at most.
fn closed_points(&self) -> usize {
match self {
Region::Shape { polys, .. } => polys.iter().filter(|p| p.closed).map(|p| p.points.len()).sum(),
Region::Union(parts) => parts.iter().map(Region::closed_points).sum(),
Region::Clip { clip, content } => clip.closed_points().saturating_add(content.closed_points()),
}
}
}
fn too_complex_clip() -> String {
"a clip group in the selection has too many outline segments to measure".into()
}
/// The pieces of `segments` inside `region` (or on its outlines), cut where they cross
/// `region_segments`, the outlines the region shows. Each segment, and each piece of one tested
/// for being inside, adds the region's closed outline points (hidden ones included) and its
/// shown segment count to `work`, and the measure errs past [`MAX_PAIRS`].
fn within(segments: &[Segment], region: &Region, region_segments: &[Segment], work: &mut usize) -> Result<Vec<Segment>, String> {
let mut out = vec![];
let cost = region.closed_points().saturating_add(region_segments.len()).max(1);
let charge = |work: &mut usize| {
*work = work.saturating_add(cost);
if *work > MAX_PAIRS { Err(too_complex_clip()) } else { Ok(()) }
};
for &(a, b) in segments {
charge(work)?;
let mut cuts = vec![0.0, 1.0];
let seg_box = Rect::from_points(a, b);
for &(c, d) in region_segments {
if touches(seg_box, Rect::from_points(c, d)) {
cuts.extend(crossing(a, b, c, d));
}
}
cuts.sort_by(f64::total_cmp);
cuts.dedup_by(|x, y| (*x - *y).abs() < 1e-12);
let at = |t: f64| a + (b - a) * t;
for w in cuts.windows(2) {
let (t0, t1) = (w[0], w[1]);
if t1 - t0 < 1e-12 {
continue;
}
charge(work)?;
if region.contains(at((t0 + t1) / 2.0), region_segments) {
out.push((at(t0), at(t1)));
}
}
}
Ok(out)
}
/// Do boxes `a` and `b` overlap or touch?
fn touches(a: Rect, b: Rect) -> bool {
a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1
}
/// Where along a→b (0 to 1) segment c→d crosses or touches it: one place, or the ends of the
/// shared stretch when the two lie on one line.
fn crossing(a: Point, b: Point, c: Point, d: Point) -> Vec<f64> {
let (r, s) = (b - a, d - c);
let denom = r.cross(s);
let qp = c - a;
let len2 = r.hypot2();
if len2 == 0.0 {
return vec![];
}
if denom.abs() <= 1e-12 * len2.sqrt() * s.hypot().max(1e-300) {
// Parallel: the ends of c→d on a→b when the two lie on one line.
if qp.cross(r).abs() > 1e-9 * len2.sqrt() {
return vec![];
}
return [c, d].iter().map(|p| (*p - a).dot(r) / len2).filter(|t| (0.0..=1.0).contains(t)).collect();
}
let t = qp.cross(s) / denom;
let u = qp.cross(r) / denom;
if (0.0..=1.0).contains(&t) && (0.0..=1.0).contains(&u) { vec![t] } else { vec![] }
}
/// The distance from `p` to segment a→b.
fn distance(p: Point, a: Point, b: Point) -> f64 {
let ab = b - a;
let len2 = ab.hypot2();
let t = if len2 > 0.0 { ((p - a).dot(ab) / len2).clamp(0.0, 1.0) } else { 0.0 };
(p - (a + ab * t)).hypot()
}
/// The stretch of the line `across` = `c` within `gap` of segment a→b, `None` when the line
/// passes farther away. With no gap it is where the segment meets the line.
fn near(a: Point, b: Point, vertical: bool, c: f64, gap: f64) -> Option<(f64, f64)> {
let ((u0, v0), (u1, v1)) = (uv(a, vertical), uv(b, vertical));
if gap <= 0.0 {
if v0 == v1 {
return (v0 == c).then_some((u0.min(u1), u0.max(u1)));
}
if c < v0.min(v1) || c > v0.max(v1) {
return None;
}
let u = u0 + (c - v0) * (u1 - u0) / (v1 - v0);
return Some((u, u));
}
let mut lo = f64::INFINITY;
let mut hi = f64::NEG_INFINITY;
let mut take = |x: f64, y: f64| {
lo = lo.min(x);
hi = hi.max(y);
};
// The round ends.
for (u, v) in [(u0, v0), (u1, v1)] {
let dv = (c - v).abs();
if dv <= gap {
let h = (gap * gap - dv * dv).max(0.0).sqrt();
take(u - h, u + h);
}
}
// The band along the segment: points whose foot falls on it, within the gap of it.
let (du, dv) = (u1 - u0, v1 - v0);
let len = du.hypot(dv);
if len > 0.0 {
// Each condition is linear in the position x along the line: k·x + m ≥ 0.
let mut range = (f64::NEG_INFINITY, f64::INFINITY);
let mut keep = |k: f64, m: f64| {
if k == 0.0 {
if m < 0.0 {
range = (1.0, 0.0);
}
} else if k > 0.0 {
range.0 = range.0.max(-m / k);
} else {
range.1 = range.1.min(-m / k);
}
};
// The foot at or after a: (x - u0)·du + (c - v0)·dv ≥ 0.
keep(du, -u0 * du + (c - v0) * dv);
// The foot at or before b.
keep(-du, u0 * du - (c - v0) * dv + len * len);
// Within the gap on either side: |(x - u0)·dv - (c - v0)·du| ≤ gap·len.
let cross0 = -u0 * dv - (c - v0) * du;
keep(-dv, -cross0 + gap * len);
keep(dv, cross0 + gap * len);
if range.0 <= range.1 {
take(range.0, range.1);
}
}
(lo <= hi).then_some((lo, hi))
}
/// How many weeding lines run each way.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct Counts {
pub horizontal: usize,
pub vertical: usize,
}
/// The weeding lines across `frame`: `n.horizontal` horizontal and `n.vertical` vertical lines that
/// divide it evenly, each from one side of the frame to the other, left out where it is inside the
/// region's area or within `gap` of one of its outlines (`outline`). Pieces are in order along each
/// line, the horizontal lines first, top to bottom. Errs past [`MAX_NEAR`] pairs of an outline
/// segment and a line it comes within `gap` of, past [`MAX_NEAR`] crossings of the region's
/// outlines with the lines one way, or past `max_pieces` pieces.
pub(super) fn lines(region: &Region, outline: &[Segment], frame: Rect, n: Counts, gap: f64, max_pieces: usize) -> Result<Vec<Segment>, String> {
let mut out = vec![];
let (layout, _) = Layout::of(region);
for (vertical, count) in [(false, n.horizontal), (true, n.vertical)] {
let ((u0, v0), (u1, v1)) = (uv(Point::new(frame.x0, frame.y0), vertical), uv(Point::new(frame.x1, frame.y1), vertical));
let step = (v1 - v0) / (count as f64 + 1.0);
// A frame with no height (for horizontal lines) or no width has no waste to divide.
if step <= 0.0 {
continue;
}
// The lines (0-based) each outline segment comes within `gap` of: lines k (1-based) lie at
// v0 + k·step.
let reach = |&(a, b): &Segment| {
let (va, vb) = (uv(a, vertical).1, uv(b, vertical).1);
let (lo, hi) = (va.min(vb) - gap, va.max(vb) + gap);
let first = ((lo - v0) / step).ceil().max(1.0);
let last = ((hi - v0) / step).floor().min(count as f64);
if step > 0.0 && first <= last { (first as usize - 1)..(last as usize) } else { 0..0 }
};
// The outlines near each line, found once for all of them, by index into `outline`.
let pairs = outline.iter().fold(0usize, |sum, s| sum.saturating_add(reach(s).len()));
if pairs > MAX_NEAR {
return Err("the weeding lines pass too close to too many outline segments to measure; use fewer lines or a smaller gap".into());
}
let mut near_line: Vec<Vec<usize>> = vec![vec![]; count];
for (j, s) in outline.iter().enumerate() {
for k in reach(s) {
if let Some(list) = near_line.get_mut(k) {
list.push(j);
}
}
}
let crossed = crossings(region, vertical, v0, step, count)?;
for (i, segments) in near_line.iter().enumerate() {
let c = v0 + step * (i as f64 + 1.0);
let mut blocked = region.spans_on(&layout, crossed.get(i).map(Vec::as_slice).unwrap_or_default(), 0);
blocked.extend(segments.iter().filter_map(|&j| outline.get(j)).filter_map(|&(a, b)| near(a, b, vertical, c, gap)));
let mut from = u0;
let point = |u: f64| if vertical { Point::new(c, u) } else { Point::new(u, c) };
for (a, b) in merged(blocked) {
if a - from >= MIN_PIECE && a <= u1 {
out.push((point(from), point(a.min(u1))));
}
from = from.max(b);
}
if u1 - from >= MIN_PIECE {
out.push((point(from), point(u1)));
}
if out.len() > max_pieces {
return Err(format!("the weeding lines would be more than {max_pieces} pieces"));
}
}
}
Ok(out)
}
@@ -0,0 +1,915 @@
//! Tests for `addon.cutting.weed`: a weed box around the selected art and weeding lines across it
//! that stop at the art's outlines.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::{Appearance, Node, NodeKind};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Shape, shapes};
use super::super::super::super::pathops::text_outline;
use crate::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
}
fn line(s: &mut Session, pts: &[(f64, f64)]) -> NodeId {
let anchors: Vec<Value> = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect();
id_of(&s.execute("path.create", &json!({"anchors": anchors, "closed": false})).unwrap())
}
fn select(s: &mut Session, ids: &[NodeId]) {
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).cloned().unwrap()
}
/// Runs Weed Box and Lines with `p` and gives the group it made.
fn weed(s: &mut Session, p: Value) -> (Value, Node) {
let r = s.execute("addon.cutting.weed", &p).unwrap();
let g = node(s, id_of(&r));
(r, g)
}
/// The weed box's rectangle in group `g`, if it has one.
fn weed_box(g: &Node) -> Option<Rect> {
g.children().unwrap().iter().find(|c| c.name.as_deref() == Some("Weed Box")).map(|c| c.geometric_bounds().unwrap())
}
/// The weeding lines in group `g` as their two ends, rounded to 1/1000 pt.
fn lines(g: &Node) -> Vec<((f64, f64), (f64, f64))> {
let r = |v: f64| (v * 1000.0).round() / 1000.0;
g.children()
.unwrap()
.iter()
.filter(|c| c.name.is_none())
.map(|c| match &c.kind {
NodeKind::Path { path, .. } => {
let a: Vec<Point> = path.anchors().map(|(_, _, a)| a.p).collect();
assert_eq!(a.len(), 2, "a weeding line is one straight segment");
((r(a[0].x), r(a[0].y)), (r(a[1].x), r(a[1].y)))
}
other => panic!("not a path: {other:?}"),
})
.collect()
}
#[test]
fn a_weed_box_and_lines_go_around_a_rectangle() {
let mut s = session();
rect(&mut s, 100.0, 100.0, 50.0, 40.0);
let undo = s.doc().unwrap().history.undo.len();
let (r, g) = weed(&mut s, json!({"margin": 10}));
assert_eq!(g.name.as_deref(), Some("Weeding"));
assert_eq!(weed_box(&g), Some(Rect::new(90.0, 90.0, 160.0, 150.0)));
// One line each way through the middle, left out over the art.
assert_eq!(
lines(&g),
[((90.0, 120.0), (100.0, 120.0)), ((150.0, 120.0), (160.0, 120.0)), ((125.0, 90.0), (125.0, 100.0)), ((125.0, 140.0), (125.0, 150.0))]
);
assert_eq!(r["lines"], json!(4));
assert_eq!(r["box"], json!(weed_box_id(&g)));
// Stroked in Registration with no fill, selected, as one undo step that takes it all away.
for c in g.children().unwrap() {
assert_eq!(c.appearance.fill_paint(), Paint::None, "no fill");
assert!(c.appearance.stroke_paint().is_registration());
}
assert_eq!(s.doc().unwrap().selection.objects, [g.id]);
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(g.id).is_none());
}
fn weed_box_id(g: &Node) -> u64 {
g.children().unwrap().iter().find(|c| c.name.as_deref() == Some("Weed Box")).unwrap().id.0
}
#[test]
fn lines_stop_at_the_outline_and_cross_the_hole_of_a_compound_path() {
let mut s = session();
let outer = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
let inner = rect(&mut s, 130.0, 130.0, 40.0, 40.0);
select(&mut s, &[outer, inner]);
let c = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap());
// Both subpaths wind the same way, so the fill rule alone decides whether the inner one is a
// hole.
let same_way = |rule: FillRule| {
move |d: &mut vectorcraft_doc::Document, _: &mut vectorcraft_doc::Selection| -> Result<()> {
let Some(NodeKind::Compound { children, rule: r }) = d.node_mut(c).map(|n| &mut n.kind) else { panic!("not a compound path") };
*r = rule;
let area = |n: &Node| match &n.kind {
NodeKind::Path { path, .. } => path.subpaths[0].signed_area(),
_ => panic!("not a path"),
};
let outer = area(&children[0]).signum();
for child in children.iter_mut() {
if area(child).signum() != outer
&& let NodeKind::Path { path, .. } = &mut std::sync::Arc::make_mut(child).kind
{
path.reverse();
}
}
Ok(())
}
};
s.edit("even-odd", same_way(FillRule::EvenOdd)).unwrap();
select(&mut s, &[c]);
let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
// The waste in the hole is cut too; the ring itself is not.
assert_eq!(lines(&g), [((90.0, 150.0), (100.0, 150.0)), ((130.0, 150.0), (170.0, 150.0)), ((200.0, 150.0), (210.0, 150.0))]);
// With the non-zero rule the inner subpath fills, and no line crosses it.
s.edit("non-zero", same_way(FillRule::NonZero)).unwrap();
select(&mut s, &[c]);
let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
assert_eq!(lines(&g), [((90.0, 150.0), (100.0, 150.0)), ((200.0, 150.0), (210.0, 150.0))]);
}
#[test]
fn a_gap_keeps_the_lines_away_from_the_outline() {
let mut s = session();
rect(&mut s, 100.0, 100.0, 50.0, 40.0);
let (_, g) = weed(&mut s, json!({"margin": 20, "gap": 5, "direction": "horizontal"}));
assert_eq!(weed_box(&g), Some(Rect::new(80.0, 80.0, 170.0, 160.0)));
assert_eq!(lines(&g), [((80.0, 120.0), (95.0, 120.0)), ((155.0, 120.0), (170.0, 120.0))]);
// The gap is a distance from the outline: a line passing 5 pt beside a corner, with a gap of
// 6, stops √11 pt short of the corner's height.
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 50.0, 40.0);
let b = rect(&mut s, 200.0, 60.0, 10.0, 120.0);
select(&mut s, &[a, b]);
let (_, g) = weed(&mut s, json!({"margin": 0, "gap": 6, "direction": "vertical"}));
assert_eq!(weed_box(&g), Some(Rect::new(100.0, 60.0, 210.0, 180.0)));
assert_eq!(lines(&g), [((155.0, 60.0), (155.0, 96.683)), ((155.0, 143.317), (155.0, 180.0))]);
}
#[test]
fn a_box_with_no_height_gets_no_lines_across_it() {
// A horizontal line with no margin: the box is as flat as the line, and no weeding line runs
// along it.
let mut s = session();
let l = line(&mut s, &[(100.0, 150.0), (200.0, 150.0)]);
let n = s.doc().unwrap().doc.node_count();
assert!(s.execute("addon.cutting.weed", &json!({"margin": 0, "direction": "horizontal", "box": false})).is_err());
assert_eq!(s.doc().unwrap().doc.node_count(), n, "nothing to add");
select(&mut s, &[l]);
let (_, g) = weed(&mut s, json!({"margin": 0, "direction": "horizontal"}));
assert_eq!(weed_box(&g), Some(Rect::new(100.0, 150.0, 200.0, 150.0)));
assert_eq!(lines(&g), []);
}
#[test]
fn an_open_path_stops_a_line_where_it_crosses() {
let mut s = session();
line(&mut s, &[(100.0, 100.0), (100.0, 200.0)]);
let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal", "gap": 2}));
assert_eq!(lines(&g), [((90.0, 150.0), (98.0, 150.0)), ((102.0, 150.0), (110.0, 150.0))]);
}
#[test]
fn type_is_measured_by_its_glyph_outlines() {
let mut s = session();
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "OHO", "size": 72})).unwrap());
let glyphs = match &node(&s, t).kind {
NodeKind::Text(t) => text_outline(t),
_ => panic!("not type"),
};
let ink = glyphs.bounds().unwrap();
let (_, g) = weed(&mut s, json!({"margin": 10, "count": 3, "direction": "horizontal"}));
let b = weed_box(&g).unwrap();
for (got, want) in [(b.x0, ink.x0 - 10.0), (b.y0, ink.y0 - 10.0), (b.x1, ink.x1 + 10.0), (b.y1, ink.y1 + 10.0)] {
assert!((got - want).abs() < 0.05, "the box is 10 pt around the glyphs: {b:?} vs {ink:?}");
}
// No piece of line runs over a glyph: each piece's middle is outside the ink.
let shape = glyphs.to_bezpath();
let pieces = lines(&g);
assert!(pieces.len() > 3, "the letters break the lines: {pieces:?}");
for ((x0, y0), (x1, y1)) in pieces {
let mid = Point::new((x0 + x1) / 2.0, (y0 + y1) / 2.0);
assert_eq!(shape.winding(mid), 0, "{mid:?} is outside the glyphs");
}
}
#[test]
fn a_clip_group_is_measured_by_what_shows() {
let mut s = session();
let big = rect(&mut s, 0.0, 0.0, 400.0, 300.0);
let clip = rect(&mut s, 100.0, 100.0, 50.0, 40.0);
select(&mut s, &[big, clip]);
s.execute("object.clippingMask.make", &json!({})).unwrap();
let (_, g) = weed(&mut s, json!({"margin": 10}));
assert_eq!(weed_box(&g), Some(Rect::new(90.0, 90.0, 160.0, 150.0)));
assert_eq!(lines(&g).len(), 4);
// Art that shows only partly: the clip path's edge where the art is, and the art's edge
// inside the clip.
let mut s = session();
let art = rect(&mut s, 120.0, 0.0, 100.0, 300.0);
let clip = rect(&mut s, 100.0, 100.0, 50.0, 40.0);
select(&mut s, &[art, clip]);
s.execute("object.clippingMask.make", &json!({})).unwrap();
let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
assert_eq!(weed_box(&g), Some(Rect::new(110.0, 90.0, 160.0, 150.0)));
assert_eq!(lines(&g), [((110.0, 120.0), (120.0, 120.0)), ((150.0, 120.0), (160.0, 120.0))]);
}
#[test]
fn a_group_as_the_clipping_path_clips_by_its_members() {
// Make Clipping Mask takes a path, a compound path or type; an opened file can clip by a
// group, as this one does.
let mut s = session();
let parent = s.doc().unwrap().target_parent().unwrap();
let id = s
.edit("clip group", |d, sel| {
let mut r = |x: f64, w: f64, y: f64, h: f64| {
Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, y, x + w, y + h)), Appearance::default()))
};
let (a, b, art) = (r(100.0, 50.0, 100.0, 40.0), r(250.0, 50.0, 100.0, 40.0), r(0.0, 400.0, 0.0, 300.0));
let clip = Arc::new(Node::group(d.alloc_id(), vec![a, b]));
let mut g = Node::group(d.alloc_id(), vec![clip, art]);
if let NodeKind::Group { clip, .. } = &mut g.kind {
*clip = true;
}
let id = g.id;
d.insert(parent, usize::MAX, g)?;
sel.set([id]);
Ok(id)
})
.unwrap();
select(&mut s, &[id]);
let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
// The art shows through both rectangles, and the line is cut between them too.
assert_eq!(weed_box(&g), Some(Rect::new(90.0, 90.0, 310.0, 150.0)));
assert_eq!(lines(&g), [((90.0, 120.0), (100.0, 120.0)), ((150.0, 120.0), (250.0, 120.0)), ((300.0, 120.0), (310.0, 120.0))]);
}
#[test]
fn lines_near_too_many_outline_segments_are_refused() {
let mut s = session();
// A zigzag of 12,000 segments; with a gap as tall as the box, each of 1,000 lines comes near
// every segment.
let mut zigzag = BezPath::new();
zigzag.move_to((100.0, 100.0));
for i in 1..=12_000 {
zigzag.line_to((100.0 + f64::from(i) * 0.01, if i % 2 == 0 { 100.0 } else { 101.0 }));
}
let parent = s.doc().unwrap().target_parent().unwrap();
let id = s
.edit("zigzag", |d, sel| {
let n = Node::path(d.alloc_id(), PathData::from_bezpath(&zigzag), Appearance::default());
let id = n.id;
d.insert(parent, usize::MAX, n)?;
sel.set([id]);
Ok(id)
})
.unwrap();
select(&mut s, &[id]);
let n = s.doc().unwrap().doc.node_count();
let e = s.execute("addon.cutting.weed", &json!({"count": 1000, "gap": 50, "direction": "horizontal"})).unwrap_err();
assert!(e.to_string().contains("too many outline segments"), "{e}");
assert_eq!(s.doc().unwrap().doc.node_count(), n);
// Fewer lines measure.
s.execute("addon.cutting.weed", &json!({"count": 10, "gap": 50, "direction": "horizontal"})).unwrap();
}
/// Adds a clip group, clipping path `clip` over `art` (both unpainted), and selects it.
fn clip_group(s: &mut Session, clip: PathData, art: PathData) -> NodeId {
let parent = s.doc().unwrap().target_parent().unwrap();
let id = s
.edit("clip group", |d, sel| {
let clip = Arc::new(Node::path(d.alloc_id(), clip, Appearance::default()));
let art = Arc::new(Node::path(d.alloc_id(), art, Appearance::default()));
let mut g = Node::group(d.alloc_id(), vec![clip, art]);
if let NodeKind::Group { clip, .. } = &mut g.kind {
*clip = true;
}
let id = g.id;
d.insert(parent, usize::MAX, g)?;
sel.set([id]);
Ok(id)
})
.unwrap();
select(s, &[id]);
id
}
#[test]
fn an_open_clipping_path_clips_as_if_closed() {
let mut s = session();
let mut v = BezPath::new();
v.move_to((100.0, 100.0));
v.line_to((150.0, 200.0));
v.line_to((200.0, 100.0));
clip_group(&mut s, PathData::from_bezpath(&v), shapes::rectangle(Rect::new(0.0, 0.0, 400.0, 300.0)));
let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
// The art shows inside the triangle the V closes; the line stops at its sides.
assert_eq!(weed_box(&g), Some(Rect::new(90.0, 90.0, 210.0, 210.0)));
assert_eq!(lines(&g), [((90.0, 150.0), (125.0, 150.0)), ((175.0, 150.0), (210.0, 150.0))]);
}
#[test]
fn art_a_nested_clip_hides_adds_no_outline() {
// A clip group, clipping path A, holding a clip group whose clipping path B shows a small
// rectangle and hides a line that runs along A's bottom edge.
let mut s = session();
let parent = s.doc().unwrap().target_parent().unwrap();
let id = s
.edit("nested clips", |d, sel| {
let mut path = |p: PathData| Arc::new(Node::path(d.alloc_id(), p, Appearance::default()));
let mut l = BezPath::new();
l.move_to((0.0, 200.0));
l.line_to((400.0, 200.0));
let (b, shown, hidden) = (
path(shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0))),
path(shapes::rectangle(Rect::new(110.0, 110.0, 140.0, 130.0))),
path(PathData::from_bezpath(&l)),
);
let a = path(shapes::rectangle(Rect::new(50.0, 50.0, 300.0, 200.0)));
let clipped = |mut g: Node| {
if let NodeKind::Group { clip, .. } = &mut g.kind {
*clip = true;
}
g
};
let inner = Arc::new(clipped(Node::group(d.alloc_id(), vec![b, shown, hidden])));
let outer = clipped(Node::group(d.alloc_id(), vec![a, inner]));
let id = outer.id;
d.insert(parent, usize::MAX, outer)?;
sel.set([id]);
Ok(id)
})
.unwrap();
select(&mut s, &[id]);
let (_, g) = weed(&mut s, json!({"margin": 10, "count": 0}));
assert_eq!(weed_box(&g), Some(Rect::new(100.0, 100.0, 150.0, 140.0)), "only the shown rectangle is measured");
}
#[test]
fn art_a_nested_clip_hides_counts_toward_the_clip_limit() {
// A clip group, clipping path A of 20,000 segments, holding a clip group whose small clipping
// path B shows a rectangle and hides a closed zigzag C of 20,000 points across every row of A.
// The inner group shows four outline pieces, but testing each piece of A for being inside it
// looks at all of C.
let mut s = session();
let parent = s.doc().unwrap().target_parent().unwrap();
let id = s
.edit("nested clips", |d, sel| {
let mut path = |p: BezPath| Arc::new(Node::path(d.alloc_id(), PathData::from_bezpath(&p), Appearance::default()));
let mut a = BezPath::new();
a.move_to((0.0, 0.0));
for i in 1..=20_000 {
a.line_to((f64::from(i) * 0.02, if i % 2 == 0 { 0.0 } else { 0.5 }));
}
a.line_to((400.0, 300.0));
a.line_to((0.0, 300.0));
a.close_path();
let mut c = BezPath::new();
c.move_to((300.0, 0.0));
for i in 1..=20_000 {
c.line_to((300.0 + f64::from(i) * 0.002, if i % 2 == 0 { 0.0 } else { 300.0 }));
}
c.close_path();
let b = path(shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)).to_bezpath());
let shown = path(shapes::rectangle(Rect::new(110.0, 110.0, 140.0, 130.0)).to_bezpath());
let (a, c) = (path(a), path(c));
let clipped = |mut g: Node| {
if let NodeKind::Group { clip, .. } = &mut g.kind {
*clip = true;
}
g
};
let inner = Arc::new(clipped(Node::group(d.alloc_id(), vec![b, shown, c])));
let outer = clipped(Node::group(d.alloc_id(), vec![a, inner]));
let id = outer.id;
d.insert(parent, usize::MAX, outer)?;
sel.set([id]);
Ok(id)
})
.unwrap();
select(&mut s, &[id]);
let n = s.doc().unwrap().doc.node_count();
let e = s.execute("addon.cutting.weed", &json!({"margin": 10, "count": 0})).unwrap_err();
assert!(e.to_string().contains("too many outline segments"), "{e}");
assert_eq!(s.doc().unwrap().doc.node_count(), n);
}
#[test]
fn art_selected_inside_a_clip_group_is_measured_by_what_shows() {
// The rectangle runs far outside the clipping path; selected on its own, it still shows only
// inside it.
let mut s = session();
let clip = shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0));
let g = clip_group(&mut s, clip, shapes::rectangle(Rect::new(120.0, 0.0, 220.0, 300.0)));
let art = node(&s, g).children().unwrap()[1].id;
select(&mut s, &[art]);
let (_, w) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
assert_eq!(weed_box(&w), Some(Rect::new(110.0, 90.0, 160.0, 150.0)));
assert_eq!(lines(&w), [((110.0, 120.0), (120.0, 120.0)), ((150.0, 120.0), (160.0, 120.0))]);
// The clipping path selected on its own is measured by the art it shows.
let clip_path = node(&s, g).children().unwrap()[0].id;
select(&mut s, &[clip_path]);
let (_, w) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
assert_eq!(weed_box(&w), Some(Rect::new(110.0, 90.0, 160.0, 150.0)));
// With the art selected too, or the group, the group is measured once.
let d = &s.doc().unwrap().doc;
let outline = |ids: &[NodeId]| super::geometry::Reader::new(d).read_ids(ids).unwrap().unwrap().outline().unwrap().len();
assert_eq!(outline(&[clip_path, art]), outline(&[g]));
assert_eq!(outline(&[g, art, g]), outline(&[g]));
}
#[test]
fn art_in_a_hidden_group_is_left_out() {
let mut s = session();
let shown = rect(&mut s, 300.0, 300.0, 20.0, 20.0);
let a = rect(&mut s, 100.0, 100.0, 50.0, 40.0);
let b = rect(&mut s, 200.0, 100.0, 50.0, 40.0);
select(&mut s, &[a, b]);
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
s.edit("hide", |d, _| {
if let Some(n) = d.node_mut(g) {
n.visible = false;
}
Ok(())
})
.unwrap();
select(&mut s, &[a]);
assert!(s.execute("addon.cutting.weed", &json!({})).is_err(), "nothing selected shows");
select(&mut s, &[a, shown]);
let (_, w) = weed(&mut s, json!({"margin": 10}));
assert_eq!(weed_box(&w), Some(Rect::new(290.0, 290.0, 330.0, 330.0)));
}
#[test]
fn art_in_a_hidden_group_is_not_measured() {
// Selected art in a hidden group with more outline points than the limit doesn't count toward
// it.
let mut s = session();
let shown = rect(&mut s, 300.0, 300.0, 20.0, 20.0);
let mut p = BezPath::new();
p.move_to((100.0, 100.0));
for i in 1..=super::geometry::MAX_POINTS as u32 {
p.line_to((100.0 + f64::from(i) * 0.001, if i % 2 == 0 { 100.0 } else { 101.0 }));
}
let dense = insert(&mut s, |id| Node::path(id, PathData::from_bezpath(&p), Appearance::default()));
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
s.edit("hide", |d, _| {
if let Some(n) = d.node_mut(g) {
n.visible = false;
}
Ok(())
})
.unwrap();
select(&mut s, &[dense, shown]);
let (_, w) = weed(&mut s, json!({"margin": 10}));
assert_eq!(weed_box(&w), Some(Rect::new(290.0, 290.0, 330.0, 330.0)));
}
#[test]
fn clip_outlines_crossing_too_often_are_refused() {
// A clipping path whose 700 edges each run across the art, and open art whose 700 segments
// each run down across the clipping path: every pair crosses, and each of the 490,000 pieces
// of art between crossings would be tested against all 700 edges.
let zigzag = |across: bool| {
let mut p = BezPath::new();
for i in 0..=700 {
let (along, side) = (100.0 + f64::from(i) * 0.25, if i % 2 == 0 { 100.0 } else { 300.0 });
let pt = if across { (side, along) } else { (along, side) };
if i == 0 { p.move_to(pt) } else { p.line_to(pt) }
}
p
};
let mut clip = zigzag(true);
clip.close_path();
let mut s = session();
clip_group(&mut s, PathData::from_bezpath(&clip), PathData::from_bezpath(&zigzag(false)));
let n = s.doc().unwrap().doc.node_count();
let e = s.execute("addon.cutting.weed", &json!({})).unwrap_err();
assert!(e.to_string().contains("too many outline segments"), "{e}");
assert_eq!(s.doc().unwrap().doc.node_count(), n);
}
/// Each weeding line's stretches inside the art, found from the crossings indexed once for all the
/// lines, match those found by measuring the whole art along that line.
#[test]
fn indexed_crossings_find_the_same_stretches() {
let mut s = session();
let mut ids = vec![];
let outer = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
let inner = rect(&mut s, 130.0, 130.0, 40.0, 40.0);
select(&mut s, &[outer, inner]);
ids.push(id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap()));
let big = rect(&mut s, 220.0, 50.0, 200.0, 200.0);
let window = id_of(&s.execute("shape.ellipse", &json!({"x": 260, "y": 80, "width": 90, "height": 120})).unwrap());
select(&mut s, &[big, window]);
ids.push(id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap()));
ids.push(id_of(&s.execute("text.create", &json!({"x": 100, "y": 320, "text": "WEED 8", "size": 60})).unwrap()));
ids.push(line(&mut s, &[(90.0, 40.0), (450.0, 380.0)]));
let a = rect(&mut s, 300.0, 300.0, 30.0, 30.0);
let b = id_of(&s.execute("shape.ellipse", &json!({"x": 320, "y": 310, "width": 60, "height": 40})).unwrap());
select(&mut s, &[a, b]);
ids.push(id_of(&s.execute("object.group", &json!({})).unwrap()));
let d = &s.doc().unwrap().doc;
let nodes: Vec<&Node> = ids.iter().filter_map(|id| d.node(*id)).collect();
let region = super::geometry::Reader::new(d).read(&nodes).unwrap().unwrap();
let (layout, _) = super::geometry::Layout::of(&region);
let count = 97;
for vertical in [false, true] {
let (v0, v1) = (30.0, 400.0);
let step = (v1 - v0) / (count as f64 + 1.0);
let crossed = super::geometry::crossings(&region, vertical, v0, step, count).unwrap();
assert_eq!(crossed.len(), count);
let mut hit = 0;
for (k, line) in crossed.iter().enumerate() {
let c = v0 + step * (k as f64 + 1.0);
let want = region.spans(vertical, c);
hit += usize::from(!want.is_empty());
assert_eq!(region.spans_on(&layout, line, 0), want, "line {k}, vertical {vertical}");
}
assert!(hit > count / 2, "most lines cross the art ({hit})");
}
}
#[test]
fn art_nested_too_deep_is_refused() {
// A rectangle inside `levels` groups, each holding the next. A document keeps none this deep,
// but the art symbol instances and live objects draw nests further.
let doc = vectorcraft_doc::Document::new(800.0, 600.0);
let nested = |levels: usize| {
let mut n = Node::path(NodeId(1), shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)), Appearance::default());
for i in 0..levels {
n = Node::group(NodeId(i as u64 + 2), vec![Arc::new(n)]);
}
n
};
let read = |n: &Node| super::geometry::Reader::new(&doc).read(&[n]);
assert!(read(&nested(super::geometry::MAX_DEPTH)).unwrap().is_some());
let e = read(&nested(super::geometry::MAX_DEPTH + 1)).unwrap_err();
assert!(e.contains("nested more than"), "{e}");
}
#[test]
fn spacing_divides_the_box_into_strips_no_wider() {
let mut s = session();
rect(&mut s, 100.0, 100.0, 10.0, 40.0);
// The box is 60 pt tall: three strips of 20 pt.
let (_, g) = weed(&mut s, json!({"margin": 10, "spacing": 25, "direction": "horizontal", "box": false}));
assert_eq!(weed_box(&g), None);
let ys: Vec<f64> = lines(&g).iter().map(|l| l.0.1).collect();
assert_eq!(ys, [110.0, 110.0, 130.0, 130.0]);
let mut s = session();
rect(&mut s, 100.0, 100.0, 10.0, 40.0);
assert!(s.execute("addon.cutting.weed", &json!({"spacing": 0.01})).is_err(), "too many lines");
}
#[test]
fn the_lines_take_a_swatch() {
let mut s = session();
s.execute("swatch.new", &json!({"name": "Cut", "color": "cmyk 0 100 0 0", "spot": true})).unwrap();
rect(&mut s, 100.0, 100.0, 50.0, 40.0);
let (_, g) = weed(&mut s, json!({"swatch": "Cut", "weight": 1}));
for c in g.children().unwrap() {
match c.appearance.stroke_paint() {
Paint::Solid { swatch, .. } => assert_eq!(swatch.as_deref(), Some("Cut")),
other => panic!("stroke {other:?}"),
}
assert_eq!(c.appearance.stroke_width(), 1.0);
}
}
/// Adds the object `make` makes with a new id to the current layer, selects it and gives its id.
fn insert(s: &mut Session, make: impl FnOnce(NodeId) -> Node) -> NodeId {
let parent = s.doc().unwrap().target_parent().unwrap();
let id = s
.edit("insert", |d, _| {
let n = make(d.alloc_id());
let id = n.id;
d.insert(parent, usize::MAX, n)?;
Ok(id)
})
.unwrap();
select(s, &[id]);
id
}
/// A 100 pt square turned 45° about (300, 300): a diamond 141.42 pt across.
fn diamond_xf() -> Affine {
Affine::translate((300.0, 300.0)) * Affine::rotate(std::f64::consts::FRAC_PI_4) * Affine::translate((-50.0, -50.0))
}
/// The weeding lines of `g` at height `y` (to 1/1000 pt).
fn at(g: &Node, y: f64) -> Vec<(f64, f64)> {
lines(g).into_iter().filter(|((_, y0), (_, y1))| *y0 == y && *y1 == y).map(|((x0, _), (x1, _))| (x0, x1)).collect()
}
/// The diamond's weeding with three horizontal lines and a margin of 10: the box, and where the
/// first line's left piece ends.
fn diamond_weeding(s: &mut Session) -> (Rect, f64) {
let (_, g) = weed(s, json!({"margin": 10, "count": 3, "direction": "horizontal"}));
let b = weed_box(&g).unwrap();
let y = ((b.y0 + b.height() / 4.0) * 1000.0).round() / 1000.0;
let first = at(&g, y);
assert_eq!(first.len(), 2, "{first:?}");
(b, first[0].1)
}
#[test]
fn an_image_is_measured_by_its_frame_as_turned() {
let mut s = session();
let im = vectorcraft_doc::ImageObject {
key: "image".into(),
width: 100,
height: 100,
xf: diamond_xf(),
link: None,
placement: Default::default(),
pixels: None,
};
insert(&mut s, |id| Node::new(id, NodeKind::Image(Box::new(im))));
let (b, end) = diamond_weeding(&mut s);
let half = 50.0 * std::f64::consts::SQRT_2;
assert!((b.x0 - (290.0 - half)).abs() < 1e-3 && (b.y1 - (310.0 + half)).abs() < 1e-3, "{b:?}");
// A quarter of the box down, the line meets the diamond's upper left edge, not its bounds.
let y = b.y0 + b.height() / 4.0;
assert!((end - (300.0 - (y - (300.0 - half)))).abs() < 0.01, "{end} at {y}");
}
#[test]
fn a_placed_file_is_measured_by_the_art_it_shows() {
let dir = crate::tests_links::Folder::new("weed-placed");
// A file with a 60 × 40 artboard and a 20 pt wide rectangle at each side (hidden when
// `hidden`), placed centered on (200, 150) and selected.
let place = |s: &mut Session, name: &str, hidden: bool| {
let src = dir.file(name);
let mut d = vectorcraft_doc::Document::new(60.0, 40.0);
let layer = d.layers[0].id;
for x in [0.0, 40.0] {
let id = d.alloc_id();
let look = Appearance::basic(Paint::solid(vectorcraft_color::Color::BLACK), Paint::None, 0.0);
let mut n = Node::path(id, shapes::rectangle(Rect::new(x, 0.0, x + 20.0, 40.0)), look);
n.visible = !hidden;
d.insert(Some(layer), 0, n).unwrap();
}
crate::tests_links::write(&src, &vectorcraft_format::save_file(&d));
let r = s.execute("file.place", &json!({"path": src, "at": [200, 150]})).unwrap();
select(s, &[NodeId(r["ids"][0].as_u64().unwrap())]);
};
let mut s = session();
place(&mut s, "pair.vectorcraft", false);
let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
// The rectangles lie at 170..190 and 210..230; the line runs between them too.
assert_eq!(at(&g, 150.0), [(160.0, 170.0), (190.0, 210.0), (230.0, 240.0)]);
// A file read but shown in a box with no size shows nothing, and its box isn't measured.
let id = NodeId(s.execute("file.place", &json!({"path": dir.file("pair.vectorcraft"), "at": [400, 150]})).unwrap()["ids"][0].as_u64().unwrap());
s.edit("no size", |d, _| {
if let Some(NodeKind::PlacedDocument(p)) = d.node_mut(id).map(|n| &mut n.kind) {
(p.width, p.height) = (0.0, 0.0);
}
Ok(())
})
.unwrap();
select(&mut s, &[id]);
let e = s.execute("addon.cutting.weed", &json!({})).unwrap_err();
assert!(e.to_string().contains("has no outlines"), "{e}");
// A file read with nothing showing has no outlines, and its box isn't measured instead.
let mut s = session();
place(&mut s, "hidden.vectorcraft", true);
let e = s.execute("addon.cutting.weed", &json!({})).unwrap_err();
assert!(e.to_string().contains("has no outlines"), "{e}");
// A file whose art is an instance of a symbol of its own: the instance's art is measured.
let src = dir.file("symbol.vectorcraft");
let mut d = vectorcraft_doc::Document::new(60.0, 40.0);
let look = Appearance::basic(Paint::solid(vectorcraft_color::Color::BLACK), Paint::None, 0.0);
let art = Node::path(NodeId(900), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 40.0)), look);
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Bar".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: "Bar".into(), xf: Affine::IDENTITY })).unwrap();
crate::tests_links::write(&src, &vectorcraft_format::save_file(&d));
let mut s = session();
let r = s.execute("file.place", &json!({"path": src, "at": [200, 150]})).unwrap();
select(&mut s, &[NodeId(r["ids"][0].as_u64().unwrap())]);
let (_, g) = weed(&mut s, json!({"margin": 10}));
let b = weed_box(&g).unwrap();
assert!(b.x0 >= 160.0 - 1e-6 && b.x1 <= 240.0 + 1e-6 && b.width() < 41.0, "the bar, not the whole box: {b:?}");
}
#[test]
fn a_placed_file_that_cant_be_read_is_measured_by_its_box_as_turned() {
let mut s = session();
let p = vectorcraft_doc::PlacedDocument {
link: vectorcraft_doc::LinkInfo::new("/no/such/file.pdf"),
key: "missing".into(),
bounding: false,
page_box: Default::default(),
width: 100.0,
height: 100.0,
xf: diamond_xf(),
placement: Default::default(),
};
insert(&mut s, |id| Node::new(id, NodeKind::PlacedDocument(Box::new(p))));
let (b, end) = diamond_weeding(&mut s);
let half = 50.0 * std::f64::consts::SQRT_2;
let y = b.y0 + b.height() / 4.0;
assert!((end - (300.0 - (y - (300.0 - half)))).abs() < 0.01, "{end} at {y}");
}
#[test]
fn a_symbol_instance_is_measured_by_its_art() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 20.0, 40.0);
let b = rect(&mut s, 140.0, 100.0, 20.0, 40.0);
select(&mut s, &[a, b]);
let inst = id_of(&s.execute("symbol.new", &json!({"name": "Pair"})).unwrap());
select(&mut s, &[inst]);
let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
// The line runs between the two rectangles too.
assert_eq!(at(&g, 120.0), [(90.0, 100.0), (120.0, 140.0), (160.0, 170.0)]);
// An instance of a symbol the document doesn't have draws nothing, and has no outlines.
s.edit("lose the symbol", |d, _| {
if let Some(NodeKind::SymbolInstance { symbol, .. }) = d.node_mut(inst).map(|n| &mut n.kind) {
*symbol = "Missing".into();
}
Ok(())
})
.unwrap();
select(&mut s, &[inst]);
let n = s.doc().unwrap().doc.node_count();
let e = s.execute("addon.cutting.weed", &json!({})).unwrap_err();
assert!(e.to_string().contains("has no outlines"), "{e}");
assert_eq!(s.doc().unwrap().doc.node_count(), n);
let shown = rect(&mut s, 300.0, 300.0, 20.0, 20.0);
select(&mut s, &[inst, shown]);
let (_, g) = weed(&mut s, json!({"margin": 10}));
assert_eq!(weed_box(&g), Some(Rect::new(290.0, 290.0, 330.0, 330.0)));
}
#[test]
fn a_blend_is_measured_by_the_art_it_draws() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 20.0, 40.0);
let b = rect(&mut s, 200.0, 100.0, 20.0, 40.0);
let blend = id_of(&s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "steps": 1})).unwrap());
select(&mut s, &[blend]);
let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
// The step halfway between the two rectangles stops the line too.
assert_eq!(at(&g, 120.0), [(90.0, 100.0), (120.0, 150.0), (170.0, 200.0), (220.0, 230.0)]);
}
#[test]
fn the_group_goes_on_top_of_the_current_layer_and_hidden_layers_are_left_out() {
let mut s = session();
let first = s.doc().unwrap().doc.layers[0].id;
let hidden = rect(&mut s, 100.0, 100.0, 50.0, 40.0);
let art = id_of(&s.execute("layer.new", &json!({"name": "Art"})).unwrap());
let shown = rect(&mut s, 300.0, 300.0, 20.0, 20.0);
let below = rect(&mut s, 400.0, 400.0, 20.0, 20.0);
s.edit("hide", |d, _| {
if let Some(n) = d.node_mut(first) {
n.visible = false;
}
Ok(())
})
.unwrap();
select(&mut s, &[hidden]);
assert!(s.execute("addon.cutting.weed", &json!({})).is_err(), "art in a hidden layer doesn't show");
select(&mut s, &[hidden, shown]);
let (_, g) = weed(&mut s, json!({"margin": 10}));
assert_eq!(weed_box(&g), Some(Rect::new(290.0, 290.0, 330.0, 330.0)));
let d = &s.doc().unwrap().doc;
assert_eq!(d.parent_of(g.id), Some(art));
assert_eq!(d.node(art).unwrap().children().unwrap().last().map(|c| c.id), Some(g.id), "on top");
// A hidden object selected on its own shows nothing.
s.edit("hide", |d, _| {
if let Some(n) = d.node_mut(below) {
n.visible = false;
}
Ok(())
})
.unwrap();
select(&mut s, &[below]);
assert!(s.execute("addon.cutting.weed", &json!({})).is_err(), "a hidden object doesn't show");
}
#[test]
fn the_group_goes_outside_a_clip_group_edited_in_isolation() {
let mut s = session();
let layer = s.doc().unwrap().doc.layers[0].id;
let g = clip_group(&mut s, shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)), shapes::rectangle(Rect::new(120.0, 0.0, 220.0, 300.0)));
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
let art = node(&s, g).children().unwrap()[1].id;
select(&mut s, &[art]);
let (_, w) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
// The clipping path would hide the lines and the box around the art inside it.
let d = &s.doc().unwrap().doc;
assert_eq!(d.parent_of(w.id), Some(layer));
assert_eq!(weed_box(&w), Some(Rect::new(110.0, 90.0, 160.0, 150.0)));
// A plain group edited in isolation inside a clip group: the group goes outside the clip group.
let mut s = session();
let parent = s.doc().unwrap().target_parent().unwrap();
let (inner, art) = s
.edit("clip group around a group", |d, _| {
let clip = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)), Appearance::default()));
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(120.0, 0.0, 220.0, 300.0)), Appearance::default());
let art_id = art.id;
let inner = Node::group(d.alloc_id(), vec![Arc::new(art)]);
let inner_id = inner.id;
let mut g = Node::group(d.alloc_id(), vec![clip, Arc::new(inner)]);
if let NodeKind::Group { clip, .. } = &mut g.kind {
*clip = true;
}
d.insert(parent, usize::MAX, g)?;
Ok((inner_id, art_id))
})
.unwrap();
s.execute("object.isolate", &json!({"id": inner.0})).unwrap();
select(&mut s, &[art]);
let (_, w) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
assert_eq!(s.doc().unwrap().doc.parent_of(w.id), parent);
assert_eq!(weed_box(&w), Some(Rect::new(110.0, 90.0, 160.0, 150.0)));
}
/// Adds one path of `count` open vertical subpaths 2 pt apart, from (100, 100) down to y = 200.
fn bars(s: &mut Session, count: u32) -> NodeId {
let mut p = BezPath::new();
for i in 0..count {
let x = 100.0 + f64::from(i) * 2.0;
p.move_to((x, 100.0));
p.line_to((x, 200.0));
}
insert(s, |id| Node::path(id, PathData::from_bezpath(&p), Appearance::default()))
}
#[test]
fn more_than_the_most_pieces_of_line_is_refused() {
// 1,000 lines each cut into 100 pieces by 101 bars: as many as one run adds.
let mut s = session();
bars(&mut s, 101);
let r = s.execute("addon.cutting.weed", &json!({"margin": 0, "count": 1000, "direction": "horizontal"})).unwrap();
assert_eq!(r["lines"], json!(super::MAX_PIECES));
// One bar more makes 1,000 pieces more.
let mut s = session();
bars(&mut s, 102);
let n = s.doc().unwrap().doc.node_count();
let e = s.execute("addon.cutting.weed", &json!({"margin": 0, "count": 1000, "direction": "horizontal"})).unwrap_err();
assert!(e.to_string().contains("more than 100000 pieces"), "{e}");
assert_eq!(s.doc().unwrap().doc.node_count(), n);
}
#[test]
fn art_with_more_than_the_most_outline_points_is_refused() {
// An open zigzag of `points` points: one subpath, measured by its points.
let zigzag = |s: &mut Session, points: u32| {
let mut p = BezPath::new();
p.move_to((100.0, 100.0));
for i in 1..points {
p.line_to((100.0 + f64::from(i) * 0.001, if i % 2 == 0 { 100.0 } else { 101.0 }));
}
insert(s, |id| Node::path(id, PathData::from_bezpath(&p), Appearance::default()));
};
let mut s = session();
zigzag(&mut s, super::geometry::MAX_POINTS as u32);
s.execute("addon.cutting.weed", &json!({"count": 0})).unwrap();
let mut s = session();
zigzag(&mut s, super::geometry::MAX_POINTS as u32 + 1);
let e = s.execute("addon.cutting.weed", &json!({"count": 0})).unwrap_err();
assert!(e.to_string().contains("too many outline points"), "{e}");
}
#[test]
fn bad_params_and_an_empty_selection_are_refused() {
let mut s = session();
rect(&mut s, 100.0, 100.0, 50.0, 40.0);
for p in [
json!({"margin": -1}),
json!({"gap": "x"}),
json!({"count": 1.5}),
json!({"count": 1001}),
json!({"count": 2, "spacing": 10}),
json!({"spacing": 0}),
json!({"direction": "diagonal"}),
json!({"box": "yes"}),
json!({"weight": 0}),
json!({"swatch": "no such swatch"}),
json!({"swatch": 3}),
json!({"box": false, "count": 0}),
] {
let n = s.doc().unwrap().doc.node_count();
assert!(s.execute("addon.cutting.weed", &p).is_err(), "{p}");
assert_eq!(s.doc().unwrap().doc.node_count(), n, "nothing added for {p}");
}
// The same art with good params is weeded; with nothing selected it is refused.
s.execute("addon.cutting.weed", &json!({"count": 2, "gap": "1 mm"})).unwrap();
s.execute("select.set", &json!({"ids": []})).unwrap();
assert!(s.execute("addon.cutting.weed", &json!({})).is_err());
}
+1 -1
View File
@@ -162,7 +162,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Brush Options…",
[],
None,
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180° (counter-clockwise from level), roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180° (counter-clockwise), modes: [fixed|random|pressure|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art (drawn as the corner that joins a side tile on its right to one below it) or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}",
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180° (counter-clockwise from level), roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180° (counter-clockwise), modes: [fixed|random|pressure|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure|tilt|bearing|rotation, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest; tilt: width with the pen lying flat to width_max upright; bearing and rotation: width at −180° to width_max at 180°; half way on a stroke drawn without that input), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art (drawn as the corner that joins a side tile on its right to one below it) or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}",
has_doc,
brush_options
),
+5 -2
View File
@@ -274,14 +274,17 @@ fn assign_profile(s: &mut Session, p: &Value) -> Result<Value> {
Ok(())
})?;
let mut st = cms::active_settings();
let before = st.clone();
if let Some(r) = &rgb {
st.rgb = r.clone();
}
if let Some(c) = &cmyk {
st.cmyk = c.clone();
}
cms::set_active(&st).map_err(|e| bad(C, e.to_string()))?;
touch_all(s);
if st != before {
cms::set_active(&st).map_err(|e| bad(C, e.to_string()))?;
touch_all(s);
}
}
Ok(json!({"rgb": rgb, "cmyk": cmyk}))
}
+1 -1
View File
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New…",
["File"],
None,
"{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792, units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), alignNewObjectsToPixelGrid?: bool (Advanced Options › Align New Objects to Pixel Grid: paths drawn in the document are pixel-aligned; default on for the Mobile and Web presets, else off; document.setup changes it later), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode}; the size is remembered in file.newPresets' Recent",
"{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792 (each more than 0 and at most 3,999,856 pt, as artboard.new takes them), units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), alignNewObjectsToPixelGrid?: bool (Advanced Options › Align New Objects to Pixel Grid: paths drawn in the document are pixel-aligned; default on for the Mobile and Web presets, else off; document.setup changes it later), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode}; the size is remembered in file.newPresets' Recent",
always,
super::newdoc::file_new
),
+1 -1
View File
@@ -71,7 +71,6 @@ pub(crate) use encode::{anti_alias, background, with_single_artboard};
pub(crate) use export::with_artboard_fills;
pub(crate) use export::{export_crop, isolated};
pub use export::{export_doc, export_source};
pub(crate) use load::check_not_lossy_overwrite;
use load::err;
pub(crate) use load::import_svg;
pub(crate) use load::is_placeholder;
@@ -82,6 +81,7 @@ pub use load::{
Loaded, RasterImage, ReadFile, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_read, open_template, raster_image,
read_for_open, texture_image,
};
pub(crate) use load::{check_files_not_lossy_overwrite, check_not_lossy_overwrite, lossy_source};
pub use load::{losses_summary, overwrite_losses};
pub use mesh3d::{DEFAULT_FORMAT_3D, FORMATS_3D, format_3d_label};
pub(crate) use mesh3d::{mesh_file_names, mesh_files};
+27 -2
View File
@@ -1019,13 +1019,38 @@ fn is_loss(note: &str) -> bool {
/// Another name keeps the original file, with all it has.
pub(crate) fn check_not_lossy_overwrite(st: &crate::DocState, target: &str, p: &Value, cmd: &str) -> Result<()> {
let Some(losses) = overwrite_losses(st, target, p) else { return Ok(()) };
Err(lossy_overwrite(target, losses, cmd))
}
/// The refusal to write `target`, the file the document was read from, when reading it left out `losses`.
fn lossy_overwrite(target: &str, losses: &[String], cmd: &str) -> EngineError {
let what = losses_summary(losses);
Err(bad(
bad(
cmd,
format!(
"{target} is the file this document was read from, and reading it left things out ({what}): writing over it would lose them for good. Write another file instead, or pass acknowledgeLoss: true to replace it anyway"
),
))
)
}
/// The file the document was read from and what reading it left out, when a write to that file
/// needs `acknowledgeLoss` and `p` does not have it ([`overwrite_losses`]).
pub(crate) fn lossy_source(st: &crate::DocState, p: &Value) -> Option<(String, Vec<String>)> {
let source = st.imported_from.as_deref().filter(|_| !st.import_losses.is_empty())?;
(!bool_or(p, "acknowledgeLoss", false)).then(|| (source.to_string(), st.import_losses.clone()))
}
/// None of the files `targets` (every file a save writes, before any is written) is the
/// `source` file that reading left `losses` out of ([`lossy_source`]).
pub(crate) fn check_files_not_lossy_overwrite<'a>(
source: &(String, Vec<String>),
targets: impl IntoIterator<Item = &'a str>,
cmd: &str,
) -> Result<()> {
match targets.into_iter().find(|t| same_file(&source.0, t)) {
Some(target) => Err(lossy_overwrite(target, &source.1, cmd)),
None => Ok(()),
}
}
/// What writing the file `target` for document `st` would lose for good: the notes of what reading
+9 -1
View File
@@ -99,6 +99,10 @@ pub struct SavePlan {
/// `preview`, `precision`) given beside the path, and each key of `options`, that the format
/// doesn't read, naming the file the save writes.
pub notes: Vec<String>,
/// The file the document was read from and what reading it left out, when the save needs
/// `acknowledgeLoss` to write over it and was not given it: every file the save writes is
/// checked against it before the first is written.
pub lossy_source: Option<(String, Vec<String>)>,
}
impl SavePlan {
@@ -313,7 +317,8 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
.chain(nested.filter(|k| !reads_option(format, k)).map(|k| format!("options.{k}")))
.map(|k| format!("`{k}` was not used: {} files don't read it", written.label))
.collect();
Ok(SavePlan { mode, path, format, options, name, folder, modified, notes })
let lossy_source = super::lossy_source(st, p);
Ok(SavePlan { mode, path, format, options, name, folder, modified, notes, lossy_source })
}
/// The native save options, which a save also takes beside the path.
@@ -910,6 +915,9 @@ impl SaveJob {
return Ok(out);
};
let files = enc.named(doc, path);
if let Some(source) = &plan.lossy_source {
super::check_files_not_lossy_overwrite(source, files.iter().map(|(p, _)| p.as_str()), plan.mode.command())?;
}
if let Some(stopped) = stop(&files.iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>()) {
return Ok(stopped);
}
+8 -1
View File
@@ -128,7 +128,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Artboard",
["Window", "Artboards"],
None,
"{x?, y?, width?, height?, name?, color?: color (its background color, as artboard.setProps takes it, #rrggbbaa and #rgba refused; default: none), guideLabel?: name | \"#rrggbb\" | null (its guide label, as artboard.setProps takes it; default: none), showCenterMark?: bool, showCrossHairs?: bool, showVideoSafeAreas?: bool (as artboard.setProps takes them; default false), videoRulerPixelAspectRatio?: number 0.1–10 (as artboard.setProps takes it; default 1), side?: \"left\"|\"right\"|\"top\"|\"bottom\" (the size of artboard `beside`, 20 pt from that side of it; refused with x, y, width or height), beside?: n (the artboard `side` places it by; default the last)} (default place: right of the last)",
"{x?, y?, width?, height? (each more than 0 and at most 3,999,856 pt, as file.new takes them), name?, color?: color (its background color, as artboard.setProps takes it, #rrggbbaa and #rgba refused; default: none), guideLabel?: name | \"#rrggbb\" | null (its guide label, as artboard.setProps takes it; default: none), showCenterMark?: bool, showCrossHairs?: bool, showVideoSafeAreas?: bool (as artboard.setProps takes them; default false), videoRulerPixelAspectRatio?: number 0.1–10 (as artboard.setProps takes it; default 1), side?: \"left\"|\"right\"|\"top\"|\"bottom\" (the size of artboard `beside`, 20 pt from that side of it; refused with x, y, width or height), beside?: n (the artboard `side` places it by; default the last)} (default place: right of the last)",
has_doc,
artboard_new
),
@@ -926,6 +926,9 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
let guide_label = artboard_guide_label(p, "artboard.new")?.flatten();
check_switches(p, &["showCenterMark", "showCrossHairs", "showVideoSafeAreas"], "artboard.new")?;
check_numbers(p, &["x", "y", "width", "height"], "artboard.new")?;
if ["width", "height"].iter().filter_map(|k| p.get(*k).and_then(Value::as_f64)).any(|side| !super::newdoc::side_in_range(side)) {
return Err(super::newdoc::side_error("artboard.new", d0.units));
}
let r = match artboard_side(p, "artboard.new")? {
Some(side) => {
if ["x", "y", "width", "height"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) {
@@ -941,6 +944,10 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
None if p.get("beside").is_some_and(|v| !v.is_null()) => return Err(bad("artboard.new", "`beside` needs `side`")),
None => rect_from(p, def),
};
// A size taken from another artboard (`side`, or width or height left out) keeps to the limit too.
if !super::newdoc::side_in_range(r.width()) || !super::newdoc::side_in_range(r.height()) {
return Err(super::newdoc::side_error("artboard.new", d0.units));
}
let given = artboard_str(p, "name", "artboard.new")?.map(str::trim).filter(|n| !n.is_empty());
let (show_center_mark, show_cross_hairs) = (bool_or(p, "showCenterMark", false), bool_or(p, "showCrossHairs", false));
let show_video_safe_areas = bool_or(p, "showVideoSafeAreas", false);
+25 -4
View File
@@ -241,8 +241,8 @@ impl DocSettings {
if let Some(h) = length("height", self.units)? {
self.height = h;
}
if !(self.width > 0.0 && self.height > 0.0) || self.width > MAX_SIDE || self.height > MAX_SIDE {
return Err(bad(cmd, "width/height must be positive and within the canvas"));
if !side_in_range(self.width) || !side_in_range(self.height) {
return Err(side_error(cmd, self.units));
}
match p.get("orientation").and_then(Value::as_str) {
None => {}
@@ -359,8 +359,29 @@ impl DocSettings {
}
}
/// Largest artboard side `file.new` accepts, points.
const MAX_SIDE: f64 = 16383.0 * 10.0;
/// Largest artboard side `file.new` and `artboard.new` accept, points: the span of the canvas
/// ([`crate::MAX_COORD`] from the origin) less the largest bleed on both sides, 3,999,856 pt.
pub(crate) const MAX_SIDE: f64 = crate::MAX_COORD - 2.0 * vectorcraft_doc::setup::MAX_BLEED;
/// Is `side` an artboard side `file.new` and `artboard.new` accept: more than 0 and at most [`MAX_SIDE`]?
pub(crate) fn side_in_range(side: f64) -> bool {
side > 0.0 && side <= MAX_SIDE
}
/// The error of an artboard side outside [`side_in_range`]: the limit in points, and in `units` as
/// well when they aren't points, rounded down to the decimals [`Unit::number`] shows so the value
/// shown is one the commands take.
pub(crate) fn side_error(cmd: &str, units: Unit) -> crate::EngineError {
let limit = match units {
Unit::Points => format!("{MAX_SIDE} pt"),
u => {
let step = if matches!(u, Unit::Pixels | Unit::Picas | Unit::Q | Unit::Ha) { 1e3 } else { 1e4 };
let shown = (u.from_pt(MAX_SIDE) * step).floor() / step;
format!("{MAX_SIDE} pt ({} {})", u.number(u.to_pt(shown)), u.suffix())
}
};
bad(cmd, format!("width and height must be more than 0 and at most {limit}"))
}
/// Largest artboard spacing, points.
const MAX_SPACING: f64 = 1.0e5;
@@ -33,6 +33,7 @@ pub(super) const ALLOWED: &[&str] = &[
"addon.color.swatchLegend",
"addon.contour.set",
"addon.cutting.bridges",
"addon.cutting.weed",
"addon.image.removeBackground",
"addon.pathtools.addPoints",
"addon.pathtools.close",
+1
View File
@@ -156,6 +156,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
p!("scrubNumericFields", "General", "Options", "Scrub Numeric Fields by Dragging", bool),
p!("trackpadRotateView", "General", "Options", "Trackpad Gesture to Rotate View", bool),
p!("holdToolKeys", "General", "Options", "Hold a Tool Shortcut to Use the Tool Temporarily", bool),
p!("boundingBoxWhileMovingAndDrawing", "General", "Options", "Show Bounding Box While Moving and Drawing", bool),
// Selection & Anchor Display
p!("selectionTolerance", "Selection & Anchor Display", "Selection", "Tolerance", num(1.0, 8.0, "px")),
p!(
+1
View File
@@ -1163,6 +1163,7 @@ pub fn jobs(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<(Vec<Reco
folder: None,
modified: None,
notes: Vec::new(),
lossy_source: None,
};
let save = fileio::job_for(s, st, plan)?;
let meta = Meta {
File diff suppressed because one or more lines are too long
+4 -2
View File
@@ -309,12 +309,12 @@ fn character_style_color_and_features_reach_text_and_follow_swatch_edits() {
// The command takes the color of the link at the tint, whatever color is sent.
tinted["tint"] = json!(0.4);
let attrs = json!({"fill": brand, "stroke": tinted, "stroke_width": 2.0, "overprint_fill": true, "overprint_stroke": false,
"features": ["dlig", "-liga", "tnum", "lnum", "-onum", "-pnum", "ss02", "-ss01"]});
"features": ["dlig", "-liga", "tnum", "lnum", "-onum", "-pnum", "ss02", "-ss01", "salt=2"]});
s.execute("charStyle.new", &json!({"name": "Ink", "attrs": attrs})).unwrap();
s.execute("charStyle.apply", &json!({"name": "Ink", "id": id})).unwrap();
let want_stroke = s.doc().unwrap().doc.tint_paint("Brand", 0.4).unwrap();
assert_eq!(run_paints(&s, id), (brand, want_stroke.clone(), 2.0, true, false));
assert_eq!(runs(&s, id)[0].style.features, ["dlig", "-liga", "tnum", "lnum", "-onum", "-pnum", "ss02", "-ss01"]);
assert_eq!(runs(&s, id)[0].style.features, ["dlig", "-liga", "tnum", "lnum", "-onum", "-pnum", "ss02", "-ss01", "salt=2"]);
let def = |s: &Session| s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Ink").unwrap().attrs.clone();
assert_eq!(def(&s)["stroke"], serde_json::to_value(&want_stroke).unwrap());
let saved = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
@@ -355,6 +355,8 @@ fn character_style_color_and_features_are_checked() {
(json!({"stroke_width": 5.0e6}), "`stroke_width` must be from 0 to 4000000"),
(json!({"features": ["dlig", "zzzz"]}), "unknown OpenType feature `zzzz`"),
(json!({"features": ["--liga"]}), "unknown OpenType feature `--liga`"),
(json!({"features": ["salt=x"]}), "unknown OpenType feature `salt=x`"),
(json!({"features": ["-salt=2"]}), "unknown OpenType feature `-salt=2`"),
(json!({"features": many}), "features lists at most 256 tags"),
(json!({"fill": {"type": "solid"}}), "missing field"),
] {
+1 -1
View File
@@ -138,7 +138,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", …] OpenType, featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
"{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", \"salt=2\", …] OpenType (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
has_doc,
set_style
),
+3
View File
@@ -27,6 +27,9 @@ pub struct Interaction {
/// A transform or a perspective edit with `copy` keeps the copies it made of the snapshot here
/// ([`PreviewCopies`]).
pub(crate) copies: Option<Arc<PreviewCopies>>,
/// The bounding box of `selection` in `doc`, measured once for a move drag
/// ([`crate::Session::move_drag_box`]): each step offsets it by the drag's move.
pub(crate) move_box: std::sync::OnceLock<Option<vectorcraft_doc::OrientedBox>>,
}
/// What Object → Transform → Transform Again (⌘D) repeats ([`SessionState::last_transform`]).
+40
View File
@@ -25,6 +25,8 @@ pub struct Journal {
bytes: usize,
/// Keep every entry from this number on ([`Journal::hold`]).
held: Option<usize>,
/// How many times kept entries were changed, dropped or renumbered ([`Journal::rewrites`]).
rewrites: u64,
}
/// Journals with the same entries (by number) are equal, whatever their history.
@@ -66,14 +68,24 @@ impl Journal {
self.entries.get(i.checked_sub(self.dropped)?)
}
pub fn get_mut(&mut self, i: usize) -> Option<&mut JournalEntry> {
self.rewrites += 1;
self.entries.get_mut(i.checked_sub(self.dropped)?)
}
pub fn last(&self) -> Option<&JournalEntry> {
self.entries.last()
}
pub fn last_mut(&mut self) -> Option<&mut JournalEntry> {
self.rewrites += 1;
self.entries.last_mut()
}
/// A number that grows whenever a kept entry may have changed, gone or been renumbered
/// ([`Journal::truncate`], [`Journal::remove`], [`Journal::replace`], [`Journal::get_mut`],
/// [`Journal::last_mut`]), and that [`Journal::push`] and letting old entries go leave as it
/// is. While it stays the same, the entries from a held number on are the ones counted before,
/// with the new ones after them.
pub fn rewrites(&self) -> u64 {
self.rewrites
}
pub fn push(&mut self, e: JournalEntry) {
let size = entry_bytes(&e);
self.entries.push(e);
@@ -83,6 +95,7 @@ impl Journal {
}
/// Drop the entries from number `len` on.
pub fn truncate(&mut self, len: usize) {
self.rewrites += 1;
let keep = len.saturating_sub(self.dropped);
self.entries.truncate(keep);
self.sizes.truncate(keep);
@@ -92,6 +105,7 @@ impl Journal {
/// Drop the entries numbered `numbers` (sorted, a number may repeat; the entries of canceled undo
/// groups). The entries after them are numbered down to close the gaps.
pub fn remove(&mut self, numbers: &[usize]) {
self.rewrites += 1;
let dropped = self.dropped;
let gone = |i: usize| numbers.binary_search(&(dropped + i)).is_ok();
let mut i = 0;
@@ -115,6 +129,7 @@ impl Journal {
}
/// Put `e` in place of entry number `i`, if kept.
pub fn replace(&mut self, i: usize, e: JournalEntry) {
self.rewrites += 1;
let Some(k) = i.checked_sub(self.dropped) else { return };
if let (Some(old), Some(size)) = (self.entries.get_mut(k), self.sizes.get_mut(k)) {
*size = entry_bytes(&e);
@@ -253,4 +268,29 @@ mod tests {
j.replace(29_000, big(0));
assert_eq!(j.bytes, j.iter().map(entry_bytes).sum::<usize>());
}
#[test]
fn rewrites_count_changes_to_kept_entries_but_not_new_ones() {
let mut j = Journal::default();
j.hold(Some(0));
for i in 0..100 {
j.push(("object.move".into(), json!({"dx": i})));
}
assert_eq!(j.rewrites(), 0, "pushes add entries after the counted ones");
let changes: [fn(&mut Journal); 5] = [
|j| j.truncate(90),
|j| j.remove(&[3]),
|j| j.replace(4, ("object.group".into(), json!({}))),
|j| {
let _ = j.get_mut(5);
},
|j| {
let _ = j.last_mut();
},
];
for (i, change) in changes.into_iter().enumerate() {
change(&mut j);
assert_eq!(j.rewrites(), i as u64 + 1);
}
}
}
+6
View File
@@ -71,6 +71,11 @@ pub struct Prefs {
/// A single-key tool shortcut held down chooses its tool until the key is let go, and then the
/// tool active before comes back; a quick press chooses the tool for good.
pub hold_tool_keys: bool,
/// With View › Show Bounding Box on, the bounding box stays visible while a drag moves the
/// selection, and the shape tools that show it draw it around the shape being drawn. Off (the
/// default), it hides during a move and while a shape is drawn, except that a Selection tool
/// move with Real-time Drawing off still moves the box.
pub bounding_box_while_moving_and_drawing: bool,
/// Offset for Paste / duplicate (Illustrator pastes to the view centre; we offset by this).
pub paste_offset: f64,
// Selection & Anchor Display
@@ -397,6 +402,7 @@ impl Default for Prefs {
scrub_numeric_fields: true,
trackpad_rotate_view: true,
hold_tool_keys: true,
bounding_box_while_moving_and_drawing: false,
paste_offset: 10.0,
selection_tolerance: 3.0,
marquee_selection: "touching".into(),
+5 -2
View File
@@ -723,7 +723,7 @@ struct Host {
/// order: the error's mark ([`HostError::Marked`]) is its index.
eval_failures: RefCell<Vec<(Arc<str>, u32, u32)>>,
/// The realm's tag for the script ([`Realm::script_tag`]) once the run has started it, and
/// [`vectorcraft_js::UNKNOWN_SOURCE`] before.
/// [`vectorcraft_js::UNKNOWN_SOURCE`] before or when the script did not compile.
script_tag: Cell<u32>,
/// The run's name as its report gives it, which [`Host::name`] shortens for console lines.
report_name: Arc<str>,
@@ -1865,7 +1865,10 @@ impl Session {
.and_then(|()| if fresh { files::install(&mut realm, &link) } else { realm.reset_folder_current() })
.and_then(|()| {
let r = realm.start(&expanded.source);
host.script_tag.set(realm.script_tag());
// Source that does not compile defines no code, so its map is not kept.
if !matches!(&r, Err(e) if e.kind == RunErrorKind::Syntax) {
host.script_tag.set(realm.script_tag());
}
r
})
.map_err(|e| located(&host, &realm, &e))
+1
View File
@@ -267,6 +267,7 @@ impl Session {
isolation: st.isolation,
perspective_again: None,
copies: None,
move_box: std::sync::OnceLock::new(),
});
Ok(())
}
+69
View File
@@ -375,3 +375,72 @@ fn the_selection_tool_reports_a_transforming_drag() {
assert!(s.tool_busy() && !s.tool_transforming());
s.pointer(&ev(PointerKind::Up, 550.0, 450.0), v).unwrap();
}
/// With General › Show Bounding Box While Moving and Drawing on, a move drag's bounding box is the
/// box measured once, at the drag's start, offset by each step's move: with Real-time Drawing on
/// and off, the later steps do not measure the selection again. Off, only the Selection tool with
/// Real-time Drawing off gives a box (the art waits for the release).
#[test]
fn a_move_drag_measures_the_bounding_box_once() {
for (real_time, while_moving) in [(true, true), (false, true), (true, false), (false, false)] {
let what = format!("real time {real_time}, box while moving {while_moving}");
let mut s = session();
let id = rect(&mut s, 100.0, 100.0, 100.0, 50.0);
sel(&mut s, &[id]);
s.execute("prefs.set", &json!({"key": "realTimeDrawing", "value": real_time})).unwrap();
s.execute("prefs.set", &json!({"key": "boundingBoxWhileMovingAndDrawing", "value": while_moving})).unwrap();
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
let ev = |kind, x: f64, y: f64| PointerEvent::new(kind, x, y).with_mods(Mods::default());
s.pointer(&ev(PointerKind::Down, 150.0, 125.0), v).unwrap();
assert_eq!(s.move_drag_box(), None, "{what}: not before the drag begins");
s.pointer(&ev(PointerKind::Drag, 170.0, 135.0), v).unwrap();
if real_time && !while_moving {
assert_eq!(s.move_drag_box(), None, "{what}: no box during the move");
s.pointer(&ev(PointerKind::Up, 170.0, 135.0), v).unwrap();
continue;
}
assert_eq!(s.move_drag_box(), Some(OrientedBox::aligned(Rect::new(120.0, 110.0, 220.0, 160.0))), "{what}");
// The measurement made at the drag's start is the one each step offsets: replaced by a
// stand-in, the next step's box is the stand-in moved.
let it = s.doc_mut().unwrap().interaction.as_mut().unwrap();
assert_eq!(it.move_box.get(), Some(&Some(OrientedBox::aligned(Rect::new(100.0, 100.0, 200.0, 150.0)))), "{what}: measured at the start");
it.move_box = std::sync::OnceLock::from(Some(OrientedBox::aligned(Rect::new(0.0, 0.0, 10.0, 10.0))));
s.pointer(&ev(PointerKind::Drag, 200.0, 165.0), v).unwrap();
assert_eq!(s.move_drag_box(), Some(OrientedBox::aligned(Rect::new(50.0, 40.0, 60.0, 50.0))), "{what}: not measured again");
s.pointer(&ev(PointerKind::Up, 200.0, 165.0), v).unwrap();
assert_eq!(s.move_drag_box(), None, "{what}: none after the release");
assert_eq!(selection_box(&s), OrientedBox::aligned(Rect::new(150.0, 140.0, 250.0, 190.0)), "{what}: moved");
}
}
/// A shape being drawn has no preview applied after a step that failed (with Real-time Drawing on,
/// also when the failed command left a document other than the interaction's snapshot), and with
/// Real-time Drawing off, where the document stays at the snapshot. A successful step with Real-time
/// Drawing on applies it.
#[test]
fn a_shape_being_drawn_reports_whether_a_preview_is_applied() {
for real_time in [true, false] {
let what = format!("real time {real_time}");
let mut s = session();
s.execute("prefs.set", &json!({"key": "realTimeDrawing", "value": real_time})).unwrap();
let v = ViewInfo::default();
s.select_tool("rectangle", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 160.0, 140.0), v).unwrap();
assert_eq!(s.interaction_unapplied(), !real_time, "{what}: after a step that succeeded");
assert!(s.preview("shape.rectangle", &json!({"x": "left"})).is_err(), "{what}: the preview fails");
assert!(s.interaction_unapplied(), "{what}: after a step that failed");
if real_time {
// A failed command that copied the document on write leaves another document than the
// snapshot; no preview is applied all the same.
let st = s.doc_mut().unwrap();
st.doc = std::sync::Arc::new((*st.doc).clone());
assert!(s.interaction_unapplied(), "{what}: a failed step with a copied document");
}
s.pointer(&PointerEvent::new(PointerKind::Drag, 180.0, 150.0), v).unwrap();
assert_eq!(s.interaction_unapplied(), !real_time, "{what}: the next step succeeded");
s.pointer(&PointerEvent::new(PointerKind::Up, 180.0, 150.0), v).unwrap();
assert!(!s.interaction_unapplied(), "{what}: no interaction after the release");
}
}
+17
View File
@@ -308,6 +308,23 @@ fn assign_profile_is_stored_and_undoable() {
assert_eq!(cmd::colormgmt::doc_profiles(&s.doc().unwrap().doc), (None, None));
}
/// Assigning the working profiles leaves the active color settings and the other documents'
/// revisions unchanged.
#[test]
fn assigning_the_working_profiles_keeps_the_active_color_settings() {
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
let mut s = session();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let working = cms::active_settings();
let active = cms::active();
let other = s.documents()[0].revision;
let r = s.execute("edit.assignProfile", &json!({"rgb": working.rgb, "cmyk": working.cmyk})).unwrap();
assert_eq!((r["rgb"].as_str(), r["cmyk"].as_str()), (Some(working.rgb.as_str()), Some(working.cmyk.as_str())));
assert_eq!(cmd::colormgmt::doc_profiles(&s.doc().unwrap().doc), (Some(working.rgb.clone()), Some(working.cmyk.clone())));
assert!(std::sync::Arc::ptr_eq(&active, &cms::active()), "the active color settings were replaced");
assert_eq!(s.documents()[0].revision, other, "the other document's revision changed");
}
#[test]
fn proof_colors_simulate_press_on_screen() {
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
+45 -2
View File
@@ -233,7 +233,7 @@ fn a_saved_preset_with_values_out_of_range_reads_in_range() {
saved,
[
(1.0, 1.0, 1.0, 0.0, vec![0.0, 0.0, 0.0, 9.0]),
(163830.0, 163830.0, 1.0, 1.0e5, vec![72.0, 72.0, 72.0, 0.0]),
(3_999_856.0, 200000.0, 1.0, 1.0e5, vec![72.0, 72.0, 72.0, 0.0]),
(10.0, 10.0, 1000.0, 0.0, vec![0.0; 4]),
]
);
@@ -242,7 +242,7 @@ fn a_saved_preset_with_values_out_of_range_reads_in_range() {
s.execute("file.new", &json!({"preset": "Low"})).unwrap();
assert_eq!((boards(&s), s.doc().unwrap().doc.setup.bleed), (vec![Rect::new(0.0, 0.0, 1.0, 1.0)], [0.0, 0.0, 0.0, 9.0]));
s.execute("file.new", &json!({"preset": "High"})).unwrap();
assert_eq!((boards(&s), s.doc().unwrap().doc.setup.bleed), (vec![Rect::new(0.0, 0.0, 163830.0, 163830.0)], [72.0, 72.0, 72.0, 0.0]));
assert_eq!((boards(&s), s.doc().unwrap().doc.setup.bleed), (vec![Rect::new(0.0, 0.0, 3_999_856.0, 200000.0)], [72.0, 72.0, 72.0, 0.0]));
s.execute("file.new", &json!({"preset": "Many"})).unwrap();
assert_eq!(boards(&s).len(), 1000);
let custom = presets(&mut s, "Recent").into_iter().find(|p| p["name"] == "Custom").unwrap();
@@ -250,6 +250,49 @@ fn a_saved_preset_with_values_out_of_range_reads_in_range() {
assert_eq!(boards(&s).iter().map(|r| r.x0).collect::<Vec<_>>(), [0.0, 100.0, 200.0], "side by side, not overlapping");
}
/// `file.new` and `artboard.new` take the same largest artboard side (the canvas less the largest
/// bleed on both sides) and refuse a side just past it, or of 0 or less, with an error that names
/// the limit in points and, when the document's units aren't points, in those units too.
#[test]
fn file_new_and_artboard_new_take_the_same_largest_side() {
let max = 3_999_856.0;
let in_meters = "at most 3999856 pt (1411.0603 m)";
let s = new_doc(json!({"width": max, "height": 170078}));
assert_eq!(boards(&s), [Rect::new(0.0, 0.0, max, 170078.0)]);
let mut s = Session::new();
let err = s.execute("file.new", &json!({"width": max + 1.0, "height": 100})).unwrap_err().to_string();
assert!(err.contains("width and height must be more than 0 and at most 3999856 pt") && !err.contains('('), "{err}");
let err = s.execute("file.new", &json!({"width": 100, "height": "1412 m", "units": "Meters"})).unwrap_err().to_string();
assert!(err.contains(in_meters), "{err}");
// The value in other units is rounded down, so it is one file.new takes.
let err = s.execute("file.new", &json!({"width": 100, "height": "56000 in", "units": "Inches"})).unwrap_err().to_string();
assert!(err.contains("at most 3999856 pt (55553.5555 in)"), "{err}");
assert!(s.execute("file.new", &json!({"width": 0, "height": 100})).is_err());
assert!(s.doc().is_err(), "no document was made");
s.execute("file.new", &json!({"width": "55553.5555 in", "height": 100, "units": "Inches"})).unwrap();
let mut s = new_doc(json!({"units": "Meters"}));
s.execute("artboard.new", &json!({"x": -2_000_000, "y": 0, "width": max, "height": 100})).unwrap();
assert_eq!(boards(&s)[1], Rect::new(-2_000_000.0, 0.0, max - 2_000_000.0, 100.0));
for p in [
json!({"x": -2_000_000, "y": 0, "width": max + 1.0, "height": 100}),
json!({"x": 0, "y": 2000, "width": 100, "height": max + 1.0}),
json!({"width": 0}),
json!({"height": -1}),
] {
let err = s.execute("artboard.new", &p).unwrap_err().to_string();
assert!(err.contains(in_meters), "{p}: {err}");
}
assert_eq!(boards(&s).len(), 2, "the refused artboards were not added");
// A size taken from a wider artboard is refused too.
s.execute("artboard.setProps", &json!({"index": 1, "x": -2_000_000, "y": 0, "width": max + 1.0, "height": 100})).unwrap();
for p in [json!({}), json!({"x": 0, "y": 5000, "height": 100}), json!({"side": "bottom", "beside": 1})] {
let err = s.execute("artboard.new", &p).unwrap_err().to_string();
assert!(err.contains(in_meters), "{p}: {err}");
}
assert_eq!(boards(&s).len(), 2);
}
#[test]
fn saved_presets_live_in_the_preferences() {
let mut s = Session::new();
+24
View File
@@ -569,6 +569,30 @@ fn eps_save_with_use_artboards_and_a_named_artboard_reports_it_as_the_range() {
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn eps_save_with_use_artboards_refuses_to_replace_the_file_read_from_before_writing_any_file() {
let d = dir("eps-artboards-lossy-sibling");
let eps_path = path(&d, "Art.eps");
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap();
rect(&mut s);
let options = json!({"useArtboards": true, "previewFormat": "none", "thumbnails": false});
let r = s.execute("document.save", &json!({"path": eps_path, "options": options})).unwrap();
let sibling = r["files"][1].as_str().unwrap().to_string();
let original = std::fs::read(&sibling).unwrap();
std::fs::remove_file(&eps_path).unwrap();
let st = s.doc_mut().unwrap();
st.imported_from = Some(sibling.clone());
st.import_losses.push("test import omitted artwork".into());
let err = s.execute("document.save", &json!({"path": eps_path, "options": options})).unwrap_err().to_string();
assert!(err.contains("acknowledgeLoss") && err.contains(&sibling), "{err}");
assert_eq!(std::fs::read(&sibling).unwrap(), original);
assert!(!std::path::Path::new(&eps_path).exists(), "no file of the set is written when one is refused");
s.execute("document.save", &json!({"path": eps_path, "options": options, "acknowledgeLoss": true})).unwrap();
assert!(std::path::Path::new(&eps_path).exists());
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn eps_save_with_use_artboards_writes_the_central_file_and_one_per_artboard() {
let d = dir("eps-artboards");
+36
View File
@@ -230,6 +230,42 @@ fn an_engine_keeps_the_newest_places_within_its_budget() {
assert_eq!((d_place.0, d_place.1, d_place.2), (None, 0, 0), "a map larger than the budget is not kept");
}
/// A run of a named engine whose script does not compile defines no code, so it keeps no place
/// and pushes out none of the places kept before. An included file without a preprocessor line is
/// first parsed with the script it joins, so its syntax error ends the run as it starts.
#[test]
fn a_run_that_does_not_compile_keeps_no_place() {
let base: PathBuf = std::env::temp_dir().join(format!("vc-script-engines-uncompiled-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
// A kept place counts the names of the files the run read: four places of the failed run's size
// would not fit in the budget.
let mut text = String::from("#targetengine e\n");
for k in 0..10 {
std::fs::write(base.join(format!("ok{k}.jsxinc")), "var ok = 1;").unwrap();
text.push_str(&format!("#include \"ok{k}.jsxinc\"\n"));
}
std::fs::write(base.join("bad.jsxinc"), "var = ;").unwrap();
text.push_str("#include \"bad.jsxinc\"");
let bad = base.join("bad.jsx");
std::fs::write(&bad, text).unwrap();
let mut s = Session::new();
run(&mut s, "#targetengine e\nvar x = 1;").unwrap();
TEST_KEPT_MAP_BYTES.with(|b| b.set(Some(3000)));
run(&mut s, "#targetengine e\n\n\nfunction c() { throw new Error('c'); }").unwrap();
let mut kinds = vec![];
for _ in 0..5 {
assert!(s.execute("script.run", &json!({"path": bad.to_string_lossy()})).is_err());
kinds.push(s.script_report().unwrap()["kind"].clone());
}
assert!(run(&mut s, "#targetengine e\nc();").is_err());
let c_place = place(&s);
TEST_KEPT_MAP_BYTES.with(|b| b.set(None));
let _ = std::fs::remove_dir_all(base);
assert!(kinds.iter().all(|k| k == "syntax"), "{kinds:?}");
assert_eq!((c_place.0.as_deref(), c_place.1), (Some("t.js"), 4));
}
/// A `$.evalFile` an earlier run kept reads files the way the run that calls it does: relative to
/// that run's script, and as `$.evalFile` does in a run given as text.
#[test]
+166
View File
@@ -139,6 +139,54 @@ fn feature_changes_keep_each_runs_other_features() {
assert_eq!(features(&s), [vec!["smcp", "numr", "liga", "dnom"], vec!["swsh", "numr", "liga", "dnom"], vec!["-onum", "swsh", "dnom"]]);
}
/// A feature with a value (`salt=2`): `features` and `featureChanges` take it, the next value
/// replaces it, `salt=1` is a plain `salt` and `salt=0` and `-salt` are off, a value this build
/// does not know is an error, one undo step each, and the native format keeps the plain string.
#[test]
fn feature_values_are_set_replaced_and_saved() {
let mut s = session();
let id = text(&mut s, "abcd");
let features = |s: &Session| obj(s, id).runs.iter().map(|r| (r.text.clone(), r.style.features.clone())).collect::<Vec<_>>();
let range = |s: &mut Session, p: Value| {
let mut p = p;
p["id"] = json!(id.0);
p["start"] = json!(1);
p["end"] = json!(2);
s.execute("text.setRangeStyle", &p).map(|_| ())
};
let undo = s.doc().unwrap().history.undo.len();
range(&mut s, json!({"features": ["salt=2"]})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
assert_eq!(features(&s), [("a".into(), vec![]), ("b".into(), vec!["salt=2".to_string()]), ("cd".into(), vec![])]);
range(&mut s, json!({"features": ["salt=3"]})).unwrap();
assert_eq!(features(&s)[1].1, ["salt=3"], "the next value replaces it");
range(&mut s, json!({"featureChanges": ["salt=2", "ss01"]})).unwrap();
assert_eq!(features(&s)[1].1, ["ss01", "salt=2"], "written in the canonical order");
range(&mut s, json!({"featureChanges": ["salt=1"]})).unwrap();
assert_eq!(features(&s)[1].1, ["ss01", "salt"], "salt=1 is a plain salt");
range(&mut s, json!({"featureChanges": ["salt=2"]})).unwrap();
let saved = vectorcraft_format::save(&s.doc().unwrap().doc, false);
let back = vectorcraft_format::load(&saved).unwrap();
let Some(NodeKind::Text(t)) = back.node(id).map(|n| &n.kind) else { panic!("the text survives") };
assert_eq!(t.runs[1].style.features, ["ss01", "salt=2"], "the native format keeps the string");
assert!(String::from_utf8_lossy(&saved).contains("\"salt=2\""));
range(&mut s, json!({"featureChanges": ["-salt"]})).unwrap();
assert_eq!(features(&s)[1].1, ["ss01"], "-salt turns it off");
range(&mut s, json!({"featureChanges": ["salt=2"], "features": null})).unwrap();
range(&mut s, json!({"featureChanges": ["salt=0"]})).unwrap();
assert_eq!(features(&s)[1].1, ["ss01"], "salt=0 turns it off");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(features(&s)[1].1, ["ss01", "salt=2"], "undo takes it back");
let before = features(&s);
for tag in ["salt=", "salt=x", "salt=65536", "-salt=2", "liga=2", "salt=2=3", "zzzz=2"] {
for key in ["features", "featureChanges"] {
let e = range(&mut s, json!({ key: [tag] })).unwrap_err().to_string();
assert!(e.contains(&format!("`{tag}`")), "{key} {tag}: {e}");
}
}
assert_eq!(features(&s), before, "the refused commands change nothing");
}
/// `text.setStyle {stroke, strokeWidth}` sets the character stroke of all the text or of a range, as
/// `text.setRangeStyle` does, and a call with `fill` and `stroke` sets both in one undo step.
#[test]
@@ -3166,6 +3214,41 @@ fn a_glyph_alternate_replaces_one_character_through_undo_and_reopen() {
assert!(s.execute("text.glyphAlternates", &json!({"id": id.0, "start": 2})).unwrap_err().to_string().contains("is not available"));
}
/// `glyphAlternate: null` in `text.setRangeStyle` takes a glyph choice away in the same undo step
/// as the other style it sets (a feature: what the Glyphs panel does for a variant a feature
/// draws); anything but null is refused.
#[test]
fn set_range_style_takes_a_glyph_choice_away_with_a_feature() {
use vectorcraft_text::test_fonts::{alternates_family, alternates_font, unencoded_glyphs};
let _fonts = crate::adding_fonts();
let db = vectorcraft_text::FontDb::global();
db.add_font(alternates_font(true).expect("the test font builds"));
let [u1, ..] = unencoded_glyphs().expect("unencoded glyphs");
let face = db.face(alternates_family(true), "Regular").expect("the test face");
let mut s = session();
let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "xay", "size": 20, "font": alternates_family(true)})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": 1, "gid": u1})).unwrap();
let gids = |s: &Session| vectorcraft_text::layout(db, &obj(s, id)).glyphs.iter().map(|g| g.gid).collect::<Vec<_>>();
assert_eq!(gids(&s)[1], u1);
let undo = s.doc().unwrap().history.undo.len();
let e = s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 1, "end": 2, "glyphAlternate": 5})).unwrap_err().to_string();
assert!(e.contains("takes only null"), "{e}");
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 1, "end": 2, "glyphAlternate": null, "featureChanges": ["ss01"]})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
assert!(obj(&s, id).runs.iter().all(|r| r.style.glyph_alternate.is_none()), "the choice is gone");
assert_eq!(
gids(&s)[1],
face.substitutions(face.glyph_for('a')).iter().find(|x| &x.feature == b"ss01").map(|x| x.glyphs[0]).unwrap(),
"ss01 draws its glyph"
);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(gids(&s)[1], u1, "undone: the choice again");
// With nothing else to set it is still a change.
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 1, "end": 2, "glyphAlternate": null})).unwrap();
assert_eq!(gids(&s)[1], face.glyph_for('a'));
}
/// The glyph already drawn, chosen again, and null for a character drawn with its default glyph
/// leave the document and its undo history as they are. An empty run before the character does
/// not stand in for the character's own choice.
@@ -3558,6 +3641,89 @@ fn an_overflowing_grapheme_keeps_the_styles_of_its_characters() {
assert_eq!(r["gid"], json!(g('b')), "the overflowing Ka is shaped apart from the nukta of another size");
}
/// A contextual alternate with no feature must name the fallback face that supplies the glyph,
/// including when the character is outside its frame. Neither another face's glyph nor an
/// out-of-range id becomes a choice.
#[test]
fn a_contextual_fallback_glyph_choice_draws_through_undo_and_reopen() {
use vectorcraft_text::test_fonts::{
CONTEXTUAL_FALLBACK_CHAR as CH, CONTEXTUAL_FALLBACK_FAMILY, alternates_family, alternates_font, contextual_fallback_font, source_sans_gid,
};
let _fonts = crate::adding_fonts();
let db = vectorcraft_text::FontDb::global();
db.add_font(alternates_font(true).expect("the primary test font builds"));
db.add_font(contextual_fallback_font().expect("the fallback test font builds"));
let primary = db.face(alternates_family(true), "Regular").expect("the primary face");
let fallback = db.face(CONTEXTUAL_FALLBACK_FAMILY, "Regular").expect("the supplying face");
let g = |c| u32::from(source_sans_gid(c).expect("a Source Sans glyph"));
let (default, alternate) = (g('m'), g('d'));
assert_eq!(primary.glyph_for(CH), 0);
assert_eq!(db.face_covering(CH).unwrap().id(), fallback.id(), "only the fixture supplies the private-use character");
assert!(!fallback.alternates(CH).iter().any(|a| a.gid == alternate), "the alternate belongs to its shaped, not nominal, glyph");
assert!(!fallback.is_unlisted_alternate(CH, alternate), "the contextual validation path is required");
let text = format!("a{CH}a");
let start = 1;
let end = start + CH.len_utf8();
let drawn = |t: &TextObject| {
let mut open = t.clone();
open.kind = TextKind::Point;
vectorcraft_text::layout(db, &open).glyphs.into_iter().find(|g| g.byte == start).map(|g| (g.font_id, g.gid))
};
let state = |s: &Session| (s.doc().unwrap().history.undo.len(), s.doc().unwrap().revision);
for overflow in [false, true] {
let mut s = session();
let mut spec = json!({"x": 0, "y": 0, "text": text, "size": 20, "font": alternates_family(true)});
if overflow {
spec["area"] = json!({"width": 200, "height": 2});
}
let id = NodeId(s.execute("text.create", &spec).unwrap()["id"].as_u64().unwrap());
let before = obj(&s, id);
assert_eq!(drawn(&before), Some((fallback.id(), default)));
if overflow {
assert!(vectorcraft_text::layout(db, &before).glyphs.is_empty(), "the original frame overflows");
}
let contextual = vectorcraft_text::alternates_in_context(db, &before, start).expect("the panel's contextual alternates");
assert_eq!((contextual.font_id, contextual.default), (fallback.id(), default));
assert!(contextual.alternates.iter().any(|a| a.gid == alternate && a.apply.is_none()));
let unchanged = state(&s);
for gid in [primary.glyph_for('a'), fallback.glyph_count()] {
let err = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": start, "gid": gid})).unwrap_err();
assert!(err.to_string().contains("is not an alternate"), "{err}");
assert_eq!(state(&s), unchanged, "a refused choice makes no edit");
assert_eq!(obj(&s, id).runs, before.runs);
}
let result = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": start, "gid": alternate})).unwrap();
assert_eq!(result, json!({"id": id.0, "start": start, "end": end, "gid": alternate}));
assert_eq!(state(&s).0, unchanged.0 + 1, "one undo step");
let chosen = obj(&s, id);
assert_eq!(chosen.plain_text(), text);
assert_eq!(drawn(&chosen), Some((fallback.id(), alternate)));
let choice = chosen.runs.iter().find_map(|r| r.style.glyph_alternate.as_ref()).expect("the stored choice");
assert_eq!(choice, &fallback.choice(CH, alternate), "the choice keeps the supplying face's identity");
let mut wrong_face = chosen.clone();
wrong_face.runs.iter_mut().find(|r| r.style.glyph_alternate.is_some()).unwrap().style.glyph_alternate = Some(primary.choice(CH, alternate));
assert_eq!(drawn(&wrong_face), Some((fallback.id(), default)), "another face's choice is not drawn");
let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc);
let reopened = vectorcraft_format::load(&bytes).unwrap();
let NodeKind::Text(reopened) = &reopened.node(id).unwrap().kind else { panic!("text missing") };
assert_eq!(drawn(reopened), Some((fallback.id(), alternate)), "native restore retains the face and glyph");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(obj(&s, id).runs, before.runs, "Undo restores the original runs");
assert_eq!(drawn(&obj(&s, id)), Some((fallback.id(), default)));
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(drawn(&obj(&s, id)), Some((fallback.id(), alternate)), "Redo restores the choice");
let result = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": start, "gid": null})).unwrap();
assert_eq!(result["gid"], json!(default), "clearing reports the fallback's shaped glyph");
assert_eq!(drawn(&obj(&s, id)), Some((fallback.id(), default)));
assert!(obj(&s, id).runs.iter().all(|r| r.style.glyph_alternate.is_none()));
}
}
#[test]
fn glyph_alternates_query_reports_the_fallback_glyph_that_is_drawn() {
use vectorcraft_text::test_fonts::{alternates_family, alternates_font};
+57 -17
View File
@@ -1090,22 +1090,51 @@ impl Session {
self.tool.resizing()
}
/// With Real-time Drawing off, how far a Selection-tool move drag has carried the selection
/// so far: the document moves on release, and the bounding box shows at this offset meanwhile.
pub fn deferred_move(&self) -> Option<vectorcraft_geom::Vec2> {
if self.tool.id() != "selection" || !self.tool.transforming() {
/// Does the canvas draw the bounding box around a shape being drawn: the active tool is drawing
/// a new shape whose box it shows once drawn (see `Tool::drawing_boxed`), and General › Show
/// Bounding Box While Moving and Drawing is on?
pub fn draws_new_shape_box(&self) -> bool {
self.prefs.bounding_box_while_moving_and_drawing && self.tool.drawing_boxed()
}
/// Has the interaction no preview applied to the document: none has run yet, the last one failed,
/// or Real-time Drawing is off and the document is still the interaction's snapshot?
pub fn interaction_unapplied(&self) -> bool {
self.active().is_some_and(|d| d.interaction.as_ref().is_some_and(|it| it.preview.is_none() || std::sync::Arc::ptr_eq(&it.doc, &d.doc)))
}
/// The bounding box of the selection a move drag is carrying (an Alt-drag copy included), where
/// the drag has carried it: the box of the selection at the drag's start, measured once per
/// drag, offset by the move of the latest preview. With General › Show Bounding Box While
/// Moving and Drawing on, for every tool that moves the selection; off, only for the Selection
/// tool with Real-time Drawing off, where the document moves on release. `None` for any other
/// drag, and before a preview has moved the selection.
pub fn move_drag_box(&self) -> Option<vectorcraft_doc::OrientedBox> {
if !self.tool.moving() {
return None;
}
let st = self.active()?;
let it = st.interaction.as_ref()?;
let deferred = std::sync::Arc::ptr_eq(&it.doc, &st.doc);
if !self.prefs.bounding_box_while_moving_and_drawing && !(self.tool.id() == "selection" && deferred) {
return None;
}
let it = self.active()?.interaction.as_ref()?;
if !matches!(it.label.as_str(), "Move" | "Copy") {
return None;
}
let (cmd, p) = self.unapplied_interaction_preview()?;
let (cmd, p) = it.preview.as_ref()?;
if cmd != "object.transform" || p.get("objects").and_then(Value::as_bool) == Some(false) {
return None;
}
let [a, b, c, d, e, f] = crate::cmd::matrix_param(&p, "matrix")?.as_coeffs();
(a == 1.0 && b == 0.0 && c == 0.0 && d == 1.0).then(|| vectorcraft_geom::Vec2::new(e, f))
let [a, b, c, d, e, f] = crate::cmd::matrix_param(p, "matrix")?.as_coeffs();
if !(a == 1.0 && b == 0.0 && c == 0.0 && d == 1.0) {
return None;
}
let measure = self.bounds_measure();
let start =
(*it.move_box.get_or_init(|| vectorcraft_render::effects::preview::oriented_bounds_with(&it.doc, &it.selection.objects, measure)))?;
let moved = start.to_local(Point::new(e, f)).to_vec2();
Some(vectorcraft_doc::OrientedBox { rect: start.rect + moved, ..start })
}
/// Does the active tool take `key` ahead of the shortcuts bound to it (see `Tool::claims_key`)?
@@ -1438,23 +1467,30 @@ impl Session {
}
/// With Real-time Drawing off, the selection's transform box ([`Session::transform_box`]) as the
/// pending preview of a handle resize leaves it, measured once per preview step; `None` when no
/// such preview is pending, or when the preview fails.
pub fn deferred_transform_box(&mut self) -> Option<vectorcraft_doc::OrientedBox> {
if !self.tool.resizing() {
/// pending preview of a handle resize or of a shape being drawn leaves it, measured once per
/// preview step, with the layer color of the first object it measures; `None` when no such
/// preview is pending, or when the preview fails.
pub fn deferred_transform_box(&mut self) -> Option<(vectorcraft_doc::OrientedBox, [u8; 3])> {
if !self.measures_deferred_box() {
return None;
}
let (cmd, p) = self.unapplied_interaction_preview()?;
self.deferred_feedback(&cmd, &p)?.transform_box
}
/// Does the canvas draw the bounding box of what the pending preview leaves: during a handle
/// resize, or while a shape is drawn by a tool that shows its box ([`Session::draws_new_shape_box`])?
fn measures_deferred_box(&self) -> bool {
self.tool.resizing() || self.draws_new_shape_box()
}
fn deferred_preview_overlays_from_command(&mut self, cmd: &str, p: &Value) -> Vec<Overlay> {
self.deferred_feedback(cmd, p).map(|f| f.overlays.clone()).unwrap_or_default()
}
/// Canvas feedback while Real-time Drawing is off: run the pending preview command once, take
/// outlines from the objects it touched (and, during a handle resize, their transform box), then
/// put the document back at the interaction snapshot.
/// outlines from the objects it touched (and, during a handle resize or while a shape is drawn,
/// their transform box), then put the document back at the interaction snapshot.
fn deferred_feedback(&mut self, cmd: &str, p: &Value) -> Option<&DeferredFeedback> {
if !self.deferred_overlay_cache.as_ref().is_some_and(|f| f.cmd == cmd && f.params == *p) {
let feedback = self.measure_deferred_preview(cmd, p)?;
@@ -1475,8 +1511,12 @@ impl Session {
};
let (overlays, transform_box) = if self.preview(cmd, p).is_ok() {
let overlays = self.active().map(|st| outlines_for_selection(&st.doc, &st.selection)).unwrap_or_default();
let objects = self.active().map(|st| st.selection.objects.clone()).unwrap_or_default();
(overlays, if self.tool.resizing() { self.transform_box(&objects) } else { None })
let (objects, color) = self
.active()
.map(|st| (st.selection.objects.clone(), st.selection.objects.first().map(|id| st.doc.layer_color(*id))))
.unwrap_or_default();
let transform_box = if self.measures_deferred_box() { self.transform_box(&objects).zip(color) } else { None };
(overlays, transform_box)
} else {
(vec![], None)
};
@@ -1500,7 +1540,7 @@ pub(crate) struct DeferredFeedback {
cmd: String,
params: Value,
overlays: Vec<Overlay>,
transform_box: Option<vectorcraft_doc::OrientedBox>,
transform_box: Option<(vectorcraft_doc::OrientedBox, [u8; 3])>,
}
/// The most points the outlines of [`outlines_for_selection`] are drawn for, the same number as the
+18
View File
@@ -57,6 +57,24 @@ fn legacy_profile_name_resolves_in_commands_and_files() {
cms::set_active(&before).unwrap();
}
/// Assigning a profile other than the working space replaces the active color settings and raises
/// the other open documents' revisions.
#[test]
fn assigning_another_profile_replaces_the_active_color_settings() {
let _g = settings();
let mut s = session();
exec(&mut s, "file.new", json!({"width": 100, "height": 100}));
let before = cms::active_settings();
assert_ne!(before.cmyk, cms::DEVICE_CMYK);
let active = cms::active();
let other = s.documents()[0].revision;
exec(&mut s, "edit.assignProfile", json!({"cmyk": cms::DEVICE_CMYK}));
assert_eq!(cms::active_settings().cmyk, cms::DEVICE_CMYK);
assert!(!std::sync::Arc::ptr_eq(&active, &cms::active()), "the active color settings were not replaced");
assert!(s.documents()[0].revision > other, "the other document's revision did not change");
cms::set_active(&before).unwrap();
}
/// Separations Preview follows the Color Settings and the profiles loaded: a renderer draws a gray
/// square on the Black plate through the new working CMYK profile, and the four process plates
/// through a proof profile loaded again under the same name, as a new renderer draws them.
+6
View File
@@ -893,6 +893,12 @@ fn structured_junk() {
("addon.pen.reshape", json!({"id": 2, "segment": 0, "t": "x", "dx": 1, "dy": 1, "mode": "symmetric"})),
("addon.pen.nextSegment", json!({"curved": "yes"})),
("addon.pen.closeJoin", json!({"ids": [u64::MAX]})),
// Cutting Tools add-on.
("addon.cutting.weed", json!({"margin": 1e5, "count": 1000, "gap": 1e5, "direction": "both"})),
("addon.cutting.weed", json!({"margin": -1, "count": 1.5, "spacing": 0, "gap": f64::MAX, "weight": 1e308})),
("addon.cutting.weed", json!({"spacing": 1e-300, "box": "no", "direction": ["both"], "swatch": 5})),
("addon.cutting.weed", json!({"spacing": "1 mm", "box": false, "direction": "vertical", "swatch": "no such swatch"})),
("addon.cutting.weed", json!({"count": u64::MAX, "margin": "x", "gap": null, "weight": 0})),
// Type Tools add-on.
("addon.type.textTweaker", json!({"attribute": "size", "from": 1296, "to": 0.1, "shape": "random", "seed": u64::MAX})),
("addon.type.textTweaker", json!({"attribute": "tracking", "from": f64::MAX, "to": -1e308, "shape": "spike"})),
+77
View File
@@ -4330,6 +4330,17 @@ proptest! {
survive("mutated .ai art styles", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?;
survive("mutated .ai art styles, trusted", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?;
}
/// A `.ai` whose brushes, as the writer writes them, are damaged: the brush art with its frame,
/// the brush manager's order, the brushes' parameters, a brushed path's style and its object, cut
/// short or edited.
#[test]
fn ai_written_brushes_never_panic(cut in 0usize..BRUSHES_FUZZ_CHARS, edits in prop::collection::vec((0usize..BRUSHES_FUZZ_CHARS, prop::sample::select(vec!['0', '1', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', ',', '%', '_', 'u', 'U', 'n', 'X', 'W'])), 0..12)) {
let text = mutate_text(brushes_text(), cut, &edits);
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat();
survive("mutated .ai brushes", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?;
survive("mutated .ai brushes, trusted", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?;
}
}
/// The range of [`ai_written_styles_never_panic`]'s cuts and edits: past the end of [`styles_text`].
@@ -4442,6 +4453,72 @@ fn the_symbol_library_fuzz_data_opens_with_its_symbols() {
assert_eq!(names, ["S2", "S1"], "{warnings:?}");
}
/// The range of [`ai_written_brushes_never_panic`]'s cuts and edits: past the end of [`brushes_text`].
const BRUSHES_FUZZ_CHARS: usize = 48_000;
/// The editing data VectorCraft writes for a document whose brushes are a Calligraphic brush, a
/// Scatter brush in Tints and an Art brush in Tints (each art a wide block over a narrow one), with a
/// path drawn with the Calligraphic brush and one with the Art brush.
fn brushes_text() -> &'static str {
static TEXT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
TEXT.get_or_init(|| {
use std::sync::Arc;
use vectorcraft_brush::{ArtBrush, Brush, BrushKind, Calligraphic, Colorization, Scatter, Variation};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Node, NodeId};
use vectorcraft_geom::{Rect, shapes};
let block = |id: u64, r: Rect| {
Arc::new(Node::path(NodeId(id), shapes::rectangle(r), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)))
};
let art = || Node::group(NodeId(1), vec![block(2, Rect::new(0.0, 0.0, 40.0, 4.0)), block(3, Rect::new(0.0, 4.0, 10.0, 8.0))]);
let nib = Calligraphic {
angle: 30.0,
roundness: 40.0,
size: 6.0,
variation: [20.0, 10.0, 2.0],
modes: Some([Variation::Rotation, Variation::Fixed, Variation::Pressure]),
};
let lib = [
Brush { name: "Nib".into(), kind: BrushKind::Calligraphic(nib) },
Brush { name: "Spray".into(), kind: BrushKind::Scatter(Scatter { art: art(), colorization: Colorization::Tints, ..Scatter::default() }) },
Brush { name: "Ribbon".into(), kind: BrushKind::Art(ArtBrush { art: art(), colorization: Colorization::Tints, ..ArtBrush::default() }) },
];
let mut doc = Document::new(200.0, 100.0);
vectorcraft_brush::store(&mut doc, &lib);
let layer = doc.layers[0].id;
for (brush, y) in [("Nib", 30.0), ("Ribbon", 70.0)] {
let mut line = Node::path(
doc.alloc_id(),
shapes::line((10.0, y).into(), (190.0, y).into()),
Appearance::basic(Paint::None, Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 1.0),
);
line.appearance.stroke_mut().unwrap().brush = Some(brush.into());
doc.insert(Some(layer), usize::MAX, line).unwrap();
}
let out = vectorcraft_eps::export_ai_editing(&doc, &vectorcraft_eps::AiEditingOptions::default()).unwrap();
String::from_utf8(out.program).unwrap()
})
}
/// The data [`ai_written_brushes_never_panic`] damages, as it is, opens with its brushes and its
/// brushed paths live, and the property's cuts and edits reach all of it.
#[test]
fn the_written_brushes_fuzz_data_opens_live() {
let text = brushes_text();
assert!(text.chars().count() < BRUSHES_FUZZ_CHARS, "{}", text.chars().count());
for mark in ["%AI8_BeginBrushPattern", "%AI8_BeginPluginObject", "%AI9_BeginArtStyles", "/GObjRef :", "%AI5_BeginLayer", "\n1 (Anon"] {
assert!(text.replace("\r\n", "\n").contains(mark), "{mark}");
}
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(text)].concat();
let (d, warnings, layered) = vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0);
assert!(layered, "{warnings:?}");
let names: Vec<String> = vectorcraft_brush::library(&d).into_iter().map(|b| b.name).collect();
assert_eq!(names, ["Nib", "Spray", "Ribbon"]);
let art = d.layers[0].children().unwrap();
let brushes: Vec<Option<&str>> = art.iter().map(|n| n.appearance.stroke().and_then(|s| s.brush.as_deref())).collect();
assert_eq!(brushes, [Some("Nib"), Some("Ribbon")]);
}
/// Editing data with symbols (S2's art holds an instance of S1, F's holds non-native art), the
/// symbol list and a layer of instances, as the format writes them.
fn symbols_text() -> String {
+19 -3
View File
@@ -133,6 +133,16 @@
//! A fill filter fills by the non-zero rule (`0 /Bool (UseEvenOdd)`), so a filled object that
//! fills by the even-odd rule is written as its drawn look. A style is written only when the
//! reader reads it back as the object's appearance (or the graphic style's).
//! - Brushes, as the reader reads them (`brush.rs`, `style.rs`): the Brushes panel's brushes as
//! plug-in objects in a non-printing block after the symbols (`%AI8_BeginPluginObject`, the
//! plug-in's name, the brush's name, its parameters in strings that `-` continues and `.` ends),
//! after the brush manager's order of them; the art of Scatter and Art brushes in a non-printing
//! block before the symbols (`%AI8_BeginBrushPattern`, its name, a group of an unpainted frame at
//! the art's bounds and the art, or the art alone when it has no width or height), when the
//! document has brushes of its own or an object is written with one.
//! A path or compound path with a brush the reader reads live is written with an art style whose
//! stroke is the brush's filter (`writer::brushes`). A brush is written only when the reader reads
//! it back with its settings.
//!
//! Lines end in CR LF. Strings are written in UTF-8. Art space is ruler space: y up, its origin at
//! the first artboard's top-left corner. On the canvas ([`CANVAS_SIZE`] points square, y down) the
@@ -168,9 +178,10 @@
//! tiles and the placement of pattern swatches that move, turn or scale their pattern, the opacity and blending modes of single fills and strokes, opacity masks on
//! layers and opacity masks nested too deep, and, on objects written without an art style, live
//! effects other than geometry effects left out; graphic styles the reader wouldn't read, links to
//! graphic styles on objects not written with them, and every art style on a large canvas, are left
//! out; art past [`crate::import::MAX_EDITING_OBJECTS`] objects in all (the most the reader reads),
//! counted as the reader counts them, is left out.
//! graphic styles on objects not written with them, brushes the reader wouldn't read back, and every
//! art style and brush on a large canvas, are left out; art past
//! [`crate::import::MAX_EDITING_OBJECTS`] objects in all (the most the reader reads), counted as the
//! reader counts them, is left out.
//!
//! The data streams hold the program compressed with Zstandard after `%AI24_ZStandard_Data`, with
//! the version numbers of the newest files users sent ([`private_data`]).
@@ -288,6 +299,7 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re
let mut w = Writer::new(doc, to_art, origin);
// On a large canvas the layers' symbol instances are written as their art.
w.symbols_live = scale == 1.0;
w.brushes_live = scale == 1.0;
w.symbol_doc = symbol_doc;
w.max_objects = max_objects;
w.reserve_swatch_gradients();
@@ -305,6 +317,8 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re
}
// The symbols no instance uses too; their art adds definitions.
w.symbols();
// The brushes, once the layers have said whether an object is written with one.
let (brush_art, brush_objects) = w.brush_blocks();
// The palette first: its gradient and pattern swatches add definitions.
let mut palette = String::new();
w.palette(&mut palette);
@@ -323,7 +337,9 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re
let mut s = header.clone();
s.push_str("%%BeginProlog\n%%EndProlog\n%%BeginSetup\n");
w.definitions(&mut s);
s.push_str(&brush_art);
w.symbol_definitions(&mut s);
s.push_str(&brush_objects);
w.art_styles(&mut s);
s.push_str(&palette);
w.art_style_list(&mut s);
+792 -2
View File
@@ -747,6 +747,7 @@ fn art_the_slice_does_not_cover_is_written_as_plain_paths() {
assert_eq!(layer[2].visual_bounds().map(|b| (r3(b.x0), r3(b.y0), r3(b.x1), r3(b.y1))), Some((r3(100.0), r3(10.0), r3(120.0), r3(20.0))));
}
/// A brushed stroke the reader reads as its drawn look (a Scatter brush's) is written as its brush's art.
#[test]
fn brushed_strokes_are_written_as_their_brush_art() {
let mut doc = Document::new(200.0, 100.0);
@@ -756,11 +757,11 @@ fn brushed_strokes_are_written_as_their_brush_art() {
shapes::line((20.0, 50.0).into(), (180.0, 50.0).into()),
Appearance::basic(Paint::None, solid(Color::rgb(1.0, 0.0, 0.0)), 4.0),
);
line.appearance.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
line.appearance.stroke_mut().unwrap().brush = Some("Dots".into());
doc.insert(Some(layer), usize::MAX, line).unwrap();
let pieces = vectorcraft_brush::stroke_pieces(
&doc,
&vectorcraft_brush::find(&doc, "3 pt. Round").unwrap(),
&vectorcraft_brush::find(&doc, "Dots").unwrap(),
&shapes::line((20.0, 50.0).into(), (180.0, 50.0).into()).to_bezpath(),
doc.layers[0].children().unwrap()[0].appearance.stroke().unwrap(),
);
@@ -895,6 +896,7 @@ fn counted() -> Vec<(&'static str, Document)> {
("styles", styles_document()),
("painted clips", painted_clips()),
("blend", blend_doc()),
("brushes", brushes_document()),
]
}
@@ -6911,6 +6913,28 @@ fn a_link_to_a_style_that_moves_the_outline_is_kept_where_the_reader_reads_it()
assert!(n.extras().mask.is_some(), "{n:?}");
}
/// An object linked to a graphic style that doesn't fit within the object limit is left out with
/// the limit's warning and the warning about links left out.
#[test]
fn a_linked_object_past_the_object_limit_warns_that_its_link_is_left_out() {
let mut doc = Document::new(200.0, 100.0);
let ap = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(black_fill(), Paint::None, 0.0) };
let mut g = vectorcraft_doc::GraphicStyle::new("Shadowed", ap.clone());
g.id = 9;
doc.graphic_styles.push(g);
// Room for everything but the object.
let room = crate::import::objects_read(&export_ai_editing(&doc, &opts()).unwrap().program).unwrap();
let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap);
n.extras_mut().graphic_style = Some(9);
put(&mut doc, n);
let all = export_ai_editing(&doc, &opts()).unwrap();
assert!(!all.warnings.iter().any(|w| w == writer::OBJECT_LIMIT || w == writer::STYLE_LINKS), "{:?}", all.warnings);
let out = export_within(&doc, &opts(), room).unwrap();
for w in [writer::OBJECT_LIMIT, writer::STYLE_LINKS] {
assert!(out.warnings.iter().any(|x| x == w), "{w}: {:?}", out.warnings);
}
}
/// Twist and Transform on an object that isn't linked to a graphic style are not written as a style
/// of its own: the object reads back without live effects.
#[test]
@@ -7258,6 +7282,772 @@ fn a_large_canvas_writes_every_object_as_its_look() {
assert!((p.x - 10.0).abs() < 1e-2 && (p.y - 10.0).abs() < 1e-2, "{p:?}");
}
// ---------- brushes ----------
/// Brush art: a wide block with a narrow one below it (y down), filled black.
fn brush_art_node() -> Node {
let block =
|id: u64, r: Rect| Arc::new(Node::path(vectorcraft_doc::NodeId(id), shapes::rectangle(r), Appearance::basic(black_fill(), Paint::None, 0.0)));
Node::group(vectorcraft_doc::NodeId(1), vec![block(2, Rect::new(0.0, 0.0, 40.0, 4.0)), block(3, Rect::new(0.0, 4.0, 10.0, 8.0))])
}
/// A Calligraphic brush varying with the barrel's rotation and the pen's pressure, a Scatter brush in
/// Tints, an Art brush in Tints and one that keeps its art's colors.
fn brush_library() -> Vec<vectorcraft_brush::Brush> {
use vectorcraft_brush::{ArtBrush, Brush, BrushKind, Calligraphic, Colorization, Scatter, Variation};
let art = |colorization| BrushKind::Art(ArtBrush { art: brush_art_node(), colorization, ..ArtBrush::default() });
vec![
Brush {
name: "Nib".into(),
kind: BrushKind::Calligraphic(Calligraphic {
angle: 30.0,
roundness: 40.0,
size: 6.0,
variation: [20.0, 10.0, 2.0],
modes: Some([Variation::Rotation, Variation::Fixed, Variation::Pressure]),
}),
},
Brush {
name: "Spray".into(),
kind: BrushKind::Scatter(Scatter {
art: brush_art_node(),
size: (50.0, 150.0),
spacing: (120.0, 200.0),
scatter: (-25.0, 25.0),
rotation: (-45.0, 30.0),
rotation_relative_to_path: true,
colorization: Colorization::Tints,
modes: Some([Variation::Random, Variation::Fixed, Variation::Pressure, Variation::Random]),
}),
},
Brush { name: "Ribbon".into(), kind: art(Colorization::Tints) },
Brush { name: "Plain ribbon".into(), kind: art(Colorization::None) },
]
}
/// A path stroked red at `width` with the brush `brush`, on the first layer of `doc`.
fn brushed(doc: &mut Document, brush: &str, from: (f64, f64), to: (f64, f64), width: f64) {
let mut line =
Node::path(doc.alloc_id(), shapes::line(from.into(), to.into()), Appearance::basic(Paint::None, solid(Color::rgb(1.0, 0.0, 0.0)), width));
line.appearance.stroke_mut().unwrap().brush = Some(brush.into());
put(doc, line);
}
/// [`brush_library`] and a path with the Calligraphic brush and one with the Art brush in Tints.
fn brushes_document() -> Document {
let mut doc = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut doc, &brush_library());
brushed(&mut doc, "Nib", (20.0, 50.0), (280.0, 60.0), 2.0);
brushed(&mut doc, "Ribbon", (20.0, 120.0), (280.0, 150.0), 1.5);
doc
}
/// Brush art `n` as a tree to compare with [`same_tree`]: each object's kind, its anchors and handles,
/// the fills and strokes that paint (a stroke with its width), and its members in order.
fn art_tree(n: &Node) -> serde_json::Value {
let kind = match &n.kind {
NodeKind::Path { .. } => "path",
NodeKind::Compound { .. } => "compound",
NodeKind::Group { clip: false, .. } => "group",
_ => "other",
};
let anchors: Vec<[f64; 6]> = match &n.kind {
NodeKind::Path { path, .. } => {
path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| [a.h_in.x, a.h_in.y, a.p.x, a.p.y, a.h_out.x, a.h_out.y])).collect()
}
_ => vec![],
};
let paints: Vec<serde_json::Value> = n
.appearance
.items
.iter()
.filter(|i| !i.paint().is_none())
.map(|i| match i {
AppearanceItem::Fill(f) => serde_json::json!(["fill", f.paint]),
AppearanceItem::Stroke(s) => serde_json::json!(["stroke", s.paint, s.width]),
})
.collect();
let members: Vec<serde_json::Value> = n.children().map(|c| c.iter().map(|m| art_tree(m)).collect()).unwrap_or_default();
serde_json::json!({"kind": kind, "anchors": anchors, "paints": paints, "members": members})
}
/// Are trees `a` and `b` ([`art_tree`]) the same but for numbers within the written numbers'
/// precision (1/1000)?
fn same_tree(a: &serde_json::Value, b: &serde_json::Value) -> bool {
use serde_json::Value;
match (a, b) {
(Value::Number(x), Value::Number(y)) => (x.as_f64().unwrap() - y.as_f64().unwrap()).abs() <= 1e-3,
(Value::Array(x), Value::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(p, q)| same_tree(p, q)),
(Value::Object(x), Value::Object(y)) => x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| same_tree(v, w))),
_ => a == b,
}
}
/// The document's brushes come back in the Brushes panel's order with their settings and art: the
/// Calligraphic brush's channels, the Scatter brush's ranges, modes and Tints, the Art brushes'
/// colorizations.
#[test]
fn brushes_are_read_back_with_their_settings() {
use vectorcraft_brush::BrushKind;
let doc = brushes_document();
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
let text = program(&doc);
assert_eq!(text.matches("%AI8_BeginPluginObject").count(), 5, "the manager's order and four brushes");
assert_eq!(text.matches("%AI8_BeginBrushPattern").count(), 3);
let back = vectorcraft_brush::library(&read_back(&doc));
let lib = brush_library();
assert_eq!(back.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["Nib", "Spray", "Ribbon", "Plain ribbon"]);
assert_eq!(back[0], lib[0]);
let (BrushKind::Scatter(got), BrushKind::Scatter(want)) = (&back[1].kind, &lib[1].kind) else { panic!("scatter") };
let near = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3;
assert!(
near(got.size, want.size) && near(got.spacing, want.spacing) && near(got.scatter, want.scatter) && near(got.rotation, want.rotation),
"{got:?}"
);
assert_eq!((got.modes, got.colorization, got.rotation_relative_to_path), (want.modes, want.colorization, true));
let (g, w) = (art_tree(&got.art), art_tree(&want.art));
assert!(same_tree(&g, &w), "the art without its frame, with its paint and members in order: {g:#} for {w:#}");
for (i, colorization) in [(2, vectorcraft_brush::Colorization::Tints), (3, vectorcraft_brush::Colorization::None)] {
let BrushKind::Art(a) = &back[i].kind else { panic!("art") };
assert_eq!(a.colorization, colorization);
let (g, w) = (art_tree(&a.art), art_tree(&brush_art_node()));
assert!(same_tree(&g, &w), "the narrow block stays below the wide one, and no frame stays: {g:#} for {w:#}");
let BrushKind::Art(want) = &lib[i].kind else { panic!("art") };
assert_eq!(ArtBrushOptions::of(a), ArtBrushOptions::of(want));
}
}
/// An Art brush's options, without its art.
#[derive(Debug, PartialEq)]
struct ArtBrushOptions(serde_json::Value);
impl ArtBrushOptions {
fn of(a: &vectorcraft_brush::ArtBrush) -> Self {
let mut v = serde_json::to_value(a).unwrap();
v.as_object_mut().unwrap().remove("art");
Self(v)
}
}
/// A path with the Calligraphic brush, and one with an Art brush in Tints whose art bends along the
/// path, come back as those paths with the brush on their stroke at its weight, not as their drawn
/// look; a path with the Art brush that keeps its art's colors comes back as its drawn look.
#[test]
fn brushed_paths_are_read_back_brushed() {
let mut doc = brushes_document();
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings);
let back = read_back(&doc);
let art = back.layers[0].children().unwrap();
assert_eq!(art.len(), 2);
for (n, (brush, width)) in art.iter().zip([("Nib", 2.0), ("Ribbon", 1.5)]) {
assert!(matches!(n.kind, NodeKind::Path { .. }), "{brush}: the path, not its look: {n:#?}");
let st = n.appearance.stroke().unwrap();
assert_eq!((st.brush.as_deref(), st.width, st.paint.color()), (Some(brush), width, Some(Color::rgb(1.0, 0.0, 0.0))));
assert!(n.appearance.fill().is_none_or(|f| f.paint.is_none()));
}
let ours = doc.layers[0].children().unwrap();
assert_eq!(art[0].path_data(), ours[0].path_data());
assert_eq!(art[1].path_data(), ours[1].path_data());
brushed(&mut doc, "Plain ribbon", (20.0, 180.0), (280.0, 180.0), 1.0);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings);
assert!(matches!(read_back(&doc).layers[0].children().unwrap()[2].kind, NodeKind::Group { .. }));
}
/// A compound path with the Calligraphic brush comes back as that compound path with the brush on its
/// stroke; a filled ellipse with it comes back with its fill and the brush.
#[test]
fn brushed_compound_and_filled_paths_are_read_back_brushed() {
let mut doc = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut doc, &brush_library());
let mut stroke = StrokeLayer::new(solid(Color::rgb(1.0, 0.0, 0.0)), 2.0);
stroke.brush = Some("Nib".into());
let ring = ring(&mut doc, Appearance { items: vec![AppearanceItem::Stroke(stroke.clone())], effects: vec![], contents_index: None });
put(&mut doc, ring);
let fill = vectorcraft_doc::FillLayer::new(solid(Color::rgb(0.2, 0.6, 0.3)));
let ap = Appearance { items: vec![AppearanceItem::Fill(fill), AppearanceItem::Stroke(stroke)], effects: vec![], contents_index: None };
let ellipse = Node::path(doc.alloc_id(), shapes::ellipse(Rect::new(20.0, 120.0, 140.0, 180.0)), ap);
put(&mut doc, ellipse);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::STROKES || w == writer::BRUSHES), "{:?}", out.warnings);
let back = read_back(&doc);
let (art, ours) = (back.layers[0].children().unwrap(), doc.layers[0].children().unwrap());
assert!(matches!(art[0].kind, NodeKind::Compound { .. }), "the compound path, not its look: {:#?}", art[0]);
assert!(same_tree(&art_tree(&art[0])["members"], &art_tree(&ours[0])["members"]), "{:#?}", art[0]);
assert!(matches!(art[1].kind, NodeKind::Path { .. }), "the ellipse, not its look: {:#?}", art[1]);
assert!(same_tree(&art_tree(&art[1])["anchors"], &art_tree(&ours[1])["anchors"]), "{:#?}", art[1]);
assert_eq!(art[1].appearance.fill().and_then(|f| f.paint.color()), Some(Color::rgb(0.2, 0.6, 0.3)));
for n in art {
let st = n.appearance.stroke().unwrap();
assert_eq!((st.brush.as_deref(), st.width), (Some("Nib"), 2.0));
}
}
/// A path with an Art brush in Tints whose art doesn't bend along the path (its art has a painted
/// stroke) comes back as its drawn look, with the stroke warning; the brush itself comes back.
#[test]
fn an_art_brush_whose_art_doesnt_bend_keeps_its_paths_look() {
use vectorcraft_brush::{ArtBrush, Brush, BrushKind, Colorization};
let mut art = brush_art_node();
if let Some(c) = art.children_mut() {
for m in c.iter_mut() {
Arc::make_mut(m).appearance = Appearance::basic(black_fill(), black_fill(), 1.0).into();
}
}
let brush =
Brush { name: "Stroked ribbon".into(), kind: BrushKind::Art(ArtBrush { art, colorization: Colorization::Tints, ..ArtBrush::default() }) };
let mut doc = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut doc, std::slice::from_ref(&brush));
brushed(&mut doc, "Stroked ribbon", (20.0, 120.0), (280.0, 150.0), 1.0);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings);
assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
let back = read_back(&doc);
assert!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::Group { .. }));
let lib = vectorcraft_brush::library(&back);
let (BrushKind::Art(got), BrushKind::Art(want)) = (&lib[0].kind, &brush.kind) else { panic!("art") };
assert!(same_tree(&art_tree(&got.art), &art_tree(&want.art)), "{:#?}", got.art);
}
/// Settings the other tests don't use come back: a Calligraphic brush varying with tilt and bearing,
/// a Scatter brush keeping its art's colors whose rotation doesn't follow the path, and brushes whose
/// modes aren't given (the reader gives them, the same ones).
#[test]
fn more_brush_settings_are_read_back() {
use vectorcraft_brush::{Brush, BrushKind, Calligraphic, Colorization, Scatter, Variation};
let tilted = Calligraphic {
angle: -40.0,
roundness: 75.0,
size: 9.0,
variation: [10.0, 5.0, 3.0],
modes: Some([Variation::Tilt, Variation::Bearing, Variation::Random]),
};
let scatter = Scatter {
art: brush_art_node(),
size: (80.0, 80.0),
spacing: (100.0, 100.0),
scatter: (0.0, 0.0),
rotation: (15.0, 15.0),
rotation_relative_to_path: false,
colorization: Colorization::None,
modes: Some([Variation::Tilt, Variation::Bearing, Variation::Fixed, Variation::Rotation]),
};
let lib = vec![
Brush { name: "Tilted".into(), kind: BrushKind::Calligraphic(tilted.clone()) },
Brush { name: "Still".into(), kind: BrushKind::Scatter(scatter.clone()) },
Brush { name: "Unset nib".into(), kind: BrushKind::Calligraphic(Calligraphic { modes: None, ..tilted }) },
Brush { name: "Unset spray".into(), kind: BrushKind::Scatter(Scatter { modes: None, ..scatter }) },
];
let mut doc = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut doc, &lib);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
let back = vectorcraft_brush::library(&read_back(&doc));
assert_eq!(back.len(), lib.len());
let near = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3;
for (got, want) in back.iter().zip(&lib) {
assert_eq!(got.name, want.name);
match (&got.kind, &want.kind) {
(BrushKind::Calligraphic(g), BrushKind::Calligraphic(w)) => {
assert_eq!(g.modes(), w.modes(), "{}", want.name);
assert_eq!(Calligraphic { modes: None, ..g.clone() }, Calligraphic { modes: None, ..w.clone() }, "{}", want.name);
}
(BrushKind::Scatter(g), BrushKind::Scatter(w)) => {
assert_eq!(g.modes(), w.modes(), "{}", want.name);
assert!(near(g.size, w.size) && near(g.spacing, w.spacing) && near(g.scatter, w.scatter) && near(g.rotation, w.rotation), "{g:?}");
assert_eq!((g.colorization, g.rotation_relative_to_path), (Colorization::None, false), "{}", want.name);
assert!(same_tree(&art_tree(&g.art), &art_tree(&w.art)), "{}: {:#?}", want.name, g.art);
}
_ => panic!("{}: {got:?}", want.name),
}
}
}
/// Brush art without width or height, a straight stroked line, comes back as that line, without a
/// frame or a group around it: a Scatter brush's horizontal line and an Art brush's vertical one.
#[test]
fn brush_art_of_a_straight_line_comes_back_as_the_line() {
use vectorcraft_brush::{ArtBrush, Brush, BrushKind, Scatter};
let line = |id: u64, to: (f64, f64)| {
Node::path(vectorcraft_doc::NodeId(id), shapes::line((0.0, 0.0).into(), to.into()), Appearance::basic(Paint::None, black_fill(), 2.0))
};
let lib = vec![
Brush { name: "Dash".into(), kind: BrushKind::Scatter(Scatter { art: line(1, (40.0, 0.0)), ..Scatter::default() }) },
Brush { name: "Post".into(), kind: BrushKind::Art(ArtBrush { art: line(2, (0.0, 40.0)), ..ArtBrush::default() }) },
];
let mut doc = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut doc, &lib);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
let back = vectorcraft_brush::library(&read_back(&doc));
assert_eq!(back.len(), 2);
for (got, want) in back.iter().zip(&lib) {
let art = |b: &Brush| match &b.kind {
BrushKind::Scatter(s) => s.art.clone(),
BrushKind::Art(a) => a.art.clone(),
_ => panic!("{}", b.name),
};
let (g, w) = (art_tree(&art(got)), art_tree(&art(want)));
assert!(same_tree(&g, &w), "{}: {g:#} for {w:#}", want.name);
}
}
/// Brush art of a path with several subpaths is written as a compound path, which the reader reads as
/// a compound path of one path for each subpath, and is weighed as the reader weighs that against the
/// art it copies into one file's brushes: of two Art brushes whose art is a path of 30,000 squares,
/// the second is left out with a warning, so that what is written is what comes back.
#[test]
fn brush_art_of_a_path_with_subpaths_is_weighed_as_it_is_read() {
use vectorcraft_brush::{ArtBrush, Brush, BrushKind};
let squares = |n: u32| {
let mut path = PathData::default();
for i in 0..n {
let x = f64::from(i % 200) * 2.0;
let y = f64::from(i / 200) * 2.0;
path.subpaths.extend(shapes::rectangle(Rect::new(x, y, x + 1.0, y + 1.0)).subpaths);
}
Node::path(vectorcraft_doc::NodeId(1), path, Appearance::basic(black_fill(), Paint::None, 0.0))
};
let brush = |name: &str, n: u32| Brush { name: name.into(), kind: BrushKind::Art(ArtBrush { art: squares(n), ..ArtBrush::default() }) };
let mut doc = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut doc, &[brush("Two", 2)]);
let back = vectorcraft_brush::library(&read_back(&doc));
let BrushKind::Art(a) = &back[0].kind else { panic!("art") };
let tree = art_tree(&a.art);
assert_eq!(tree["kind"], "compound", "{tree:#}");
assert_eq!(tree["members"].as_array().map(Vec::len), Some(2), "{tree:#}");
let mut doc = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut doc, &[brush("Many 1", 30_000), brush("Many 2", 30_000)]);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
let names: Vec<String> = vectorcraft_brush::library(&read_back(&doc)).into_iter().map(|b| b.name).collect();
assert_eq!(names, ["Many 1"]);
assert_eq!(program(&doc).matches("%AI8_BeginBrushPattern").count(), 1);
}
/// Brush art that is a compound path is written as one path of all its members' subpaths, and is
/// weighed as the reader reads that back: a compound path of 24,000 members of two squares each is
/// read as 48,000 paths, within what the reader copies into one file's brushes, so the brush is
/// written without a warning; a compound path with one square comes back as one path.
#[test]
fn brush_art_of_a_compound_path_is_weighed_as_it_is_read() {
use vectorcraft_brush::{ArtBrush, Brush, BrushKind};
let square = |i: u32| {
let x = f64::from(i % 400) * 2.0;
let y = f64::from(i / 400) * 2.0;
shapes::rectangle(Rect::new(x, y, x + 1.0, y + 1.0)).subpaths
};
let compound = |members: u32, per: u32| {
let children = (0..members)
.map(|m| {
let path = PathData::new((0..per).flat_map(|k| square(m * per + k)).collect());
Arc::new(Node::path(vectorcraft_doc::NodeId(2), path, Appearance::basic(black_fill(), Paint::None, 0.0)))
})
.collect();
let mut n = Node::path(vectorcraft_doc::NodeId(1), PathData::default(), Appearance::basic(black_fill(), Paint::None, 0.0));
n.kind = NodeKind::Compound { children, rule: FillRule::NonZero };
n
};
let brush = |name: &str, art: Node| Brush { name: name.into(), kind: BrushKind::Art(ArtBrush { art, ..ArtBrush::default() }) };
let mut doc = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut doc, &[brush("One", compound(1, 1))]);
let back = vectorcraft_brush::library(&read_back(&doc));
let BrushKind::Art(a) = &back[0].kind else { panic!("art") };
assert_eq!(art_tree(&a.art)["kind"], "path", "{:#}", art_tree(&a.art));
let mut doc = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut doc, &[brush("Many", compound(24_000, 2))]);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
let back = vectorcraft_brush::library(&read_back(&doc));
assert_eq!(back.len(), 1);
let BrushKind::Art(a) = &back[0].kind else { panic!("art") };
assert_eq!(a.art.children().map(Vec::len), Some(48_000));
}
/// A path with a drop shadow and the Calligraphic brush keeps the brush and the effect, and its drawn
/// look doesn't warn about the brushed stroke; a group with a drop shadow whose member has the Scatter
/// brush still warns, as the member is written as its brush's art.
#[test]
fn a_brushed_path_with_effects_keeps_its_brush_without_the_stroke_warning() {
let mut doc = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut doc, &brush_library());
let mut line = Node::path(
doc.alloc_id(),
shapes::line((20.0, 50.0).into(), (280.0, 60.0).into()),
Appearance::basic(Paint::None, solid(Color::rgb(1.0, 0.0, 0.0)), 2.0),
);
line.appearance.stroke_mut().unwrap().brush = Some("Nib".into());
line.appearance.effects = vec![drop_shadow()];
put(&mut doc, line);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings);
let back = read_back(&doc);
let n = &back.layers[0].children().unwrap()[0];
assert!(matches!(n.kind, NodeKind::Path { .. }), "the path, not its look: {n:#?}");
assert_eq!(n.appearance.stroke().and_then(|s| s.brush.as_deref()), Some("Nib"));
assert_eq!(n.appearance.effects.len(), 1);
let mut doc = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut doc, &brush_library());
let mut member = Node::path(
doc.alloc_id(),
shapes::line((20.0, 50.0).into(), (280.0, 60.0).into()),
Appearance::basic(Paint::None, solid(Color::rgb(1.0, 0.0, 0.0)), 2.0),
);
member.appearance.stroke_mut().unwrap().brush = Some("Spray".into());
let mut g = group(&mut doc, vec![member], false);
g.appearance.effects = vec![drop_shadow()];
put(&mut doc, g);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings);
}
/// A brushed stroke that paints nothing isn't kept by an art style: a path with only such a stroke is
/// written without a style, and a document whose brushes are a new document's writes no brushes.
#[test]
fn a_brushed_stroke_that_paints_nothing_writes_no_brushes() {
let mut doc = Document::new(300.0, 200.0);
let mut line =
Node::path(doc.alloc_id(), shapes::line((20.0, 50.0).into(), (280.0, 60.0).into()), Appearance::basic(Paint::None, Paint::None, 1.0));
line.appearance.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
put(&mut doc, line);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
let text = program(&doc);
assert!(!text.contains("%AI8_BeginPluginObject"), "no brushes");
assert!(!text.contains("/KnownStyle"), "no art style");
}
/// The lines of `program` that hold brushes and what is drawn with them: the brush art, the plug-in
/// objects, the art styles and the layers.
fn brush_lines(program: &str) -> Vec<&str> {
let mut out = vec![];
let mut inside = false;
for line in program.lines() {
if ["%AI8_BeginBrushPattern", "%AI8_BeginPluginObject", "%AI9_BeginArtStyles", "%%EndSetup"].iter().any(|m| line.starts_with(m)) {
inside = true;
}
if inside {
out.push(line);
}
if ["%AI8_EndBrushPattern", "%AI8_EndPluginObject", "%AI9_EndArtStyles"].iter().any(|m| line.starts_with(m)) {
inside = false;
}
}
out
}
/// A document with brushes and brushed paths is written as the same bytes again, and what the reader
/// reads back is written with the same brushes, art styles and layers.
#[test]
fn brushes_write_the_same_bytes_again() {
let doc = brushes_document();
let first = export_ai_editing(&doc, &opts()).unwrap();
assert_eq!(first.program, export_ai_editing(&doc, &opts()).unwrap().program);
let again = String::from_utf8(export_ai_editing(&read_back(&doc), &opts()).unwrap().program).unwrap();
let first = String::from_utf8(first.program).unwrap();
let (again, first) = (brush_lines(&again), brush_lines(&first));
let differ = again.iter().zip(&first).enumerate().find(|(_, (a, b))| a != b);
assert_eq!(differ, None, "the first line that differs");
assert_eq!(again.len(), first.len());
}
/// Brushes the reader wouldn't read back as they are are left out with a warning, each on its own
/// beside a brush that comes back, and each changed from a brush that comes back by the one setting
/// that leaves it out: an Art brush whose art is a group with a fill of its own, a Scatter brush whose
/// art is a path without points, an Art brush whose art is a group without members, Pattern and Bristle
/// brushes, Art brushes flipped along or across, overlapping, at another width, with a width that
/// varies, in another direction or scale, Art and Scatter brushes in Tints and Shades or Hue Shift, a
/// brush whose name holds a slash and a second brush of a name. A brush left out doesn't take its
/// name from a later one.
#[test]
fn brushes_the_reader_cant_read_are_left_out() {
use vectorcraft_brush::{ArtBrush, ArtScale, Bristle, Brush, BrushKind, Colorization, Direction, PatternBrush, Variation};
let lib = brush_library();
let keep = lib[0].clone();
let (BrushKind::Scatter(scatter), BrushKind::Art(art)) = (&lib[1].kind, &lib[2].kind) else { panic!("library") };
let hue = Colorization::HueShift { key: Color::rgb(1.0, 0.0, 0.0) };
let art_with = |f: &dyn Fn(&mut ArtBrush)| {
let mut a = art.clone();
f(&mut a);
BrushKind::Art(a)
};
let mut painted_group = brush_art_node();
painted_group.appearance = Appearance::basic(black_fill(), Paint::None, 0.0).into();
let empty_path = Node::path(vectorcraft_doc::NodeId(1), PathData::new(vec![]), Appearance::basic(black_fill(), Paint::None, 0.0));
let cases: Vec<(&str, BrushKind)> = vec![
("painted group", art_with(&|a| a.art = painted_group.clone())),
("empty path", BrushKind::Scatter(vectorcraft_brush::Scatter { art: empty_path.clone(), ..scatter.clone() })),
("empty group", art_with(&|a| a.art = Node::group(vectorcraft_doc::NodeId(1), vec![]))),
("pattern", BrushKind::Pattern(PatternBrush::default())),
("bristle", BrushKind::Bristle(Bristle::default())),
("flipped along", art_with(&|a| a.flip_along = true)),
("flipped across", art_with(&|a| a.flip_across = true)),
("overlap", art_with(&|a| a.overlap = true)),
("width", art_with(&|a| a.width = 50.0)),
("width varies", art_with(&|a| a.width_mode = Some(Variation::Pressure))),
("direction", art_with(&|a| a.direction = Direction::RightToLeft)),
("proportional", art_with(&|a| a.scale = ArtScale::Proportional)),
("art tints and shades", art_with(&|a| a.colorization = Colorization::TintsAndShades)),
("art hue shift", art_with(&|a| a.colorization = hue)),
(
"scatter tints and shades",
BrushKind::Scatter(vectorcraft_brush::Scatter { colorization: Colorization::TintsAndShades, ..scatter.clone() }),
),
("scatter hue shift", BrushKind::Scatter(vectorcraft_brush::Scatter { colorization: hue, ..scatter.clone() })),
];
let left_out = |extra: Vec<Brush>, why: &str| {
let mut doc = Document::new(300.0, 200.0);
let mut lib = vec![keep.clone()];
lib.extend(extra);
vectorcraft_brush::store(&mut doc, &lib);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{why}: {:?}", out.warnings);
assert_eq!(vectorcraft_brush::library(&read_back(&doc)), std::slice::from_ref(&keep), "{why}");
};
for (why, kind) in cases {
left_out(vec![Brush { name: "Other".into(), kind }], why);
}
left_out(vec![Brush { name: "A/B".into(), ..keep.clone() }], "slash");
left_out(vec![Brush { kind: BrushKind::Bristle(Bristle::default()), ..keep.clone() }], "a second brush of the name");
// A Pattern brush named "X" is left out, so a Calligraphic brush named "X" after it comes back. A
// path with "X" is drawn with the Pattern brush, so it comes back as its drawn look.
let mut doc = Document::new(300.0, 200.0);
let x = Brush { name: "X".into(), ..keep.clone() };
vectorcraft_brush::store(&mut doc, &[Brush { name: "X".into(), kind: BrushKind::Pattern(PatternBrush::default()) }, x.clone()]);
brushed(&mut doc, "X", (20.0, 50.0), (280.0, 60.0), 2.0);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
let back = read_back(&doc);
assert_eq!(vectorcraft_brush::library(&back), [x]);
let n = &back.layers[0].children().unwrap()[0];
assert!(n.appearance.stroke().is_none_or(|s| s.brush.is_none()), "not with the other brush of the name: {n:#?}");
}
/// Brushes past the most the reader reads from one file are left out with a warning: of 4,097
/// Calligraphic brushes, the first 4,096 come back.
#[test]
fn brushes_past_the_readers_count_are_left_out() {
let nib = brush_library().remove(0);
let lib: Vec<vectorcraft_brush::Brush> = (0..4097).map(|i| vectorcraft_brush::Brush { name: format!("Nib {i}"), ..nib.clone() }).collect();
let mut doc = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut doc, &lib);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
let back = vectorcraft_brush::library(&read_back(&doc));
assert_eq!(back.len(), 4096);
assert_eq!(back.last().map(|b| b.name.as_str()), Some("Nib 4095"));
}
/// A brush whose art the editing data would change (a stroke inside the path, written as its
/// outline) is left out, also after an object on the layers was already written with that change.
#[test]
fn a_brush_whose_art_would_change_is_left_out() {
use vectorcraft_brush::{ArtBrush, Brush, BrushKind};
let inside = |doc: &mut Document| {
let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 8.0)), Appearance::basic(Paint::None, black_fill(), 2.0));
n.appearance.stroke_mut().unwrap().align = vectorcraft_doc::StrokeAlign::Inside;
n
};
let mut doc = Document::new(300.0, 200.0);
let on_layer = inside(&mut doc);
put(&mut doc, on_layer);
let keep = brush_library().remove(0);
let art = inside(&mut doc);
vectorcraft_brush::store(
&mut doc,
&[keep.clone(), Brush { name: "Outlined".into(), kind: BrushKind::Art(ArtBrush { art, ..ArtBrush::default() }) }],
);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings);
assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
assert_eq!(vectorcraft_brush::library(&read_back(&doc)), [keep]);
}
/// Brushes past the art the reader copies into one file's brushes are left out with a warning, so
/// that what is written is what comes back: three Art brushes of 100,000 anchors each, of which the
/// reader keeps two.
#[test]
fn brushes_past_the_readers_art_limit_are_left_out() {
use vectorcraft_brush::{ArtBrush, Brush, BrushKind};
let heavy = |id: u64| {
let points: Vec<vectorcraft_geom::Point> =
(0..100_000).map(|i| vectorcraft_geom::Point::new(f64::from(i) / 1000.0, if i % 2 == 0 { 0.0 } else { 4.0 })).collect();
let path = PathData::single(vectorcraft_geom::SubPath::polyline(&points, true));
Node::path(vectorcraft_doc::NodeId(id), path, Appearance::basic(black_fill(), Paint::None, 0.0))
};
let lib: Vec<Brush> =
(1..=3).map(|i| Brush { name: format!("Heavy {i}"), kind: BrushKind::Art(ArtBrush { art: heavy(i), ..ArtBrush::default() }) }).collect();
let mut doc = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut doc, &lib);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
let back: Vec<String> = vectorcraft_brush::library(&read_back(&doc)).into_iter().map(|b| b.name).collect();
assert_eq!(back, ["Heavy 1", "Heavy 2"]);
assert_eq!(program(&doc).matches("%AI8_BeginBrushPattern").count(), 2);
// The art of the brush left out isn't counted: there is room for it all with room for what is read.
let read = crate::import::objects_read(&out.program).unwrap();
let room = export_within(&doc, &opts(), read).unwrap();
assert!(!room.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{read}: {:?}", room.warnings);
}
/// A brush whose art doesn't fit within the object limit is left out with the limit's warning, and
/// what its art took is given back: a later brush whose art fits comes back.
#[test]
fn a_brush_whose_art_passes_the_object_limit_leaves_room_for_the_next() {
use vectorcraft_brush::{ArtBrush, Brush, BrushKind};
let squares = |n: u64| {
let block = |i: u64| {
let x = i as f64 * 10.0;
Arc::new(Node::path(
vectorcraft_doc::NodeId(i + 1),
shapes::rectangle(Rect::new(x, 0.0, x + 5.0, 5.0)),
Appearance::basic(black_fill(), Paint::None, 0.0),
))
};
Node::group(vectorcraft_doc::NodeId(100), (0..n).map(block).collect())
};
let art = |name: &str, n: u64| Brush { name: name.into(), kind: BrushKind::Art(ArtBrush { art: squares(n), ..ArtBrush::default() }) };
let mut doc = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut doc, &[art("Big", 6), art("Small", 2)]);
let all = export_ai_editing(&doc, &opts()).unwrap();
assert!(!all.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", all.warnings);
let mut small = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut small, &[art("Small", 2)]);
let room = crate::import::objects_read(&export_ai_editing(&small, &opts()).unwrap().program).unwrap();
let out = export_within(&doc, &opts(), room).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", out.warnings);
assert!(crate::import::objects_read(&out.program).unwrap() <= room);
let mut data = ZSTD_MARKER.to_vec();
data.extend(ruzstd::encoding::compress_to_vec(out.program.as_slice(), ruzstd::encoding::CompressionLevel::Fastest));
let back = crate::ai_alone(&data).unwrap().0;
let names: Vec<String> = vectorcraft_brush::library(&back).into_iter().map(|b| b.name).collect();
assert_eq!(names, ["Small"]);
}
/// A graphic style with a brushed stroke, which is left out, takes no room from the layers' objects:
/// a document with a new document's brushes and no brushed path writes no brush art, so with room for
/// what its layers write, every object comes back.
#[test]
fn a_graphic_style_with_a_brush_takes_no_room_from_the_layers() {
let mut doc = Document::new(300.0, 200.0);
for i in 0..3 {
let x = 20.0 + f64::from(i) * 40.0;
let square =
Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(x, 20.0, x + 30.0, 50.0)), Appearance::basic(black_fill(), Paint::None, 0.0));
put(&mut doc, square);
}
let room = crate::import::objects_read(&export_ai_editing(&doc, &opts()).unwrap().program).unwrap();
let mut stroke = StrokeLayer::new(solid(Color::rgb(1.0, 0.0, 0.0)), 1.0);
stroke.brush = Some("3 pt. Round".into());
let ap = Appearance { items: vec![AppearanceItem::Stroke(stroke)], effects: vec![], contents_index: None };
doc.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Brushed", ap));
let out = export_within(&doc, &opts(), room).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{room}: {:?}", out.warnings);
let program = String::from_utf8(out.program).unwrap();
assert!(!program.contains("%AI8_BeginBrushPattern"), "no brush art");
}
/// A graphic style whose brushed stroke paints nothing comes back in the Graphic Styles panel.
#[test]
fn a_graphic_style_with_an_unpainted_brushed_stroke_comes_back() {
let mut doc = Document::new(300.0, 200.0);
let mut ap = Appearance::basic(black_fill(), Paint::None, 1.0);
ap.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
doc.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Unpainted brush", ap));
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings);
assert!(read_back(&doc).graphic_style("Unpainted brush").is_some());
}
/// A brush whose art doesn't fit within the object limit after a path was written with it on its
/// stroke is left out with both warnings, and the path comes back as its drawn look.
#[test]
fn a_brushed_path_whose_brush_art_doesnt_fit_comes_back_as_its_look() {
let ribbon = brush_library().remove(2);
let mut doc = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut doc, std::slice::from_ref(&ribbon));
brushed(&mut doc, "Ribbon", (20.0, 120.0), (280.0, 150.0), 1.5);
let all = export_ai_editing(&doc, &opts()).unwrap();
assert!(!all.warnings.iter().any(|w| w == writer::OBJECT_LIMIT || w == writer::BRUSHES), "{:?}", all.warnings);
let room = crate::import::objects_read(&all.program).unwrap() - 1;
let out = export_within(&doc, &opts(), room).unwrap();
for w in [writer::OBJECT_LIMIT, writer::BRUSHES] {
assert!(out.warnings.iter().any(|x| x == w), "{w}: {:?}", out.warnings);
}
assert!(crate::import::objects_read(&out.program).unwrap() <= room);
let mut data = ZSTD_MARKER.to_vec();
data.extend(ruzstd::encoding::compress_to_vec(out.program.as_slice(), ruzstd::encoding::CompressionLevel::Fastest));
let back = crate::ai_alone(&data).unwrap().0;
assert!(vectorcraft_brush::library(&back).is_empty());
let n = &back.layers[0].children().unwrap()[0];
assert!(matches!(n.kind, NodeKind::Group { .. }), "the path's drawn look: {n:#?}");
}
/// An empty Brushes panel comes back empty, and a document with a new document's brushes and no
/// brushed path writes no brushes (the reader gives such a file a new document's).
#[test]
fn an_empty_brushes_panel_comes_back_empty() {
let mut doc = Document::new(300.0, 200.0);
assert!(!program(&doc).contains("%AI8_BeginPluginObject"));
assert_eq!(vectorcraft_brush::library(&read_back(&doc)), vectorcraft_brush::library(&doc));
vectorcraft_brush::store(&mut doc, &[]);
assert!(vectorcraft_brush::library(&read_back(&doc)).is_empty());
}
/// A path with one of a new document's brushes writes the brushes the reader reads, and keeps its
/// brush. The new document's Art brush with other options, its Pattern brushes and its Bristle
/// brushes are left out with a warning, so the file opens without them.
#[test]
fn a_new_documents_brush_on_a_path_writes_the_brushes() {
let mut doc = Document::new(300.0, 200.0);
brushed(&mut doc, "3 pt. Round", (20.0, 50.0), (280.0, 60.0), 1.0);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
let back = read_back(&doc);
let n = &back.layers[0].children().unwrap()[0];
assert_eq!(n.appearance.stroke().and_then(|s| s.brush.as_deref()), Some("3 pt. Round"));
let left_out = ["Arrow", "Chain", "Stitches", "Diamonds", "Bristle Round", "Bristle Flat Fan"];
let want: Vec<&str> = vectorcraft_brush::defaults().iter().map(|b| b.name.as_str()).filter(|n| !left_out.contains(n)).collect();
let lib = vectorcraft_brush::library(&back);
assert_eq!(lib.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), want);
assert_eq!(vectorcraft_brush::defaults().len(), want.len() + left_out.len());
}
/// A path with one of a new document's brushes that doesn't fit within the object limit is left
/// out, and no brushes are written: the file opens with a new document's brushes, all of them.
#[test]
fn a_new_documents_brush_on_a_path_past_the_object_limit_writes_no_brushes() {
let empty = Document::new(300.0, 200.0);
let room = crate::import::objects_read(&export_ai_editing(&empty, &opts()).unwrap().program).unwrap();
let mut doc = Document::new(300.0, 200.0);
brushed(&mut doc, "3 pt. Round", (20.0, 50.0), (280.0, 60.0), 1.0);
let out = export_within(&doc, &opts(), room).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", out.warnings);
assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
assert!(!String::from_utf8(out.program).unwrap().contains("%AI8_BeginPluginObject"));
}
/// On a large canvas the brushes are left out with a warning, and brushed paths come back as their
/// drawn look.
#[test]
fn a_large_canvas_leaves_the_brushes_out() {
let mut doc = Document::new(20000.0, 100.0);
vectorcraft_brush::store(&mut doc, &brush_library());
brushed(&mut doc, "Nib", (20.0, 50.0), (280.0, 60.0), 2.0);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
assert!(!program(&doc).contains("%AI8_BeginPluginObject"));
assert!(matches!(read_back(&doc).layers[0].children().unwrap()[0].kind, NodeKind::Group { .. }));
// An empty panel is kept, without a warning.
let mut doc = Document::new(20000.0, 100.0);
vectorcraft_brush::store(&mut doc, &[]);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
assert!(vectorcraft_brush::library(&read_back(&doc)).is_empty());
}
// ---------- variables ----------
/// A 200 × 100 document with variables: a text variable `name` bound to point type, a visibility
+16 -2
View File
@@ -15,6 +15,7 @@ use vectorcraft_pathops::BoolOp;
use crate::ai_ops::{self, unit};
use crate::ps;
mod brushes;
mod images;
mod paints;
mod styles;
@@ -22,6 +23,8 @@ mod symbols;
mod texts;
mod variables;
#[cfg(test)]
pub(super) use brushes::BRUSHES;
pub(super) use styles::{LARGE_CANVAS_STYLES, style_links, styled_objects};
#[cfg(test)]
pub(super) use styles::{PANEL_EMPTY, PANEL_STYLES, STYLE_LINKS, same_appearance};
@@ -51,7 +54,7 @@ pub(super) const CLIP_PAINT: &str = "the fills and strokes of clipping paths tha
pub(super) const CLIP_PAINT_MASKS: &str = "the opacity masks of clipping paths whose fills and strokes are written as paths of their own stay on the clipping path, not on those paths, in the editing data for other apps";
pub(super) const LIVE: &str =
"blends, envelopes, meshes, repeats, placed documents and intertwines are written as their art in the editing data for other apps";
const STROKES: &str = "strokes a plain line can't draw (inside or outside the path, variable width, dots, dashes fitted to corners, gradients, patterns, arrowheads) are written as filled outlines, and brushed strokes as their brush's art, in the editing data for other apps";
pub(super) const STROKES: &str = "strokes a plain line can't draw (inside or outside the path, variable width, dots, dashes fitted to corners, gradients, patterns, arrowheads) are written as filled outlines, and brushed strokes as their brush's art, in the editing data for other apps";
pub(super) const ITEM_TRANSPARENCY: &str =
"the opacity and blending modes of single fills and strokes are left out of the editing data for other apps";
pub(super) const LAYER_MASKS: &str = "opacity masks on layers are left out of the editing data for other apps";
@@ -255,6 +258,10 @@ pub(super) struct Writer<'a> {
/// An object's drawn look is being written: no object in it is written with a style, and its
/// effects are the style's.
in_look: bool,
/// The brushes written ([`brushes`]).
brush_set: brushes::Brushes,
/// Brushes may be written; false on a large canvas, where they are left out.
pub(super) brushes_live: bool,
}
impl<'a> Writer<'a> {
@@ -310,6 +317,8 @@ impl<'a> Writer<'a> {
styled: HashMap::new(),
styles: styles::Styles::default(),
in_look: false,
brush_set: brushes::Brushes::default(),
brushes_live: true,
objects: 0,
left_out: 0,
definition_objects: 0,
@@ -1028,7 +1037,12 @@ impl<'a> Writer<'a> {
AppearanceItem::Fill(f) => self.fill_object(path, rule, f, xy),
AppearanceItem::Stroke(s) if self.direct_stroke(s) => self.path_object(path, rule, None, Some(s), xy),
AppearanceItem::Stroke(s) => {
self.warn(STROKES);
// In a drawn look a brushed stroke is kept by the object's art style, which is written
// only when the reader reads the brush back from it; a styled group's members are
// written again after the look, and warn there.
if !(self.in_look && s.brush.is_some()) {
self.warn(STROKES);
}
self.wrote_fixed = true;
if self.brush_art(path, s, xy) {
return;
+558
View File
@@ -0,0 +1,558 @@
//! Brushes: the document's brushes as the `.ai` reader reads them ([`crate::import`]'s `.ai` reader,
//! `brush.rs`), and the brush filter of a brushed stroke in an art style (`style.rs`).
//!
//! Each brush is a plug-in object (`%AI8_BeginPluginObject`, the plug-in's name, the brush's name,
//! its parameters as strings that `-` continues and `.` ends, `%AI8_EndPluginObject`), after the
//! brush manager's order of them all, in a non-printing block after the symbols. The art of a
//! Scatter or Art brush is a `%AI8_BeginBrushPattern` section (its name; a group of the art's frame,
//! an unpainted rectangle at the art's geometric bounds, and the art, or the art alone when it has no
//! width or height; its end) in a non-printing block before the symbols. Each brush is checked with
//! the reader's own reading ([`crate::import::read_brush_object`]) before it is written: one the
//! reader would read as another brush is left out.
use std::collections::HashSet;
use std::fmt::Write;
use std::sync::Arc;
use vectorcraft_brush::{ArtBrush, ArtScale, Brush, BrushKind, Calligraphic, Colorization, Direction, Scatter, Variation};
use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_geom::{Affine, PathData, SubPath};
use super::{Writer, Xy};
use crate::ai_ops;
use crate::ps;
pub(in crate::ai_editing) const BRUSHES: &str = "Pattern and Bristle brushes, Art brushes that don't stretch their art along the path from left to right at 100% unflipped without overlap, Scatter and Art brushes in Tints and Shades or Hue Shift, brushes whose art holds an empty path or group or more than paths, compound paths and groups with one solid fill and one plain stroke each, brushes whose names hold a slash or repeat an earlier brush's name, brushes that would open with other settings, brushes past the number or the amount of art one file's brushes can hold, and every brush on a large canvas are left out of the Brushes panel in the editing data for other apps";
/// Most brushes written (the reader's limit).
const MAX_BRUSHES: usize = 4096;
/// Most bytes of a plug-in object's parameters in one of its strings.
const CHUNK: usize = 60;
/// A brush written as a plug-in object.
pub(super) struct BrushOut {
pub brush: Brush,
pub plugin: &'static str,
pub params: String,
/// The art its parameters name, as the reader reads it.
pub art: Option<Node>,
/// Its art section to write ([`Writer::brush_blocks`] writes it).
section: Option<SectionArt>,
/// A path whose art style names it at its own settings is read with the brush on its stroke.
pub live: bool,
}
/// A brush's art section to write: its name, the art and the space it is written in
/// ([`Writer::brush_art_section`]).
type SectionArt = (String, Node, Affine);
/// The brushes written and their art.
#[derive(Default)]
pub(super) struct Brushes {
/// The document's brushes have been worked out.
done: bool,
pub written: Vec<BrushOut>,
/// The art sections named so far.
sections: usize,
/// A brush of the document was left out.
left_out: bool,
/// How many objects were written with a brush on their stroke.
pub live_written: usize,
/// The brushes written as the reader reads them, for checking art styles; made once, when
/// the first style is checked ([`Writer::brushed_style_reads`]).
read: Option<crate::import::BrushesRead>,
}
/// A number with at most six decimals, as brush parameters hold factors.
fn num6(s: &mut String, v: f64) {
let v = if v.is_finite() { (v * 1e6).round() / 1e6 } else { 0.0 };
if v == v.trunc() && v.abs() < 1e15 {
let _ = write!(s, "{}", v as i64);
return;
}
let start = s.len();
let _ = write!(s, "{v:.6}");
let keep = s[start..].trim_end_matches('0').trim_end_matches('.').len();
s.truncate(start + keep);
}
/// `vs` as [`num6`] numbers separated by spaces.
fn nums6(vs: &[f64]) -> String {
let mut s = String::new();
for (i, v) in vs.iter().enumerate() {
if i > 0 {
s.push(' ');
}
num6(&mut s, *v);
}
s
}
/// The code a brush's parameters give how a value varies, in the order of the Brush Options menu.
fn code(v: Variation) -> f64 {
match v {
Variation::Fixed => 0.0,
Variation::Random => 1.0,
Variation::Pressure => 2.0,
Variation::Tilt => 4.0,
Variation::Bearing => 5.0,
Variation::Rotation => 6.0,
}
}
/// The colorization code of Scatter and Art brushes' parameters: `None` for one the reader doesn't read.
fn colorization_code(c: Colorization) -> Option<f64> {
match c {
Colorization::None => Some(0.0),
Colorization::Tints => Some(1.0),
Colorization::TintsAndShades | Colorization::HueShift { .. } => None,
}
}
/// A Calligraphic brush's parameters: 1, the diameter, roundness and angle as (low, high) pairs,
/// the codes of how the diameter, roundness and angle vary, and 0.
fn calligraphic_params(c: &Calligraphic) -> String {
let [angle, roundness, size] = [c.angle, c.roundness, c.size];
let [av, rv, dv] = c.variation;
let [am, rm, dm] = c.modes().map(code);
nums6(&[1.0, size - dv, size + dv, roundness - rv, roundness + rv, angle - av, angle + av, dm, rm, am, 0.0])
}
/// A Scatter brush's parameters, its art the section `art`: `None` for a colorization the reader
/// doesn't read.
fn scatter_params(s: &Scatter, art: &str) -> Option<String> {
let f = |v: f64| v / 100.0;
let m = s.modes().map(code);
let numbers = [
colorization_code(s.colorization)?,
f(s.size.0),
f(s.size.1),
m[0],
f(s.spacing.0),
f(s.spacing.1),
m[1],
f(s.scatter.0),
f(s.scatter.1),
m[2],
s.rotation.0,
s.rotation.1,
m[3],
f64::from(u8::from(s.rotation_relative_to_path)),
0.0,
];
Some(format!("2 /{art}/ 5 0 0 0 / {}", nums6(&numbers)))
}
/// An Art brush's parameters, its art the section `art`: `None` for options the reader doesn't read
/// (it reads an Art brush that stretches its art along the path from left to right at 100%,
/// unflipped, without overlap, in Tints or keeping its colors).
fn art_params(a: &ArtBrush, art: &str) -> Option<String> {
let plain = a.direction == Direction::LeftToRight
&& matches!(a.scale, ArtScale::Stretch)
&& a.width == 100.0
&& a.width_mode.is_none()
&& !a.flip_along
&& !a.flip_across
&& !a.overlap;
if !plain {
return None;
}
let c = colorization_code(a.colorization)?;
let numbers = [1.0, c, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0];
Some(format!("2 / {art}/ / / / / 0 0/ 5 0 0 0 / {}", nums6(&numbers)))
}
/// Is `art` written as the reader reads it back, within a brush: paths and compound paths with
/// points and groups with members (not clipping, without paint of their own), each opaque in the
/// normal blending mode, without effects or opacity masks, with at most one fill and one plain
/// stroke (no brush, width profile, arrowheads, alignment off the path or dashes fitted to corners)
/// over it, each in a color or none? A path without points isn't written ([`Writer::plain_object`]).
fn plain_art(art: &Node) -> bool {
let mut plain = true;
art.walk(&mut |n| {
let kind = match &n.kind {
NodeKind::Path { path, guide: false, clipping: false, .. } => path.subpaths.iter().any(|sp| !sp.anchors.is_empty()),
NodeKind::Compound { .. } => super::compound_path(n).subpaths.iter().any(|sp| !sp.anchors.is_empty()),
NodeKind::Group { children, clip: false } => !children.is_empty(),
_ => false,
};
let items = &n.appearance.items;
let fills = items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).count();
let strokes = items.iter().filter(|i| matches!(i, AppearanceItem::Stroke(_))).count();
let order = !matches!(items.as_slice(), [AppearanceItem::Stroke(_), AppearanceItem::Fill(_)]);
let quiet = items.iter().all(|i| {
i.effects().is_empty()
&& i.opacity() >= 1.0
&& i.blend() == BlendMode::Normal
&& matches!(i.paint(), Paint::Solid { .. } | Paint::None)
&& !matches!(i, AppearanceItem::Stroke(s) if !vectorcraft_effects::stroke::is_plain(s))
});
// A group's own fills and strokes aren't written.
let group_paint = matches!(n.kind, NodeKind::Group { .. }) && items.iter().any(|i| !i.paint().is_none());
plain &= kind
&& !group_paint
&& fills <= 1
&& strokes <= 1
&& order
&& quiet
&& n.appearance.effects.is_empty()
&& n.extras().mask.is_none()
&& n.opacity >= 1.0
&& n.blend == BlendMode::Normal;
});
plain
}
/// `art` (checked by [`plain_art`]) with its structure as the reader reads it back. A path, or a
/// compound path with the subpaths of all its members as one path ([`super::compound_path`]), is
/// written as one path when it has one subpath with points and as a compound path when it has more
/// ([`Writer::plain_object`]); the reader reads that compound path as one path for each subpath,
/// each with the object's paint.
fn as_read(art: &mut Node) {
let (subpaths, rule) = match &art.kind {
NodeKind::Path { path, rule, .. } => (path.subpaths.clone(), *rule),
NodeKind::Compound { rule, .. } => (super::compound_path(art).subpaths, *rule),
_ => {
if let Some(children) = art.children_mut() {
for c in children.iter_mut() {
as_read(Arc::make_mut(c));
}
}
return;
}
};
let mut subpaths: Vec<SubPath> = subpaths.into_iter().filter(|sp| !sp.anchors.is_empty()).collect();
let path = |sp: SubPath| {
let mut p = Node::path(art.id, PathData::single(sp), Appearance::default());
p.appearance = art.appearance.clone();
p
};
match subpaths.len() {
0 => {}
1 => {
let Some(sp) = subpaths.pop() else { return };
art.kind = path(sp).kind;
if let NodeKind::Path { rule: r, .. } = &mut art.kind {
*r = rule;
}
}
_ => {
let children = subpaths.into_iter().map(|sp| Arc::new(path(sp))).collect();
art.kind = NodeKind::Compound { children, rule };
}
}
}
/// Do brushes `a` and `b` have the same settings, within the written numbers' precision, whatever
/// their art and however their modes are given?
fn same_settings(a: &Brush, b: &Brush) -> bool {
let norm = |b: &Brush| -> serde_json::Value {
let mut b = b.clone();
let none = || Node::group(NodeId(0), vec![]);
match &mut b.kind {
BrushKind::Calligraphic(c) => c.modes = Some(c.modes()),
BrushKind::Scatter(s) => {
s.modes = Some(s.modes());
s.art = none();
}
BrushKind::Art(a) => a.art = none(),
BrushKind::Pattern(_) | BrushKind::Bristle(_) => {}
}
serde_json::to_value(&b).unwrap_or(serde_json::Value::Null)
};
super::styles::near(&norm(a), &norm(b))
}
/// A plug-in object: its plug-in's name, its name, and `params` in strings of at most [`CHUNK`] bytes.
fn plugin_object(plugin: &str, name: &str, params: &str) -> String {
let mut s = format!("%AI8_BeginPluginObject\n{}\n{}\n", ps::bytes(plugin.as_bytes()), ps::bytes(name.as_bytes()));
let chunks: Vec<&[u8]> = params.as_bytes().chunks(CHUNK).collect();
if chunks.is_empty() {
s.push_str("() .\n");
}
for (i, c) in chunks.iter().enumerate() {
let _ = writeln!(s, "{} {}", ps::bytes(c), if i + 1 == chunks.len() { "." } else { "-" });
}
s.push_str("%AI8_EndPluginObject\n");
s
}
impl Writer<'_> {
/// Work out, once, which of the document's brushes are written: each one the reader reads back
/// with its settings. Their art is written with the brushes ([`Self::brush_blocks`]).
pub(super) fn brush_set(&mut self) {
if self.brush_set.done {
return;
}
self.brush_set.done = true;
let doc = self.doc;
let lib = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc)).clone();
if !self.brushes_live {
self.brush_set.left_out = !lib.is_empty();
return;
}
// A name is taken by a brush written, as the reader takes it by a brush it keeps. A brush after
// another of its name isn't the one a path of that name is drawn with, so no path is written
// with it on its stroke.
let mut names: HashSet<String> = HashSet::new();
let mut seen: HashSet<String> = HashSet::new();
for b in lib {
let fits = !b.name.contains('/') && !names.contains(&b.name) && self.brush_set.written.len() < MAX_BRUSHES;
let first = seen.insert(b.name.clone());
match fits.then(|| self.brush_out(&b)).flatten() {
Some(mut out) => {
names.insert(b.name.clone());
out.live &= first;
self.brush_set.written.push(out);
}
None => self.brush_set.left_out = true,
}
}
// Read together, as one file's: the reader copies only so much art into one file's brushes.
let objects: Vec<(&str, &str, &str, Option<&Node>)> =
self.brush_set.written.iter().map(|b| (b.plugin, b.brush.name.as_str(), b.params.as_str(), b.art.as_ref())).collect();
let kept = crate::import::brushes_kept(&objects);
if kept.iter().any(|k| !k) {
self.brush_set.left_out = true;
let mut keep = kept.into_iter();
self.brush_set.written.retain(|_| keep.next().unwrap_or(false));
}
}
/// Brush `b` as it is written, with its art section's name, art and placement
/// ([`Self::brush_art_section`] writes it): `None` when the reader wouldn't read it back.
fn brush_out(&mut self, b: &Brush) -> Option<BrushOut> {
let plugin = crate::import::brush_plugin_name(&b.kind);
let section = format!("Brush Art {}", self.brush_set.sections + 1);
// The art as the reader reads it: a section is read with y down, and an Art brush's art is
// turned over after that (its section's y up is the right of the path).
let (params, art) = match &b.kind {
BrushKind::Calligraphic(c) => (calligraphic_params(c), None),
BrushKind::Scatter(s) => (scatter_params(s, &section)?, Some((s.art.clone(), Affine::FLIP_Y))),
BrushKind::Art(a) => (art_params(a, &section)?, Some((a.art.clone(), Affine::IDENTITY))),
BrushKind::Pattern(_) | BrushKind::Bristle(_) => return None,
};
let read_art = match &art {
Some((a, to_section)) => {
if !plain_art(a) {
return None;
}
let mut read = a.clone();
if *to_section == Affine::IDENTITY {
read.transform(Affine::FLIP_Y, false);
}
as_read(&mut read);
Some(read)
}
None => None,
};
let read = crate::import::read_brush_object(plugin, &b.name, &params, read_art.as_ref())?;
if !same_settings(&read.brush, b) {
return None;
}
if art.is_some() {
self.brush_set.sections += 1;
}
let section = art.map(|(a, to_section)| (section, a, to_section));
Some(BrushOut { brush: b.clone(), plugin, params, art: read_art, section, live: read.live })
}
/// A brush's art section `name`: `art` written with `to_section` taking the document's space to
/// the section's (y up), from the state a definition starts in. Its objects are counted as the
/// reader counts them ([`Self::take_objects`]: the group, the frame and the art). `None` when
/// writing it warns: what it took is given back, and when it didn't fit within the object limit,
/// the limit's warning is kept.
fn brush_art_section(&mut self, name: &str, art: &Node, to_section: Affine) -> Option<String> {
let before = self.charges();
// The art's own warnings, apart from those the document already has (a warning is kept once).
let warnings = std::mem::take(&mut self.warnings);
let saved = (
std::mem::take(&mut self.body),
(self.to_art, self.grad_space, self.canvas),
self.state.take(),
self.gs.replace(Xy::OPAQUE),
self.clip.take(),
(self.block_start, self.block_end),
// The art is the brush's own: not on `%_` lines, in a drawn look or in a symbol that
// first uses the brush.
(std::mem::take(&mut self.hidden), std::mem::take(&mut self.in_look), std::mem::take(&mut self.in_symbol)),
);
(self.to_art, self.grad_space, self.canvas, self.block_end) = (to_section, to_section, to_section, usize::MAX);
self.state(false, false);
// The art in a group after its frame, an unpainted rectangle around it. Art without width or
// height (a straight line) is written alone: the reader keeps a frame of no area as art.
let frame = art
.geometric_bounds()
.filter(|b| b.width() > 0.0 && b.height() > 0.0)
.and_then(|b| vectorcraft_geom::shapes::rectangle(b).subpaths.first().cloned());
match frame {
Some(frame) if self.take_objects(2) => {
self.line("u\n0 XR");
ai_ops::subpath(&mut self.body, to_section, &frame);
self.line("n");
self.nest += 1;
self.node(art);
self.nest -= 1;
self.line("U");
}
Some(_) => {}
None => {
self.nest += 1;
self.node(art);
self.nest -= 1;
}
}
let body;
(
body,
(self.to_art, self.grad_space, self.canvas),
self.state,
self.gs,
self.clip,
(self.block_start, self.block_end),
(self.hidden, self.in_look, self.in_symbol),
) = saved;
let text = std::mem::replace(&mut self.body, body);
if !std::mem::replace(&mut self.warnings, warnings).is_empty() {
self.give_back(before, &text);
if self.left_out_since(before) {
self.warn(super::OBJECT_LIMIT);
}
return None;
}
self.keep_since(before);
Some(format!("%AI8_BeginBrushPattern\n{}\n{text}%AI8_EndBrushPattern\n", ps::bytes(name.as_bytes())))
}
/// The written brush named `name` that a path can be written with live.
pub(super) fn live_brush(&self, name: &str) -> Option<&BrushOut> {
self.brush_set.written.iter().find(|b| b.live && b.brush.name == name)
}
/// The basic filter of stroke `st` with the brush it names: the brush's plug-in as the filter,
/// its settings for the object, the stroke's style and the brush by name. `None` when the brush
/// isn't written live.
pub(super) fn brush_filter(&mut self, s: &mut String, st: &StrokeLayer, visible: bool) -> Option<()> {
let name = st.brush.as_deref()?;
self.brush_set();
let (plugin, kind) = self.live_brush(name).map(|b| (b.plugin, b.brush.kind.clone()))?;
if !matches!(st.paint, Paint::Solid { .. }) || st.overprint {
return None;
}
let ops = self.color_ops(&st.paint, true);
let mut d = String::new();
let title = match &kind {
BrushKind::Calligraphic(c) => {
let modes = c.modes().map(code);
for (key, value, variation, mode) in [
("Diameter", c.size, c.variation[2], modes[2]),
("Roundness", c.roundness, c.variation[1], modes[1]),
("Angle", c.angle, c.variation[0], modes[0]),
] {
let _ = writeln!(d, "{} /Real ({key}) ,", ps::num(value));
let _ = writeln!(d, "{} /Real ({key}Variation) ,", ps::num(variation));
let _ = writeln!(d, "{} /Int ({key}DependsOn) ,", mode as i64);
}
d.push_str("0 /Int (AngleRelativeTo) ,\n");
"(Calligraphic Brush) /Title ,\n"
}
BrushKind::Art(_) => {
d.push_str("15 /Int (Adobe Effect Expand Before Version) ,\n"); // brand-ok: the entry's name in the files
for (key, value, kind) in [
("StartSegmentLength", "0", "Real"),
("EndSegmentLength", "0", "Real"),
("Counterclockwise", "0", "Int"),
("MinScaling", "1", "Real"),
("MaxScaling", "1", "Real"),
("Colorization", "1", "Int"),
("Spacing", "0", "Real"),
("Adjustment", "1", "Int"),
("Proportional", "0", "Bool"),
("Segmented", "0", "Bool"),
("UpsideDown", "0", "Int"),
("FoldAdjustment", "1", "Bool"),
("ScaleYDependsOn", "0", "Int"),
("Scaling", "1", "Real"),
] {
let _ = writeln!(d, "{value} /{kind} ({key}) ,");
}
"(Art Brush) /Title ,\n"
}
_ => return None,
};
super::styles::basic(s, plugin, true, visible);
s.push_str(title);
s.push_str("2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n");
s.push_str(&d);
s.push_str("/StrokeStyle : 0 R\n");
s.push_str(&ops);
s.push_str("0 1 0 0 0 Xy\n");
ai_ops::line_style(s, st);
s.push_str("0 XR\n1 0 Xd\n/Def ; \n (StrokeStyle) ,\n");
let quoted = ps::bytes(name.as_bytes());
let _ =
write!(s, "/GObjRef : {quoted} {} /PluginObject ; (Brush) ,\n{quoted} /UnicodeString (DisplayString) ,\n", ps::bytes(plugin.as_bytes()));
s.push_str(super::styles::END_BASIC);
Some(())
}
/// Does the reader read the brushed strokes of style text `text` with their brushes on them?
pub(super) fn brushed_style_reads(&mut self, text: &str) -> bool {
self.brush_set();
let section = super::styles::known_style("x", text);
let written = &self.brush_set.written;
let read = self.brush_set.read.get_or_insert_with(|| {
let brushes: Vec<(&str, &str, &str, Option<&Node>)> =
written.iter().map(|b| (b.plugin, b.brush.name.as_str(), b.params.as_str(), b.art.as_ref())).collect();
crate::import::BrushesRead::new(&brushes)
});
read.style_reads(section.as_bytes(), "x")
}
/// The brushes' art in a non-printing block, then the brush manager's order and the brushes in
/// two more, when the document has brushes of its own or an object is written with a brush on
/// its stroke (a file without brushes opens with a new document's). Warns when a brush is left
/// out.
pub(in crate::ai_editing) fn brush_blocks(&mut self) -> (String, String) {
let stored = self.doc.unknown.contains_key(vectorcraft_brush::DOC_KEY);
if !stored && self.brush_set.live_written == 0 {
return (String::new(), String::new());
}
self.brush_set();
// The art is written now that the file holds it, after the layers' objects. A brush whose art
// doesn't fit is left out; a path written with it on its stroke opens as its drawn look.
let mut sections = String::new();
for mut b in std::mem::take(&mut self.brush_set.written) {
if let Some((name, art, to_section)) = b.section.take() {
let Some(text) = self.brush_art_section(&name, &art, to_section) else {
self.brush_set.left_out = true;
continue;
};
sections.push_str(&text);
}
self.brush_set.written.push(b);
}
if self.brush_set.left_out {
self.warn(BRUSHES);
}
// On a large canvas an empty panel is still written: the manager alone, with no art to scale.
let empty = self.brushes.as_ref().is_some_and(|l| l.is_empty());
if !self.brushes_live && !(stored && empty) {
return (String::new(), String::new());
}
let art = if sections.is_empty() { String::new() } else { format!("%AI5_Begin_NonPrinting\nNp\n{sections}%AI5_End_NonPrinting--\n") };
let manager = crate::import::BRUSH_MANAGER;
let order: String = self.brush_set.written.iter().map(|b| format!(" {}/ {}/", b.plugin, b.brush.name)).collect();
let mut objects = format!("%AI5_Begin_NonPrinting\nNp\n{}%AI5_End_NonPrinting--\n", plugin_object(manager, manager, &order));
if !self.brush_set.written.is_empty() {
objects.push_str("%AI5_Begin_NonPrinting\nNp\n");
for b in &self.brush_set.written {
objects.push_str(&plugin_object(b.plugin, &b.brush.name, &b.params));
}
objects.push_str("%AI5_End_NonPrinting--\n");
}
(art, objects)
}
}
+39 -15
View File
@@ -40,8 +40,9 @@ pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize {
}
/// The objects of `doc`'s layers that may be written with an art style ([`Writer::styled_object`]),
/// by id: paths and compound paths with points and groups, with live effects or linked to a graphic
/// style, without Opacity & Mask Define Knockout Shape (the reader takes an object's transparency
/// by id: paths and compound paths with points and groups, with live effects, linked to a graphic
/// style or (paths and compound paths) with a brush on a stroke, without Opacity & Mask Define
/// Knockout Shape (the reader takes an object's transparency
/// from its look, which doesn't carry it). A group's members are paths, compound paths or such
/// groups, none with effects or anything baking changes.
pub(in crate::ai_editing) fn styled_objects(doc: &Document) -> HashMap<NodeId, Arc<Node>> {
@@ -76,7 +77,8 @@ fn bakes_members(n: &Node) -> bool {
/// Whether `n` may be written with an art style ([`styled_objects`]).
fn candidate(n: &Node) -> bool {
let styled = !n.appearance.effects.is_empty() || n.extras().graphic_style.is_some();
let brushed = brushed(n) && matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. });
let styled = !n.appearance.effects.is_empty() || n.extras().graphic_style.is_some() || brushed;
if !styled || n.knockout_shape {
return false;
}
@@ -88,6 +90,12 @@ fn candidate(n: &Node) -> bool {
}
}
/// Does `n` have a stroke with a brush that paints, one that its art style holds ([`Writer::style_text`]
/// leaves out what paints nothing)?
fn brushed(n: &Node) -> bool {
n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.brush.is_some() && !s.paint.is_none()))
}
/// Does `path` write anything ([`Writer::plain_object`] skips subpaths without points)?
fn has_points(path: &vectorcraft_geom::PathData) -> bool {
path.subpaths.iter().any(|sp| !sp.anchors.is_empty())
@@ -144,7 +152,7 @@ impl Styles {
}
/// One style as the reader's section holds it: `/KnownStyle :`, `(name) /Name ,`, its text.
fn known_style(name: &str, text: &str) -> String {
pub(super) fn known_style(name: &str, text: &str) -> String {
format!("/KnownStyle :\n{} /Name ,\n{text}", ps::bytes(name.as_bytes()))
}
@@ -172,7 +180,7 @@ const NO_LINE: &str = "0 J 0 j 1 w 10 M []0 d\n";
/// A basic filter's start: its name and kind numbers (`1 0` for an effect, `0 0` for the
/// style's own filters), `/Visible` and the dictionary's start.
fn basic(s: &mut String, name: &str, effect: bool, visible: bool) {
pub(super) fn basic(s: &mut String, name: &str, effect: bool, visible: bool) {
let _ = write!(s, "/BasicFilter :\n{} {} 0 /Filter ,\n{} /Visible ,\n", ps::bytes(name.as_bytes()), u8::from(effect), u8::from(visible));
}
@@ -180,7 +188,7 @@ fn basic(s: &mut String, name: &str, effect: bool, visible: bool) {
const CONDUIT: &str = "/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n";
/// A basic filter's end.
const END_BASIC: &str = "; /Dict ;\n /Part ,\n";
pub(super) const END_BASIC: &str = "; /Dict ;\n /Part ,\n";
/// The blend filter of transparency `xy`.
fn blend_filter(s: &mut String, xy: Xy) {
@@ -228,7 +236,7 @@ fn flag(p: &Value, key: &str) -> Option<bool> {
impl Writer<'_> {
/// What `paint` writes with [`Self::color`] (a color, a spot or global color), as a string.
fn color_ops(&mut self, paint: &Paint, stroke: bool) -> String {
pub(super) fn color_ops(&mut self, paint: &Paint, stroke: bool) -> String {
let saved = std::mem::take(&mut self.body);
self.color(paint, stroke);
std::mem::replace(&mut self.body, saved)
@@ -267,9 +275,12 @@ impl Writer<'_> {
}
/// A stroke's basic filter: a color, its line style, and its width profile as the arrays
/// `BSVariableWidthTValuesArray`, `BSLeftWidthsArray` and `BSRightWidthsArray`. `None` for
/// other paint.
/// `BSVariableWidthTValuesArray`, `BSLeftWidthsArray` and `BSRightWidthsArray`; a stroke with a
/// brush, the brush's filter ([`Self::brush_filter`]). `None` for other paint.
fn stroke_filter(&mut self, s: &mut String, st: &StrokeLayer, visible: bool) -> Option<()> {
if st.brush.is_some() {
return self.brush_filter(s, st, visible);
}
if !matches!(st.paint, Paint::Solid { .. }) || st.overprint {
return None;
}
@@ -652,17 +663,19 @@ impl Writer<'_> {
&& let Some(text) = self.styles.known.iter().find(|(n, _)| *n == name).map(|(_, t)| t.clone())
&& object_reads(orig, &text)
{
self.styles.links_written += 1;
return Some(name);
}
if orig.appearance.effects.is_empty() || !own_style_holds(&orig.appearance) {
let brushed = brushed(orig);
if (orig.appearance.effects.is_empty() && !brushed) || !own_style_holds(&orig.appearance) {
return None;
}
let graded = orig.appearance.items.iter().any(|i| matches!(i.paint(), Paint::Gradient(_)));
let saved = (graded.then(|| self.gradient_mark()), self.warnings.len());
let path = drawn_outline(n, orig);
let rule = object_rule(orig);
let text = self.style_text(&orig.appearance, Xy::of(orig), path.as_ref().map(|p| (p, rule))).filter(|t| object_reads(orig, t));
let text = self
.style_text(&orig.appearance, Xy::of(orig), path.as_ref().map(|p| (p, rule)))
.filter(|t| object_reads(orig, t) && (!brushed || self.brushed_style_reads(t)));
let Some(text) = text else {
if let Some(mark) = saved.0 {
self.gradient_rollback(mark);
@@ -693,7 +706,8 @@ impl Writer<'_> {
/// compound path's points painted with nothing, or with its gradient fill when its style has
/// one; a group with its members), its name and opacity mask; then `1 (name) XW`.
/// The look's group and the object itself are counted first ([`Self::take_objects`]): without
/// room for them, nothing is written.
/// room for them, nothing is written, and the object counts neither as written with its graphic
/// style nor as written with a brush on its stroke.
pub(super) fn styled_object(&mut self, n: &Node, orig: &Node, name: &str) {
let own = match &orig.kind {
NodeKind::Path { path, .. } => super::object_count(path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count()),
@@ -704,6 +718,12 @@ impl Writer<'_> {
if !self.take_objects(1 + own) {
return;
}
let linked = orig.extras().graphic_style.and_then(|id| self.styles.panel.get(&id)).is_some_and(|p| p == name);
if linked {
self.styles.links_written += 1;
} else if brushed(orig) {
self.brush_set.live_written += 1;
}
self.state(orig.locked, !orig.visible);
let xy = Xy::of(orig);
let was = self.in_look;
@@ -834,8 +854,12 @@ fn own_style_holds(ap: &Appearance) -> bool {
ap.items.iter().filter(|i| !i.paint().is_none()).all(plain) && !ap.effects.iter().any(panel_only)
}
/// Does the reader read graphic style `g` from style text `text` as it is?
/// Does the reader read graphic style `g` from style text `text` as it is? It leaves out a graphic
/// style with a brush on a stroke that paints.
fn panel_reads(g: &GraphicStyle, text: &str) -> bool {
if g.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.brush.is_some() && !s.paint.is_none())) {
return false;
}
let section = known_style("x", text);
let Some(read) = crate::import::read_art_style(section.as_bytes(), "x", None) else { return false };
// The opacity within the written number's precision.
@@ -918,7 +942,7 @@ fn normalized_params(e: &Effect) -> Value {
const NEAR: f64 = 6e-3;
/// JSON values equal but for numbers within the written numbers' precision.
fn near(a: &Value, b: &Value) -> bool {
pub(super) fn near(a: &Value, b: &Value) -> bool {
match (a, b) {
(Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) {
(Some(x), Some(y)) => (x - y).abs() <= NEAR,
+2 -2
View File
@@ -86,8 +86,8 @@ pub use ai::NameEncoding;
#[cfg(test)]
pub(crate) use ai::objects_read;
pub(crate) use ai::{
MAX_EDITING_OBJECTS, MAX_GRADIENT_STOPS, MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, read_art_style, read_style_list, style_moves_with,
symbol_art_member, warp_number,
BRUSH_MANAGER, BrushesRead, MAX_EDITING_OBJECTS, MAX_GRADIENT_STOPS, MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, brush_plugin_name, brushes_kept,
read_art_style, read_brush_object, read_style_list, style_moves_with, symbol_art_member, warp_number,
};
pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES};
pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_alone_with, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from};
+1
View File
@@ -34,6 +34,7 @@ use std::io::Read as _;
use super::lex::find;
use crate::ps;
pub(crate) use brush::{BrushesRead, MANAGER_NAME as BRUSH_MANAGER, brushes_kept, plugin_name as brush_plugin_name, read_brush_object};
pub(crate) use envelope::DRAWN as ENVELOPE_DRAWN;
#[cfg(test)]
pub(crate) use envelope::MAX_CHECKED;
+124
View File
@@ -563,3 +563,127 @@ pub(super) fn without_frame(art: Node) -> Node {
}
out
}
/// The plug-in name the objects of brushes of kind `kind` have, for writers.
pub(crate) fn plugin_name(kind: &BrushKind) -> &'static str {
let tool = match kind {
BrushKind::Calligraphic(_) => Tool::Calligraphic,
BrushKind::Scatter(_) => Tool::Scatter,
BrushKind::Art(_) => Tool::Art,
BrushKind::Pattern(_) => Tool::Pattern,
BrushKind::Bristle(_) => Tool::Bristle,
};
TOOLS.iter().find(|(_, t)| *t == tool).map_or("", |(n, _)| *n)
}
/// The plug-in name of the brush manager's order, for writers.
pub(crate) const MANAGER_NAME: &str = MANAGER;
/// A brush object as the reader reads it, for writers that check a brush before they write it.
#[derive(Clone, Debug)]
pub(crate) struct BrushRead {
/// The brush the Brushes panel gets.
pub brush: Brush,
/// A path whose art style names the brush, at the brush's own settings, opens with the brush on
/// its stroke: a Calligraphic brush, or an Art brush whose art an Art brush draws as the file
/// does ([`art_bends`]) and whose numbers are those of a brush that stretches its art along the
/// path, unflipped, at 100% in Tints.
pub live: bool,
}
/// The brush named `name` whose object has the plug-in name `plugin` and the parameters `params`,
/// with `art` as the art its parameters name (as the reader reads that art), as the reader reads
/// it: `None` when the reader doesn't make a brush of it.
pub(crate) fn read_brush_object(plugin: &str, name: &str, params: &str, art: Option<&Node>) -> Option<BrushRead> {
let draft = parse_tool_object(tool(plugin)?, name, params).ok()?;
let mut sections = HashMap::new();
if let (DraftKind::Scatter { art: section, .. } | DraftKind::Art { art: section, .. }, Some(a)) = (&draft.kind, art) {
sections.insert(section.clone(), a.clone());
}
let drafts = [draft];
let live = kept(&drafts, &sections).first().copied().unwrap_or(false)
&& match &drafts[0].kind {
DraftKind::Calligraphic(_) => true,
DraftKind::Art { plain: true, art: section, .. } => bending_art(&drafts, &sections).contains(section),
_ => false,
};
let [draft] = drafts;
let (mut brushes, _) = finish_brushes(vec![draft], &sections, None);
Some(BrushRead { brush: brushes.pop()?, live })
}
/// Which of the brush objects `brushes` (plug-in name, name, parameters, the art each names as the
/// reader reads it) the reader makes into brushes when one file holds them all: not one it can't read,
/// nor one past the art the reader copies into one file's brushes ([`kept`]).
pub(crate) fn brushes_kept(brushes: &[(&str, &str, &str, Option<&Node>)]) -> Vec<bool> {
let mut drafts = vec![];
let mut at = vec![];
let mut sections = HashMap::new();
for (i, (plugin, name, params, art)) in brushes.iter().enumerate() {
let Some(d) = tool(plugin).and_then(|t| parse_tool_object(t, name, params).ok()) else { continue };
if let (DraftKind::Scatter { art: s, .. } | DraftKind::Art { art: s, .. }, Some(a)) = (&d.kind, art) {
sections.insert(s.clone(), (*a).clone());
}
drafts.push(d);
at.push(i);
}
let mut out = vec![false; brushes.len()];
for (i, k) in at.into_iter().zip(kept(&drafts, &sections)) {
if let Some(slot) = out.get_mut(i) {
*slot = k;
}
}
out
}
/// Brush objects a writer writes, as the reader reads them together from one file, for checking
/// the art styles that name them ([`Self::style_reads`]).
pub(crate) struct BrushesRead {
drafts: Vec<BrushDraft>,
/// The brush a stroke that names a brush is drawn with, by name: the first one kept
/// ([`kept`]), as an index in [`Self::drafts`].
by_name: HashMap<String, usize>,
/// The art an Art brush draws as the file does ([`bending_art`]).
bending: HashSet<String>,
}
impl BrushesRead {
/// `brushes`: the brush objects written (plug-in name, name, parameters) and the art each names,
/// as the reader reads it.
pub(crate) fn new(brushes: &[(&str, &str, &str, Option<&Node>)]) -> Self {
let mut drafts = vec![];
let mut sections = HashMap::new();
for (plugin, name, params, art) in brushes {
let Some(d) = tool(plugin).and_then(|t| parse_tool_object(t, name, params).ok()) else { continue };
if let (DraftKind::Scatter { art: s, .. } | DraftKind::Art { art: s, .. }, Some(a)) = (&d.kind, art) {
sections.insert(s.clone(), (*a).clone());
}
drafts.push(d);
}
let mut by_name = HashMap::new();
for (i, (d, k)) in drafts.iter().zip(kept(&drafts, &sections)).enumerate() {
if k {
by_name.entry(d.name.clone()).or_insert(i);
}
}
let bending = bending_art(&drafts, &sections);
Self { drafts, by_name, bending }
}
/// Does the reader read every stroke of art style `style` in `section` (the text between
/// `%AI9_BeginArtStyles` and its end) that names a brush with that brush on it, at the brush's
/// own settings? A style the reader doesn't read, or that names a brush no object gives, or an
/// Art brush in a style with effects, isn't read so.
pub(crate) fn style_reads(&self, section: &[u8], style: &str) -> bool {
use super::style::StyleItem;
let styles = super::style::art_styles(section);
let Some(st) = styles.get(style).filter(|st| st.plain) else { return false };
st.items.iter().all(|item| match item {
StyleItem::Stroke { brush: Some((name, settings)), .. } => {
let Some(draft) = self.by_name.get(name).and_then(|i| self.drafts.get(*i)) else { return false };
draws_as(draft, settings, &self.bending) && !(matches!(settings, PathBrush::Art) && !st.effects.is_empty())
}
_ => true,
})
}
}
+20 -3
View File
@@ -1260,8 +1260,11 @@ pub(crate) fn read_style_list(section: &[u8]) -> Vec<String> {
/// Art style `name` of `section` as the reader reads it ([`StyleRead`]): [`appearance_from_art_style`],
/// with a style's one gradient fill as `own_gradient`, the object's own gradient, as [`styled`] reads
/// it on objects other than type. `None` when the section doesn't define it, the reader doesn't read
/// it, or it paints with a pattern, a brush or a gradient that isn't the object's own.
/// it on objects other than type. A stroke that names a brush is read with that brush's name, as
/// [`styled`] reads it on a path or compound path when the Brushes panel keeps that brush at those
/// settings, which [`crate::import::ai::brush::BrushesRead::style_reads`] checks. `None` when the section
/// doesn't define it, the reader doesn't read it, or it paints with a pattern or a gradient that
/// isn't the object's own.
pub(crate) fn read_art_style(section: &[u8], name: &str, own_gradient: Option<&Paint>) -> Option<StyleRead> {
use crate::import::ai::style::StyleItem;
let mut st = crate::import::ai::style::art_styles(section).remove(name)?;
@@ -1273,7 +1276,21 @@ pub(crate) fn read_art_style(section: &[u8], name: &str, own_gradient: Option<&P
}
}
}
let appearance = appearance_from_art_style(&st)?;
// The brush names, by item; `appearance_from_art_style` reads a style's items in order.
let brushes: Vec<Option<String>> = st
.items
.iter_mut()
.map(|item| match item {
StyleItem::Stroke { brush, .. } => brush.take().map(|(name, _)| name),
_ => None,
})
.collect();
let mut appearance = appearance_from_art_style(&st)?;
for (item, brush) in appearance.items.iter_mut().zip(brushes) {
if let (AppearanceItem::Stroke(s), Some(brush)) = (item, brush) {
s.brush = Some(brush);
}
}
Some(StyleRead { appearance, simple: st.simple, pathfinder: st.pathfinder, transparency: st.transparency })
}
+14 -3
View File
@@ -828,6 +828,8 @@ const ANYTHING: Under = Under { strokes: true, patterns: true };
/// The Transform effect `f` (the factor and radian entries; the percent and degree ones are what
/// its dialog shows). `None` keeps the drawn look: an entry not read, Random (the file holds no
/// seed), or a missing Scale Strokes & Effects or Transform Patterns that would change `under`.
/// The keys are LiveEffect XML's
/// (https://community.adobe.com/questions-652/how-to-define-values-in-action-menu-command-816878).
fn transform(f: &Filter, under: Under) -> Option<serde_json::Value> {
for key in ["scaleH_Percent", "scaleV_Percent", "rotate_Degrees"] {
entry(f, key, Kind::Real).ok()?;
@@ -877,6 +879,9 @@ fn transform(f: &Filter, under: Under) -> Option<serde_json::Value> {
/// corners are measured in: the distorted box is laid over the object's own bounds, as the catalog
/// effect lays it, also when the frame is another size (a unit square, or bounds with the stroke).
/// No corners is no change; some but not all, or a frame that isn't an upright rectangle, isn't read.
/// The keys are LiveEffect XML's (https://community.adobe.com/questions-652/pageitem-applyeffect-liveeffectxml-769450),
/// with the corners in that order
/// (https://raw.githubusercontent.com/mark1bean/live-effect-functions-for-illustrator/master/scripts/LE_Functions.js).
fn free_distort(f: &Filter) -> Option<serde_json::Value> {
let mut corners = [[[0.0; 2]; 4]; 2];
let mut held = 0;
@@ -931,8 +936,10 @@ fn points(f: &Filter) -> Option<&'static str> {
Some(if switch(f, "roundness", Kind::Real).ok()?.unwrap_or(false) { "smooth" } else { "corner" })
}
/// The Tweak effect `f` (`Adobe Scribble and Tweak`): relative unless `absoluteness` says otherwise,
/// moving anchors and both handles unless the dictionary turns them off.
/// The Tweak effect `f` (`TWEAK`): relative unless `absoluteness` says otherwise,
/// moving anchors and both handles unless the dictionary turns them off. The keys are LiveEffect
/// XML's: `horz` and `vert` relative, `ahor` and `aver` absolute, and `anch`, `in` and `out`
/// (https://raw.githubusercontent.com/mark1bean/live-effect-functions-for-illustrator/master/scripts/LE_Functions.js).
fn tweak(f: &Filter) -> Option<serde_json::Value> {
let (absolute, h) = sized(f, false, "horz", "ahor")?;
let (_, v) = sized(f, false, "vert", "aver")?;
@@ -942,7 +949,9 @@ fn tweak(f: &Filter) -> Option<serde_json::Value> {
}
/// The Zig Zag effect `f`: absolute unless `absoluteness` says otherwise, 4 ridges and Corner points
/// when they are missing.
/// when they are missing. The keys are LiveEffect XML's: `amount` absolute, `relAmount` relative,
/// `ridges` and `roundness` (https://community.adobe.com/questions-652/pageitem-applyeffect-liveeffectxml-769450,
/// https://raw.githubusercontent.com/mark1bean/live-effect-functions-for-illustrator/master/scripts/LE_Functions.js).
fn zig_zag(f: &Filter) -> Option<serde_json::Value> {
let (absolute, size) = sized(f, true, "relAmount", "amount")?;
let ridges = real(f, "ridges", 4.0, 0.0..=100.0).filter(|r| r.fract() == 0.0)?;
@@ -1203,6 +1212,8 @@ fn effect(f: &Filter, under: Under) -> Option<Effect> {
TRANSFORM => ("distort.transform", transform(f, under)?),
FREE_DISTORT => ("distort.freeDistort", free_distort(f)?),
// Negative puckers (https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html).
// Pucker & Bloat's `d_factor` and Twist's `angle` are LiveEffect XML's keys
// (https://community.adobe.com/questions-652/pageitem-applyeffect-liveeffectxml-769450).
PUCKER_BLOAT => ("distort.puckerBloat", json!({"amount": real(f, "d_factor", 0.0, -200.0..=200.0)?})),
TWEAK => ("distort.tweak", tweak(f).filter(still)?),
// A positive angle turns clockwise in both.
+9 -9
View File
@@ -404,7 +404,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"view.actualSize",
"Actual Size",
"Cmd+1",
"{} 100%: with Display Print Size at 100% Zoom off, one document point per screen point; on, one document inch fills 96 screen points (an inch at the system's reference density, whatever the display scaling)",
"{} 100%: with Display Print Size at 100% Zoom off, one document point per screen point; on, one document inch fills 96 screen points (an inch at the system's reference density, whatever the display scaling); the same at every UI Scaling",
),
("view.setZoom", "Set Zoom", "", "{zoom: percent, center?: [x,y]}"),
(
@@ -1371,25 +1371,25 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.action.rename",
"Rename Action",
"",
"{set: index, action: index, name} rename the Actions panel's action at action in set (0 is the top set) to name, with spaces trimmed from both ends; its steps, function key and color stay. An index past the last set or action, or an empty name, is an error and renames nothing → {name}",
"{set: index, action: index, name} rename the Actions panel's action at action in set (0 is the top set) to name, with spaces trimmed from both ends; its steps, function key and color stay. An index past the last set or action, an empty name, or a name that would give the set's action set file more than 8 MiB (counting the steps the recording in progress adds to it; a name that leaves the file as large or makes it smaller is never refused) is an error and renames nothing → {name}",
),
(
"ui.action.stepsIncluded",
"Include Action Steps",
"",
"{set: index, action?: index, step?: index, included: bool, others?: bool} include or exclude the playback of every step in a set, every step in one action, or one step. With a step and others: true, leave that step unchanged and apply included to every other step in its action (Alt/Option-click). Invalid indexes change nothing → {set, action?, step?, included}",
"{set: index, action?: index, step?: index, included: bool, others?: bool} include or exclude the playback of every step in a set, every step in one action, or one step. With a step and others: true, leave that step unchanged and apply included to every other step in its action (Alt/Option-click). Invalid indexes change nothing. An exclusion that would give the set's action set file more than 8 MiB, counting the steps the recording in progress adds to the set, is an error and changes nothing; a change that leaves the file as large or makes it smaller is never refused → {set, action?, step?, included}",
),
(
"ui.action.new",
"New Action",
"",
"{set: index, name, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} add an action named name (spaces trimmed from both ends) after the last action of the Actions panel's set at set (0 is the top set) and start recording into it; Stop Playing/Recording (ui.action.stop) ends the recording. key 1 to 15 is the function key F1 to F15 that plays the action (0 or null: none), with cmd (Ctrl, Command on macOS) and shift; color is its Button Mode color (null or \"\": none), which the panel does not show. Action function keys take priority over command shortcuts and preserve the command's binding. On Windows F1, Ctrl+F4 and Ctrl+F6 cannot be used. The set becomes current and the new action is selected. The panel's Create New Action button and menu's New Action… (dialog `actionNew` with set, name, key, cmd, shift and color) run this on Record. While an action is recording, and for an index past the last set, an empty name, or a key or color that is not one of these, it is an error and nothing is added → {set, action}",
"{set: index, name, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} add an action named name (spaces trimmed from both ends) after the last action of the Actions panel's set at set (0 is the top set) and start recording into it; Stop Playing/Recording (ui.action.stop) ends the recording. key 1 to 15 is the function key F1 to F15 that plays the action (0 or null: none), with cmd (Ctrl, Command on macOS) and shift; color is its Button Mode color (null or \"\": none), which the panel does not show. Action function keys take priority over command shortcuts and preserve the command's binding. On Windows F1, Ctrl+F4 and Ctrl+F6 cannot be used. The set becomes current and the new action is selected. The panel's Create New Action button and menu's New Action… (dialog `actionNew` with set, name, key, cmd, shift and color) run this on Record. While an action is recording, and for an index past the last set, an empty name, a key or color that is not one of these, or an action that would give the set more than 1,000 actions or an action set file larger than 8 MiB, it is an error, nothing is added and no recording starts → {set, action}",
),
(
"ui.action.options",
"Action Options",
"",
"{set: index, action: index, name?, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} change the Actions panel's action at action in set (0 is the top set); its steps stay. name renames it (spaces trimmed from both ends), key 1 to 15 with cmd (Ctrl, Command on macOS) and shift sets the function key F1 to F15 that plays it (0 or null: none), and color sets its Button Mode color (null or \"\": none); a param left out keeps what the action has, and cmd or shift alone changes that modifier of its key. Action function keys take priority over command shortcuts and preserve the command's binding. The panel menu's Action Options… (dialog `actionSetOptions` with set, action, name, key, cmd, shift and color) runs this for the selected action. An index past the last set or action, an empty name, or a key or color that is not one of these is an error and changes nothing → {name, key, color}",
"{set: index, action: index, name?, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} change the Actions panel's action at action in set (0 is the top set); its steps stay. name renames it (spaces trimmed from both ends), key 1 to 15 with cmd (Ctrl, Command on macOS) and shift sets the function key F1 to F15 that plays it (0 or null: none), and color sets its Button Mode color (null or \"\": none); a param left out keeps what the action has, and cmd or shift alone changes that modifier of its key. Action function keys take priority over command shortcuts and preserve the command's binding. The panel menu's Action Options… (dialog `actionSetOptions` with set, action, name, key, cmd, shift and color) runs this for the selected action. An index past the last set or action, an empty name, a key or color that is not one of these, or a change that would give the set's action set file more than 8 MiB (counting the steps the recording in progress adds to it; a change that leaves the file as large or makes it smaller is never refused) is an error and changes nothing → {name, key, color}",
),
(
"ui.action.play",
@@ -1407,13 +1407,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.action.record",
"Begin Recording",
"",
"{set?: index} begin recording an automatically named Action N in the Actions panel's set at set (0 is the top set), by default the current set or the first set; with no sets, make Set 1. The set becomes current, and ui.action.stop adds the recorded action after that set's existing actions. An explicit invalid set or an existing recording is an error and changes nothing → {set, name}",
"{set?: index} begin recording an automatically named Action N in the Actions panel's set at set (0 is the top set), by default the current set or the first set; with no sets, make Set 1. The set becomes current, and ui.action.stop adds the recorded action after that set's existing actions. An explicit invalid set, an existing recording, or a set where the new action would give it more than 1,000 actions or an action set file larger than 8 MiB is an error and changes nothing. While an action records, a recorded command whose step would give the action more than 10,000 steps or its set's action set file more than 8 MiB is an error and does not run, and the recording goes on. A recorded command whose step passes a limit only as it runs, from values it adds to its step, keeps its edit and result; the status bar names the limit, later recorded commands are an error and do not run, and ui.action.stop leaves its step out → {set, name}",
),
(
"ui.action.stop",
"Stop Playing/Recording",
"",
"{} end the Actions panel's recording and keep its steps, in the action ui.action.new made for it, else in a new action after the last action of its set, as the panel's Stop Playing/Recording button does. It also ends an action playing step by step or with pauses, or one waiting at a stop, whose message box closes: the steps played stay and the rest do not run. With nothing recording or playing it changes nothing → {stopped}",
"{} end the Actions panel's recording and keep its steps, in the action ui.action.new made for it, else in a new action after the last action of its set, as the panel's Stop Playing/Recording button does. It also ends an action playing step by step or with pauses, or one waiting at a stop, whose message box closes: the steps played stay and the rest do not run. Recorded steps still past 10,000 in the action, or past 8 MiB in its set's action set file, are left out, and the status bar names the limit. With nothing recording or playing it changes nothing → {stopped}",
),
(
"ui.action.insertStop",
@@ -1455,7 +1455,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.actionSet.load",
"Load Actions…",
"",
"{path? | data?: the file's text} read an action set file (.vcactions) at path, else a picked one, and add its set after the Actions panel's sets; a set named like one in the panel gets the next free number (Print 2), and the other sets stay. Files of another version, over 8 MiB, with more than 1,000 actions or with an action of more than 10,000 steps are refused; the panel menu's Load Actions… (disabled where there is no open dialog, the web) → {set: index, name, actions: n}. An action set another app saved as text (.aia, starting /version; read with actionSet.read) comes in with its actions. A step with a label and no recorded values comes in when exactly one menu command has that label (in English or the interface language, any case, no final …), and that command is directly in a top menu, can be recorded in actions, is what its menu bar item runs with no values and opens no dialog (never a submenu's item, a file or document command, Paste, Undo, Redo or an add-on's command), with no params and excluded from playback when it is off in the file; the other steps, every step with no label and every step recorded with values (a recorded menu item too) among them, are left out → {set, name, actions, skipped: [{action, step: its label, else its name}] (the first 1,000), leftOut: n}, and the status bar says how many were left out; a set with no name takes the file's name, else Actions; a file actionSet.read marks truncated is refused, and text that starts /version and isn't an action set gives \"not an action set file\"",
"{path? | data?: the file's text} read an action set file (.vcactions) at path, else a picked one, and add its set after the Actions panel's sets; a set named like one in the panel gets the next free number (Print 2), and the other sets stay. Files of another version, over 8 MiB, with more than 1,000 actions or with an action of more than 10,000 steps are refused, and so is any set, .aia ones too, that Save Actions… would write larger than 8 MiB (a file with less white space, or a name that gets a number); the panel menu's Load Actions… (disabled where there is no open dialog, the web) → {set: index, name, actions: n}. An action set another app saved as text (.aia, starting /version; read with actionSet.read) comes in with its actions. A step with a label and no recorded values comes in when exactly one menu command has that label (in English or the interface language, any case, no final …), and that command is directly in a top menu, can be recorded in actions, is what its menu bar item runs with no values and opens no dialog (never a submenu's item, a file or document command, Paste, Undo, Redo or an add-on's command), with no params and excluded from playback when it is off in the file; the other steps, every step with no label and every step recorded with values (a recorded menu item too) among them, are left out → {set, name, actions, skipped: [{action, step: its label, else its name}] (the first 1,000), leftOut: n}, and the status bar says how many were left out; a set with no name takes the file's name, else Actions; a file actionSet.read marks truncated is refused, and text that starts /version and isn't an action set gives \"not an action set file\"",
),
(
"ui.actionSet.delete",
@@ -1467,7 +1467,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.actionSet.duplicate",
"Duplicate Action or Action Set",
"",
"{set?: index, action?: index} copy the Actions panel's action at action in set (0 is the top set) to right after it in the same set; without action, copy set with all its actions to right after it (default: the current set). The copy is named after the original with \" copy\" added, a copied set gets an id of its own, and the copy is selected. The panel menu's Duplicate runs this for the selected action, else the current set. An index past the last set or action is an error → {set, action?}",
"{set?: index, action?: index} copy the Actions panel's action at action in set (0 is the top set) to right after it in the same set; without action, copy set with all its actions to right after it (default: the current set). The copy is named after the original with \" copy\" added, a copied set gets an id of its own, and the copy is selected. The panel menu's Duplicate runs this for the selected action, else the current set. An index past the last set or action, or a copy that would give its set more than 1,000 actions or an action set file larger than 8 MiB, is an error and copies nothing → {set, action?}",
),
("file.showPackage", "Show Package", "", "{folder} show a package folder (file.package's folder) in the file manager → {folder}"),
(
+1 -1
View File
@@ -146,7 +146,7 @@ fn choose(contours: &[Contour], spec: BridgeSpec, work: usize) -> Result<(Vec<Br
}
let mut picker = Picker::new(contours, &views, spec.width);
picker.connect();
if views.iter().any(View::exhausted) {
if views.iter().any(View::exhausted) && picker.unjoined() > 0 {
return Err(PathOpsError::TooComplex);
}
picker.spread(spec.count);
@@ -210,6 +210,37 @@ fn bridges_found_before_the_budget_runs_out_are_kept() {
assert!(matches!(choose(&found, spec(4.0, 1, BridgeDirection::Vertical), least - 1), Err(PathOpsError::TooComplex)));
}
#[test]
fn joined_art_is_kept_when_lines_in_one_direction_run_out() {
// A ring whose thin left bar has edges of many short zigzag segments: lines across it in the
// horizontal direction look at many more segments than vertical lines across the top bar.
let zigzag = |x: f64, from: f64, to: f64, out: &mut Vec<Point>| {
let n = ((to - from).abs() / 0.05) as usize;
out.extend((1..n).map(|i| Point::new(x + if i % 2 == 1 { 0.02 } else { 0.0 }, from + (to - from) * i as f64 / n as f64)));
};
let mut outer = vec![Point::new(0.0, 0.0), Point::new(200.0, 0.0), Point::new(200.0, 100.0), Point::new(0.0, 100.0)];
zigzag(0.0, 100.0, 0.0, &mut outer);
let mut hole = vec![Point::new(10.0, 20.0), Point::new(170.0, 20.0), Point::new(170.0, 80.0), Point::new(10.0, 80.0)];
zigzag(9.98, 80.0, 20.0, &mut hole);
let art = PathData::new(vec![SubPath::polyline(&outer, true), SubPath::polyline(&hole, true)]);
let found = contours(&art, 4.0).unwrap();
let shortest = spec(4.0, 1, BridgeDirection::Shortest);
let views = (0..180).step_by(SHORTEST_STEP_DEG).count();
// A share of the work that runs out in the horizontal direction while another direction joins
// the island.
let share = (1..5000)
.find(|&budget| {
let all: Vec<View> =
(0..180).step_by(SHORTEST_STEP_DEG).map(|d| View::new(&found, (d as f64).to_radians(), 4.0, budget).unwrap()).collect();
let mut picker = Picker::new(&found, &all, 4.0);
picker.connect();
all.iter().any(View::exhausted) && picker.unjoined() == 0
})
.expect("a share of the work that runs out with the island joined");
let (bridges, unjoined) = choose(&found, shortest, share * views).unwrap();
assert_eq!((bridges.len(), unjoined), (1, 0));
}
#[test]
fn curves_are_followed_within_a_twentieth_of_the_width() {
let circle = PathData::from_bezpath(&kurbo::Circle::new((0.0, 0.0), 100.0).to_path(0.001));
+3 -2
View File
@@ -35,6 +35,7 @@ mod builder;
mod cid;
mod device;
mod entry;
mod reshaped;
mod state;
#[cfg(test)]
@@ -42,8 +43,8 @@ pub(crate) use cid::{CidText, font_chars, identify_glyph, unify_radical};
#[cfg(any(test, feature = "read-count"))]
pub use entry::imports_of;
pub use entry::{
INVISIBLE_TEXT_NOTE, OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, holds_outlines,
import, import_opened, import_with_report, is_glyph_outlines, is_outlines_note,
INVISIBLE_TEXT_NOTE, OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, RESHAPED_TEXT_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE,
holds_outlines, import, import_opened, import_with_report, is_glyph_outlines, is_outlines_note,
};
#[cfg(test)]
pub(crate) use entry::{TEST_MAX_OBJECTS, import_with_reader_options, read};
+80 -9
View File
@@ -1,9 +1,10 @@
use super::cid::{CidText, FontInfo, comparable, font_chars, identify_glyph, matching_face, replacement, unify_radical};
use super::entry::{
MAX_NESTED, OVERPRINT_UNREAD, OVERPRINT_ZERO_MODE, PATTERN_REUSE, TEXT_OUTLINES, TOO_MANY_OBJECTS, UNMATCHED_GLYPHS_NOTE,
MAX_NESTED, OVERPRINT_UNREAD, OVERPRINT_ZERO_MODE, PATTERN_REUSE, RESHAPED_TEXT_NOTE, TEXT_OUTLINES, TOO_MANY_OBJECTS, UNMATCHED_GLYPHS_NOTE,
UNNAMED_CHARACTERS_NOTE, max_objects,
};
use super::state::{Builder, Frame, FrameKind, Saved, bounds, grouped, line_width, mean_scale, paints, round3, stroke_layer};
use super::reshaped;
use super::state::{Builder, Frame, FrameKind, GlyphRun, Saved, bounds, grouped, line_width, mean_scale, paints, round3, stroke_layer};
use super::{
Affine, Appearance, AppearanceItem, Arc, BezPath, BfString, BlendMode, CacheKey, Color, Colors, Families, FillLayer, FillRule, FunctionBudget,
FunctionPaint, HashMap, HashSet, ImageBlob, ImageObject, Knockout, Look, MaskSpec, MaskType, Native, Node, NodeId, NodeKind, OpacityMask, Paint,
@@ -22,6 +23,7 @@ impl<'p> Builder<'p> {
page: Rect::ZERO,
glyphs: None,
text: None,
text_glyphs: vec![],
text_as,
actual: Default::default(),
images: HashMap::new(),
@@ -101,6 +103,7 @@ impl<'p> Builder<'p> {
self.blend = BlendMode::Normal;
self.glyphs = None;
self.text = None;
self.text_glyphs.clear();
self.mask = None;
self.masked.clear();
self.marked.clear();
@@ -376,18 +379,56 @@ impl<'p> Builder<'p> {
}
pub(super) fn flush_text(&mut self) {
let Some(mut line) = self.text.take() else { return };
if self.end_text() {
self.flush_glyphs();
}
}
/// Finish the open line of type. `true`: it was kept as outlines instead ([`reshaped`]), which
/// are left open as the glyph run for an outline drawn next to join.
pub(super) fn end_text(&mut self) -> bool {
let glyphs = std::mem::take(&mut self.text_glyphs);
let Some(mut line) = self.text.take() else { return false };
let held = std::mem::take(&mut line.held);
// Type the file names itself (ActualText) is in reading order as it stands.
let checked = !glyphs.is_empty() && line.mapped();
let mut outlined = false;
if let Some((t, opacity, facts)) = line.finish() {
let mut n = Node::new(self.id(), NodeKind::Text(Box::new(t)));
n.opacity = opacity;
self.lines.insert(n.id, facts);
self.push_node(n);
if checked && reshaped::reshapes(&t.plain_text()) && !reshaped::draws_as(&t, &facts.ink) {
outlined = true;
for g in glyphs {
let same = self.glyphs.as_ref().is_some_and(|r| {
r.paint == g.paint
&& r.opacity == g.opacity
&& r.overprint == g.overprint
&& r.stroke.is_some() == g.stroke.is_some()
&& r.stroke.as_ref().map(|s| s.line_width) == g.stroke.as_ref().map(|s| s.line_width)
&& (r.scale - g.scale).abs() < 1e-9
});
match &mut self.glyphs {
Some(r) if same => r.path.extend(g.path.iter()),
_ => {
self.flush_glyphs();
self.glyphs = Some(g);
}
}
}
self.warn(RESHAPED_TEXT_NOTE);
} else {
let mut n = Node::new(self.id(), NodeKind::Text(Box::new(t)));
n.opacity = opacity;
self.lines.insert(n.id, facts);
self.push_node(n);
}
}
if outlined && !held.is_empty() {
self.flush_glyphs();
}
// The strokes of its glyphs' outlines, right after it ([`import_lines`] makes them its stroke).
for n in held {
self.push_node(n);
}
outlined
}
/// A stroke of the outline of the glyph just drawn, in the glyph's own paint, while every glyph
@@ -737,6 +778,27 @@ impl<'p> Builder<'p> {
n
}
/// Glyph `o` of the open line of type, placed by `m` and drawn with `drawn` (its paint, and its
/// stroke when it is stroked), as the outline it is kept as if the line is
/// ([`Self::end_text`]). Nothing for a glyph that shows nothing.
fn keep_glyph(
&mut self,
o: &craft_pdf::interpret::font::OutlineGlyph,
m: Affine,
drawn: Option<(Paint, Option<StrokeProps>)>,
opacity: f32,
scale: f64,
overprint: bool,
) {
let Some((paint, stroke)) = drawn else { return };
let mut path = o.outline();
if path.elements().is_empty() {
return;
}
path.apply_affine(m);
self.text_glyphs.push(GlyphRun { path, paint, opacity, stroke, scale, overprint });
}
/// Glyph `o` as type: added to the line being gathered (or starting one). `false`: it keeps
/// its outline (no Unicode text, mirrored, or not type mode). No `paint`: invisible text,
/// type with neither fill nor stroke.
@@ -794,8 +856,11 @@ impl<'p> Builder<'p> {
return false;
}
let width = o.advance_width().filter(|w| w.is_finite());
// Set vertically (WMode 1): no horizontal advance.
let upright = width.is_some_and(|w| w.abs() <= 1.0);
// Set vertically (WMode 1): no horizontal advance. Not in a font the file says is written
// horizontally: there a glyph without an advance is a mark over the letter before it (a
// vowel sign, an anusvara), which stays on its line (#5235).
let horizontal = self.fonts.get(&key).is_some_and(|f| f.vertical.is_none());
let upright = !horizontal && width.is_some_and(|w| w.abs() <= 1.0);
let mut info = self.font_name(key, o);
// A font the file doesn't carry is drawn with a stand-in: its glyphs can't tell which
// style of the installed family it is, so the name decides.
@@ -841,6 +906,8 @@ impl<'p> Builder<'p> {
}
None => (None, 1.0),
};
// What the glyph is drawn with as an outline, should its line be kept so.
let drawn = paint.clone().map(|p| (p, stroke.cloned()));
let stroke = stroke.zip(paint.clone()).map(|(p, paint)| (paint, line_width(p, scale)));
let look = Look {
font: key,
@@ -879,6 +946,7 @@ impl<'p> Builder<'p> {
&& look.fill.is_none()
&& line.stroke_last(key, at, s.clone(), overprint)
{
self.keep_glyph(o, m, drawn, opacity, scale, overprint);
return true;
}
if !self.text.as_mut().is_some_and(place) {
@@ -889,6 +957,9 @@ impl<'p> Builder<'p> {
}
// Its ink: a stroke the file draws as outlines of the glyphs is told by it.
let mut ink = o.outline();
if top.is_none() {
self.keep_glyph(o, m, drawn, opacity, scale, overprint);
}
if let Some(line) = &mut self.text
&& !ink.elements().is_empty()
{
+2 -1
View File
@@ -191,7 +191,8 @@ impl<'a> Device<'a> for Builder<'_> {
if self.type_glyph(o, transform * glyph_transform, scale, Some(paint), stroke.as_ref(), overprint) {
return;
}
self.flush_text();
// A line kept as outlines leaves them open: this glyph joins them.
self.end_text();
let mut bp = o.outline();
bp.apply_affine(transform * glyph_transform);
let (paint, opacity) = self.paint(paint, stroke.is_some(), self.walked(true));
+10 -2
View File
@@ -171,14 +171,22 @@ pub const INVISIBLE_TEXT_NOTE: &str = "invisible text (such as a scanned page's
/// Warning: glyphs that differ from the installed font of their name were kept as outlines.
pub const UNMATCHED_GLYPHS_NOTE: &str = "text whose glyphs differ from the installed font of the same name was kept as outlines";
/// Warning: type in a script whose letters reorder and join (Indic scripts) that, set again from
/// its characters, wouldn't draw the glyphs where the file drew them was kept as outlines (#5235).
pub const RESHAPED_TEXT_NOTE: &str =
"text that would not look as it does in the file if set as type (a script whose letters join) was kept as outlines";
/// The end of the warning that glyphs whose characters the file doesn't name were kept as
/// outlines, after "text in" and the font's name.
pub const UNNAMED_CHARACTERS_NOTE: &str = "whose characters the file doesn't name (U+FFFD) was kept as outlines";
/// Is `note` one of the warnings that glyphs were kept as outlines ([`OUTLINED_TEXT_NOTE`],
/// [`UNMATCHED_GLYPHS_NOTE`], [`UNNAMED_CHARACTERS_NOTE`])?
/// [`UNMATCHED_GLYPHS_NOTE`], [`RESHAPED_TEXT_NOTE`], [`UNNAMED_CHARACTERS_NOTE`])?
pub fn is_outlines_note(note: &str) -> bool {
note == OUTLINED_TEXT_NOTE || note == UNMATCHED_GLYPHS_NOTE || note.starts_with("text in ") && note.ends_with(UNNAMED_CHARACTERS_NOTE)
note == OUTLINED_TEXT_NOTE
|| note == UNMATCHED_GLYPHS_NOTE
|| note == RESHAPED_TEXT_NOTE
|| note.starts_with("text in ") && note.ends_with(UNNAMED_CHARACTERS_NOTE)
}
/// Is `n` glyphs kept as outlines (a path the import names `<Text Outlines>`)?
+53
View File
@@ -0,0 +1,53 @@
//! Type in a script whose letters reorder and join as they are set (#5235). A PDF draws such text
//! as glyphs already shaped: a vowel sign before the consonant it follows in reading order, a
//! conjunct as one glyph that the file may not name, a mark placed over its base. Read glyph by
//! glyph, the characters don't always make the text that was set, and type made from them draws
//! other glyphs in other places. A line of such type is therefore set again and compared with what
//! the file drew; one that would look different keeps the file's glyphs as outlines.
use kurbo::{Rect, Shape};
use vectorcraft_doc::TextObject;
/// Does `text` have a character of a script whose letters reorder and join into conjuncts: the
/// Brahmic scripts of U+0900 to U+0DFF (Devanagari, Bengali, Gurmukhi, Gujarati, Oriya, Tamil,
/// Telugu, Kannada, Malayalam, Sinhala)?
pub(super) fn reshapes(text: &str) -> bool {
text.chars().any(|c| ('\u{0900}'..='\u{0DFF}').contains(&c))
}
/// Would type `t` draw its glyphs where the file drew them (`ink`: each glyph's ink box in
/// document space)? As many glyphs with ink, each within 3 % of the type's size of one of the
/// file's.
pub(super) fn draws_as(t: &TextObject, ink: &[Rect]) -> bool {
let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let mut set: Vec<Rect> =
laid.glyphs.iter().filter(|g| !g.outline.elements().is_empty()).map(|g| (t.xf * g.outline.clone()).bounding_box()).collect();
if set.len() != ink.len() {
return false;
}
let size = t.runs.iter().map(|r| r.style.size).fold(0.0, f64::max);
let tol = size * 0.03;
if !tol.is_finite() || set.iter().chain(ink).any(|r| !(r.x0.is_finite() && r.y0.is_finite() && r.x1.is_finite() && r.y1.is_finite())) {
return false;
}
set.sort_by(|a, b| a.x0.total_cmp(&b.x0));
let mut used = vec![false; set.len()];
// Each of the file's glyphs takes one the type draws in its place: looked for among those
// whose left edge is as near.
ink.iter().all(|r| {
let from = set.partition_point(|s| s.x0 < r.x0 - tol);
let found = set
.iter()
.zip(used.iter_mut())
.skip(from)
.take_while(|(s, _)| s.x0 <= r.x0 + tol)
.find(|(s, used)| !**used && (s.y0 - r.y0).abs() <= tol && (s.x1 - r.x1).abs() <= tol && (s.y1 - r.y1).abs() <= tol);
match found {
Some((_, used)) => {
*used = true;
true
}
None => false,
}
})
}
+4
View File
@@ -81,6 +81,10 @@ pub(super) struct Builder<'p> {
pub(super) page: Rect,
pub(super) glyphs: Option<GlyphRun>,
pub(super) text: Option<TextLine>,
/// The glyphs of the open line of type (`text`) that show, each as the outline it would be
/// kept as: what the line becomes when its type wouldn't draw as the file does
/// ([`super::reshaped`]).
pub(super) text_glyphs: Vec<GlyphRun>,
pub(super) text_as: TextAs,
pub(super) actual: super::actual_text::ActualText,
pub(super) images: HashMap<String, ImageBlob>,
+6
View File
@@ -269,6 +269,12 @@ impl TextLine {
}
}
/// Horizontal type whose characters are all read glyph by glyph from the font's mappings (none
/// from the file's own ActualText).
pub fn mapped(&self) -> bool {
self.upright.is_none() && !self.column && !self.authored.contains(&true)
}
/// The paint the last glyph is filled with.
pub fn last_fill(&self) -> Option<&Paint> {
self.runs.last()?.0.fill.as_ref()
+2 -2
View File
@@ -80,8 +80,8 @@ pub use file_info::{FileInfo, file_info};
#[cfg(any(test, feature = "read-count"))]
pub use import::imports_of;
pub use import::{
INVISIBLE_TEXT_NOTE, OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, holds_outlines,
import, import_opened, import_with_report, is_glyph_outlines, is_outlines_note,
INVISIBLE_TEXT_NOTE, OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, RESHAPED_TEXT_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE,
holds_outlines, import, import_opened, import_with_report, is_glyph_outlines, is_outlines_note,
};
#[cfg(any(test, feature = "read-count"))]
pub use pages::reads_of;
+1
View File
@@ -44,6 +44,7 @@ mod printadvanced;
mod reader_compat;
mod reader_streaming;
mod realtext;
mod reshaped;
mod settings;
mod spread;
pub(super) mod stroke;
+119
View File
@@ -0,0 +1,119 @@
//! #5235: type in a script whose letters reorder as they are set (here Devanagari) opens as type
//! only when that type draws what the file drew; otherwise the file's glyphs stay, as outlines.
//! The file is written from scratch from the PDF specification (ISO 32000-1 §9.7, composite
//! fonts), its font program a test font built in code over Source Sans 3
//! ([`vectorcraft_text::test_fonts::matra_font`]): Ka draws Source Sans 3's `a` and the vowel sign
//! I, set before its consonant, draws its `i`.
use vectorcraft_doc::{Document, NodeKind};
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with};
use vectorcraft_text::FontDb;
use vectorcraft_text::test_fonts::{MATRA_FAMILY, matra_font};
use crate::tests_import_fidelity::stream;
use crate::*;
const KA: char = 'क';
const SIGN_I: char = '\u{093F}';
/// The test font's glyph of `c` (Source Sans 3's glyph of the Latin letter that draws it) and its
/// advance in thousandths of an em.
fn glyph(c: char) -> (u32, f64) {
let face = FontDb::global().face(MATRA_FAMILY, "Regular").expect("the test font is added");
let g = face.glyph_for(c);
(g, (face.advance(g) * 1000.0 / face.units_per_em()).round())
}
/// A 300 × 200 pt page showing the glyphs of `drawn`, in that order, in the test font at 40 pt
/// from (20, 100): a Type 0 font (Identity-H, two-byte glyph ids) with the font program embedded
/// and a ToUnicode map naming each glyph's character. `sign_width`: the advance the file gives
/// the vowel sign, in thousandths of an em (`None`: the font's).
fn page(drawn: &[char], sign_width: Option<f64>) -> Vec<u8> {
FontDb::global().add_font(matra_font().expect("the test font builds"));
let program = matra_font().unwrap();
let hex: String = program.iter().map(|b| format!("{b:02X}")).collect::<String>() + ">";
let n = first_extra(1);
let rows: Vec<String> = [KA, SIGN_I].iter().map(|&c| format!("<{:04X}> <{:04X}>", glyph(c).0, c as u32)).collect();
let width = |c| sign_width.filter(|_| c == SIGN_I).unwrap_or(glyph(c).1);
let widths: Vec<String> = [KA, SIGN_I].iter().map(|&c| format!("{} [{}]", glyph(c).0, width(c))).collect();
let objs = [
stream(&format!("/Length1 {} /Filter /ASCIIHexDecode", program.len()), &hex),
format!(
"<< /Type /FontDescriptor /FontName /MatratestSans-Regular /Flags 4 /FontBBox [-500 -300 1500 1000] /ItalicAngle 0 /Ascent 1024 /Descent -400 /CapHeight 660 /StemV 80 /FontFile2 {n} 0 R >>"
),
format!(
"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /MatratestSans-Regular /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor {} 0 R /CIDToGIDMap /Identity /DW 1000 /W [{}] >>",
n + 1,
widths.join(" ")
),
stream(
"",
&format!(
"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def /CMapName /Test-UCS def /CMapType 2 def 1 begincodespacerange <0000> <FFFF> endcodespacerange {} beginbfchar\n{}\nendbfchar endcmap CMapName currentdict /CMap defineresource pop end end",
rows.len(),
rows.join("\n")
),
),
];
let refs: Vec<&str> = objs.iter().map(String::as_str).collect();
let resources = format!(
"/Font << /F1 << /Type /Font /Subtype /Type0 /BaseFont /MatratestSans-Regular /Encoding /Identity-H /DescendantFonts [{} 0 R] /ToUnicode {} 0 R >> >>",
n + 2,
n + 3
);
let shown: String = drawn.iter().map(|&c| format!("{:04X}", glyph(c).0)).collect();
let content = format!("BT /F1 40 Tf 20 100 Td <{shown}> Tj ET");
pdf_with(&[PdfPage { resources, ..PdfPage::new(300.0, 200.0, &content) }], &refs, None)
}
fn open(bytes: &[u8]) -> ImportReport {
import_with_report(bytes, &ImportOptions { text_as: TextAs::Text, ..Default::default() }).unwrap()
}
fn texts(d: &Document) -> Vec<String> {
let mut v = vec![];
d.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
v.push(t.plain_text());
}
});
v
}
fn outlines(d: &Document) -> usize {
let mut n = 0;
d.walk(|node| n += usize::from(is_glyph_outlines(node)));
n
}
/// The file draws ककि as it is set: Ka, then the vowel sign before the Ka it follows in reading
/// order. Read glyph by glyph that is Ka, the sign, Ka, which reads किक: set as type, the sign
/// would move in front of the first Ka. The file's three glyphs stay as they are drawn.
#[test]
fn a_syllable_drawn_out_of_reading_order_keeps_the_files_glyphs() {
let report = open(&page(&[KA, SIGN_I, KA], None));
assert_eq!(texts(&report.document), [""; 0], "no type that draws something else");
assert_eq!(outlines(&report.document), 1, "the glyphs, as one path");
assert!(report.warnings.iter().any(|w| w == RESHAPED_TEXT_NOTE), "{:?}", report.warnings);
assert!(report.warnings.iter().all(|w| !is_outlines_note(w) || w == RESHAPED_TEXT_NOTE), "{:?}", report.warnings);
}
/// Letters the file draws in reading order make type that draws the same: they stay type.
#[test]
fn letters_drawn_in_reading_order_stay_type() {
let report = open(&page(&[KA, KA], None));
assert_eq!(texts(&report.document), [format!("{KA}{KA}")]);
assert_eq!(outlines(&report.document), 0);
assert!(!report.warnings.iter().any(|w| is_outlines_note(w)), "{:?}", report.warnings);
}
/// A mark the file gives no advance (as fonts do for the signs they set over a letter) belongs to
/// the line of its letter in a font written horizontally: it is not a glyph of vertical type, set
/// as an object of its own. Here the line it stays on then reads कि, whose sign type would set
/// before the Ka, so the line keeps the file's glyphs, the mark with them.
#[test]
fn a_mark_without_an_advance_stays_with_its_line() {
let report = open(&page(&[KA, SIGN_I], Some(0.0)));
assert_eq!(texts(&report.document), [""; 0], "the mark is no type of its own");
assert_eq!(outlines(&report.document), 1, "both glyphs, as one path");
}
+23 -12
View File
@@ -251,19 +251,30 @@ fn kept_tiles_stay_within_the_budget() {
#[test]
fn kept_tiles_count_the_images_they_could_not_decode() {
use vectorcraft_doc::{ImageBlob, ImageObject};
let mut d = doc_with_pattern(TileType::Grid);
// A PNG signature with nothing after it.
d.images.insert("cut".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec()));
let im = ImageObject { key: "cut".into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
d.patterns[0].art = vec![Arc::new(Node::new(NodeId(901), NodeKind::Image(Box::new(im))))];
let mut r = Renderer::new();
let start = tiles_rasterized();
for n in 0..3 {
let before = crate::paint::decode_failures();
frame(&mut r, &d, 1.0);
assert!(crate::paint::decode_failures() > before, "frame {n}");
// A tile of art with an image is drawn again when the fonts change, and other tests add
// fonts, so the frames come from a run in which the fonts stayed the same. Each run draws a
// new document, whose first frame finds no tile kept from an earlier run.
let _fonts = crate::tests_fontchange::fonts_steady();
let (failures, rasterized) = crate::tests_fontchange::with_fonts_unchanged(|| {
let mut d = doc_with_pattern(TileType::Grid);
// A PNG signature with nothing after it.
d.images.insert("cut".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec()));
let im =
ImageObject { key: "cut".into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
d.patterns[0].art = vec![Arc::new(Node::new(NodeId(901), NodeKind::Image(Box::new(im))))];
let mut r = Renderer::new();
let start = tiles_rasterized();
let failures = [0, 1, 2].map(|_| {
let before = crate::paint::decode_failures();
frame(&mut r, &d, 1.0);
crate::paint::decode_failures() > before
});
(failures, tiles_rasterized() - start)
});
for (n, failed) in failures.into_iter().enumerate() {
assert!(failed, "frame {n}");
}
assert_eq!(tiles_rasterized() - start, 1, "the later frames draw from the tile kept");
assert_eq!(rasterized, 1, "the later frames draw from the tile kept");
}
#[test]
+9 -2
View File
@@ -177,11 +177,18 @@ pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props {
.iter()
.map(|t| match t.strip_prefix('-') {
Some(off) => (off, 0),
None => (t.as_str(), 1),
// `tag=N` picks the font's alternate N: CSS takes the number as the feature's value.
None => {
let on = t.strip_prefix('+').unwrap_or(t);
match on.split_once('=').map(|(tag, n)| (tag, n.parse::<u32>())) {
Some((tag, Ok(n))) => (tag, n),
_ => (on, 1),
}
}
})
// A feature tag is four letters or digits: anything else could end the CSS string.
.filter(|(tag, _)| tag.len() == 4 && tag.bytes().all(|b| b.is_ascii_alphanumeric()))
.map(|(tag, on)| format!("\"{tag}\" {on}"))
.map(|(tag, value)| format!("\"{tag}\" {value}"))
.collect();
// Proportional Metrics: `palt` (SVG writes vertical type as outlines).
if st.proportional_metrics {
+13
View File
@@ -177,6 +177,19 @@ fn proportional_metrics_write_palt() {
assert!(tags[2].contains("dlig") && tags[2].contains("palt"), "{}", tags[2]);
}
/// A feature with a value (`salt=2`) is written as that value, `-tag` as 0, a plain tag as 1; a
/// tag whose value is not a number is left out.
#[test]
fn feature_values_write_their_number() {
let valued = CharStyle { features: vec!["salt=2".into(), "-liga".into(), "ss01".into(), "swsh=12".into(), "salt=x".into()], ..style(12.0) };
let d = area_doc(vec![run("ab ", style(12.0)), run("cd", valued)], 280.0, Justify::Left);
let svg = export(&d, &ExportOptions::default());
let tags = tspans(&svg);
assert_eq!(tags.len(), 2, "{svg}");
assert!(tags[1].contains("font-feature-settings:&quot;salt&quot; 2, &quot;liga&quot; 0, &quot;ss01&quot; 1, &quot;swsh&quot; 12"), "{}", tags[1]);
assert!(!tags[1].contains("salt=x") && !tags[0].contains("salt"), "{svg}");
}
/// Character Direction: live text writes each run set Left to Right as a bidi embedding
/// (`direction` and `unicode-bidi`). Type with a run set Right to Left is written as outlines.
#[test]
+147
View File
@@ -0,0 +1,147 @@
//! The alternate glyphs of one character as it stands in its text: the glyph the shaper makes for
//! it in its word (a medial letter's medial form, not its isolated one), the alternates the font
//! gives that glyph, and for each the OpenType feature value that draws it on that character alone
//! (`salt=2`, `ss03`). Applying that feature to the character's range swaps the glyph in place
//! while the text stays real Unicode and the word is shaped as one piece, so the letters still
//! join. Adobe's Glyphs panel does the same for its Show › Alternates for Current Selection:
//! <https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html>
use kurbo::Point;
use vectorcraft_doc::{CharStyle, TextObject};
use crate::{FontDb, GlyphAlternate, OtFeatures, edit, layout};
/// The most characters on each side of the selected one that are shaped with it: the word it is in.
const MAX_WORD: usize = 256;
/// The most alternates tried against the text.
const MAX_CANDIDATES: usize = 512;
/// Does feature `tag` (as a style lists it, with its prefix and value) choose a glyph among a
/// glyph's alternates: `aalt`, `salt`, `swsh` or a stylistic set `ss01` to `ss20`?
pub fn is_alternate_feature(tag: &str) -> bool {
let name = OtFeatures::tag_name(tag);
matches!(name, "aalt" | "salt" | "swsh") || (name.len() == 4 && name.starts_with("ss") && OtFeatures::known_tag(name))
}
/// The tags to change a character's features by, as `featureChanges` takes them, so that it has
/// `tag` as its only feature that chooses an alternate: each such feature of `features` turned
/// off, and then `tag` (`salt=2`) when it is given. Empty when the character already has exactly
/// that.
pub fn alternate_changes(features: &[String], tag: Option<&str>) -> Vec<String> {
let mut changes: Vec<String> = vec![];
for t in features.iter().filter(|t| is_alternate_feature(t) && !t.starts_with('-')) {
let off = format!("-{}", OtFeatures::tag_name(t));
if tag.is_none_or(|n| OtFeatures::tag_name(n) != OtFeatures::tag_name(t)) && !changes.contains(&off) {
changes.push(off);
}
}
if let Some(tag) = tag {
let now = OtFeatures::from_tags(features.iter().map(String::as_str));
let wanted = OtFeatures::from_tags([tag]);
let name = OtFeatures::tag_name(tag);
if now.feature_value(name) != wanted.feature_value(name) {
changes.push(tag.to_string());
}
}
changes
}
/// One glyph [`alternates_in_context`] lists.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ContextAlternate {
pub gid: u32,
/// The feature that lists the glyph among the default glyph's alternates (`aalt` first).
pub feature: [u8; 4],
/// The feature tag that draws the glyph on the character in its word (`salt=2`), when one does.
pub apply: Option<String>,
}
/// What [`alternates_in_context`] finds.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ContextAlternates {
/// The face that supplied the glyph ([`crate::PositionedGlyph::font_id`]).
pub font_id: u32,
/// The glyph the shaper makes for the character with none of its own alternate features: its
/// form in its position.
pub default: u32,
/// The glyph it is drawn with now.
pub current: u32,
/// The alternates of [`Self::default`] in the font's order (`aalt`, `salt`, `ss01`… as
/// [`crate::FontFace::alternates`]), without it unless `aalt` lists it.
pub alternates: Vec<ContextAlternate>,
}
/// The glyph of the layout `text` that draws the character at `byte`, as (face id, glyph id).
fn glyph_at(db: &FontDb, text: &TextObject, byte: usize) -> Option<(u32, u32, usize)> {
layout(db, text).glyphs.into_iter().find(|g| g.byte <= byte && byte < g.byte.saturating_add(g.len)).map(|g| (g.font_id, g.gid, g.len))
}
/// The alternate glyphs of the character at `byte` of `text` as it stands in its word, from the
/// glyph the shaper makes for it there, and the feature value that draws each on that character
/// alone. The character's own alternate features (`salt`, `ss01`…) are left out when the default
/// glyph is found; each alternate's feature is tried on the character in its word, and the one
/// that draws that glyph there is its `apply` (`salt` before the stylistic sets, `swsh` and
/// `aalt`; a glyph no feature of the font draws in this position has none). `None` when `byte` is
/// not a character of the text or the layout holds no glyph for it. Only the word is shaped (up to
/// 256 characters each side), so the cost does not grow with the text.
pub fn alternates_in_context(db: &FontDb, text: &TextObject, byte: usize) -> Option<ContextAlternates> {
let plain = text.plain_text();
let ch = plain.get(byte..)?.chars().next()?;
let end = byte + ch.len_utf8();
let breaks = |c: char| c.is_whitespace() || matches!(c, '\u{2028}' | '\u{2029}');
let start = plain[..byte].char_indices().rev().take(MAX_WORD).take_while(|(_, c)| !breaks(*c)).last().map_or(byte, |(i, _)| i);
let stop = plain[end..].char_indices().take(MAX_WORD).take_while(|(_, c)| !breaks(*c)).last().map_or(end, |(i, c)| end + i + c.len_utf8());
let mut probe = TextObject::point(Point::ZERO, "", CharStyle::default());
probe.runs = edit::slice_runs(&text.runs, start, stop);
probe.vertical = text.vertical;
probe.para = text.para.clone();
let (a, b) = (byte - start, end - start);
let (_, current, _) = glyph_at(db, &probe, a)?;
let own = |probe: &TextObject, tag: Option<&str>| {
let mut p = probe.clone();
edit::style_range(&mut p.runs, a, b, |st| {
st.glyph_alternate = None;
st.features.retain(|t| !is_alternate_feature(t));
st.features.extend(tag.map(str::to_string));
});
glyph_at(db, &p, a)
};
let (default_font, default, len) = own(&probe, None)?;
let face = db.face_by_id(default_font)?;
// A glyph that more than the character makes (a ligature) has no alternates of its own here.
let subs = if len == ch.len_utf8() { face.substitutions(default) } else { vec![] };
let mut alternates: Vec<ContextAlternate> = vec![];
let mut tried: Vec<(String, u32)> = vec![];
for sub in &subs {
let name = String::from_utf8_lossy(&sub.feature).to_string();
let values = if sub.glyphs.is_empty() { 0 } else { u16::try_from(sub.glyphs.len()).unwrap_or(u16::MAX) };
for value in 1..=values {
let tag = if value == 1 { name.clone() } else { format!("{name}={value}") };
if !is_alternate_feature(&tag) || !OtFeatures::known_tag(&tag) || tried.len() >= MAX_CANDIDATES || tried.iter().any(|(t, _)| *t == tag) {
continue;
}
// A single substitution gives its glyph at any value: only the plain tag is tried.
if sub.kind == crate::SubstitutionKind::Single && value > 1 {
continue;
}
if let Some((font, gid, _)) = own(&probe, Some(&tag))
&& font == default_font
{
tried.push((tag, gid));
}
}
}
for GlyphAlternate { gid, feature } in crate::fontdb::listed(&subs, default) {
let rank = |tag: &str| match OtFeatures::tag_name(tag) {
"salt" => 0,
"swsh" => 2,
"aalt" => 3,
_ => 1,
};
let mut drawing: Vec<&(String, u32)> = tried.iter().filter(|(_, g)| *g == gid).collect();
drawing.sort_by_key(|(t, _)| rank(t));
alternates.push(ContextAlternate { gid, feature, apply: drawing.first().map(|(t, _)| t.clone()) });
}
Some(ContextAlternates { font_id: default_font, default, current, alternates })
}
+184 -24
View File
@@ -43,20 +43,32 @@ pub struct OtFeatures {
pub swash: bool,
/// The features of [`EXTRA_FEATURES`] turned on, one bit each (off by default): stylistic
/// sets, alternates, titling, proportional and lining figures, superscript, subscript,
/// numerators and denominators.
/// numerators and denominators, `aalt`.
pub extra: u32,
/// The value of each feature of [`EXTRA_FEATURES`] that is on, as a tag `salt=2` gives it: for
/// a feature with alternates, which of them the font draws (the HarfBuzz feature value, 1 for
/// the first). 0 stands for 1, a plain `salt`. Only read for a feature whose bit of
/// [`Self::extra`] is on; use [`Self::set_extra`] to change a bit and its value together.
pub extra_values: [u16; EXTRA_FEATURES.len()],
/// The value `swsh=N` gives swashes ([`Self::swash`]), as [`Self::extra_values`] for `salt`.
pub swash_value: u16,
}
/// The largest value a `tag=N` feature takes: a font's glyph ids are 16 bits, so is an alternate's
/// position in a set.
pub const MAX_FEATURE_VALUE: u16 = u16::MAX;
/// The most entries of a style's feature list read: every segment of its text reads the list again.
pub const MAX_STYLE_FEATURES: usize = 256;
/// Features beyond the named fields, off unless asked for, each bit `i` of [`OtFeatures::extra`]:
/// stylistic sets `ss01`–`ss20`, stylistic alternates `salt`, titling `titl`, proportional `pnum`
/// and lining `lnum` figures, superscript `sups`, subscript `subs`, numerators `numr` and
/// denominators `dnom`.
pub const EXTRA_FEATURES: [&str; 28] = [
/// and lining `lnum` figures, superscript `sups`, subscript `subs`, numerators `numr`,
/// denominators `dnom` and access all alternates `aalt`.
/// <https://learn.microsoft.com/en-us/typography/opentype/spec/features_ae#tag-aalt>
pub const EXTRA_FEATURES: [&str; 29] = [
"ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09", "ss10", "ss11", "ss12", "ss13", "ss14", "ss15", "ss16", "ss17", "ss18",
"ss19", "ss20", "salt", "titl", "pnum", "lnum", "sups", "subs", "numr", "dnom",
"ss19", "ss20", "salt", "titl", "pnum", "lnum", "sups", "subs", "numr", "dnom", "aalt",
];
impl Default for OtFeatures {
@@ -73,12 +85,53 @@ impl Default for OtFeatures {
ordinals: false,
swash: false,
extra: 0,
extra_values: [0; EXTRA_FEATURES.len()],
swash_value: 0,
}
}
}
fn f(tag: &[u8; 4], on: bool) -> Feature {
Feature::new(Tag::new(tag), on as u32, ..)
/// A feature of `value` over the whole text: 1 and 0 turn it on and off, more picks the font's
/// alternate of that number.
fn f(tag: &[u8; 4], value: impl Into<u32>) -> Feature {
Feature::new(Tag::new(tag), value.into(), ..)
}
/// What a feature tag of a style's list says about one feature ([`parse_tag`]).
struct ParsedTag<'a> {
/// The tag without a `-` or `+` prefix or a `=N` value.
name: &'a str,
/// On (`tag`, `+tag`, `tag=N` for `N` from 1) or off (`-tag`, `tag=0`).
on: bool,
/// The value `tag=N` gives; 1 for a plain tag.
value: u16,
}
/// Can feature `name` take a value (`salt=2`)? The features of [`EXTRA_FEATURES`] and `swsh` can:
/// each substitutes glyphs, and the value says which alternate.
fn takes_value(name: &str) -> bool {
name == "swsh" || EXTRA_FEATURES.contains(&name)
}
/// Read a feature tag: `tag`, `+tag`, `-tag` or, for a feature that takes a value, `tag=N` (`N`
/// from 0 to [`MAX_FEATURE_VALUE`] in decimal digits; `-tag=N` is refused). `None` for a tag this
/// set does not understand.
fn parse_tag(tag: &str) -> Option<ParsedTag<'_>> {
let (off, rest) = match tag.strip_prefix('-') {
Some(r) => (true, r),
None => (false, tag.strip_prefix('+').unwrap_or(tag)),
};
let (name, value) = match rest.split_once('=') {
None => (rest, None),
Some((name, digits)) => {
if off || digits.is_empty() || digits.len() > 5 || !digits.bytes().all(|b| b.is_ascii_digit()) || !takes_value(name) {
return None;
}
(name, Some(u16::try_from(digits.parse::<u32>().ok()?).ok()?))
}
};
let known = matches!(name, "liga" | "calt" | "dlig" | "smcp" | "frac" | "onum" | "tnum" | "ordn" | "swsh") || EXTRA_FEATURES.contains(&name);
known.then_some(ParsedTag { name, on: !off && value != Some(0), value: value.unwrap_or(1) })
}
impl OtFeatures {
@@ -104,6 +157,8 @@ impl OtFeatures {
ordinals: true,
swash: true,
extra: u32::MAX >> (32 - EXTRA_FEATURES.len()),
extra_values: [0; EXTRA_FEATURES.len()],
swash_value: 0,
};
change(&mut off);
change(&mut on);
@@ -137,28 +192,68 @@ impl OtFeatures {
(self.swash, d.swash, "swsh"),
] {
if on != def {
v.push(if on { tag.to_string() } else { format!("-{tag}") });
v.push(match (on, tag, self.swash_value) {
(true, "swsh", n @ 2..) => format!("{tag}={n}"),
(true, ..) => tag.to_string(),
(false, ..) => format!("-{tag}"),
});
}
}
v.extend(EXTRA_FEATURES.iter().enumerate().filter(|(i, _)| self.extra & (1 << i) != 0).map(|(_, t)| t.to_string()));
v.extend(EXTRA_FEATURES.iter().enumerate().filter(|(i, _)| self.extra & (1 << i) != 0).map(|(i, t)| match self.extra_values.get(i) {
None | Some(0 | 1) => t.to_string(),
Some(n) => format!("{t}={n}"),
}));
v
}
/// Is `tag` (optionally prefixed with `-` or `+`) one this set understands?
/// Is `tag` one this set understands? A feature tag, optionally prefixed with `-` or `+`, or
/// `tag=N` (`N` from 0 to [`MAX_FEATURE_VALUE`], no prefix but `+`) for a feature of
/// [`EXTRA_FEATURES`] or `swsh`, which picks the font's alternate number `N` (`salt=2`; 0 is
/// off, 1 is a plain tag).
pub fn known_tag(tag: &str) -> bool {
parse_tag(tag).is_some()
}
/// The feature a tag of a style's list names, without its `-` or `+` prefix or `=N` value
/// (`salt` for `-salt`, `+salt` and `salt=2`).
pub fn tag_name(tag: &str) -> &str {
let t = tag.strip_prefix(['-', '+']).unwrap_or(tag);
matches!(t, "liga" | "calt" | "dlig" | "smcp" | "frac" | "onum" | "tnum" | "ordn" | "swsh") || EXTRA_FEATURES.contains(&t)
t.split_once('=').map_or(t, |(name, _)| name)
}
/// The value feature `tag` of [`EXTRA_FEATURES`] or `swsh` is on with: `Some(1)` for a plain
/// tag, `Some(N)` for `tag=N`, `None` when it is off or is another tag.
pub fn feature_value(&self, tag: &str) -> Option<u16> {
if tag == "swsh" {
return self.swash.then_some(self.swash_value.max(1));
}
let i = EXTRA_FEATURES.iter().position(|e| *e == tag)?;
(self.extra & (1 << i) != 0).then(|| self.extra_values.get(i).copied().unwrap_or(0).max(1))
}
/// Turn the features of [`EXTRA_FEATURES`] in `mask` (bit `i` for the feature `i`) on, with
/// value 1 (a plain tag), or off.
pub fn set_extra(&mut self, mask: u32, on: bool) {
for (i, value) in self.extra_values.iter_mut().enumerate() {
if mask & (1 << i) != 0 {
*value = 0;
}
}
if on {
self.extra |= mask;
} else {
self.extra &= !mask;
}
}
/// This set with `tags` applied on top.
pub fn with_tags<'a>(mut self, tags: impl IntoIterator<Item = &'a str>) -> Self {
let o = &mut self;
for t in tags {
let (on, t) = match t.strip_prefix('-') {
Some(r) => (false, r),
None => (true, t.strip_prefix('+').unwrap_or(t)),
};
match t {
let Some(ParsedTag { name, on, value }) = parse_tag(t) else { continue };
// 1 is a plain tag: only a value from 2 is kept.
let kept = if on && value > 1 { value } else { 0 };
match name {
"liga" => o.ligatures = on,
"calt" => o.contextual = on,
"dlig" => o.discretionary_ligatures = on,
@@ -167,13 +262,15 @@ impl OtFeatures {
"onum" => o.oldstyle_figures = on,
"tnum" => o.tabular_figures = on,
"ordn" => o.ordinals = on,
"swsh" => o.swash = on,
"swsh" => {
o.swash = on;
o.swash_value = kept;
}
_ => {
if let Some(i) = EXTRA_FEATURES.iter().position(|e| *e == t) {
if on {
o.extra |= 1 << i;
} else {
o.extra &= !(1 << i);
if let Some(i) = EXTRA_FEATURES.iter().position(|e| *e == name) {
o.set_extra(1 << i, on);
if let Some(slot) = o.extra_values.get_mut(i) {
*slot = kept;
}
}
}
@@ -214,12 +311,12 @@ impl OtFeatures {
(s.swash, b"swsh"),
] {
if on {
v.push(f(tag, true));
v.push(f(tag, if tag == b"swsh" { u32::from(s.swash_value.max(1)) } else { 1 }));
}
}
for (i, tag) in EXTRA_FEATURES.iter().enumerate() {
if let (true, Ok(t)) = (s.extra & (1 << i) != 0, <[u8; 4]>::try_from(tag.as_bytes())) {
v.push(f(&t, true));
v.push(f(&t, u32::from(s.extra_values.get(i).copied().unwrap_or(0).max(1))));
}
}
if self.vertical {
@@ -354,6 +451,69 @@ mod tests {
assert_eq!(OtFeatures::from_tags(start.iter().copied()).with_tags(tags.iter().map(String::as_str)), want, "{start:?}");
}
}
/// `tag=N` gives a feature a value: parsed, validated, kept in the style's list as written
/// (1 is a plain tag, 0 is off), replaced by the next value or by `-tag`, and given to the
/// shaper as that value.
#[test]
fn feature_values_are_parsed_validated_and_shaped() {
for t in ["salt=2", "+salt=2", "ss03=12", "aalt=65535", "swsh=3", "salt=0", "salt=01", "titl=2"] {
assert!(OtFeatures::known_tag(t), "{t}");
}
for t in [
"salt=",
"salt=-1",
"salt=65536",
"salt=1.5",
"salt=x",
"-salt=2",
"liga=2",
"dlig=1",
"zzzz=2",
"salt==2",
"=2",
"salt=2=3",
"salt=123456",
"salt= 2",
] {
assert!(!OtFeatures::known_tag(t), "{t}");
}
assert_eq!(OtFeatures::tag_name("salt=2"), "salt");
assert_eq!((OtFeatures::tag_name("-ss01"), OtFeatures::tag_name("+salt=3"), OtFeatures::tag_name("liga")), ("ss01", "salt", "liga"));
let o = OtFeatures::from_tags(["salt=2", "ss01", "-liga", "swsh=4"]);
assert_eq!(o.to_tags(), ["-liga", "swsh=4", "ss01", "salt=2"]);
assert_eq!(
(o.feature_value("salt"), o.feature_value("ss01"), o.feature_value("swsh"), o.feature_value("ss02")),
(Some(2), Some(1), Some(4), None)
);
// The next value replaces the last, `salt=1` is a plain `salt`, `salt=0` and `-salt` are off.
assert_eq!(OtFeatures::from_tags(["salt=2", "salt=3"]).to_tags(), ["salt=3"]);
assert_eq!(OtFeatures::from_tags(["salt=3", "salt"]).to_tags(), ["salt"]);
assert_eq!(OtFeatures::from_tags(["salt=1"]).to_tags(), ["salt"]);
assert_eq!(OtFeatures::from_tags(["salt=2", "-salt"]).to_tags(), Vec::<String>::new());
assert_eq!(OtFeatures::from_tags(["salt=2", "salt=0"]).to_tags(), Vec::<String>::new());
assert_eq!(OtFeatures::from_tags(["salt=2", "-salt", "salt"]).to_tags(), ["salt"], "a value does not outlive being turned off");
// A tag with a value this set does not know is ignored.
assert_eq!(OtFeatures::from_tags(["salt=x", "liga=2"]), OtFeatures::default());
// The panel's own switches turn a feature on at value 1.
let mut f = OtFeatures::from_tags(["salt=3"]);
f.set_extra(1 << 20, false);
f.set_extra(1 << 20, true);
assert_eq!(f.to_tags(), ["salt"]);
let values = |tags: &[&str]| -> Vec<(String, u32)> {
let st = CharStyle { features: tags.iter().map(|t| t.to_string()).collect(), ..CharStyle::default() };
OtFeatures::default()
.resolve(&st)
.iter()
.filter(|f| f.value > 0 && f.tag != harfrust::Tag::new(b"kern"))
.map(|f| (f.tag.to_string(), f.value))
.collect()
};
assert_eq!(values(&["salt=2"]), [("salt".to_string(), 2)]);
assert_eq!(values(&["salt"]), [("salt".to_string(), 1)]);
assert_eq!(values(&["swsh=3", "ss01"]), [("swsh".to_string(), 3), ("ss01".to_string(), 1)]);
assert!(values(&["-salt", "salt=0"]).is_empty());
}
use crate::{FontDb, layout};
use vectorcraft_doc::TextObject;
use vectorcraft_geom::Point;
+3
View File
@@ -25,6 +25,7 @@ mod db;
mod dirs;
mod face;
mod glyph_pen;
mod gsub;
mod lookup;
mod names;
mod scan;
@@ -33,6 +34,7 @@ mod traits;
mod wanted;
pub use alternates::GlyphAlternate;
pub(crate) use alternates::listed;
pub use build::style_weight;
#[cfg(test)]
use cache::GlyphCache;
@@ -52,6 +54,7 @@ pub(crate) use face::MAX_LIVE_INSTANCES;
pub(crate) use face::PathAlignHeights;
pub use face::{FALLBACK_FAMILY, FontFace, IcfMargins};
pub(crate) use glyph_pen::FlipPen;
pub use gsub::{GlyphOrigin, JoiningPosition, Substitution, SubstitutionKind};
#[cfg(not(target_arch = "wasm32"))]
use names::SysFallback;
#[cfg(test)]
+68 -115
View File
@@ -1,16 +1,16 @@
//! The alternate glyphs a face's `GSUB` table gives one character: the Glyphs panel's Show ›
//! Alternates for Current Selection. The application finds the character's default glyph in the
//! `cmap` and looks it up in the coverage of each lookup of the features that substitute one glyph
//! for another, as the OpenType registry describes `aalt`.
//! `cmap` and lists what the features that substitute one glyph for another give it, as the
//! OpenType registry describes `aalt` ([`FontFace::substitutions`] reads them).
//! <https://learn.microsoft.com/en-us/typography/opentype/spec/features_ae#tag-aalt>
//! <https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt>
use skrifa::MetadataProvider;
use skrifa::raw::TableProvider;
use skrifa::raw::tables::gsub::{AlternateSubstFormat1, SingleSubst, SubstitutionSubtables};
use vectorcraft_doc::GlyphChoice;
use super::FontFace;
use super::gsub::Substitution;
/// One glyph [`FontFace::alternates`] lists: its id and the feature it was found in (`aalt`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@@ -19,108 +19,83 @@ pub struct GlyphAlternate {
pub feature: [u8; 4],
}
/// Caps on what a damaged or very large `GSUB` table can make [`FontFace::alternates`] read:
/// feature records, lookups per feature and in all, subtables per lookup and in all, glyphs per
/// alternate set and glyphs listed in all.
const MAX_FEATURES: usize = 4096;
const MAX_FEATURE_LOOKUPS: usize = 256;
const MAX_LOOKUPS: usize = 4096;
const MAX_SUBTABLES: usize = 256;
const MAX_SUBTABLE_READS: usize = 16384;
const MAX_SET: usize = 512;
/// The most glyphs [`FontFace::alternates`] lists.
const MAX_ALTERNATES: usize = 1024;
/// The other discretionary features that substitute one glyph for another (historical forms,
/// justification alternates, alternate annotation forms, ornaments, titling), after `cv99`, in
/// tag order.
const OTHER_FEATURES: [&[u8; 4]; 5] = [b"hist", b"jalt", b"nalt", b"ornm", b"titl"];
/// Where the glyphs of feature `tag` come in the list: `aalt`, `salt`, `ss01` to `ss20`, `swsh`,
/// `cv01` to `cv99`, then [`OTHER_FEATURES`]. `None` for a feature that is not listed.
fn feature_rank(tag: [u8; 4]) -> Option<(u8, u8)> {
let number = |t: &[u8]| std::str::from_utf8(t).ok().filter(|s| s.bytes().all(|b| b.is_ascii_digit())).and_then(|s| s.parse::<u8>().ok());
match &tag {
b"aalt" => Some((0, 0)),
b"salt" => Some((1, 0)),
[b's', b's', ..] => number(&tag[2..]).filter(|n| (1..=20).contains(n)).map(|n| (2, n)),
b"swsh" => Some((3, 0)),
[b'c', b'v', ..] => number(&tag[2..]).filter(|n| (1..=99).contains(n)).map(|n| (4, n)),
_ => OTHER_FEATURES.iter().position(|t| **t == tag).map(|i| (5, i as u8)),
/// The alternates of glyph `default` that its substitutions `subs` ([`FontFace::substitutions`])
/// list, in their order, each glyph once with the first feature that gives it: the default glyph
/// itself only when `aalt` gives it.
pub(crate) fn listed(subs: &[Substitution], default: u32) -> Vec<GlyphAlternate> {
let mut out: Vec<GlyphAlternate> = vec![];
for substitution in subs {
for &gid in &substitution.glyphs {
let wanted = gid != default || substitution.feature == *b"aalt";
if wanted && out.len() < MAX_ALTERNATES && !out.iter().any(|a| a.gid == gid) {
out.push(GlyphAlternate { gid, feature: substitution.feature });
}
}
}
out
}
impl FontFace {
/// The face's alternate glyphs for `c`, from its `GSUB` table: the glyphs single substitutions
/// (lookup type 1) and alternate substitutions (type 3, the whole set in the font's order), in
/// extension lookups (type 7) too, put in place of `c`'s default glyph. `aalt` comes first,
/// then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99`, `hist`, `jalt`, `nalt`, `ornm` and
/// `titl`; within a feature its lookups go in lookup order. A glyph found again is left out.
/// The default glyph is listed only when `aalt` lists it. Ligatures and contextual
/// substitutions are not read. Empty when the face does not map `c`, has no `GSUB` table, or
/// the table cannot be read; glyph ids past the face's glyph count are left out.
/// extension lookups (type 7) too, put in place of `c`'s default glyph
/// ([`FontFace::substitutions`]). `aalt` comes first, then `salt`, `ss01` to `ss20`, `swsh`,
/// `cv01` to `cv99`, `hist`, `jalt`, `nalt`, `ornm` and `titl`; within a feature its lookups go
/// in lookup order. A glyph found again is left out. The default glyph is listed only when
/// `aalt` lists it. Ligatures and contextual substitutions are not read. Empty when the face
/// does not map `c`, has no `GSUB` table, or the table cannot be read; glyph ids past the face's
/// glyph count are left out.
pub fn alternates(&self, c: char) -> Vec<GlyphAlternate> {
let mut out: Vec<GlyphAlternate> = vec![];
let Some(f) = self.skrifa() else { return out };
let Some(default) = f.charmap().map(c) else { return out };
let Ok(gsub) = f.gsub() else { return out };
let (Ok(features), Ok(lookups)) = (gsub.feature_list(), gsub.lookup_list()) else { return out };
let glyphs = f.maxp().map_or(0, |m| u32::from(m.num_glyphs()));
// The lookups of the listed features, by rank and lookup index.
// The listed feature records are read in rank order, so the cap on lookups in all leaves
// out the lowest ranked ones.
let records = features.feature_records();
let mut ranked: Vec<((u8, u8), usize, [u8; 4])> = records
.iter()
.take(MAX_FEATURES)
.enumerate()
.filter_map(|(i, r)| {
let tag = r.feature_tag().to_be_bytes();
feature_rank(tag).map(|rank| (rank, i, tag))
let Some(default) = self.skrifa().and_then(|f| f.charmap().map(c)).map(|g| g.to_u32()) else { return vec![] };
listed(&self.substitutions(default), default)
}
/// The glyphs no character maps (its `cmap` gives no character for them), by glyph id, without
/// `.notdef`: the positional forms, alternates and ligatures the `GSUB` table reaches and any
/// other glyph of the font. Read once for the face.
pub fn unencoded_glyphs(&self) -> std::sync::Arc<[u32]> {
self.unencoded
.get_or_init(|| {
let Some(font) = self.skrifa() else { return std::sync::Arc::from([]) };
let mapped: std::collections::HashSet<u32> = font.charmap().mappings().map(|(_, g)| g.to_u32()).collect();
let count = font.maxp().map_or(0, |m| u32::from(m.num_glyphs()));
(1..count).filter(|g| !mapped.contains(g)).collect()
})
.collect();
ranked.sort_unstable_by_key(|r| (r.0, r.1));
let mut wanted: Vec<((u8, u8), u16, [u8; 4])> = vec![];
for (rank, i, tag) in ranked {
let room = MAX_LOOKUPS.saturating_sub(wanted.len());
if room == 0 {
break;
}
let Some(record) = records.get(i) else { continue };
let Ok(feature) = record.feature(features.offset_data()) else { continue };
wanted.extend(feature.lookup_list_indices().iter().take(MAX_FEATURE_LOOKUPS.min(room)).map(|i| (rank, i.get(), tag)));
}
wanted.sort_unstable_by_key(|w| (w.0, w.1));
wanted.dedup_by_key(|w| (w.0, w.1));
let mut push = |gid: u32, feature: [u8; 4]| {
let listed = gid != default.to_u32() || feature == *b"aalt";
if listed && gid < glyphs && out.len() < MAX_ALTERNATES && !out.iter().any(|a| a.gid == gid) {
out.push(GlyphAlternate { gid, feature });
}
};
let all = lookups.lookups();
let mut reads = MAX_SUBTABLE_READS;
for (_, index, tag) in wanted {
if reads == 0 {
break;
}
let Ok(subtables) = all.get(usize::from(index)).and_then(|l| l.subtables()) else { continue };
let n = MAX_SUBTABLES.min(reads);
// A glyph takes the first subtable of a lookup whose coverage holds it.
match subtables {
SubstitutionSubtables::Single(s) => {
if let Some(gid) = s.iter().take(n).inspect(|_| reads -= 1).flatten().find_map(|t| single(&t, default.to_u32())) {
push(gid, tag);
}
.clone()
}
/// Is glyph `gid` one no character maps and no feature reaches, whose name does not give it
/// to another letter than `c` (`a.salt` for `a` in a font with no `salt`; `A.salt` is not
/// `a`'s)? Such a glyph is not among [`Self::alternates`], yet it can be drawn for `c` as a
/// glyph choice ([`Self::choice`]).
pub fn is_unlisted_alternate(&self, c: char, gid: u32) -> bool {
self.unencoded_glyphs().binary_search(&gid).is_ok()
&& self.feature_reaching(gid).is_none()
&& self.name_owner(gid).is_none_or(|owner| owner == c)
}
/// The character a glyph's name says it belongs to: the one whose glyph carries the name up
/// to its first `.` (`A` for `A.salt`, `s` for `s.ss1`; the lowest character when several map
/// that glyph). `None` when the glyph has no name, or no character maps a glyph of that name.
fn name_owner(&self, gid: u32) -> Option<char> {
let base = self.glyph_name(gid)?;
let base = base.split('.').next().unwrap_or_default();
let names = self.named_chars.get_or_init(|| {
let Some(font) = self.skrifa() else { return Default::default() };
let mut by_char: Vec<(char, u32)> = font.charmap().mappings().filter_map(|(c, g)| Some((char::from_u32(c)?, g.to_u32()))).collect();
by_char.sort_unstable();
let mut out = std::collections::HashMap::new();
for (c, g) in by_char {
if let Some(name) = self.glyph_name(g) {
out.entry(name).or_insert(c);
}
SubstitutionSubtables::Alternate(s) => {
if let Some(set) = s.iter().take(n).inspect(|_| reads -= 1).flatten().find_map(|t| alternate_set(&t, default.to_u32())) {
set.into_iter().for_each(|gid| push(gid, tag));
}
}
_ => {}
}
}
out
out
});
names.get(base).copied()
}
/// How many glyphs the face has (its `maxp` table; 0 when it can't be read).
@@ -141,25 +116,3 @@ impl FontFace {
GlyphChoice { ch: c, gid, font: self.postscript_name(), version: self.version.clone() }
}
}
/// The glyph single substitution `t` puts in place of `gid`, when its coverage holds `gid`.
fn single(t: &SingleSubst<'_>, gid: u32) -> Option<u32> {
match t {
SingleSubst::Format1(t) => {
t.coverage().ok()?.get(skrifa::GlyphId::new(gid))?;
// The delta is added modulo 65536.
Some((gid as i32 + i32::from(t.delta_glyph_id())).rem_euclid(0x1_0000) as u32)
}
SingleSubst::Format2(t) => {
let i = t.coverage().ok()?.get(skrifa::GlyphId::new(gid))?;
t.substitute_glyph_ids().get(usize::from(i)).map(|g| u32::from(g.get().to_u16()))
}
}
}
/// The alternate set of alternate substitution `t` for `gid`, when its coverage holds `gid`.
fn alternate_set(t: &AlternateSubstFormat1<'_>, gid: u32) -> Option<Vec<u32>> {
let i = t.coverage().ok()?.get(skrifa::GlyphId::new(gid))?;
let set = t.alternate_sets().get(usize::from(i)).ok()?;
Some(set.alternate_glyph_ids().iter().take(MAX_SET).map(|g| u32::from(g.get().to_u16())).collect())
}
+3
View File
@@ -79,6 +79,9 @@ pub(super) fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Opt
colour_bitmaps,
colour_layers,
svg_glyphs,
gsub: std::sync::OnceLock::new(),
unencoded: std::sync::OnceLock::new(),
named_chars: std::sync::OnceLock::new(),
})
}
+7
View File
@@ -201,6 +201,13 @@ pub struct FontFace {
pub(super) colour_layers: bool,
/// Has an `SVG ` table: OpenType SVG glyphs ([`FontDb::glyph_colour`]).
pub(super) svg_glyphs: bool,
/// What the `GSUB` table substitutes ([`Self::substitutions`]), read the first time it is asked
/// for.
pub(super) gsub: std::sync::OnceLock<super::gsub::GsubIndex>,
/// [`Self::unencoded_glyphs`], read once.
pub(super) unencoded: std::sync::OnceLock<std::sync::Arc<[u32]>>,
/// [`Self::name_owner`]'s glyph names to characters, read once.
pub(super) named_chars: std::sync::OnceLock<std::collections::HashMap<String, char>>,
}
/// Where the ideographic character face (ICF) lies inside the ideographic em box: its distance
+359
View File
@@ -0,0 +1,359 @@
//! What a face's `GSUB` table substitutes for a glyph: the alternates of one glyph (the Glyphs
//! panel's Show › Alternates for Current Selection), the feature that gives a glyph no character
//! maps to (Show › Entire Font) and glyph names. Read once for a face, the first time one is asked
//! for, and kept with it ([`FontFace`]).
//!
//! A feature of alternates (an alternate substitution, lookup type 3; `salt`, `aalt`, `swsh`)
//! takes the alternate its value says: with the feature at value `N`, a glyph in its coverage is
//! replaced by the `N`th glyph of its set (the first at value 1, none at 0 or past the set), which
//! is how the shaper reads it. A feature of single substitutions (type 1; `ss01`, `smcp`) replaces
//! the glyph at any value from 1.
//! <https://learn.microsoft.com/en-us/typography/opentype/spec/gsub#lookuptype-3-alternate-substitution-subtable>
//! <https://learn.microsoft.com/en-us/typography/opentype/spec/features_ae#tag-aalt>
use std::collections::{HashMap, HashSet};
use skrifa::MetadataProvider;
use skrifa::raw::TableProvider;
use skrifa::raw::tables::gsub::{AlternateSubstFormat1, LigatureSubstFormat1, MultipleSubstFormat1, SingleSubst, SubstitutionSubtables};
use super::FontFace;
/// Caps on what a damaged or very large `GSUB` table can make a face read: feature records,
/// lookups per feature and in all, subtables per lookup and in all, glyphs per alternate set, and
/// glyphs and substitutions listed in all.
pub(super) const MAX_FEATURES: usize = 4096;
const MAX_FEATURE_LOOKUPS: usize = 256;
const MAX_LOOKUPS: usize = 4096;
const MAX_SUBTABLES: usize = 256;
const MAX_SUBTABLE_READS: usize = 16384;
pub(super) const MAX_SET: usize = 512;
const MAX_ENTRIES: usize = 1 << 18;
/// The other discretionary features that substitute one glyph for another (historical forms,
/// justification alternates, alternate annotation forms, ornaments, titling), after `cv99`, in
/// tag order.
const OTHER_FEATURES: [&[u8; 4]; 5] = [b"hist", b"jalt", b"nalt", b"ornm", b"titl"];
/// Where the substitutions of feature `tag` come in a glyph's list: `aalt`, `salt`, `ss01` to
/// `ss20`, `swsh`, `cv01` to `cv99`, then [`OTHER_FEATURES`]. `None` for a feature that is not
/// listed.
fn feature_rank(tag: [u8; 4]) -> Option<(u8, u8)> {
let number = |t: &[u8]| std::str::from_utf8(t).ok().filter(|s| s.bytes().all(|b| b.is_ascii_digit())).and_then(|s| s.parse::<u8>().ok());
match &tag {
b"aalt" => Some((0, 0)),
b"salt" => Some((1, 0)),
[b's', b's', ..] => number(&tag[2..]).filter(|n| (1..=20).contains(n)).map(|n| (2, n)),
b"swsh" => Some((3, 0)),
[b'c', b'v', ..] => number(&tag[2..]).filter(|n| (1..=99).contains(n)).map(|n| (4, n)),
_ => OTHER_FEATURES.iter().position(|t| **t == tag).map(|i| (5, i as u8)),
}
}
/// The rank of the features that give no listed alternates but reach glyphs ([`FontFace::feature_reaching`]).
const UNLISTED: (u8, u8) = (9, 0);
/// How a feature gives its glyphs.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum SubstitutionKind {
/// One glyph, whatever the feature's value is from 1 (a single substitution, type 1).
Single,
/// A set of glyphs, the one at the feature's value (an alternate substitution, type 3).
Alternate,
}
/// What one feature substitutes for a glyph ([`FontFace::substitutions`]).
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Substitution {
/// The feature's tag (`salt`).
pub feature: [u8; 4],
pub kind: SubstitutionKind,
/// The glyphs, in the font's order: the one glyph of a single substitution, or the alternate
/// set (the feature at value `N` gives the `N`th).
pub glyphs: Vec<u32>,
}
impl Substitution {
/// The value the feature takes to give `glyph` (`salt=2` for 2), `None` when it never gives it.
pub fn value_for(&self, glyph: u32) -> Option<u16> {
match self.kind {
SubstitutionKind::Single => (self.glyphs.first() == Some(&glyph)).then_some(1),
SubstitutionKind::Alternate => self.glyphs.iter().position(|g| *g == glyph).and_then(|i| u16::try_from(i + 1).ok()),
}
}
}
type Tag = [u8; 4];
/// A face's substitutions, by the glyph they replace and by the glyph they give.
#[derive(Default)]
pub(super) struct GsubIndex {
/// The listed features' ([`feature_rank`]) substitutions of each glyph, by feature rank and
/// lookup.
by_input: HashMap<u32, Vec<Substitution>>,
/// The first feature, listed ones first, that gives each glyph: by a single, multiple,
/// alternate or ligature substitution, and the glyph it replaces (not known for a ligature).
reached: HashMap<u32, (Tag, Option<u32>)>,
}
/// The glyph single substitution `t` puts in place of each glyph its coverage holds.
fn single_pairs(t: &SingleSubst<'_>, room: usize) -> Vec<(u32, u32)> {
match t {
SingleSubst::Format1(t) => {
let Ok(coverage) = t.coverage() else { return vec![] };
// The delta is added modulo 65536.
let delta = i32::from(t.delta_glyph_id());
coverage.iter().take(room).map(|g| (g.to_u32(), (g.to_u32() as i32 + delta).rem_euclid(0x1_0000) as u32)).collect()
}
SingleSubst::Format2(t) => {
let Ok(coverage) = t.coverage() else { return vec![] };
let out = t.substitute_glyph_ids();
coverage.iter().zip(out).take(room).map(|(g, to)| (g.to_u32(), u32::from(to.get().to_u16()))).collect()
}
}
}
/// The alternate set of each glyph alternate substitution `t`'s coverage holds.
fn alternate_sets(t: &AlternateSubstFormat1<'_>, room: usize) -> Vec<(u32, Vec<u32>)> {
let Ok(coverage) = t.coverage() else { return vec![] };
let sets = t.alternate_sets();
coverage
.iter()
.enumerate()
.take(room)
.filter_map(|(i, g)| {
let set = sets.get(i).ok()?;
Some((g.to_u32(), set.alternate_glyph_ids().iter().take(MAX_SET).map(|g| u32::from(g.get().to_u16())).collect()))
})
.collect()
}
/// The glyphs multiple substitution `t` puts in place of its covered glyphs, and the ligature
/// glyphs of ligature substitution `l`, as (glyph, glyphs it becomes).
fn multiple_pairs(t: &MultipleSubstFormat1<'_>, room: usize) -> Vec<(u32, Vec<u32>)> {
let Ok(coverage) = t.coverage() else { return vec![] };
let sequences = t.sequences();
coverage
.iter()
.enumerate()
.take(room)
.filter_map(|(i, g)| {
let sequence = sequences.get(i).ok()?;
Some((g.to_u32(), sequence.substitute_glyph_ids().iter().map(|g| u32::from(g.get().to_u16())).collect()))
})
.collect()
}
fn ligature_glyphs(t: &LigatureSubstFormat1<'_>, room: usize) -> Vec<u32> {
let Ok(coverage) = t.coverage() else { return vec![] };
let sets = t.ligature_sets();
let mut out = vec![];
for (i, _) in coverage.iter().enumerate().take(room) {
let Ok(set) = sets.get(i) else { continue };
out.extend(set.ligatures().iter().flatten().take(room).map(|l| u32::from(l.ligature_glyph().to_u16())));
if out.len() >= room {
break;
}
}
out
}
impl GsubIndex {
/// Read `face`'s `GSUB` table. Empty when the face has none or it cannot be read; glyph ids past
/// the face's glyph count are left out; the caps above bound what a damaged table makes it read.
fn read(face: &FontFace) -> Self {
let mut index = Self::default();
let Some(font) = face.skrifa() else { return index };
let Ok(gsub) = font.gsub() else { return index };
let (Ok(features), Ok(lookups)) = (gsub.feature_list(), gsub.lookup_list()) else { return index };
let glyphs = font.maxp().map_or(0, |m| u32::from(m.num_glyphs()));
// The lookups of the features, by rank and lookup index. The feature records are read in
// rank order, so the cap on lookups in all leaves out the lowest ranked ones.
let records = features.feature_records();
let mut ranked: Vec<((u8, u8), usize, [u8; 4])> = records
.iter()
.take(MAX_FEATURES)
.enumerate()
.map(|(i, r)| {
let tag = r.feature_tag().to_be_bytes();
(feature_rank(tag).unwrap_or(UNLISTED), i, tag)
})
.collect();
ranked.sort_unstable_by_key(|r| (r.0, r.1));
let mut wanted: Vec<((u8, u8), u16, [u8; 4])> = vec![];
for (rank, i, tag) in ranked {
let room = MAX_LOOKUPS.saturating_sub(wanted.len());
if room == 0 {
break;
}
let Some(record) = records.get(i) else { continue };
let Ok(feature) = record.feature(features.offset_data()) else { continue };
wanted.extend(feature.lookup_list_indices().iter().take(MAX_FEATURE_LOOKUPS.min(room)).map(|i| (rank, i.get(), tag)));
}
wanted.sort_by_key(|w| (w.0, w.1));
wanted.dedup_by_key(|w| (w.0, w.1));
let all = lookups.lookups();
let mut reads = MAX_SUBTABLE_READS;
let mut entries = 0_usize;
for (rank, lookup, tag) in wanted {
if reads == 0 || entries >= MAX_ENTRIES {
break;
}
let Ok(subtables) = all.get(usize::from(lookup)).and_then(|l| l.subtables()) else { continue };
let n = MAX_SUBTABLES.min(reads);
let listed = rank != UNLISTED;
// A glyph takes the first subtable of a lookup whose coverage holds it.
let mut seen: HashSet<u32> = HashSet::new();
let mut note = |index: &mut Self, input: u32, kind: SubstitutionKind, out: Vec<u32>| {
if !seen.insert(input) {
return;
}
let out: Vec<u32> = out.into_iter().filter(|g| *g < glyphs).collect();
for g in &out {
if *g != input && index.reached.len() < MAX_ENTRIES {
index.reached.entry(*g).or_insert((tag, Some(input)));
}
}
if listed && !out.is_empty() && entries < MAX_ENTRIES {
entries += 1;
index.by_input.entry(input).or_default().push(Substitution { feature: tag, kind, glyphs: out });
}
};
match subtables {
SubstitutionSubtables::Single(s) => {
for t in s.iter().take(n).inspect(|_| reads -= 1).flatten() {
for (input, to) in single_pairs(&t, MAX_ENTRIES) {
note(&mut index, input, SubstitutionKind::Single, vec![to]);
}
}
}
SubstitutionSubtables::Alternate(s) => {
for t in s.iter().take(n).inspect(|_| reads -= 1).flatten() {
for (input, set) in alternate_sets(&t, MAX_ENTRIES) {
note(&mut index, input, SubstitutionKind::Alternate, set);
}
}
}
SubstitutionSubtables::Multiple(s) => {
let mut covered: HashSet<u32> = HashSet::new();
for t in s.iter().take(n).inspect(|_| reads -= 1).flatten() {
for (input, to) in multiple_pairs(&t, MAX_ENTRIES) {
// Not listed as alternates, only reached.
if covered.insert(input) {
for g in to.into_iter().filter(|g| *g < glyphs && *g != input) {
if index.reached.len() < MAX_ENTRIES {
index.reached.entry(g).or_insert((tag, Some(input)));
}
}
}
}
}
}
SubstitutionSubtables::Ligature(s) => {
for t in s.iter().take(n).inspect(|_| reads -= 1).flatten() {
for g in ligature_glyphs(&t, MAX_ENTRIES).into_iter().filter(|g| *g < glyphs) {
if index.reached.len() < MAX_ENTRIES {
index.reached.entry(g).or_insert((tag, None));
}
}
}
}
_ => {}
}
}
index
}
}
/// The most substitutions [`FontFace::glyph_origin`] follows back.
const MAX_ORIGIN_STEPS: usize = 8;
/// The character a glyph comes from ([`FontFace::glyph_origin`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GlyphOrigin {
pub ch: char,
/// The features from the glyph back to the character's glyph, the glyph's own first.
pub features: Vec<[u8; 4]>,
}
impl GlyphOrigin {
/// The place in a word that the glyph's features give it: the first of `init`, `medi`, `fina`
/// and `isol` among them, `None` when none is (a letter that does not join).
pub fn position(&self) -> Option<crate::JoiningPosition> {
self.features.iter().find_map(|f| match f {
b"init" => Some(crate::JoiningPosition::Initial),
b"medi" => Some(crate::JoiningPosition::Medial),
b"fina" => Some(crate::JoiningPosition::Final),
b"isol" => Some(crate::JoiningPosition::Isolated),
_ => None,
})
}
}
/// Where a letter stands in a word, as the `init`, `medi`, `fina` and `isol` features say.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum JoiningPosition {
Initial,
Medial,
Final,
Isolated,
}
impl FontFace {
fn gsub_index(&self) -> &GsubIndex {
self.gsub.get_or_init(|| GsubIndex::read(self))
}
/// What the features `aalt`, `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99`, `hist`,
/// `jalt`, `nalt`, `ornm` and `titl` of the face's `GSUB` table substitute for glyph `gid`, in
/// that order of features and within a feature in lookup order: the glyph of each single
/// substitution (lookup type 1) and the whole set of each alternate substitution (type 3), in
/// extension lookups (type 7) too, as the shaper reads them. A feature gives the glyph its
/// first substitution (the first subtable of the lookup whose coverage holds it). Ligatures and
/// contextual substitutions are not read, nor is the choice of a variable font's instance of
/// `FeatureVariations`. Empty when the face has no `GSUB` table or it cannot be read; glyph ids
/// past the face's glyph count are left out.
pub fn substitutions(&self, gid: u32) -> Vec<Substitution> {
self.gsub_index().by_input.get(&gid).cloned().unwrap_or_default()
}
/// The feature of the `GSUB` table that gives glyph `gid` as a substitute (by a single,
/// multiple, alternate or ligature substitution), a listed feature ([`Self::substitutions`])
/// before another (`init`, `liga`), if any does. Not read through a contextual lookup.
pub fn feature_reaching(&self, gid: u32) -> Option<[u8; 4]> {
self.gsub_index().reached.get(&gid).map(|r| r.0)
}
/// Where glyph `gid` comes from: the character whose glyph the `GSUB` table's substitutions
/// lead to it from, and the features on the way, `gid`'s own first (`aalt`, then `fina` for
/// `yehfarsi.fina.alt2`, whose character is U+06CC). The chain follows the first substitution
/// that gives each glyph back to a glyph none gives, as far as [`MAX_ORIGIN_STEPS`] steps; a
/// presentation-form character that maps a glyph in the chain does not stop it, and the
/// character is the lowest one outside the Arabic presentation forms that maps the glyph it
/// ends on (the lowest of all when only presentation forms do). `None` when no character maps
/// that glyph (a glyph no feature reaches, a ligature).
pub fn glyph_origin(&self, gid: u32) -> Option<GlyphOrigin> {
let index = self.gsub_index();
let mut features = vec![];
let mut at = gid;
for _ in 0..MAX_ORIGIN_STEPS {
let Some((tag, from)) = index.reached.get(&at) else { break };
features.push(*tag);
at = (*from)?;
}
let font = self.skrifa()?;
let presentation = |c: u32| (0xFB50..=0xFDFF).contains(&c) || (0xFE70..=0xFEFF).contains(&c);
let mut chars: Vec<u32> = font.charmap().mappings().filter(|(_, g)| g.to_u32() == at).map(|(c, _)| c).collect();
chars.sort_by_key(|c| (presentation(*c), *c));
let ch = chars.first().copied().and_then(char::from_u32)?;
Some(GlyphOrigin { ch, features })
}
/// The name of glyph `gid` from the font's `post` or `CFF` table: `None` for a glyph past the
/// face's glyphs, or when the font has no names (the ids are then `gid12`).
pub fn glyph_name(&self, gid: u32) -> Option<String> {
let font = self.skrifa()?;
let name = font.glyph_names().get(skrifa::GlyphId::new(gid))?;
(!name.is_synthesized()).then(|| name.as_str().to_string())
}
}
+7 -7
View File
@@ -37,10 +37,11 @@ pub(crate) fn is_harakat(c: char) -> bool {
}
/// Do character styles `a` and `b` differ, if at all, only in paint: fill, stroke and how the
/// stroke is drawn, overprint, and the character style name? Those are not read by the shaper
/// and change no glyph, advance or position. Nor is the space before a line's first character
/// inside a cluster, which takes it from its first character (#5111), so it is ignored too. Stricter than [`crate::shape`]'s `shapes_alike`
/// (which also ignores baseline shift, tracking, underline, …).
/// stroke is drawn, overprint, and the character style name? OpenType feature differences are
/// ignored here: they already apply to their own character ranges during shaping. Nor is the
/// space before a line's first character read inside a cluster, which takes it from its first
/// character (#5111). Stricter than [`crate::shape`]'s `shapes_alike` (which also ignores baseline
/// shift, tracking, underline, …).
pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool {
// Exhaustive: a new attribute must explicitly keep or relax this boundary rule.
let CharStyle {
@@ -61,7 +62,7 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool {
underline,
strikethrough,
all_caps,
features,
features: _,
position,
small_caps,
char_align,
@@ -110,7 +111,6 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool {
&& underline == &b.underline
&& strikethrough == &b.strikethrough
&& all_caps == &b.all_caps
&& features == &b.features
&& position == &b.position
&& small_caps == &b.small_caps
&& char_align == &b.char_align
@@ -129,7 +129,7 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool {
}
/// Is the run boundary at byte `at` of `text`, between styles `before` and `after`, one that
/// stays inside a shaping cluster: they differ only in paint and a harakat is on either side?
/// stays inside a shaping cluster: only paint or features differ and a harakat is on either side?
pub(crate) fn paint_only_mark_boundary(text: &str, at: usize, before: &CharStyle, after: &CharStyle) -> bool {
let next = text.get(at..).and_then(|s| s.chars().next());
let previous = text.get(..at).and_then(|s| s.chars().next_back());
+9 -4
View File
@@ -13,6 +13,7 @@
mod bitmap;
mod colr;
mod composer;
mod context_alternates;
pub mod craft_fonts;
pub mod edit;
pub mod embed;
@@ -34,12 +35,16 @@ pub mod test_fonts;
pub mod thread;
pub use bitmap::GlyphImage;
pub use context_alternates::{ContextAlternate, ContextAlternates, alternate_changes, alternates_in_context, is_alternate_feature};
pub use craft_fonts::{CRAFT_FONTS, CraftFont, WEB_FONTS, WebFont};
pub use features::{EXTRA_FEATURES, LIGATURE_TRACKING_LIMITS, MAX_STYLE_FEATURES, OtFeatures, explicit_ligatures, ligatures_suppressed_by};
pub use features::{
EXTRA_FEATURES, LIGATURE_TRACKING_LIMITS, MAX_FEATURE_VALUE, MAX_STYLE_FEATURES, OtFeatures, explicit_ligatures, ligatures_suppressed_by,
};
pub use fontdb::{
CompositeCoverage, FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, GlyphAlternate, IcfMargins, LentCompositeFonts,
PlatformFontFiles, StylisticSet, WantedFont, WantedFonts, app_font_dir, is_font_file, is_suitcase, set_app_font_dir, set_platform_font_files,
set_user_font_dirs, set_user_font_dirs_confined, style_weight, system_font_dirs, user_font_dirs,
CompositeCoverage, FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, GlyphAlternate, GlyphOrigin, IcfMargins, JoiningPosition,
LentCompositeFonts, PlatformFontFiles, StylisticSet, Substitution, SubstitutionKind, WantedFont, WantedFonts, app_font_dir, is_font_file,
is_suitcase, set_app_font_dir, set_platform_font_files, set_user_font_dirs, set_user_font_dirs_confined, style_weight, system_font_dirs,
user_font_dirs,
};
pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions};
pub use layout::{envelope_ink, register_envelope_ink};
+150 -30
View File
@@ -875,33 +875,113 @@ fn source_run_at(runs: &[(Range<usize>, &CharStyle)], span: &Range<usize>, byte:
source.get(i).filter(|(range, _)| range.contains(&byte)).map(|_| span.start + i)
}
/// Can one glyph draw characters in styles `a` and `b`? Only when the styles are equal, except that
/// horizontal type (`vertical` false) does not read Standard Vertical Roman Alignment or the
/// tate-chu-yoko offsets, so styles that differ only in those are drawn alike there. The space
/// before a line's first character ([`space_before`]) is never read inside a cluster, which takes it
/// from its first character, so it doesn't split a ligature either.
/// Can one glyph draw characters in styles `a` and `b`? OpenType features already apply to their
/// own character ranges during shaping, so feature-only differences do not split a cluster.
/// Horizontal type does not read Standard Vertical Roman Alignment or the tate-chu-yoko offsets.
/// The space before a line's first character ([`space_before`]) comes from the cluster's first
/// character, so it does not split a ligature either.
fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool {
let b = &CharStyle { kerning_before: a.kerning_before, ..b.clone() };
if a == b {
return true;
}
!vertical
&& *a
== CharStyle {
standard_vertical_roman: a.standard_vertical_roman,
tcy_up_down: a.tcy_up_down,
tcy_left_right: a.tcy_left_right,
..b.clone()
}
// Exhaustive, like `shapes_alike`: compare borrowed fields without copying feature lists.
let CharStyle {
font_family,
font_style,
font_version,
font_axes,
size,
leading,
tracking,
kerning,
kerning_before,
optical_kerning,
baseline_shift,
h_scale,
v_scale,
rotation,
fill,
stroke,
stroke_width,
underline,
strikethrough,
all_caps,
features: _,
style_name,
overprint_fill,
overprint_stroke,
stroke_cap,
stroke_join,
stroke_miter_limit,
stroke_dash,
stroke_align,
position,
small_caps,
char_align,
char_direction,
kashidas,
proportional_metrics,
tate_chu_yoko,
tcy_up_down,
tcy_left_right,
standard_vertical_roman,
digits,
font_height_reference,
no_break,
language,
glyph_alternate,
} = a;
// Keep PartialEq's non-reflexive behavior for ignored numeric fields, without cloning styles.
let offsets = (standard_vertical_roman, tcy_up_down, tcy_left_right);
let other_offsets = if vertical { (&b.standard_vertical_roman, &b.tcy_up_down, &b.tcy_left_right) } else { offsets };
kerning_before == &a.kerning_before
&& offsets == other_offsets
&& font_family == &b.font_family
&& font_style == &b.font_style
&& font_version == &b.font_version
&& font_axes == &b.font_axes
&& size == &b.size
&& leading == &b.leading
&& tracking == &b.tracking
&& kerning == &b.kerning
&& optical_kerning == &b.optical_kerning
&& baseline_shift == &b.baseline_shift
&& h_scale == &b.h_scale
&& v_scale == &b.v_scale
&& rotation == &b.rotation
&& fill == &b.fill
&& stroke == &b.stroke
&& stroke_width == &b.stroke_width
&& underline == &b.underline
&& strikethrough == &b.strikethrough
&& all_caps == &b.all_caps
&& style_name == &b.style_name
&& overprint_fill == &b.overprint_fill
&& overprint_stroke == &b.overprint_stroke
&& stroke_cap == &b.stroke_cap
&& stroke_join == &b.stroke_join
&& stroke_miter_limit == &b.stroke_miter_limit
&& stroke_dash == &b.stroke_dash
&& stroke_align == &b.stroke_align
&& position == &b.position
&& small_caps == &b.small_caps
&& char_align == &b.char_align
&& char_direction == &b.char_direction
&& kashidas == &b.kashidas
&& proportional_metrics == &b.proportional_metrics
&& tate_chu_yoko == &b.tate_chu_yoko
&& digits == &b.digits
&& font_height_reference == &b.font_height_reference
&& no_break == &b.no_break
&& language == &b.language
&& glyph_alternate == &b.glyph_alternate
}
/// Do text runs in styles `a` and `b` shape alike, so that they are shaped as one piece of text?
/// They do when they differ only in paint and stroke, underline and strikethrough, overprint, the
/// character style's name, leading, baseline shift, rotation, Character Alignment, Character
/// Direction (text is shaped apart where its bidi level changes), Kashidas, the kerning (each glyph takes its
/// run's, see [`shape_segment`]), the space before a line's first character ([`space_before`])
/// and the tracking, as long as the tracking keeps or drops standard
/// ligatures alike ([`crate::features::ligatures_suppressed_by`]).
/// run's, see [`shape_segment`]), the space before a line's first character ([`space_before`]),
/// the OpenType features (each run's apply to its own characters, as features over a range of the
/// one buffer) and the tracking, as long as the tracking keeps or drops standard ligatures alike
/// ([`crate::features::ligatures_suppressed_by`]).
fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
// Every field is named, so that a new attribute has to be sorted into one group or the other.
let CharStyle {
@@ -925,7 +1005,9 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
underline: _,
strikethrough: _,
all_caps,
features,
// Runs with different feature lists shape as one buffer: each run's features apply to its
// own characters only ([`shape_segment`]).
features: _,
style_name: _,
overprint_fill: _,
overprint_stroke: _,
@@ -958,7 +1040,6 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
&& *h_scale == b.h_scale
&& *v_scale == b.v_scale
&& *all_caps == b.all_caps
&& *features == b.features
&& *position == b.position
&& *small_caps == b.small_caps
&& *proportional_metrics == b.proportional_metrics
@@ -1121,6 +1202,23 @@ struct Shaped {
chars: Vec<u32>,
}
/// The features that turn the whole-text features `base` into `own` over the clusters `range`, put
/// after `base` so that they win there: each feature of `own` that `base` lacks or has with
/// another value, and each of `base` that `own` leaves out, back at the value the shaper has
/// without it (on for the default features, off for the rest). The range is given with an
/// inclusive end because harfrust's `Feature::new` takes an exclusive end one cluster short, but
/// applies it as exclusive: a one-byte character would get an empty range.
fn ranged_features(base: &[Feature], own: &[Feature], range: Range<usize>, out: &mut Vec<Feature>) {
const ON_BY_DEFAULT: [&[u8; 4]; 7] = [b"kern", b"liga", b"clig", b"calt", b"apkn", b"vkrn", b"vapk"];
for f in own.iter().filter(|f| !base.iter().any(|b| b.tag == f.tag && b.value == f.value)) {
out.push(Feature::new(f.tag, f.value, range.start..=range.end));
}
for b in base.iter().filter(|b| !own.iter().any(|f| f.tag == b.tag)) {
let default = ON_BY_DEFAULT.iter().any(|t| Tag::new(t) == b.tag);
out.push(Feature::new(b.tag, u32::from(default), range.start..=range.end));
}
}
fn is_mark(c: char) -> bool {
use unicode_general_category::{GeneralCategory, get_general_category};
matches!(get_general_category(c), GeneralCategory::NonspacingMark | GeneralCategory::SpacingMark | GeneralCategory::EnclosingMark)
@@ -1132,8 +1230,8 @@ fn is_mark(c: char) -> bool {
/// characters with automatic kerning: the manual value replaces the font's pair after its
/// character, and a character before it with automatic kerning keeps the font's pair with it
/// (except before a character that All Caps or Small Caps sets as several letters, such as ß as
/// SS). A cluster that would take in characters of runs with different styles (a ligature, a
/// base with its marks) is not formed: the segment is shaped in pieces at those run boundaries.
/// SS). A cluster that would take in characters of runs with different drawing attributes (a
/// ligature, a base with its marks) is not formed, except for paint-only harakat differences.
fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFeatures, out: &mut Vec<SGlyph>) {
let Segment { range, runs, source_runs, st, face, small, level } = seg;
let (range, small) = (range.clone(), *small);
@@ -1196,6 +1294,23 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
let styled_marks = !mark_boundaries.is_empty();
let char_level = std::cell::Cell::new(false);
// Runs that differ only in their OpenType feature lists are shaped as one buffer: the features
// of each run apply to its own characters only (a feature with a range of clusters, which here
// are source bytes), over those of `base_style`, whose features apply to the whole segment. So
// a feature or an alternate (`salt=2`) on one letter of an Arabic word leaves the joining,
// ligatures and kerning of the whole word to the shaper, as the same text without it.
// `variant` makes a style's features and `base` is what it makes for `base_style`.
let run_features = |variant: &dyn Fn(&CharStyle) -> Vec<Feature>, base_style: &CharStyle, base: &[Feature]| {
let mut ranged = Vec::new();
for (r, run_style) in runs.get(source_runs.clone()).unwrap_or_default() {
let (a, b) = (r.start.max(range.start), r.end.min(range.end));
if run_style.features != base_style.features && a < b {
let own = variant(&CharStyle { features: run_style.features.clone(), ..base_style.clone() });
ranged_features(base, &own, a..b, &mut ranged);
}
}
ranged
};
// Shape the segment as if all of it were in `st`, with the features `more` on top.
let shape_in = |st: &CharStyle, more: &[Feature]| {
// A BCP 47 tag is short ASCII; anything else (a file may hold anything) is shaped without one.
@@ -1210,8 +1325,13 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
// The baseline and character-level pass use exactly the same resolved features.
// Alternate positioning passes still resolve their own features, with `more` last.
let with_more = |resolved: Vec<Feature>| resolved.into_iter().chain(more.iter().cloned()).collect::<Vec<_>>();
let along_features = with_more(feats.resolve(st));
let with_more = |variant: &dyn Fn(&CharStyle) -> Vec<Feature>| {
let mut resolved = variant(st);
resolved.extend(run_features(variant, st, &resolved));
resolved.extend(more.iter().cloned());
resolved
};
let along_features = with_more(&|s| feats.resolve(s));
// gid, cluster, x and y advance, x and y offset.
let shape = |feats: &[Feature], direction: Direction| {
let mut buf = UnicodeBuffer::new();
@@ -1274,7 +1394,7 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
if feats.proportional(st) {
// `palt` is positioning only: the same glyphs set on their full widths tell which
// advances it changed (kerning and the rest change both alike).
let full = shape(&with_more(feats.resolve_fixed_width(st)), along);
let full = shape(&with_more(&|s| feats.resolve_fixed_width(s)), along);
if same_glyphs(&full) {
*proportional = full.iter().zip(raw).map(|(a, b)| a.2 != b.2).collect();
}
@@ -1285,7 +1405,7 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
// and without either tell how it moves each and changes its advance down the column
// (y up, advances negative). Which advances `vpal` itself changed comes from setting
// them with it alone.
let ttb = |proportional, kerning| shape(&with_more(feats.resolve_vertical(st, proportional, kerning)), Direction::TopToBottom);
let ttb = |proportional, kerning| shape(&with_more(&|s| feats.resolve_vertical(s, proportional, kerning)), Direction::TopToBottom);
let (with, without, alone) = (ttb(true, true), ttb(false, false), ttb(true, false));
if same_glyphs(&with) && same_glyphs(&without) && same_glyphs(&alone) {
*vpal = with
@@ -1385,8 +1505,8 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
cluster_starts.dedup();
// A glyph is drawn in one style: where a cluster would take in characters of runs in different
// styles, the runs are shaped apart, as runs that do not shape alike are.
// Except at harakat styled apart only in paint ([`crate::harakat`]), and, in horizontal type, where
// the runs differ only in settings that vertical type alone reads.
// Except at harakat styled apart only in paint ([`crate::harakat`]), feature-only boundaries,
// and, in horizontal type, settings that vertical type alone reads.
let inside_clusters: Vec<usize> = boundaries()
.filter(|&b| cluster_starts.binary_search(&b).is_err() && !drawn_alike(run_at(b - 1).1, run_at(b).1, feats.vertical))
.filter(|b| mark_boundaries.binary_search(b).is_err())
+240
View File
@@ -128,6 +128,41 @@ pub fn variable_font() -> Option<Vec<u8>> {
Some(builder.build())
}
/// The family of [`unlisted_variable_font`].
pub const UNLISTED_VARIABLE_FAMILY: &str = "Unlisted Varitest Sans";
/// A variable face with no GSUB: `a.a` is an unencoded alternate named for `a`, but the Glyphs
/// panel has no listed alternates. The weight axis comes from [`variable_font`].
pub fn unlisted_variable_font() -> Option<Vec<u8>> {
use write_fonts::tables::cmap::Cmap;
let bytes = variable_font()?;
let font = skrifa::FontRef::new(&bytes).ok()?;
let mappings = [' ', 'a', 'l']
.into_iter()
.map(|c| Some((c, write_fonts::types::GlyphId::new(font.charmap().map(c)?.to_u32()))))
.collect::<Option<Vec<_>>>()?;
let cmap = Cmap::from_mappings(mappings).ok()?;
let name = name_table(&[
(1, UNLISTED_VARIABLE_FAMILY),
(2, "Regular"),
(4, "Unlisted Varitest Sans Regular"),
(5, "Version 1.000"),
(6, "UnlistedVaritestSans-Regular"),
(256, "Weight"),
])?;
let mut builder = FontBuilder::new();
builder.add_raw(Tag::new(b"name"), name);
builder.add_table(&cmap).ok()?;
for r in font.table_directory.table_records() {
let tag = Tag::new(&r.tag().to_be_bytes());
if tag != Tag::new(b"GSUB") && !builder.contains(tag) {
builder.add_raw(tag, font.table_data(r.tag())?.as_bytes());
}
}
Some(builder.build())
}
/// The family of [`two_axis_font`].
pub const TWO_AXIS_FAMILY: &str = "Biaxtest Sans";
@@ -791,6 +826,211 @@ pub fn alternates_font(aalt: bool) -> Option<Vec<u8>> {
Some(builder.build())
}
/// The family of [`capped_candidates_font`].
pub const CAPPED_CANDIDATES_FAMILY: &str = "Captest Sans";
/// The bundled Source Sans 3 Regular, its `cmap` kept, with a `GSUB` table for `a` of an `aalt`
/// alternate substitution that lists `b` 512 times (the longest set read: 512 values to try) and a
/// `salt` single substitution that gives `c`. Family [`CAPPED_CANDIDATES_FAMILY`]. `None` when it
/// can't be built.
pub fn capped_candidates_font() -> Option<Vec<u8>> {
use write_fonts::tables::gsub::{
AlternateSet, AlternateSubstFormat1, Gsub, SingleSubst, SingleSubstFormat2, SubstitutionLookup, SubstitutionLookupList,
};
use write_fonts::tables::layout::{CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, Script, ScriptList, ScriptRecord};
use write_fonts::types::GlyphId16;
const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
let font = skrifa::FontRef::new(BASE).ok()?;
let charmap = font.charmap();
let name =
name_table(&[(1, CAPPED_CANDIDATES_FAMILY), (2, "Regular"), (4, "Captest Sans Regular"), (5, "Version 1.000"), (6, "CaptestSans-Regular")])?;
let mut builder = FontBuilder::new();
builder.add_raw(Tag::new(b"name"), name);
let gid = |c| Some(GlyphId16::new(u16::try_from(charmap.map(c)?.to_u32()).ok()?));
let a = gid('a')?;
let cover = || CoverageFormat1::new(vec![a]);
let alternates = AlternateSubstFormat1::new(cover().into(), vec![AlternateSet::new(vec![gid('b')?; 512])]);
let salt = SingleSubst::from(SingleSubstFormat2::new(cover().into(), vec![gid('c')?]));
let lookups = SubstitutionLookupList::new(vec![
SubstitutionLookup::Alternate(Lookup::new(Default::default(), vec![alternates])),
SubstitutionLookup::Single(Lookup::new(Default::default(), vec![salt])),
]);
let records =
vec![FeatureRecord::new(Tag::new(b"aalt"), Feature::new(None, vec![0])), FeatureRecord::new(Tag::new(b"salt"), Feature::new(None, vec![1]))];
let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"DFLT"), Script::new(Some(LangSys::new(vec![0, 1])), vec![]))]);
builder.add_table(&Gsub::new(scripts, FeatureList::new(records), lookups)).ok()?;
for r in font.table_directory.table_records() {
let tag = Tag::new(&r.tag().to_be_bytes());
if !builder.contains(tag) {
builder.add_raw(tag, font.table_data(r.tag())?.as_bytes());
}
}
Some(builder.build())
}
/// The family of [`joining_alternates_font`].
pub const JOINING_ALTERNATES_FAMILY: &str = "Jointest Sans";
/// Beh, teh, noon and alef: the letters [`joining_alternates_font`] maps.
pub const JOINING_LETTERS: [char; 4] = ['\u{0628}', '\u{062A}', '\u{0646}', '\u{0627}'];
/// A test-only Arabic font over the bundled Source Sans 3, whose glyphs stand for the positional
/// forms of beh, teh, noon and alef ([`JOINING_LETTERS`]), each a Latin letter's outline: isolated
/// b t n I, initial B T N, medial v u m, final V U M A (alef joins to the right only). Its `arab`
/// `GSUB` has `init`, `medi` and `fina` single substitutions from the isolated glyphs, `salt`
/// alternate substitutions of the medial teh u (w, x, y: three alternates), of the final teh U
/// (W, X), of the isolated teh t (E) and of the medial beh v (z), and `ss01` single substitutions
/// u to w, U to W, t to E and v to z. A set differs by position, as in a font that draws each
/// letter's alternates for the position they join in. `cv01` gives the medial noon m the glyph d,
/// an alternate that has no style tag. Family [`JOINING_ALTERNATES_FAMILY`]. `None` when it can't
/// be built.
pub fn joining_alternates_font() -> Option<Vec<u8>> {
use write_fonts::tables::cmap::Cmap;
use write_fonts::tables::gsub::{
AlternateSet, AlternateSubstFormat1, Gsub, SingleSubst, SingleSubstFormat2, SubstitutionLookup, SubstitutionLookupList,
};
use write_fonts::tables::layout::{CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, Script, ScriptList, ScriptRecord};
use write_fonts::types::GlyphId16;
let font = skrifa::FontRef::new(SOURCE_SANS).ok()?;
let charmap = font.charmap();
let gid = |c: char| Some(GlyphId16::new(u16::try_from(charmap.map(c)?.to_u32()).ok()?));
let family = JOINING_ALTERNATES_FAMILY;
let full = format!("{family} Regular");
let postscript = format!("{}-Regular", family.replace(' ', ""));
let name = name_table(&[(1, family), (2, "Regular"), (4, &full), (5, "Version 1.000"), (6, &postscript)])?;
let mapping = [(JOINING_LETTERS[0], 'b'), (JOINING_LETTERS[1], 't'), (JOINING_LETTERS[2], 'n'), (JOINING_LETTERS[3], 'I'), (' ', ' ')];
let mappings = mapping.into_iter().map(|(c, source)| Some((c, write_fonts::types::GlyphId::new(charmap.map(source)?.to_u32()))));
let cmap = Cmap::from_mappings(mappings.collect::<Option<Vec<_>>>()?).ok()?;
let mut builder = FontBuilder::new();
builder.add_table(&cmap).ok()?;
builder.add_raw(Tag::new(b"name"), name);
// A single substitution by (glyph, replacement) pairs, sorted by glyph id as coverage needs.
let single = |pairs: &[(char, char)]| {
let mut ids = pairs.iter().map(|(from, to)| Some((gid(*from)?, gid(*to)?))).collect::<Option<Vec<_>>>()?;
ids.sort_by_key(|p| p.0.to_u16());
let from = ids.iter().map(|p| p.0).collect();
let to = ids.iter().map(|p| p.1).collect();
Some(SubstitutionLookup::Single(Lookup::new(
Default::default(),
vec![SingleSubst::from(SingleSubstFormat2::new(CoverageFormat1::new(from).into(), to))],
)))
};
let alternates = |sets: &[(char, &[char])]| {
let mut ids = sets
.iter()
.map(|(from, set)| Some((gid(*from)?, set.iter().map(|c| gid(*c)).collect::<Option<Vec<_>>>()?)))
.collect::<Option<Vec<_>>>()?;
ids.sort_by_key(|p| p.0.to_u16());
let from = ids.iter().map(|p| p.0).collect();
let sets = ids.into_iter().map(|p| AlternateSet::new(p.1)).collect();
Some(SubstitutionLookup::Alternate(Lookup::new(
Default::default(),
vec![AlternateSubstFormat1::new(CoverageFormat1::new(from).into(), sets)],
)))
};
// Lookups: 0 init, 1 medi, 2 fina, 3 salt, 4 ss01, 5 cv01.
let lookups = SubstitutionLookupList::new(vec![
single(&[('b', 'B'), ('t', 'T'), ('n', 'N')])?,
single(&[('b', 'v'), ('t', 'u'), ('n', 'm')])?,
single(&[('b', 'V'), ('t', 'U'), ('n', 'M'), ('I', 'A')])?,
alternates(&[('u', &['w', 'x', 'y']), ('U', &['W', 'X']), ('t', &['E']), ('v', &['z'])])?,
single(&[('u', 'w'), ('U', 'W'), ('t', 'E'), ('v', 'z')])?,
single(&[('m', 'd')])?,
]);
// Features sorted by tag, as OpenType requires: cv01, fina, init, medi, salt, ss01.
let records: Vec<FeatureRecord> = [(b"cv01", 5), (b"fina", 2), (b"init", 0), (b"medi", 1), (b"salt", 3), (b"ss01", 4)]
.into_iter()
.map(|(tag, lookup)| FeatureRecord::new(Tag::new(tag), Feature::new(None, vec![lookup])))
.collect();
let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"arab"), Script::new(Some(LangSys::new(vec![0, 1, 2, 3, 4, 5])), vec![]))]);
builder.add_table(&Gsub::new(scripts, FeatureList::new(records), lookups)).ok()?;
for r in font.table_directory.table_records() {
let tag = Tag::new(&r.tag().to_be_bytes());
if !builder.contains(tag) {
builder.add_raw(tag, font.table_data(r.tag())?.as_bytes());
}
}
Some(builder.build())
}
/// The family of [`aliased_joining_font`].
pub const ALIASED_JOINING_FAMILY: &str = "Alias Jointest Sans";
/// [`joining_alternates_font`] with Latin `B` and the Arabic initial beh presentation form
/// U+FE91 both mapped to beh's initial glyph. Only the presentation form should insert beh.
pub fn aliased_joining_font() -> Option<Vec<u8>> {
use write_fonts::tables::cmap::Cmap;
let bytes = joining_alternates_font()?;
let font = skrifa::FontRef::new(&bytes).ok()?;
let source = skrifa::FontRef::new(SOURCE_SANS).ok()?;
let initial = write_fonts::types::GlyphId::new(source.charmap().map('B')?.to_u32());
let mut mappings =
font.charmap().mappings().filter_map(|(c, g)| Some((char::from_u32(c)?, write_fonts::types::GlyphId::new(g.to_u32())))).collect::<Vec<_>>();
mappings.extend([('B', initial), ('\u{FE91}', initial)]);
let cmap = Cmap::from_mappings(mappings).ok()?;
let name = name_table(&[
(1, ALIASED_JOINING_FAMILY),
(2, "Regular"),
(4, "Alias Jointest Sans Regular"),
(5, "Version 1.000"),
(6, "AliasJointestSans-Regular"),
])?;
let mut builder = FontBuilder::new();
builder.add_raw(Tag::new(b"name"), name);
builder.add_table(&cmap).ok()?;
for r in font.table_directory.table_records() {
let tag = Tag::new(&r.tag().to_be_bytes());
if !builder.contains(tag) {
builder.add_raw(tag, font.table_data(r.tag())?.as_bytes());
}
}
Some(builder.build())
}
/// The family and private-use character of [`contextual_fallback_font`].
pub const CONTEXTUAL_FALLBACK_FAMILY: &str = "Contextual Fallbacktest Sans";
pub const CONTEXTUAL_FALLBACK_CHAR: char = '\u{F6084}';
/// A fallback fixture with coverage no bundled face shares: `ccmp` draws its private-use
/// character (n) as m, whose `cv01` alternate d has no applicable character-style feature.
pub fn contextual_fallback_font() -> Option<Vec<u8>> {
use write_fonts::tables::gsub::{Gsub, SingleSubst, SingleSubstFormat2, SubstitutionLookup, SubstitutionLookupList};
use write_fonts::tables::layout::{CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, Script, ScriptList, ScriptRecord};
use write_fonts::types::GlyphId16;
let base = remapped_source_sans(
CONTEXTUAL_FALLBACK_FAMILY,
"ContextualFallbacktestSans-Regular",
&[(CONTEXTUAL_FALLBACK_CHAR, 'n'), ('a', 'a'), (' ', ' ')],
)?;
let font = skrifa::FontRef::new(&base).ok()?;
let [n, m, d] = source_sans_gids(['n', 'm', 'd'])?;
let single = |from, to| {
SubstitutionLookup::Single(Lookup::new(
Default::default(),
vec![SingleSubst::from(SingleSubstFormat2::new(CoverageFormat1::new(vec![GlyphId16::new(from)]).into(), vec![GlyphId16::new(to)]))],
))
};
let lookups = SubstitutionLookupList::new(vec![single(n, m), single(m, d)]);
let features = FeatureList::new(vec![
FeatureRecord::new(Tag::new(b"ccmp"), Feature::new(None, vec![0])),
FeatureRecord::new(Tag::new(b"cv01"), Feature::new(None, vec![1])),
]);
let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"DFLT"), Script::new(Some(LangSys::new(vec![0, 1])), vec![]))]);
let mut builder = FontBuilder::new();
builder.add_table(&Gsub::new(scripts, features, lookups)).ok()?;
for r in font.table_directory.table_records() {
let tag = Tag::new(&r.tag().to_be_bytes());
if !builder.contains(tag) {
builder.add_raw(tag, font.table_data(r.tag())?.as_bytes());
}
}
Some(builder.build())
}
/// The family of [`ligature_caret_font`].
pub const LIGATURE_CARET_FAMILY: &str = "Carettest Sans";
+1
View File
@@ -13,6 +13,7 @@ mod composite;
mod devanagari;
mod embed;
mod fit;
mod glyph_alternates;
mod hang;
mod harakat;
mod inline;
+504
View File
@@ -0,0 +1,504 @@
//! A feature with a value (`salt=2`) on some characters of an Arabic word: the font's alternate
//! for them, with the word shaped once so that every other letter keeps its joining form and the
//! letters still touch. Read from a font built in code (and, when it is installed, the owner's
//! Kufi Murabba PIIIX).
use kurbo::Point;
use vectorcraft_doc::{CharStyle, TextRun};
use crate::test_fonts::{JOINING_ALTERNATES_FAMILY, JOINING_LETTERS, joining_alternates_font, source_sans_gid};
use crate::*;
const BEH: &str = "\u{0628}";
const TEH: &str = "\u{062A}";
const NOON: &str = "\u{0646}";
fn db() -> FontDb {
let db = FontDb::with_font_dirs(vec![]);
db.add_font(joining_alternates_font().expect("the test font builds"));
db
}
fn style(family: &str, features: &[&str]) -> CharStyle {
CharStyle { font_family: family.into(), size: 100.0, features: features.iter().map(|f| f.to_string()).collect(), ..CharStyle::default() }
}
/// `text` as one text object in `family`, each piece with its own features.
fn set(db: &FontDb, family: &str, pieces: &[(&str, &[&str])]) -> Vec<PositionedGlyph> {
let mut text = TextObject::point(Point::ZERO, "", style(family, &[]));
text.runs = pieces.iter().map(|(t, features)| TextRun { text: t.to_string(), style: style(family, features), inline: None }).collect();
// In reading order of the characters, whichever way the line runs.
let mut glyphs = layout(db, &text).glyphs;
glyphs.sort_by_key(|g| g.byte);
glyphs
}
fn joined(db: &FontDb, pieces: &[(&str, &[&str])]) -> Vec<PositionedGlyph> {
set(db, JOINING_ALTERNATES_FAMILY, pieces)
}
fn gids(glyphs: &[PositionedGlyph]) -> Vec<u32> {
glyphs.iter().map(|g| g.gid).collect()
}
fn g(c: char) -> u32 {
u32::from(source_sans_gid(c).expect("a Source Sans glyph"))
}
/// The gap between each glyph and the next along the line, whichever way the line runs: 0 when the
/// letters touch.
fn gaps(glyphs: &[PositionedGlyph]) -> Vec<f64> {
// `origin` is the pen position at the glyph's left edge, also in right-to-left text.
let mut spans: Vec<(f64, f64)> = glyphs.iter().map(|g| (g.origin.x, g.origin.x + g.advance)).collect();
spans.sort_by(|a, b| a.0.total_cmp(&b.0));
spans.windows(2).map(|w| w[1].0 - w[0].1).collect()
}
/// Beh, teh, noon: initial, medial and final in one word, teh drawn with `features`.
fn word(db: &FontDb, teh_features: &[&str]) -> Vec<PositionedGlyph> {
joined(db, &[(BEH, &[]), (TEH, teh_features), (NOON, &[])])
}
/// Without a feature the word is beh initial, teh medial, noon final.
#[test]
fn the_fixture_word_takes_its_joining_forms() {
let db = db();
assert_eq!(gids(&joined(&db, &[(&format!("{BEH}{TEH}{NOON}"), &[])])), [g('B'), g('u'), g('M')]);
assert_eq!(JOINING_LETTERS.len(), 4);
}
/// `salt=N` on the middle letter draws the font's Nth alternate for it, its neighbours keep their
/// initial and final forms (they are not shaped as lone letters), and the letters still touch.
#[test]
fn a_salt_value_on_one_letter_picks_its_alternate_and_keeps_the_word_joined() {
let db = db();
let plain = word(&db, &[]);
assert_eq!(gids(&plain), [g('B'), g('u'), g('M')]);
let plain_gaps = gaps(&plain);
assert!(plain_gaps.iter().all(|gap| gap.abs() < 1e-6), "the word's glyphs touch: {plain_gaps:?}");
for (features, middle) in [(&["salt"][..], 'w'), (&["salt=1"][..], 'w'), (&["salt=2"][..], 'x'), (&["salt=3"][..], 'y')] {
let glyphs = word(&db, features);
assert_eq!(gids(&glyphs), [g('B'), g(middle), g('M')], "{features:?}: the alternate between the word's own initial and final forms");
let gap = gaps(&glyphs);
assert!(gap.iter().all(|gap| gap.abs() < 1e-6), "{features:?}: the letters touch: {gap:?}");
}
// An alternate number the font has no glyph for, and the feature off, draw the default glyph.
for features in [&["salt=4"][..], &["-salt"][..], &["salt=0"][..], &["salt=2", "-salt"][..], &["ss03"][..]] {
assert_eq!(gids(&word(&db, features)), [g('B'), g('u'), g('M')], "{features:?}");
}
// The last value of a feature wins.
assert_eq!(gids(&word(&db, &["salt=3", "salt=2"]))[1], g('x'));
// The font's salt lookup comes before its ss01 lookup, so salt takes the glyph first.
assert_eq!(gids(&word(&db, &["salt=2", "ss01"]))[1], g('x'));
}
/// A feature on the first or the last letter of the word does not split it either.
#[test]
fn a_feature_on_an_end_letter_does_not_break_the_joining() {
let db = db();
for (pieces, expected) in [
(vec![(BEH, &["salt=2"][..]), (TEH, &[][..]), (NOON, &[][..])], [g('B'), g('u'), g('M')]),
(vec![(BEH, &[][..]), (TEH, &[][..]), (NOON, &["salt=2"][..])], [g('B'), g('u'), g('M')]),
(vec![(BEH, &["ss01"][..]), (TEH, &["ss01"][..]), (NOON, &["ss01"][..])], [g('B'), g('w'), g('M')]),
] {
let glyphs = joined(&db, &pieces);
assert_eq!(gids(&glyphs), expected, "{pieces:?}");
assert!(gaps(&glyphs).iter().all(|gap| gap.abs() < 1e-6), "{pieces:?}");
}
}
/// The alternates of a letter depend on the form it takes where it stands: the final teh has two,
/// the medial teh three, so `salt=3` is an alternate of one and nothing for the other.
#[test]
fn the_alternate_a_value_picks_depends_on_the_letters_position() {
let db = db();
let medial = |features: &[&str]| gids(&word(&db, features))[1];
let final_form = |features: &[&str]| gids(&joined(&db, &[(BEH, &[]), (TEH, features)]))[1];
assert_eq!((medial(&["salt=3"]), final_form(&["salt=3"])), (g('y'), g('U')), "no third alternate for the final form");
assert_eq!((medial(&["salt=2"]), final_form(&["salt=2"])), (g('x'), g('X')));
// A letter alone takes the isolated form's alternate.
assert_eq!(gids(&joined(&db, &[(TEH, &["salt"])])), [g('E')]);
assert_eq!(gids(&joined(&db, &[(TEH, &[])])), [g('t')]);
}
/// A run with its own feature next to runs of another style in the word shapes with them: the
/// same glyph ids and places as a word typed in one run, when the feature changes nothing.
#[test]
fn runs_that_differ_only_in_features_shape_as_one_word() {
let db = db();
let one_run = joined(&db, &[(&format!("{BEH}{TEH}{NOON}"), &[])]);
let three_runs = joined(&db, &[(BEH, &[]), (TEH, &["salt=9"]), (NOON, &["-liga"])]);
assert_eq!(gids(&three_runs), gids(&one_run));
let (a, b): (Vec<f64>, Vec<f64>) = (one_run.iter().map(|g| g.origin.x).collect(), three_runs.iter().map(|g| g.origin.x).collect());
assert_eq!(a, b, "the same places");
}
/// Where the owner's Kufi Murabba PIIIX is installed.
const KUFI_FILE: &str = "Library/Fonts/KufiMurabbaPIIIX-Regular.ttf";
/// The owner's Heavy weight of it, the one a final yeh was reported against.
const KUFI_HEAVY_FILE: &str = "Library/Fonts/KufiMurabbaPIIIX-Heavy.ttf";
/// The Kufi Murabba PIIIX of the font's author in a font database of its own, `None` when it is not
/// installed (the test then has nothing to read). When the file is there, a face that does not
/// load is a failure, never a skip.
fn kufi() -> Option<(FontDb, std::sync::Arc<FontFace>)> {
kufi_weight(KUFI_FILE, "Regular")
}
/// [`kufi`] for the installed file `file` (under the home folder), whose face has style `style`.
fn kufi_weight(file: &str, style: &str) -> Option<(FontDb, std::sync::Arc<FontFace>)> {
let path = std::path::Path::new(&std::env::var_os("HOME")?).join(file);
if !path.exists() {
eprintln!("skipped: {} is not installed", path.display());
return None;
}
let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{} exists but cannot be read: {e}", path.display()));
let db = FontDb::with_font_dirs(vec![]);
let added = db.add_font(bytes);
assert!(added > 0, "{} exists but no face of it loaded", path.display());
let face = db.face("Kufi Murabba PIIIX", style).expect("a face");
assert_eq!((face.family.as_str(), face.style.as_str()), ("Kufi Murabba PIIIX", style), "the installed font's face, not a fallback");
Some((db, face))
}
/// The word al-Khwarizmi: alef, lam, khah, waw, alef, reh, zain, meem, farsi yeh. The lam, khah and
/// waw join (initial, medial, final); the letters around them do not join forward.
const KHWARIZMI: &str = "الخوارزمی";
/// The medial khah, the middle letter of the joined lam-khah-waw.
const KHAH: usize = 2;
/// The word with `features` on the character `at` only, shaped as one piece (the runs differ only
/// in features), in reading order of the characters.
fn khwarizmi(db: &FontDb, family: &str, at: usize, features: &[&str]) -> Vec<PositionedGlyph> {
let letters: Vec<char> = KHWARIZMI.chars().collect();
let part = |r: std::ops::Range<usize>| -> String { letters[r].iter().collect() };
let (before, middle, after) = (part(0..at), part(at..at + 1), part(at + 1..letters.len()));
set(db, family, &[(&before, &[]), (&middle, features), (&after, &[])])
}
/// The gap between the drawn outlines of the letters that join (the lam, khah and waw), along the
/// line: 0 when the ink of one reaches the ink of the next. Unlike [`gaps`], which only checks that
/// the pen advances by each glyph's advance, this reads the glyphs drawn.
fn ink_gaps(glyphs: &[PositionedGlyph]) -> Vec<f64> {
use kurbo::Shape;
let mut spans: Vec<(f64, f64)> = glyphs[KHAH - 1..=KHAH + 1].iter().map(|g| (g.outline.bounding_box().x0, g.outline.bounding_box().x1)).collect();
spans.sort_by(|a, b| a.0.total_cmp(&b.0));
spans.windows(2).map(|w| w[1].0 - w[0].1).collect()
}
/// One line of what the shaper made, for a failure message and `--nocapture`.
fn describe(glyphs: &[PositionedGlyph]) -> String {
glyphs.iter().map(|g| format!("{}@{:.1}+{:.1}", g.gid, g.origin.x, g.advance)).collect::<Vec<_>>().join(" ")
}
/// The Kufi Murabba PIIIX of the font's author, when it is installed: the medial khah of a word
/// takes each of its `salt` alternates, every other letter keeps the glyph it has without one and
/// the letters still touch.
#[test]
fn kufi_murabba_alternates_keep_the_word_joined() {
let Some((db, face)) = kufi() else { return };
let family = face.family.clone();
let plain = khwarizmi(&db, &family, KHAH, &[]);
assert_eq!(plain.len(), KHWARIZMI.chars().count(), "one glyph for each letter: {}", describe(&plain));
assert!(gaps(&plain).iter().all(|gap| gap.abs() < 1e-3), "the word joins without a gap: {:?}", gaps(&plain));
assert!(ink_gaps(&plain).iter().all(|gap| gap.abs() < 1e-3), "the drawn letters touch: {:?}", ink_gaps(&plain));
let default = plain[KHAH].gid;
assert_eq!(face.glyph_name(default).as_deref(), Some("khah.medi"), "the khah in the middle of the word takes its medial form");
let salt =
face.substitutions(default).into_iter().find(|s| &s.feature == b"salt" && s.glyphs.len() >= 2).expect("the medial khah has salt alternates");
for (i, alternate) in salt.glyphs.iter().enumerate() {
let value = format!("salt={}", i + 1);
let glyphs = khwarizmi(&db, &family, KHAH, &[value.as_str()]);
assert_eq!(glyphs.len(), plain.len(), "{value}: {}", describe(&glyphs));
assert_eq!(glyphs[KHAH].gid, *alternate, "{value}: the alternate");
assert_ne!(glyphs[KHAH].gid, default, "{value}: not the default glyph");
assert_eq!(face.glyph_name(*alternate), Some(format!("khah.medi.alt{}", i + 1)), "{value}: an alternate of the medial form");
for (n, (a, b)) in glyphs.iter().zip(&plain).enumerate().filter(|(n, _)| *n != KHAH) {
assert_eq!(a.gid, b.gid, "{value}: the letter {n} keeps its form: {}", describe(&glyphs));
}
assert!(gaps(&glyphs).iter().all(|gap| gap.abs() < 1e-3), "{value}: no gap: {:?}", gaps(&glyphs));
assert!(ink_gaps(&glyphs).iter().all(|gap| gap.abs() < 1e-3), "{value}: the drawn letters touch: {:?}", ink_gaps(&glyphs));
}
}
/// The stylistic sets `ss01` to `ss05` on a letter in the middle of a joined group: the glyph the
/// font's set gives its medial form (the default when it has none), the same forms for the others,
/// no gap.
#[test]
fn kufi_murabba_stylistic_sets_on_a_middle_letter_keep_the_word_joined() {
let Some((db, face)) = kufi() else { return };
let family = face.family.clone();
let plain = khwarizmi(&db, &family, KHAH, &[]);
let default = plain[KHAH].gid;
let mut changed = 0;
for set_number in 1..=5 {
let tag = format!("ss{set_number:02}");
let wanted = face.substitutions(default).into_iter().find(|s| s.feature == tag.as_bytes() && !s.glyphs.is_empty()).map(|s| s.glyphs[0]);
let glyphs = khwarizmi(&db, &family, KHAH, &[tag.as_str()]);
assert_eq!(glyphs.len(), plain.len(), "{tag}: {}", describe(&glyphs));
assert_eq!(glyphs[KHAH].gid, wanted.unwrap_or(default), "{tag}: the set's glyph for the medial khah");
changed += usize::from(wanted.is_some_and(|w| w != default));
for (n, (a, b)) in glyphs.iter().zip(&plain).enumerate().filter(|(n, _)| *n != KHAH) {
assert_eq!(a.gid, b.gid, "{tag}: the letter {n} keeps its form: {}", describe(&glyphs));
}
assert!(gaps(&glyphs).iter().all(|gap| gap.abs() < 1e-3), "{tag}: no gap: {:?}", gaps(&glyphs));
assert!(ink_gaps(&glyphs).iter().all(|gap| gap.abs() < 1e-3), "{tag}: the drawn letters touch: {:?}", ink_gaps(&glyphs));
}
assert!(changed >= 1, "the font has stylistic sets for the medial khah");
}
/// The `GSUB` reader gives the alternate set of each glyph (position in the set = the feature's
/// value), single substitutions, the feature that reaches a glyph, names and the glyph count.
#[test]
fn the_gsub_reader_lists_alternate_sets_singles_and_the_feature_reaching_a_glyph() {
use crate::{Substitution, SubstitutionKind};
let db = db();
let face = db.face(JOINING_ALTERNATES_FAMILY, "Regular").expect("the test face");
let salt = Substitution { feature: *b"salt", kind: SubstitutionKind::Alternate, glyphs: vec![g('w'), g('x'), g('y')] };
let ss01 = Substitution { feature: *b"ss01", kind: SubstitutionKind::Single, glyphs: vec![g('w')] };
assert_eq!(face.substitutions(g('u')), [salt.clone(), ss01.clone()], "salt (an alternate set) before ss01 (a single)");
assert_eq!(salt.value_for(g('x')), Some(2));
assert_eq!(salt.value_for(g('u')), None);
assert_eq!((ss01.value_for(g('w')), ss01.value_for(g('x'))), (Some(1), None));
// Each form of a letter has its own alternates.
let sets = |c| face.substitutions(g(c)).iter().map(|s| (s.feature, s.glyphs.len())).collect::<Vec<_>>();
assert_eq!(sets('U'), [(*b"salt", 2), (*b"ss01", 1)]);
assert_eq!(sets('t'), [(*b"salt", 1), (*b"ss01", 1)]);
assert!(sets('B').is_empty() && sets('M').is_empty(), "the initial and final forms of the others have none");
assert!(face.substitutions(face.glyph_count()).is_empty(), "past the glyphs");
// The feature that reaches a glyph no character maps: the listed ones first, then positional forms.
assert_eq!(face.feature_reaching(g('x')), Some(*b"salt"));
assert_eq!(face.feature_reaching(g('w')), Some(*b"salt"), "salt ranks before ss01");
assert_eq!(face.feature_reaching(g('B')), Some(*b"init"));
assert_eq!(face.feature_reaching(g('m')), Some(*b"medi"));
assert_eq!(face.feature_reaching(g('A')), Some(*b"fina"));
assert_eq!(face.feature_reaching(g('b')), None, "the isolated form is reached by no feature");
// Names and the glyph count.
let bundled = db.face(FALLBACK_FAMILY, "Regular").expect("the bundled face");
assert_eq!(face.glyph_count(), bundled.glyph_count());
assert_eq!(face.glyph_name(g('B')).as_deref(), Some("B"));
assert_eq!(face.glyph_name(face.glyph_count()), None);
// `alternates` is the same reading, for a character.
let listed: Vec<([u8; 4], u32)> = face.alternates('\u{062A}').into_iter().map(|a| (a.feature, a.gid)).collect();
assert_eq!(listed, [(*b"salt", g('E'))], "the isolated teh");
}
/// The alternates of a letter as it stands in its word: the glyph the shaper makes for it there,
/// its alternates, and the feature value that draws each on that letter alone.
#[test]
fn alternates_in_context_list_the_letters_positional_form_and_the_feature_that_draws_each() {
let db = db();
let text = |teh: &[&str]| {
let mut t = TextObject::point(Point::ZERO, "", style(JOINING_ALTERNATES_FAMILY, &[]));
t.runs = [(BEH, &[][..]), (TEH, teh), (NOON, &[][..])]
.into_iter()
.map(|(s, f)| TextRun { text: s.to_string(), style: style(JOINING_ALTERNATES_FAMILY, f), inline: None })
.collect();
t
};
let teh = BEH.len();
let found = alternates_in_context(&db, &text(&[]), teh).expect("the medial teh");
assert_eq!((found.default, found.current), (g('u'), g('u')));
let listed: Vec<(u32, Option<String>)> = found.alternates.iter().map(|a| (a.gid, a.apply.clone())).collect();
assert_eq!(
listed,
[(g('w'), Some("salt".to_string())), (g('x'), Some("salt=2".to_string())), (g('y'), Some("salt=3".to_string()))],
"the medial form's three alternates, not the isolated form's one"
);
// Drawn with salt=2 now: the default is still the form without it.
let now = alternates_in_context(&db, &text(&["salt=2"]), teh).expect("the medial teh");
assert_eq!((now.default, now.current, now.alternates.len()), (g('u'), g('x'), 3));
// The first and last letters take their own forms.
let beh = alternates_in_context(&db, &text(&[]), 0).expect("the initial beh");
assert_eq!((beh.default, beh.alternates.len()), (g('B'), 0));
let noon = alternates_in_context(&db, &text(&[]), teh + TEH.len()).expect("the final noon");
assert_eq!((noon.default, noon.alternates.len()), (g('M'), 0));
assert_eq!(alternates_in_context(&db, &text(&[]), 1), None, "not a character boundary");
// alternate_changes: the character keeps only the alternate feature it is given.
let own = |f: &[&str]| f.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert_eq!(alternate_changes(&own(&["ss01", "salt=2", "dlig", "-ss02"]), Some("salt=3")), ["-ss01", "salt=3"]);
assert_eq!(alternate_changes(&own(&["ss01", "salt=2", "dlig"]), None), ["-ss01", "-salt"]);
assert!(alternate_changes(&own(&["salt=2", "dlig"]), Some("salt=2")).is_empty());
assert_eq!(alternate_changes(&own(&["salt"]), Some("salt=2")), ["salt=2"]);
assert!(alternate_changes(&own(&["dlig"]), None).is_empty());
assert!(is_alternate_feature("-ss03") && is_alternate_feature("salt=2") && !is_alternate_feature("dlig") && !is_alternate_feature("titl"));
}
/// A glyph no feature of the style draws is listed without one, and a feature's glyph is found
/// with the feature that draws it.
#[test]
fn a_variant_no_feature_draws_has_no_feature_to_apply() {
use crate::test_fonts::{alternates_family, alternates_font, unencoded_glyphs};
let db = FontDb::with_font_dirs(vec![]);
db.add_font(alternates_font(true).expect("the test font builds"));
let [u1, u2, u3, u4] = unencoded_glyphs().expect("unencoded glyphs");
let face = db.face(alternates_family(true), "Regular").expect("the test face");
let st = CharStyle { font_family: alternates_family(true).into(), size: 100.0, ..CharStyle::default() };
let found = alternates_in_context(&db, &TextObject::point(Point::ZERO, "xay", st), 1).expect("the a");
let apply = |gid: u32| found.alternates.iter().find(|a| a.gid == gid).map(|a| a.apply.clone());
assert_eq!(found.default, face.glyph_for('a'));
assert_eq!(apply(u1), Some(Some("swsh=2".to_string())), "swsh before aalt");
assert_eq!(apply(u2), Some(Some("ss01".to_string())));
assert_eq!(apply(u3), Some(Some("swsh".to_string())));
assert_eq!(apply(u4), Some(Some("ss02".to_string())));
assert_eq!(apply(face.glyph_for('c')), Some(None), "cv01 has no style tag");
assert!(face.unencoded_glyphs().contains(&u1) && !face.unencoded_glyphs().contains(&face.glyph_for('a')));
}
/// The alternates are listed `aalt` first, then `salt`, the stylistic sets, `swsh` and `cv01`, each
/// glyph once under the first feature that lists it, whichever feature draws it in the text.
#[test]
fn alternates_are_listed_aalt_first_under_the_first_feature_that_lists_them() {
use crate::test_fonts::{alternates_family, alternates_font, unencoded_glyphs};
let db = FontDb::with_font_dirs(vec![]);
db.add_font(alternates_font(true).expect("the test font builds"));
let [u1, u2, u3, u4] = unencoded_glyphs().expect("unencoded glyphs");
let face = db.face(alternates_family(true), "Regular").expect("the test face");
let st = CharStyle { font_family: alternates_family(true).into(), size: 100.0, ..CharStyle::default() };
let found = alternates_in_context(&db, &TextObject::point(Point::ZERO, "xay", st), 1).expect("the a");
let listed: Vec<(u32, [u8; 4], Option<String>)> = found.alternates.iter().map(|a| (a.gid, a.feature, a.apply.clone())).collect();
let (a, b, c) = (face.glyph_for('a'), face.glyph_for('b'), face.glyph_for('c'));
assert_eq!(
listed,
[
(a, *b"aalt", Some("aalt".to_string())),
(u1, *b"aalt", Some("swsh=2".to_string())),
(b, *b"aalt", Some("salt".to_string())),
(u2, *b"ss01", Some("ss01".to_string())),
(u4, *b"ss02", Some("ss02".to_string())),
(u3, *b"swsh", Some("swsh".to_string())),
(c, *b"cv01", None),
],
"aalt's set first, with the default glyph it lists; b is aalt's though salt draws it"
);
}
/// A glyph that more than its character makes (the lam-alef ligature) has no alternates of its own
/// there, though the font substitutes it (`salt`).
#[test]
fn a_ligature_has_no_alternates_in_context() {
use crate::test_fonts::{LAM_ALEF_SALT, lam_alef_family, lam_alef_font};
let db = FontDb::with_font_dirs(vec![]);
db.add_font(lam_alef_font(b"rlig").expect("the test font builds"));
let family = lam_alef_family(b"rlig");
let face = db.face(family, "Regular").expect("the test face");
let lam_alef = TextObject::point(Point::ZERO, "\u{0644}\u{0627}", style(family, &[]));
let ligature = g('H');
assert_eq!(
face.substitutions(ligature).iter().map(|s| s.glyphs.clone()).collect::<Vec<_>>(),
[vec![g(LAM_ALEF_SALT)]],
"the font gives the ligature an alternate"
);
let found = alternates_in_context(&db, &lam_alef, 0).expect("the ligature");
assert_eq!((found.default, found.current), (ligature, ligature));
assert!(found.alternates.is_empty(), "{:?}", found.alternates);
// The lam alone is its own character: it has no alternates because the font gives it none.
let alone = alternates_in_context(&db, &TextObject::point(Point::ZERO, "\u{0644}", style(family, &[])), 0).expect("the lam");
assert_eq!(alone.default, g('l'));
}
/// A word is shaped as one piece (to 256 characters each side of the selected one), the selected
/// character found in it by its offset: a letter deep in a long word and the letters next to a
/// space, a line feed or a line separator each get the form their place in their own word gives.
#[test]
fn the_probe_finds_the_character_in_its_word_across_a_long_word_and_breaks() {
let db = db();
let text = |s: &str| TextObject::point(Point::ZERO, s, style(JOINING_ALTERNATES_FAMILY, &[]));
let default_of = |t: &TextObject, byte: usize| {
let found = alternates_in_context(&db, t, byte).expect("a character");
(found.default, found.alternates.len())
};
// 700 letters: beh, 698 teh, noon. Each is found at its own offset, 300 of them past the
// 256 characters either side that are shaped.
let long = text(&format!("{BEH}{}{NOON}", TEH.repeat(698)));
assert_eq!(default_of(&long, 0), (g('B'), 0), "the first letter is initial");
assert_eq!(default_of(&long, 2 * 300), (g('u'), 3), "a medial teh deep in the word, with its alternates");
assert_eq!(default_of(&long, 2 * 699), (g('M'), 0), "the last letter is final");
// Two words: the break leaves each its own initial and final letters. The teh before the break
// ends its word (final) and the one after starts the next (initial), not medial.
for brk in [" ", "\n", "\u{2028}", "\u{2029}"] {
let two = text(&format!("{BEH}{TEH}{brk}{TEH}{NOON}"));
let after = 2 * 2 + brk.len();
assert_eq!(default_of(&two, 2), (g('U'), 2), "{brk:?}: final teh before the break, with its two alternates");
assert_eq!(default_of(&two, after), (g('T'), 0), "{brk:?}: initial teh after it");
assert_eq!(default_of(&two, after + 2), (g('M'), 0), "{brk:?}: the noon after it is final");
}
}
/// A word of alternates longer than the candidates tried (512) leaves the features after it
/// untried: `aalt`'s 512 values use them all, so `salt`'s glyph is listed but no feature is applied
/// for it.
#[test]
fn the_candidates_tried_are_capped() {
use crate::test_fonts::{CAPPED_CANDIDATES_FAMILY, capped_candidates_font};
let db = FontDb::with_font_dirs(vec![]);
db.add_font(capped_candidates_font().expect("the test font builds"));
let face = db.face(CAPPED_CANDIDATES_FAMILY, "Regular").expect("the test face");
let found = alternates_in_context(&db, &TextObject::point(Point::ZERO, "xay", style(CAPPED_CANDIDATES_FAMILY, &[])), 1).expect("the a");
let listed: Vec<(u32, Option<String>)> = found.alternates.iter().map(|a| (a.gid, a.apply.clone())).collect();
assert_eq!(listed, [(face.glyph_for('b'), Some("aalt".to_string())), (face.glyph_for('c'), None)]);
}
/// A feature on a one-byte character (Latin) reaches that character: the range of a feature is
/// its source bytes, and harfrust applies a range `a..b` to clusters `a..b - 1` unless the end is
/// inclusive, which would leave a one-byte character out.
#[test]
fn a_feature_on_a_one_byte_character_reaches_it() {
use crate::test_fonts::{alternates_family, alternates_font};
let db = FontDb::with_font_dirs(vec![]);
db.add_font(alternates_font(true).expect("the test font builds"));
let face = db.face(alternates_family(true), "Regular").expect("the test face");
let st = |features: &[&str]| style(alternates_family(true), features);
let mut text = TextObject::point(Point::ZERO, "", st(&[]));
text.runs = vec![
TextRun { text: "x".into(), style: st(&[]), inline: None },
TextRun { text: "a".into(), style: st(&["salt"]), inline: None },
TextRun { text: "y".into(), style: st(&[]), inline: None },
];
let gids: Vec<u32> = layout(&db, &text).glyphs.iter().map(|g| g.gid).collect();
assert_eq!(gids, [face.glyph_for('x'), face.glyph_for('b'), face.glyph_for('y')], "salt draws b for the a alone");
}
/// The owner's Kufi Murabba PIIIX Heavy, with the final letter of al-Khwarizmi as Persian yeh
/// (U+06CC), Arabic yeh (U+064A) or alef maksura (U+0649): in each the letter takes its own final
/// form, the byte offset of the last character finds its alternates, `salt` to `salt=6` draw them
/// on that letter alone with the rest of the word and its joining untouched, and the glyph an
/// alternate is (`yehfarsi.fina.alt2`) is traced back to its letter and its place in the word.
#[test]
fn kufi_murabba_heavy_final_letters_alternates_belong_to_their_letter() {
let Some((db, face)) = kufi_weight(KUFI_HEAVY_FILE, "Heavy") else { return };
let family = face.family.clone();
for (letter, base) in [('\u{06CC}', "yehfarsi"), ('\u{064A}', "yeh"), ('\u{0649}', "alefmaksura")] {
let head: String = KHWARIZMI.chars().take(KHWARIZMI.chars().count() - 1).collect();
let word = format!("{head}{letter}");
let last = word.len() - letter.len_utf8();
let letter = letter.to_string();
let plain = set(&db, &family, &[(&head, &[]), (&letter, &[])]);
assert_eq!(plain.len(), KHWARIZMI.chars().count(), "{base}: one glyph for each letter: {}", describe(&plain));
assert_eq!(face.glyph_name(plain[plain.len() - 1].gid), Some(format!("{base}.fina")), "{base}: the letter ends the word in its final form");
let mut text = TextObject::point(Point::ZERO, "", style(&family, &[]));
text.runs = vec![TextRun { text: word.clone(), style: style(&family, &[]), inline: None }];
let found = alternates_in_context(&db, &text, last).expect("the final letter");
assert_eq!((found.default, found.current), (plain[plain.len() - 1].gid, plain[plain.len() - 1].gid), "{base}");
assert_eq!(found.alternates.len(), 6, "{base}: the six alternates of the final form");
for (n, a) in found.alternates.iter().enumerate() {
let tag = if n == 0 { "salt".to_string() } else { format!("salt={}", n + 1) };
assert_eq!(a.apply.as_deref(), Some(tag.as_str()), "{base}: the feature that draws alternate {}", n + 1);
assert_eq!(face.glyph_name(a.gid), Some(format!("{base}.fina.alt{}", n + 1)), "{base}");
let glyphs = set(&db, &family, &[(&head, &[]), (&letter, &[tag.as_str()])]);
assert_eq!(glyphs.len(), plain.len(), "{base} {tag}: {}", describe(&glyphs));
assert_eq!(glyphs[glyphs.len() - 1].gid, a.gid, "{base} {tag}: the alternate");
assert!(
glyphs.iter().zip(&plain).take(plain.len() - 1).all(|(x, y)| x.gid == y.gid),
"{base} {tag}: the rest of the word keeps its forms"
);
let origin = face.glyph_origin(a.gid).expect("the alternate comes from a letter");
assert_eq!(origin.ch.to_string(), letter, "{base}: {} is a form of its own letter", face.glyph_name(a.gid).unwrap_or_default());
assert_eq!(origin.position(), Some(JoiningPosition::Final), "{base}: and of its end of a word");
}
}
// Glyph 820 of the owner's file is yehfarsi.fina.alt2: Persian yeh's, at the end of a word.
let g820 = face.glyph_origin(820).expect("glyph 820 comes from a letter");
assert_eq!((face.glyph_name(820).as_deref(), g820.ch, g820.position()), (Some("yehfarsi.fina.alt2"), '\u{06CC}', Some(JoiningPosition::Final)));
}
+43 -6
View File
@@ -152,13 +152,13 @@ fn paint_only_differences_are_told_apart() {
assert_eq!(harakat_ranges("اللَّهِ", 0.."اللَّهِ".len()), [6..10, 12..14], "shadda and fatha together");
}
/// The allocation-free comparison must keep the previous comparison's complete contract,
/// including attributes that affect layout rather than shaping and non-reflexive file values.
/// Compare every drawing attribute without allocations, retaining PartialEq for non-reflexive
/// values while allowing features already resolved by shaping.
#[test]
fn paint_only_keeps_every_nonpaint_attribute_and_partial_equality() {
fn paint_only_keeps_every_drawing_attribute_and_partial_equality() {
use vectorcraft_doc::{CharAlign, CharPosition, Dash, Digits, FontHeightReference, GlyphChoice, LineCap, LineJoin, ParaDirection, StrokeAlign};
fn previous(a: &CharStyle, b: &CharStyle) -> bool {
fn normalized(a: &CharStyle, b: &CharStyle) -> bool {
let mut b = b.clone();
b.fill = a.fill.clone();
b.stroke = a.stroke.clone();
@@ -172,12 +172,13 @@ fn paint_only_keeps_every_nonpaint_attribute_and_partial_equality() {
b.stroke_align = a.stroke_align;
b.style_name = a.style_name.clone();
b.kerning_before = a.kerning_before;
b.features = a.features.clone();
*a == b
}
fn check(a: &CharStyle, b: &CharStyle, want: bool, name: &str) {
for (left, right) in [(a, b), (b, a)] {
assert_eq!(paint_only(left, right), previous(left, right), "{name}: old contract");
assert_eq!(paint_only(left, right), normalized(left, right), "{name}: normalized contract");
}
assert_eq!(paint_only(a, b), want, "{name}");
}
@@ -213,7 +214,7 @@ fn paint_only_keeps_every_nonpaint_attribute_and_partial_equality() {
field!(underline, true, false);
field!(strikethrough, true, false);
field!(all_caps, true, false);
field!(features, vec!["-liga".into()], false);
field!(features, vec!["-liga".into()], true);
field!(position, CharPosition::Superscript(Default::default()), false);
field!(small_caps, Some(80.0), false);
field!(char_align, CharAlign::EmBoxTop, false);
@@ -294,6 +295,42 @@ fn synthetic_harakat_keep_geometry_with_and_without_gdef_marks() {
}
}
#[test]
fn feature_only_harakat_boundaries_keep_attachment_and_ownership() {
let db = FontDb::with_font_dirs(vec![]);
assert_eq!(db.add_font(crate::test_fonts::lam_alef_font(b"rlig").expect("the test font builds")), 1);
let family = crate::test_fonts::lam_alef_family(b"rlig");
let face = db.face(family, "Regular").expect("the test face");
const WORD: &str = "بَبَبَ";
let plain = TextObject::point(Point::ZERO, WORD, style(family));
let before = layout(&db, &plain);
assert_eq!(before.glyphs.len(), 6, "three bases with their attached marks");
assert!(before.glyphs.iter().any(|g| is_gdef_mark(&face, g.gid)));
for feature_marks in [false, true] {
for coloured in [false, true] {
let mut text = plain.clone();
for (byte, ch) in WORD.char_indices() {
crate::edit::style_range(&mut text.runs, byte, byte + ch.len_utf8(), |st| {
if is_harakat(ch) == feature_marks {
st.features = vec!["salt=2".into()];
}
if coloured && is_harakat(ch) {
st.fill = red();
}
});
}
let after = layout(&db, &text);
assert_eq!(geometry(&after), geometry(&before), "features on marks {feature_marks}, coloured {coloured}");
for glyph in &after.glyphs {
let mark = is_gdef_mark(&face, glyph.gid);
let owner = &run_of(&text, glyph).style;
assert_eq!(!owner.features.is_empty(), mark == feature_marks, "each glyph keeps its own character's features");
assert_eq!(owner.fill == red(), coloured && mark, "only marks take the mark paint");
}
}
}
}
/// A range is normalised as `edit::style_range` takes one: clamped, each end moved back to the
/// start of its character, put in order. Never a silent empty result for a usable range.
#[test]
+60
View File
@@ -629,6 +629,66 @@ fn an_upright_ligature_takes_its_caret_down_its_cell() {
assert!((at(900) - 0.5).abs() < 1e-9, "{}", at(900));
}
#[test]
fn feature_only_settings_keep_a_ligature_whole() {
let db = db_with(vertical_ligature_caret_font(280));
for vertical in [false, true] {
let mut text = point("fi", VERTICAL_LIGATURE_CARET_FAMILY);
text.vertical = vertical;
let before = layout(&db, &text);
assert_eq!(before.glyphs.len(), 1, "the fixture ligates fi");
let plain = &before.glyphs[0];
for byte in [0, 1] {
for feature in ["salt=2", "ss03"] {
let mut featured = text.clone();
crate::edit::style_range(&mut featured.runs, byte, byte + 1, |st| st.features = vec![feature.into()]);
let after = layout(&db, &featured);
assert_eq!(after.glyphs.len(), 1, "{feature} at {byte}, vertical {vertical}: an unchanged fi stays ligated");
let glyph = &after.glyphs[0];
assert_eq!((glyph.gid, glyph.byte, glyph.len), (plain.gid, 0, 2));
assert_eq!(glyph.origin, plain.origin);
assert_eq!(glyph.xf, plain.xf);
assert_eq!(glyph.advance, plain.advance);
assert_eq!(glyph.carets, plain.carets);
}
}
}
}
#[test]
fn drawing_differences_still_split_a_feature_styled_ligature() {
let db = db_with(ligature_caret_font(None));
let face = db.face(LIGATURE_CARET_FAMILY, "Regular").expect("the test face");
let changes: [fn(&mut CharStyle); 3] =
[|st| st.fill = vectorcraft_doc::color::Paint::None, |st| st.baseline_shift = 10.0, |st| st.underline = true];
for (i, change) in changes.into_iter().enumerate() {
for byte in [0, 1] {
let mut text = point("fi", LIGATURE_CARET_FAMILY);
crate::edit::style_range(&mut text.runs, byte, byte + 1, |st| {
st.features = vec!["salt=2".into()];
change(st);
});
let after = layout(&db, &text);
assert_eq!(after.glyphs.iter().map(|g| g.gid).collect::<Vec<_>>(), [face.glyph_for('f'), face.glyph_for('i')], "attribute {i} at {byte}");
for (byte, glyph) in after.glyphs.iter().enumerate() {
assert_eq!((glyph.byte, glyph.len, glyph.run), (byte, 1, byte), "split glyphs keep their own drawing style");
}
}
}
}
#[test]
fn a_ligature_feature_still_applies_only_to_its_character_range() {
let db = db_with(ligature_caret_font(None));
let face = db.face(LIGATURE_CARET_FAMILY, "Regular").expect("the test face");
let mut text = point("fifi", LIGATURE_CARET_FAMILY);
crate::edit::style_range(&mut text.runs, 0, 1, |st| st.features = vec!["-liga".into()]);
let after = layout(&db, &text);
let glyphs = after.glyphs.iter().map(|g| (g.gid, g.byte, g.len)).collect::<Vec<_>>();
let ligature = u32::from(source_sans_gid(LIGATURE_CARET_GLYPH).expect("the fixture's unencoded ligature glyph"));
assert_eq!(glyphs, [(face.glyph_for('f'), 0, 1), (face.glyph_for('i'), 1, 1), (ligature, 2, 2)]);
}
/// Horizontal type does not read Standard Vertical Roman Alignment or the tate-chu-yoko offsets, so
/// setting one of them on the f of "fi" keeps the ligature. Vertical type reads them per glyph, so
/// there the f is shaped apart from the i.
+14 -1
View File
@@ -148,7 +148,8 @@ pub trait Tool: Send {
false
}
/// Is a drag moving, scaling or rotating the selection (the canvas then hides the bounding box,
/// so only the art is seen going with the pointer, except during a handle resize)?
/// so only the art is seen going with the pointer, except during a handle resize, and during a
/// move with General › Show Bounding Box While Moving and Drawing on)?
fn transforming(&self) -> bool {
false
}
@@ -157,6 +158,18 @@ pub trait Tool: Send {
fn resizing(&self) -> bool {
false
}
/// Is the transforming drag a move of the selection (an Alt-drag copy included)? With General ›
/// Show Bounding Box While Moving and Drawing on, the canvas then draws the bounding box where
/// the drag has moved the selection.
fn moving(&self) -> bool {
false
}
/// Is a drag drawing a new shape that the tool shows the bounding box of once drawn? With
/// General › Show Bounding Box While Moving and Drawing on, the canvas then draws the box around
/// the shape as it is drawn.
fn drawing_boxed(&self) -> bool {
false
}
/// Does the tool take `key` now, ahead of the command shortcuts bound to it (the Gradient tool
/// with a stop selected takes Delete and the arrows)?
fn claims_key(&self, _cx: &ToolContext, _key: ToolKey) -> bool {
+4
View File
@@ -841,6 +841,10 @@ impl Tool for SelectionTool {
matches!(self.state, State::Scaling { .. })
}
fn moving(&self) -> bool {
matches!(self.state, State::Moving { began: true, .. })
}
fn transforming(&self) -> bool {
match &self.state {
State::Moving { began: true, .. } | State::Scaling { .. } | State::Rotating { .. } => true,
+5 -2
View File
@@ -716,8 +716,10 @@ fn drag_moves_with_single_undo() {
let cx = cx(&d, &s, &p);
let mut t = SelectionTool::default();
assert!(t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0)).is_empty());
assert!(!t.moving(), "a press is not a move yet");
let a = t.pointer(&cx, &ev(PointerKind::Drag, 160.0, 150.0));
assert_eq!(a[0], Action::Begin("Move".into()));
assert!(t.moving());
assert!(matches!(&a[1], Action::Preview(c, v) if c == "object.transform" && v["matrix"][4] == 10.0));
let a = t.pointer(&cx, &ev(PointerKind::Up, 160.0, 150.0));
assert_eq!(a, vec![Action::Commit]);
@@ -737,9 +739,10 @@ fn escape_cancels_a_move_or_an_alt_drag_copy() {
t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0).with_mods(m));
let a = t.pointer(&cx, &ev(PointerKind::Drag, 160.0, 150.0).with_mods(m));
assert!(matches!(&a[1], Action::Preview(_, v) if v["copy"] == m.alt), "{what}: {a:?}");
assert!(t.transforming(), "{what}");
// A move or a copy, which the canvas can show the bounding box for.
assert!(t.transforming() && t.moving() && !t.resizing(), "{what}");
assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{what}: Escape cancels the drag");
assert!(!t.busy() && !t.transforming(), "{what}");
assert!(!t.busy() && !t.transforming() && !t.moving(), "{what}");
assert!(t.overlays(&cx).iter().all(|o| !matches!(o, Overlay::Measure { .. })), "{what}: no readout stays");
assert!(t.pointer(&cx, &ev(PointerKind::Drag, 170.0, 150.0).with_mods(m)).is_empty(), "{what}: the drag is over");
assert!(t.pointer(&cx, &ev(PointerKind::Up, 170.0, 150.0).with_mods(m)).is_empty(), "{what}: the release commits nothing");
+9
View File
@@ -668,6 +668,15 @@ impl Tool for ShapeTool {
fn resizing(&self) -> bool {
matches!(self.selection, Some(SelectionDrag::Scale { .. }))
}
/// A move of the selection by its center point, as the Selection tool's move.
fn moving(&self) -> bool {
matches!(self.selection, Some(SelectionDrag::Move { began: true, .. }))
}
/// Drawing a shape with a tool that shows the selection's bounding box. The Line Segment tool
/// shows no box, so it reports none.
fn drawing_boxed(&self) -> bool {
shows_shape_widgets(self.id) && self.began
}
/// Adjusting a widget (the canvas hides the bounding box meanwhile, as for the Selection tool).
fn transforming(&self) -> bool {
match &self.widget {
+31 -4
View File
@@ -468,7 +468,7 @@ fn a_shape_tool_drags_the_selected_shapes_widgets() {
matches!(a.last(), Some(Action::Preview(cmd, v)) if cmd == "object.setLiveShape" && v["radius"].as_f64().is_some_and(|r| r > 0.0)),
"{id}: {a:?}"
);
assert!(t.transforming(), "{id}");
assert!(t.transforming() && !t.drawing_boxed() && !t.moving(), "{id}");
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 130.0, 130.0)), vec![Action::Commit], "{id}");
assert!(!t.busy() && !t.transforming(), "{id}");
// Escape cancels a widget drag.
@@ -529,14 +529,15 @@ fn a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corn
// A drag moves the selection by the pointer's offset.
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 170.0, 160.0));
assert_eq!(a.first(), Some(&Action::Begin("Move".into())), "{id}: {a:?}");
assert!(t.transforming(), "{id}");
// A move, which the canvas can show the bounding box for.
assert!(t.transforming() && t.moving() && !t.resizing() && !t.drawing_boxed(), "{id}");
assert_eq!(
a.last(),
Some(&Action::Preview("object.transform".into(), json!({"matrix": [1.0, 0.0, 0.0, 1.0, 20.0, 10.0], "copy": false}))),
"{id}"
);
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 170.0, 160.0)), vec![Action::Commit], "{id}");
assert!(!t.busy() && !t.transforming(), "{id}");
assert!(!t.busy() && !t.transforming() && !t.moving(), "{id}");
}
// Alt pressed once the drag has begun copies, and the copy cursor shows, as the Selection
// tool's do.
@@ -581,6 +582,32 @@ fn a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corn
t.pointer(&c, &PointerEvent::new(PointerKind::Down, away.x, away.y));
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 350.0, 340.0));
assert_eq!(a.first(), Some(&Action::Begin("Rectangle".into())), "{a:?}");
assert!(t.drawing_boxed() && !t.moving() && !t.transforming());
}
/// The shape tools that show the selection's bounding box report a shape being drawn once the drag
/// has begun, until the release or Escape. The Line Segment tool shows no box and reports none.
#[test]
fn a_shape_being_drawn_has_its_bounding_box_drawn_by_the_tools_that_show_one() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let c = cx(&d, &s, &p);
for id in ["rectangle", "roundedRectangle", "ellipse", "polygon", "star", "lineSegment"] {
let mut t = ShapeTool::new(id);
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 100.0));
assert!(!t.drawing_boxed(), "{id}: not before the drag begins");
t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 160.0, 140.0));
assert_eq!(t.drawing_boxed(), id != "lineSegment", "{id}");
assert!(!t.moving() && !t.resizing(), "{id}");
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 160.0, 140.0));
assert!(!t.drawing_boxed(), "{id}: not after the release");
// Escape ends the drag.
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 100.0));
t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 160.0, 140.0));
t.key(&c, ToolKey::Escape, Mods::default());
assert!(!t.drawing_boxed(), "{id}: not after Escape");
}
}
/// With a shape tool, a drag on one of the eight handles of the selection's bounding box scales the
@@ -599,7 +626,7 @@ fn a_shape_tool_scales_the_selection_by_its_bounding_box_handles() {
assert!(t.overlays(&c).is_empty(), "{id}");
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, corner.x, corner.y)), vec![Action::Begin("Scale".into())], "{id}");
// A resize: the canvas keeps the box on show at the changing bounds.
assert!(t.busy() && t.transforming() && t.resizing(), "{id}");
assert!(t.busy() && t.transforming() && t.resizing() && !t.moving(), "{id}");
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 220.0, 230.0));
let want = scale_for_drag(Rect::new(100.0, 100.0, 200.0, 200.0), Handle::BottomRight, Point::new(220.0, 230.0), false, false);
assert_eq!(a, vec![Action::Preview("object.transform".into(), json!({"matrix": matrix_json(want), "copy": false}))], "{id}");
+18 -1
View File
@@ -70,12 +70,25 @@ pub(super) fn settled(mut q: Quantized, img: &Raster, own: &[bool]) -> Quantized
/// colours and what noise and lighting make of them, and eight colours come within a few levels
/// of every pixel; a photo's tones spread all through its range, and eight colours stay far from
/// most of them (more than 6 levels, and more than a tenth of how far a single colour stays).
/// A gray picture's tones lie along one line instead of through the whole color cube, and eight
/// colors come nearer to them. A neutral picture (19 in 20 of its opaque pixels with their
/// channels within 8 levels of each other) is therefore also continuous tone when eight colors
/// stay more than 0.065 as far as a single color does, unless sixteen colors stay at most a fifth
/// as far as eight do. That exception holds for gray flat art of up to sixteen clean levels, where
/// sixteen colors come within about a level of every pixel. On gray photos sixteen colors stay
/// 0.18 to 0.48 as far as eight do, and on most anti-aliased or noisy gray flat art 0.24 to 0.5
/// as far, so the exception holds for neither.
pub(crate) fn continuous_tone(img: &Raster) -> bool {
let hk = HistKey::new(img);
let mut bins = vec![Bin::default(); 1 << 15];
let (mut opaque, mut neutral) = (0u64, 0u64);
for p in img.rgba.as_chunks::<4>().0 {
if p[3] >= 128 {
bins[hk.key(p[0], p[1], p[2])].add(p);
opaque += 1;
if p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2]) <= 8 {
neutral += 1;
}
}
}
let used: Vec<Used> = bins.iter().enumerate().filter(|(_, b)| b.n > 0).map(|(i, b)| (i, b.mean(), b.n as f64)).collect();
@@ -91,7 +104,11 @@ pub(crate) fn continuous_tone(img: &Raster) -> bool {
if n > 0.0 { (sum / n).sqrt() } else { 0.0 }
};
let eight = unsaid(8);
eight > 6.0 && eight > 0.1 * unsaid(1)
if eight <= 6.0 {
return false;
}
let one = unsaid(1);
eight > 0.1 * one || (20 * neutral >= 19 * opaque && eight > 0.065 * one && unsaid(16) > 0.2 * eight)
}
/// Drop unused palette entries and merge duplicates.
+71
View File
@@ -1396,6 +1396,77 @@ fn a_noisy_gradient_keeps_its_tones_without_specks() {
assert!(res.paths.len() <= 2 * res.palette.len(), "{} paths of {} colours", res.paths.len(), res.palette.len());
}
/// A shaded ball and a lit wall on a dark mottled ground, with `noise` levels of noise per channel:
/// a photo's tones, in color or (`gray`) as their grays.
fn photo_scene(noise: f64, gray: bool) -> Raster {
Raster::from_fn(160, 128, |x, y| {
let ground = f64::from(20 + 10 * ((x / 16 + y / 16) % 3));
let (dx, dy) = (f64::from(x) - 60.0, f64::from(y) - 64.0);
let c = if dx * dx + dy * dy < 1600.0 {
let shade = 0.25 + 0.75 * (1.0 - (dx + dy + 40.0) / 110.0).max(0.0);
[210.0 * shade + 20.0, 150.0 * shade + 20.0, 100.0 * shade + 20.0]
} else if x >= 110 {
let t = f64::from(y) / 127.0;
[200.0 - 90.0 * t, 180.0 - 40.0 * t, 70.0 + 50.0 * t]
} else {
[ground; 3]
};
let p = [0, 1, 2].map(|k| (c[k] + noise * jitter(x, y, k as u32)).round().clamp(0.0, 255.0) as u8);
if gray {
let g = quantize::luma([p[0], p[1], p[2], 255]);
[g, g, g, 255]
} else {
[p[0], p[1], p[2], 255]
}
})
}
#[test]
fn a_gray_photo_is_continuous_tone_as_its_color_original_is() {
for noise in [4.0, 6.0] {
assert!(quantize::continuous_tone(&photo_scene(noise, false)), "color, noise {noise}");
assert!(quantize::continuous_tone(&photo_scene(noise, true)), "gray, noise {noise}");
}
// Three-color gray flat art stays flat art with up to 16 levels of uniform noise. (Heavier
// noise or JPEG artifacts can make gray flat art continuous tone.)
let clean = three_colour_art(0.0);
for noise in [0.0, 8.0, 16.0] {
let mut gray = three_colour_art(noise);
for p in gray.rgba.as_chunks_mut::<4>().0 {
let g = quantize::luma(*p);
p[..3].fill(g);
}
assert!(!quantize::continuous_tone(&gray), "gray flat art, noise {noise}");
// The same noise on every channel, as a gray scan has it.
let scan = Raster::from_fn(clean.width, clean.height, |x, y| {
let i = ((y * clean.width + x) * 4) as usize;
let g = f64::from(quantize::luma([clean.rgba[i], clean.rgba[i + 1], clean.rgba[i + 2], 255]));
let v = (g + noise * jitter(x, y, 0)).round().clamp(0.0, 255.0) as u8;
[v, v, v, 255]
});
assert!(!quantize::continuous_tone(&scan), "gray scan of flat art, noise {noise}");
}
// Nine clean equal gray bands, more levels than eight colors can hold, stay flat art.
for step in [15, 30] {
let bands = Raster::from_fn(180, 60, |x, _| {
let v = (x / 20 * step) as u8;
[v, v, v, 255]
});
assert!(!quantize::continuous_tone(&bands), "nine gray bands {step} levels apart");
}
}
#[test]
fn a_gray_photo_is_smoothed_before_it_is_traced() {
// As a color photo is. Traced with its noise, the noise mixes neighboring tones into one,
// leaving fewer colors than asked for, and steps the edges between them (more anchors).
for noise in [4.0, 6.0] {
let res = trace(&photo_scene(noise, true), &sixteen_colors());
assert!(res.palette.len() >= 14, "noise {noise}: {} colors", res.palette.len());
assert!(res.anchor_count() <= 1100, "noise {noise}: {} anchors", res.anchor_count());
}
}
#[test]
fn stacked_layers_go_by_lightness_and_leave_ignored_white_alone() {
// Stripes: black, white, mid gray, light gray (the widest).
+14 -2
View File
@@ -1,6 +1,7 @@
//! Cutting Tools in the interface: its submenu (Object › Plug-ins › Cutting Tools). Stencil
//! Bridges… opens the generic command dialog (`ui.paramDialog`, `dialogs::command`) on the options
//! of `addon.cutting.bridges`.
//! of `addon.cutting.bridges`. Weed Box and Lines… opens it on the options of `addon.cutting.weed`,
//! with a Preview, and runs the command on OK. A Spacing above 0 is sent instead of the Count.
use serde_json::json;
@@ -10,10 +11,21 @@ use crate::menus::Item;
pub const BRIDGES: &str = "addon.cutting.bridges";
/// Stencil Bridges › Direction: what the command's `direction` takes, by name.
pub const BRIDGE_DIRECTIONS: &[(&str, &str)] = &[("Vertical", "vertical"), ("Horizontal", "horizontal"), ("Shortest Crossing", "shortest")];
/// Weed Box and Lines' command.
pub const WEED: &str = "addon.cutting.weed";
/// Weed Box and Lines › Direction: what the command's `direction` takes, by name.
pub const WEED_DIRECTIONS: &[(&str, &str)] = &[("Horizontal", "horizontal"), ("Vertical", "vertical"), ("Both", "both")];
/// Object › Plug-ins › Cutting Tools.
pub fn menu() -> Vec<Item> {
vec![Item::Cmd("Stencil Bridges…", BRIDGES, json!({"width": 2, "count": 1, "direction": "vertical", "keepOriginal": true}))]
vec![
Item::Cmd("Stencil Bridges…", BRIDGES, json!({"width": 2, "count": 1, "direction": "vertical", "keepOriginal": true})),
Item::Cmd(
"Weed Box and Lines…",
WEED,
json!({"margin": 9, "box": true, "direction": "both", "count": 1, "spacing": 0, "gap": 0, "weight": 0.3, "swatch": "[Registration]"}),
),
]
}
#[cfg(test)]
@@ -52,3 +52,94 @@ fn stencil_bridges_opens_its_dialog_and_runs_on_ok() {
assert_eq!(steps(&app), n + 1);
assert!(app.ui.status.contains("no islands"), "{}", app.ui.status);
}
/// Object › Plug-ins › Cutting Tools › Weed Box and Lines… opens the command dialog on its
/// defaults with Preview off. With Preview on the canvas shows the weed box and lines while the
/// dialog is open; Cancel takes them away with no step of history, and OK keeps them as one step.
#[test]
fn weed_box_and_lines_previews_cancels_and_runs_on_ok() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 600, "height": 400})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap();
app.run("select.set", json!({"ids": [id]})).unwrap();
let steps = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len();
let count = |app: &VectorcraftApp| app.session.doc().unwrap().doc.node_count();
let (start, n, nodes) = (app.session.doc().unwrap().doc.clone(), steps(&app), count(&app));
let open = |app: &mut VectorcraftApp| {
let entries = menus::menu_entries(app);
let item = entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Cutting Tools"] && e.label == "Weed Box and Lines…").unwrap();
assert!(item.enabled);
let (cmd, p) = menus::click_target(&item.label, item.command.as_deref().unwrap(), &item.params);
menus::invoke(app, &cmd, p);
};
open(&mut app);
let d = app.ui.dialog.as_ref().expect("a dialog opens");
assert_eq!((d.str("__command"), d.str("__label")), (super::WEED.to_string(), "Weed Box and Lines".to_string()));
assert_eq!((d.str("direction"), d.bool("box"), d.bool("preview")), ("both".to_string(), true, false));
let shown = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx()));
for s in ["Weed Box and Lines", "Margin", "Box", "Direction", "Both", "Count", "Spacing", "Gap", "Weight", "Swatch", "Preview"] {
assert!(shown.contains(s), "{s} in {shown}");
}
// Every field label is translated.
crate::i18n::set_current(crate::i18n::Lang::from_code("de").expect("de registered"));
let german = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx()));
crate::i18n::set_current(crate::i18n::Lang::EN);
for s in ["Rand:", "Rahmen:", "Richtung:", "Farbfeld:"] {
assert!(german.contains(s), "{s} in {german}");
}
assert_eq!(count(&app), nodes, "no preview without the checkbox");
// Preview on: the group with the box and four lines is on the canvas.
crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "preview".into(), json!(true));
painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx()));
assert_eq!(count(&app), nodes + 6, "previewed: a group, the box and four lines");
// Cancel: as it was, with no step of history.
crate::dialogs::cancel(&mut app);
assert_eq!(app.session.doc().unwrap().doc, start, "Cancel takes the preview away");
assert_eq!(steps(&app), n, "and adds no undo step");
// OK: one step.
open(&mut app);
crate::dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(steps(&app), n + 1, "one undo step");
assert_eq!(count(&app), nodes + 6);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, start);
// A Spacing above 0 is used instead of the Count: the 68 pt wide box in strips at most 20 pt
// wide takes three vertical lines, each cut in two by the rectangle.
app.run("select.set", json!({"ids": [id]})).unwrap();
open(&mut app);
let d = app.ui.dialog.as_mut().unwrap();
crate::dialogs::set_field(d, "direction".into(), json!("vertical"));
crate::dialogs::set_field(d, "spacing".into(), json!(20));
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(count(&app), nodes + 8, "a group, the box and six pieces of line");
// A Spacing below 0 is refused, not swapped for the Count.
app.run("edit.undo", json!({})).unwrap();
app.run("select.set", json!({"ids": [id]})).unwrap();
open(&mut app);
let n = steps(&app);
crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "spacing".into(), json!(-5));
let e = crate::dialogs::confirm(&mut app).unwrap_err().to_string();
assert!(e.contains("`spacing` must be more than 0"), "{e}");
assert_eq!(steps(&app), n, "no undo step");
// A Spacing with no value, or of 0 written with its unit, leaves the Count in use: a Count of 0
// draws the box alone.
crate::dialogs::cancel(&mut app);
for spacing in [serde_json::Value::Null, json!("0 pt")] {
app.run("select.set", json!({"ids": [id]})).unwrap();
open(&mut app);
let d = app.ui.dialog.as_mut().unwrap();
crate::dialogs::set_field(d, "spacing".into(), spacing.clone());
crate::dialogs::set_field(d, "count".into(), json!(0));
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(count(&app), nodes + 2, "{spacing}: a group and the box");
app.run("edit.undo", json!({})).unwrap();
}
}
+1
View File
@@ -127,6 +127,7 @@ impl VectorcraftApp {
updates_rt: Default::default(),
frame_rate: Default::default(),
canvas_rect: None,
canvas_ui_zoom: 1.0,
control_rotate_press: None,
hover_doc: None,
titlebar_from_env: false,
+11
View File
@@ -150,6 +150,7 @@ impl VectorcraftApp {
io::remember_active_artboard(self);
let artboards = self.artboard_tool_state();
if let Some(r) = menus::run_ui_command(self, id, &params) {
self.ui.recording_base = None;
self.follow_artboard_tool(artboards);
return r;
}
@@ -221,6 +222,15 @@ impl VectorcraftApp {
if fileio::SaveMode::of(id).is_some() {
io::remember_view(self);
}
// While an action records, a command that the recording would keep as a step runs only
// when the step fits in the action.
let recorded = match panels::actions::before_recorded_command(self, id, &params) {
Ok(recorded) => recorded,
Err(e) => {
self.ui.status = e.clone();
return Err(e);
}
};
// A command that changes the edited text is an application edit (undo, a panel or menu
// edit, a control request). Capture the bounded committed context around it so `Other`
// dominates the frame even if an IME receipt follows. Selection/view commands that leave
@@ -230,6 +240,7 @@ impl VectorcraftApp {
let ime_before = crate::ime::stamp(&mut self.session, ime_view);
let cursor_settings = (self.session.caps_lock, self.session.prefs.use_precise_cursors);
let r = self.session.execute(id, &params).map_err(|e| e.to_string());
let r = panels::actions::after_recorded_command(self, recorded, r);
if r.is_ok() && cursor_settings != (self.session.caps_lock, self.session.prefs.use_precise_cursors) {
self.canvas.hover_cursor = None;
}
+2 -3
View File
@@ -1,4 +1,4 @@
use super::{GeometryStamp, InputGeneration, VectorcraftApp, canvas};
use super::{GeometryStamp, InputGeneration, VectorcraftApp};
impl VectorcraftApp {
/// The host iteration count: one increment per `App::logic`/`App::ui` iteration (or a
@@ -9,8 +9,7 @@ impl VectorcraftApp {
/// The canvas on screen in document coordinates (none before it is laid out).
pub fn screen_frame(&self) -> Option<vectorcraft_tools::ScreenFrame> {
let (rect, view) = (self.canvas_rect?, self.view()?);
let xf = canvas::Xf::new(rect, view);
let (rect, xf) = (self.canvas_rect?, self.canvas_xf()?);
let px = |x: f32, y: f32| xf.delta_to_doc(egui::vec2(x, y));
Some(vectorcraft_tools::ScreenFrame {
origin: xf.to_doc(rect.left_top()),
+8 -1
View File
@@ -110,9 +110,16 @@ impl VectorcraftApp {
self.views.get_mut(i)
}
/// The mapping between the document and the canvas of the last frame, at the active view (none
/// before the canvas is laid out or without a document).
pub(crate) fn canvas_xf(&self) -> Option<crate::canvas::Xf> {
Some(crate::canvas::Xf::at(self.canvas_rect?, self.view()?, self.canvas_ui_zoom))
}
pub fn view_info(&self) -> ViewInfo {
ViewInfo {
zoom: self.view().map(|v| v.zoom).unwrap_or(1.0),
// The tools measure their tolerances and widgets in the interface's points.
zoom: self.view().map_or(1.0, |v| v.screen_zoom(self.canvas_ui_zoom)),
outline: self.outline_view(),
smart_guides: self.ui.view.smart_guides,
guides: self.ui.view.guides,
+19 -6
View File
@@ -98,8 +98,16 @@ pub struct Xf {
}
impl Xf {
/// The mapping of view `v` onto the canvas `rect`, in the points of an interface zoomed by
/// `ui_zoom` (Preferences › User Interface › UI Scaling): a document point covers the same
/// device pixels at every UI Scaling ([`View::screen_zoom`]).
pub fn at(rect: egui::Rect, v: &View, ui_zoom: f32) -> Self {
Self { rect, zoom: v.screen_zoom(ui_zoom), center: v.center, rot: v.rotation.to_radians() }
}
/// [`Self::at`] with the interface unscaled.
#[cfg(test)]
pub fn new(rect: egui::Rect, v: &View) -> Self {
Self { rect, zoom: v.zoom, center: v.center, rot: v.rotation.to_radians() }
Self::at(rect, v, 1.0)
}
pub fn to_screen(&self, p: Point) -> Pos2 {
let c = self.rect.center();
@@ -255,6 +263,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let bars = app.ui.screen_mode < 2;
let (full, rect) = view_rects(app, outer, ui.ctx().pixels_per_point());
app.canvas_rect = Some(rect);
app.canvas_ui_zoom = ui.ctx().zoom_factor();
let fitted = app.view().is_some_and(|v| v.fitted);
// A document's opening view is fitted to the canvas of the first frame that shows it, and the
// window's layout can still move the canvas's edges over the next frames. Until the view is
@@ -276,7 +285,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let resp = ui.interact(rect, egui::Id::unique("canvas"), Sense::click_and_drag());
canvas_input(app, ui, &resp, rect);
let v = *app.view().unwrap_or(&View::default());
let xf = Xf::new(rect, &v);
let xf = Xf::at(rect, &v, app.canvas_ui_zoom);
// While the view pans, zooms or turns under the pointer, the art under it isn't hit-tested.
let wait = hover_wait(&mut app.canvas.view_moved, ViewKey { rect, zoom: v.zoom, center: v.center, rotation: v.rotation }, ui.input(|i| i.time));
if let Some(secs) = wait {
@@ -313,12 +322,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
);
// View › Pixel Preview: the art as it rasterizes, one pixel per point, while a document pixel
// is bigger than a screen pixel (below that the screen render already shows it).
let pixel = (app.ui.view.pixel_preview && v.zoom * ppp as f64 > 1.0).then(|| pixel_region(&xf)).flatten();
let pixel = (app.ui.view.pixel_preview && xf.zoom * ppp as f64 > 1.0).then(|| pixel_region(&xf)).flatten();
// File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview: off, images show
// their pixels as they rasterize, hard-edged.
let smooth_images = pixel.is_none() || app.session.prefs.anti_aliased_bitmaps;
let plugins = plugin_key(&mut app.canvas.plugin_keys, &app.session, st);
let key = cache_key(app, ui, st, &t, &v, (w, h, ppp), (pixel, smooth_images, plugins));
// The frame renders the view at its zoom in the interface's points, as `xf` maps it.
let drawn = v.on_screen(app.canvas_ui_zoom);
let key = cache_key(app, ui, st, &t, &drawn, (w, h, ppp), (pixel, smooth_images, plugins));
// Placed documents' bitmaps are being made: draw again when they are ready.
if vectorcraft_render::placed_document::busy() {
ui.ctx().request_repaint_after(std::time::Duration::from_millis(30));
@@ -348,7 +359,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
!waiting
});
if wait.is_none() && (!same_frame || app.canvas.texture.is_none() || settle) {
let (w, h, view) = frame_view(pixel, (w, h), &v, ppp);
let (w, h, view) = frame_view(pixel, (w, h), &drawn, ppp);
let opts = frame_options(app, &key, mask_view, smooth_images);
draw_frame(app, ui, Frame { key: &key, doc: &doc, effects: &effects, size: (w, h), view, opts, pixel });
}
@@ -446,7 +457,7 @@ fn canvas_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
// wheel, a pan, a Rotate View drag, the Zoom tool and a Presentation Mode click move the view
// there. A dialog, floating panel or menu over the canvas hides it (Object Highlighting
// showed through them: #4518), unless a drag that began on the canvas goes on there.
let moved = Xf::new(rect, app.view().unwrap_or(&View::default()));
let moved = Xf::at(rect, app.view().unwrap_or(&View::default()), app.canvas_ui_zoom);
let dragging = ui.data(|d| d.get_temp::<Drag>(drag_id()).is_some());
app.hover_doc =
ui.input(|i| i.pointer.hover_pos()).filter(|p| rect.contains(*p) && (dragging || drag::on_canvas(ui, resp, *p))).map(|p| moved.to_doc(p));
@@ -1208,3 +1219,5 @@ fn selection_visuals(
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_ui_scaling;
+11 -8
View File
@@ -14,7 +14,7 @@ use super::{Color32, Drag, Mods, Point, PointerEvent, PointerKind, Pos2, Shape,
pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) {
let (pointer, m, space, hover) = wheel_view(app, ui, resp, rect);
let v = *app.view().unwrap_or(&View::default());
let xf = Xf::new(rect, &v);
let xf = Xf::at(rect, &v, app.canvas_ui_zoom);
// A lifted pen or finger takes the pointer away in the frame it lifts (egui's `PointerGone`),
// so a tap whose press and lift come in one frame has no hover position: it presses (and
// double-taps) where it was seen last (#491).
@@ -227,10 +227,11 @@ fn wheel_view(
// slide. Then turn the view about the pointer for a trackpad rotation ([`trackpad_turn`]). The
// tool's events below take their document points from the view this moves to.
if resp.hovered() {
let ui_zoom = app.canvas_ui_zoom;
if (factor - 1.0).abs() > 1e-6
&& let (Some(p), Some(vm)) = (hover, app.view_mut())
{
zoom_about(vm, rect, p, vm.zoom * factor);
zoom_about(vm, rect, p, vm.zoom * factor, ui_zoom);
}
if scroll != egui::Vec2::ZERO {
scroll_view(app, ui, scroll);
@@ -239,7 +240,7 @@ fn wheel_view(
&& let Some(deg) = trackpad_turn(ui, app.session.prefs.trackpad_rotate_view)
&& let Some(vm) = app.view_mut()
{
rotate_about(vm, rect, p, deg);
rotate_about(vm, rect, p, deg, ui_zoom);
}
}
(pointer, m, space, hover)
@@ -328,12 +329,13 @@ fn drag_held(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2)
// Where the pointer was in the frame before: a zoom marquee moved with Space follows it.
let before: Option<Pos2> = ui.data(|dd| dd.get_temp(drag_pos_id()));
ui.data_mut(|dd| dd.insert_temp(drag_pos_id(), p));
let ui_zoom = app.canvas_ui_zoom;
match d {
Drag::Pan { start, center, .. } => {
let ppp = ui.ctx().pixels_per_point();
if let Some(vm) = app.view_mut() {
vm.center = center;
pan_pixels(vm, ppp, whole_pixels(p - start, ppp));
pan_pixels(vm, (ppp, ui_zoom), whole_pixels(p - start, ppp));
}
}
Drag::RotateView { start_angle, start_rot, snaps } => {
@@ -360,7 +362,7 @@ fn drag_held(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2)
if exponent != 0.0
&& let Some(vm) = app.view_mut()
{
zoom_about(vm, rect, start, vm.zoom * exponent.exp());
zoom_about(vm, rect, start, vm.zoom * exponent.exp(), ui_zoom);
}
ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::ZoomScrub { start, held, moved }));
}
@@ -446,6 +448,7 @@ fn drag_released(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Po
});
// Backtick as it was at the button-up: a key pressed or released later in the frame does not count.
let backquote = backquote_at_release(ui, egui::PointerButton::Primary, f.backquote_before);
let ui_zoom = app.canvas_ui_zoom;
match d {
Drag::ZoomBox { start } => {
let start = zoom_marquee_start(ui, start, before, p, space);
@@ -456,16 +459,16 @@ fn drag_released(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Po
// The marquee's center and its size on screen set the view, at any rotation.
vm.center = xf.to_doc(r.center());
let fill = (rect.width() / r.width()).min(rect.height() / r.height());
vm.zoom = (xf.zoom * f64::from(fill)).clamp(0.0313, 640.0);
vm.zoom = (xf.zoom * f64::from(ui_zoom) * f64::from(fill)).clamp(0.0313, 640.0);
} else {
zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt));
zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt), ui_zoom);
}
}
}
// A click (not held, not dragged) steps the zoom as with the marquee.
Drag::ZoomScrub { held, moved: false, .. } if held <= HOLD_DELAY => {
if let Some(vm) = app.view_mut() {
zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt));
zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt), ui_zoom);
}
}
Drag::ZoomScrub { .. } => {}
+37 -25
View File
@@ -110,36 +110,44 @@ pub(super) fn mark_options(app: &VectorcraftApp, curve_points_from_tool: bool) -
pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf, curve_points_from_tool: bool, marks: &mut SelectionMarks) {
// The Selection tool's bounding box (rotated with the objects after a rotation), also shown by
// the shape tools that keep a shape just drawn ready to adjust while they draw none (#5477). It
// hides while a drag moves or resizes the selection, so only the art is seen going with the
// pointer (#712). A handle resize keeps it at the changing bounds (#3526).
// With Real-time Drawing off the art waits for the release of a move drag, and the box moves
// with the pointer instead.
// hides while a drag moves, rotates or resizes the selection, so only the art is seen going with
// the pointer (#712). A handle resize keeps it at the changing bounds (#3526). With General ›
// Show Bounding Box While Moving and Drawing on, a move draws it where the drag has moved the
// selection, and a shape tool draws it around the shape being drawn.
// With Real-time Drawing off the art waits for the release of a move drag. With the preference
// off, the box of a Selection tool move then moves with the pointer instead.
let resizing = app.session.tool_resizing();
let moved = app.session.deferred_move();
let move_box = app.session.move_drag_box();
let drawing_boxed = app.session.draws_new_shape_box();
let follows = resizing || move_box.is_some() || drawing_boxed;
let shape_tool = vectorcraft_tools::shape::shows_shape_widgets(app.session.tool_id());
// A shape tool drawing a shape shows none of this; adjusting a widget, it shows the widgets.
// A shape tool drawing a shape shows none of its widgets; adjusting a widget, it shows the widgets.
let drawing = shape_tool && app.session.tool_busy() && !app.session.tool_transforming();
// Cmd held lends the Selection tool to a press (Direct Selection's included, #3901): its box
// shows while the key is down, where its handles take that press.
let held = super::drag::mods(p.ctx().input(|i| i.modifiers), false);
let lends_selection = !app.session.tool_busy() && app.session.lent_tool_id(&held) == Some("selection");
let show_box = (app.session.tool_id() == "selection" || lends_selection || (shape_tool && (!app.session.tool_busy() || resizing)))
&& (resizing || !app.session.tool_transforming() || moved.is_some())
&& app.ui.view.bounding_box
&& app.session.active().is_some_and(|st| !st.selection.is_empty() && st.selection.anchors.is_empty());
// With Real-time Drawing off the document stays at the drag's snapshot, so a resize measures the
// box from the pending preview; otherwise each step's revision measures it once (`selection_box`).
let bbox = if !show_box {
None
} else if let Some(b) = resizing.then(|| app.session.deferred_transform_box()).flatten() {
Some(b)
let show_box = (app.session.tool_id() == "selection" || lends_selection || (shape_tool && (!app.session.tool_busy() || follows)))
&& (follows || !app.session.tool_transforming())
&& app.ui.view.bounding_box;
let selected_whole = app.session.active().is_some_and(|st| !st.selection.is_empty() && st.selection.anchors.is_empty());
// With Real-time Drawing off the document stays at the drag's snapshot, so a resize or a shape
// being drawn measures the box from the pending preview. A shape being drawn shows no box until
// a preview has made it (never the box of the selection from before the drag, which a failed
// preview also leaves). A move offsets the box measured at the drag's start. Otherwise each
// step's revision measures it once (`selection_box`).
let deferred = if show_box && (resizing || drawing_boxed) { app.session.deferred_transform_box() } else { None };
let (bbox, deferred_color) = if !show_box {
(None, None)
} else if let Some((b, color)) = deferred {
(Some(b), Some(color))
} else if !selected_whole || (drawing_boxed && app.session.interaction_unapplied()) {
(None, None)
} else if move_box.is_some() {
(move_box, None)
} else {
app.selection_box()
(app.selection_box(), None)
};
let bbox = bbox.map(|b| match moved {
Some(d) => vectorcraft_doc::OrientedBox { rect: b.rect + b.to_local(d.to_point()).to_vec2(), ..b },
None => b,
});
let st = app.session.active();
let show_marks = !crate::dialogs::halftone::previewing(app);
let big = show_marks && st.is_some_and(|st| too_many_anchors(st.selection.objects.iter().filter_map(|id| st.doc.node(*id))));
@@ -209,8 +217,11 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf:
}
}
// Bounding box with handles (Selection tool).
if let Some(b) = bbox {
let color = c32(st.doc.layer_color(st.selection.objects[0]));
// The layer color of the box's first object: with Real-time Drawing off, the shape being drawn
// is only in the pending preview, which gives its color.
let box_color = deferred_color.or_else(|| st.selection.objects.first().map(|id| st.doc.layer_color(*id)));
if let Some((b, color)) = bbox.zip(box_color) {
let color = c32(color);
p.add(Shape::closed_line(b.corners().iter().map(|q| xf.to_screen(*q)).collect(), Stroke::new(1.0, color)));
for h in vectorcraft_tools::bbox::Handle::ALL {
let c = xf.to_screen(b.to_doc() * h.pos(b.rect));
@@ -218,8 +229,9 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf:
p.rect_filled(hr, 0.0, Color32::WHITE);
p.rect_stroke(hr, 0.0, Stroke::new(1.0, color), StrokeKind::Inside);
}
// The type widget beside it: hollow on point type, filled on area type.
if let Some(w) = vectorcraft_tools::typewidget::TypeWidget::of(&st.doc, &st.selection, &b, xf.zoom) {
// The type widget beside it: hollow on point type, filled on area type. A shape being drawn
// has none (with Real-time Drawing off, the selection is still the one before the drag).
if !drawing_boxed && let Some(w) = vectorcraft_tools::typewidget::TypeWidget::of(&st.doc, &st.selection, &b, xf.zoom) {
let (c, r) = (xf.to_screen(w.at), vectorcraft_tools::typewidget::RADIUS_PX);
p.circle_filled(c, r, if w.area { color } else { Color32::WHITE });
p.circle_stroke(c, r, Stroke::new(1.0, color));

Some files were not shown because too many files have changed in this diff Show More