Merge main into feat/character-anti-aliasing-method-r

# Conflicts:
#	crates/engine/src/cmd/panelcmds.rs
#	crates/ui-egui/src/panels/character.rs
This commit is contained in:
dotCooCoo
2026-10-31 22:16:39 -07:00
133 changed files with 5777 additions and 281 deletions
+3 -1
View File
@@ -9,7 +9,7 @@
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Progress log row for the Path Precision Tools add-on's Expand Dashes into Paths; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Progress log row for the Color Effects add-on's Find Nearest Swatch; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
@@ -129,6 +129,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
## Progress log
- 2026-11-01: Find Nearest Swatch joins the Color Effects add-on (Object › Plug-ins › Color Effects): for each colour of the selection, the closest swatches of the document, a built-in library or a library or colour book the user loaded, with their CIEDE2000 difference; Apply gives each colour its nearest swatch as one undo step, linking global and spot swatches and adding a library's to the document first, so a process colour becomes the closest spot colour (`addon.color.nearestSwatch`, `addon.color.applyNearestSwatch`, #4923).
- 2026-11-01: Expand Dashes into Paths joins the Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): each selected path's dashed stroke becomes separate open paths, one per dash, laid out exactly as the stroke draws them (pattern, offset, Align Dashes to Corners and Path Ends), each keeping the stroke with the dashes off, so the gaps are real gaps for cutters and plotters, as one undo step (`addon.pathtools.expandDashes`, #4922).
- 2026-11-01: Randomize Opacity joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): each selected object gets its own opacity at random between a minimum and a maximum percent, with a seed that repeats the result, as one undo step (`addon.arrange.randomizeOpacity`, #4925).
- 2026-11-01: Sort Objects joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): the selected objects are sorted by width, height, area, position, opacity or stacking order (small to large, large to small or at random) and take another of those in that order, trading their values or stepping them evenly, as one undo step (`addon.arrange.sort`, #4917).
@@ -211,6 +212,7 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap
| Date | Change | Summary |
|---|---|---|
| 2026-11-01 | minor | Progress log row for the Color Effects add-on's Find Nearest Swatch (#4923); headline numbers unchanged. |
| 2026-11-01 | minor | Progress log row for the Path Precision Tools add-on's Expand Dashes into Paths (#4922); headline numbers unchanged. |
| 2026-11-01 | minor | Progress log row for the Arrange Tools add-on's Randomize Opacity (#4925); headline numbers unchanged. |
| 2026-11-01 | minor | Progress log row for the Arrange Tools add-on's Sort Objects (#4917); headline numbers unchanged. |
+3 -2
View File
@@ -21,5 +21,6 @@ image = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
# The tests lay type out in the bundled fonts (the workspace leaves turning them on to the engine).
vectorcraft-text = { workspace = true, features = ["bundled-fonts"] }
# The tests lay type out in the bundled fonts (the workspace leaves turning them on to the engine)
# and in fonts built in code (`vectorcraft_text::test_fonts`).
vectorcraft-text = { workspace = true, features = ["bundled-fonts", "test-fonts"] }
+4 -1
View File
@@ -1062,7 +1062,8 @@ impl<'a> Scene<'a> {
}
/// Type: glyph outlines (Preserve Appearance, Outline Text, type on a path), else one text
/// entity per run of each line.
/// entity per run of each line, whose height (group 40) is [`CAP_HEIGHT`] times the em size its
/// run is drawn at ([`vectorcraft_text::FontDb::font_heights_resolved`]).
fn text(&mut self, n: &Node, t: &TextObject) {
let outline = self.o.preserve == Preserve::Appearance || self.o.outline_text || matches!(t.kind, TextKind::OnPath { .. });
if outline {
@@ -1074,6 +1075,8 @@ impl<'a> Scene<'a> {
if !n.appearance.items.is_empty() {
self.warn(TYPE_ITEMS);
}
let resolved = vectorcraft_text::FontDb::global().font_heights_resolved(t);
let t = &*resolved;
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let plain = t.plain_text();
let xf = self.to_dxf * t.xf;
+17
View File
@@ -906,6 +906,23 @@ fn text_outside_ascii_is_escaped_before_2007() {
assert_eq!(text(DxfVersion::R2018), "Größe");
}
/// Type whose font size measures the cap height is written at the em size it is drawn at: 14 pt
/// measuring a 0.7 em cap height is 20 pt type, whose TEXT height is 14.
#[test]
fn a_font_height_reference_is_written_as_the_size_drawn() {
use vectorcraft_text::test_fonts::{HEIGHT_FAMILY, height_font};
vectorcraft_text::FontDb::global().add_font(height_font(true).unwrap());
let mut d = text_doc("Plan A", 14.0);
let id = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap();
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
t.runs[0].style.font_family = HEIGHT_FAMILY.into();
t.runs[0].style.font_height_reference = vectorcraft_doc::FontHeightReference::CapHeight;
}
let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
let e = entities(&editable);
assert!((all(&e[0], 40)[0] - 14.0).abs() < 1e-6, "{:?}", all(&e[0], 40));
}
#[test]
fn images_link_png_or_jpeg_files() {
let mut png = vec![];
+67 -14
View File
@@ -108,6 +108,30 @@ fn perceptual_steps(a: &Color, b: &Color, (v0, v1): (f32, f32), depth: u32, out:
}
}
/// The mix positions (`v`, 0..1) of the stops a Perceptual gradient's curve has between stops `a`
/// and `b`, in order: [`perceptual_steps`] on either side of the midpoint, and the midpoint (½).
fn perceptual_mixes(a: &GradientStop, b: &GradientStop) -> Vec<f32> {
let mut vs = Vec::new();
perceptual_steps(&a.color, &b.color, (0.0, 0.5), 1, &mut vs);
vs.push(0.5);
perceptual_steps(&a.color, &b.color, (0.5, 1.0), 1, &mut vs);
vs
}
/// The offset of mix position `v` between stops `a` and `b`, placed through `a`'s midpoint
/// (`v` = ½ at the midpoint) as [`Gradient::sample`] maps it.
fn curve_offset(a: &GradientStop, b: &GradientStop, v: f32) -> f32 {
let m = a.midpoint.clamp(0.01, 0.99);
let u = if v == 0.5 {
a.midpoint
} else if v < 0.5 {
2.0 * v * m
} else {
m + (v - 0.5) * 2.0 * (1.0 - m)
};
a.offset + (b.offset - a.offset) * u
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GradientStop {
/// 0..=1 along the gradient.
@@ -270,21 +294,9 @@ impl Gradient {
// Perceptual: stops along the mix (`v`) where a straight line would stray from it, the
// midpoint (v = ½) among them, placed through the midpoint as [`Self::sample`] maps them.
let perceptual = next.filter(|_| !self.method.is_classic()).map(|n| {
let mut vs = Vec::new();
perceptual_steps(&s.color, &n.color, (0.0, 0.5), 1, &mut vs);
vs.push(0.5);
perceptual_steps(&s.color, &n.color, (0.5, 1.0), 1, &mut vs);
let m = s.midpoint.clamp(0.01, 0.99);
vs.into_iter().map(move |v| {
perceptual_mixes(s, n).into_iter().map(move |v| {
let mid = v == 0.5;
let u = if mid {
s.midpoint
} else if v < 0.5 {
2.0 * v * m
} else {
m + (v - 0.5) * 2.0 * (1.0 - m)
};
let t = s.offset + (n.offset - s.offset) * u;
let t = curve_offset(s, n, v);
let o = s.opacity + (n.opacity - s.opacity) * v;
(t, self.method.mix(&s.color, &n.color, v), o, (mid && off_centre).then_some(s.midpoint))
})
@@ -329,6 +341,47 @@ impl Gradient {
pub fn fits_ink_tints(&self) -> bool {
self.expanded().nth(MAX_INK_TINTS).is_none()
}
/// A Perceptual gradient's curve ([`Self::expanded_stops`]) as mixes of its stops' inks:
/// (offset, `i`, weight, opacity) at each stop on it, where the inks there are those of stop
/// `i` and stop `i + 1` mixed linearly at the weight (0..1; 0 on the gradient's own stops).
/// The weight is the one at which the two stops' colors mixed in display RGB have the OKLab
/// lightness of the color on the curve there, found to [`TINT_STEPS`] halvings, or the mix
/// position itself when the two colors' lightness differs by less than
/// [`MIN_INK_LIGHTNESS_SPAN`]. `None` for a Classic gradient, and when the curve has more than
/// [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]).
pub fn ink_mixes(&self) -> Option<Vec<(f32, usize, f32, f32)>> {
use crate::libraries::srgb_to_oklab;
if self.method.is_classic() || !self.fits_ink_tints() {
return None;
}
let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0];
let mut out = Vec::new();
for (i, s) in self.stops.iter().enumerate() {
out.push((s.offset, i, 0.0, s.opacity));
let Some(n) = self.stops.get(i + 1) else { continue };
let (l0, l1) = (lightness(&s.color), lightness(&n.color));
for v in perceptual_mixes(s, n) {
let weight = if (l1 - l0).abs() < MIN_INK_LIGHTNESS_SPAN {
v
} else {
let want = lightness(&self.method.mix(&s.color, &n.color, v));
let (mut lo, mut hi) = (0.0f32, 1.0f32);
for _ in 0..TINT_STEPS {
let mid = (lo + hi) / 2.0;
// The mix at `mid` has not yet gone from stop `i`'s lightness to the wanted one.
if (lightness(&GradientMethod::Classic.mix(&s.color, &n.color, mid)) - want) * (l1 - l0) < 0.0 {
lo = mid;
} else {
hi = mid;
}
}
(lo + hi) / 2.0
};
out.push((curve_offset(s, n, v), i, weight, s.opacity + (n.opacity - s.opacity) * v));
}
}
Some(out)
}
/// The same ramp the other way round: the stops in reverse order at mirrored offsets, each
/// segment's midpoint mirrored with it.
pub fn reverse(&mut self) {
+37
View File
@@ -486,3 +486,40 @@ fn ink_tints_follow_the_perceptual_curve() {
assert_eq!(g.ink_tints(&ink), None);
assert!(!g.fits_ink_tints());
}
/// A Perceptual gradient's curve as mixes of its stops' inks: the stops on the curve at their
/// offsets, each weighted so the two stops' colors mixed in display RGB have the lightness of
/// the color on the curve there.
#[test]
fn ink_mixes_follow_the_perceptual_curve() {
use crate::libraries::srgb_to_oklab;
let (magenta, yellow) = (Color::cmyk(0.0, 1.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.6, 0.0));
let mut g = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, magenta), GradientStop::new(1.0, yellow)]);
g.stops[0].midpoint = 0.3;
assert_eq!(g.ink_mixes(), None, "a Classic gradient");
g.method = GradientMethod::Perceptual;
let mixes = g.ink_mixes().expect("a Perceptual gradient's curve");
let curve = g.expanded_stops();
assert_eq!(mixes.len(), curve.len(), "a mix for each stop on the curve");
assert_eq!((mixes[0], mixes[mixes.len() - 1]), ((0.0, 0, 0.0, 1.0), (1.0, 1, 0.0, 1.0)), "the gradient's own stops");
let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0];
for ((t, i, w, _), (ct, c, _)) in mixes.iter().zip(&curve) {
assert!((t - ct).abs() < 1e-6, "{t} on the curve at {ct}");
if *i == 0 && *t > 0.0 {
let mixed = GradientMethod::Classic.mix(&magenta, &yellow, *w);
assert!((lightness(&mixed) - lightness(c)).abs() < 1e-3, "at {t}: weight {w}");
}
}
let mid = mixes.iter().find(|m| (m.0 - 0.3).abs() < 1e-6).expect("the midpoint");
assert!((mid.2 - 0.5).abs() > 0.01, "not halfway at the midpoint: {mid:?}");
assert!(mixes.windows(2).all(|w| w[0].0 <= w[1].0), "in order: {mixes:?}");
// Two colors of one lightness: the weights are the mix positions.
g.stops[1].color = magenta;
let mixes = g.ink_mixes().expect("the curve");
let mid = mixes.iter().find(|m| (m.0 - 0.3).abs() < 1e-6).expect("the midpoint");
assert!((mid.2 - 0.5).abs() < 1e-6, "{mid:?}");
// More stops on the curve than MAX_INK_TINTS: no mixes.
let n = MAX_INK_TINTS + 1;
g.stops = (0..n).map(|i| GradientStop::new(i as f32 / (n - 1) as f32, if i % 2 == 0 { magenta } else { Color::WHITE })).collect();
assert_eq!(g.ink_mixes(), None);
}
+3
View File
@@ -47,6 +47,7 @@ impl Document {
patterns,
pattern_edit,
mask_edit,
symbol_edit,
next_id: _,
unknown,
page_isolate,
@@ -113,6 +114,7 @@ impl Document {
(a, b) => a.is_none() && b.is_none(),
}
&& *mask_edit == other.mask_edit
&& *symbol_edit == other.symbol_edit
&& *unknown == other.unknown
&& *page_isolate == other.page_isolate
&& *page_knockout == other.page_knockout
@@ -221,6 +223,7 @@ impl Document {
patterns: vec![],
pattern_edit: None,
mask_edit: None,
symbol_edit: None,
next_id: 1,
unknown: Default::default(),
page_isolate: false,
+9 -5
View File
@@ -98,18 +98,22 @@ impl Document {
self.views = kept;
}
/// Leave opacity-mask editing: drop the temporary editing layer, a working copy of art the
/// mask already holds (the engine syncs it after every edit).
/// Leave opacity-mask editing and symbol editing: drop the temporary editing layer, a working
/// copy of art the mask or the symbol already holds (the engine syncs it after every edit).
pub fn drop_edit_modes(&mut self) {
if let Some(me) = self.mask_edit.take() {
self.layers.retain(|l| l.id != me.layer);
}
if let Some(se) = self.symbol_edit.take() {
self.layers.retain(|l| l.id != se.layer);
}
}
/// The document as saved and exported: without the opacity-mask editing layer. Borrowed when
/// no mask is being edited. (Pattern editing is saved: its tile layer holds unapplied edits.)
/// The document as saved and exported: without the opacity-mask or symbol editing layer.
/// Borrowed when neither mode is on. (Pattern editing is saved: its tile layer holds unapplied
/// edits.)
pub fn without_edit_modes(&self) -> std::borrow::Cow<'_, Document> {
if self.mask_edit.is_none() {
if self.mask_edit.is_none() && self.symbol_edit.is_none() {
return std::borrow::Cow::Borrowed(self);
}
let mut d = self.clone();
+6 -1
View File
@@ -106,6 +106,7 @@ pub use selection::{AnchorRef, SegmentRef, Selection};
pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource};
pub use style_libs::StyleLibrary;
pub use text::FontHeightReference;
pub use text::{
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt,
JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, RubyPosition,
@@ -141,7 +142,7 @@ pub use guides::{GridPrefs, Guide, RulerMode};
#[cfg(test)]
use limits::MAX_NEXT_ID;
pub use limits::{DocError, FORMAT_VERSION, MAX_COORD};
pub use records::{ImageBlob, MaskEdit, NewArtIds, SavedSelection, SavedView, Symbol};
pub use records::{ImageBlob, MaskEdit, NewArtIds, SavedSelection, SavedView, Symbol, SymbolEdit};
pub use units::{ColorMode, Unit, parse_number};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -221,6 +222,10 @@ pub struct Document {
/// still read so files saved mid-edit by older versions load without the editing layer.
#[serde(default, skip_serializing)]
pub mask_edit: Option<MaskEdit>,
/// Symbol editing mode, while active. Never saved (see [`Document::without_edit_modes`]): the
/// symbol already holds the art of its editing layer.
#[serde(default, skip_serializing)]
pub symbol_edit: Option<SymbolEdit>,
next_id: u64,
/// Foreign data preserved on round-trip.
#[serde(default, skip_serializing_if = "skip::is_empty_map")]
+24 -1
View File
@@ -1,4 +1,4 @@
use super::{Arc, Deserialize, Node, NodeId, Point, Serialize};
use super::{Affine, Arc, Deserialize, Node, NodeId, Point, Serialize};
/// New art on a locked artboard ([`Document::new_art`]). Setting it changes the document in
/// place, so a selection change that sets it doesn't replace the document.
@@ -44,6 +44,29 @@ pub struct MaskEdit {
pub layer: NodeId,
}
/// Symbol editing mode: the art of symbol `symbol` lives on the temporary `layer` while editing,
/// placed by `xf` (from the symbol's coordinates to the document's), and goes back into the symbol
/// after each edit, so every instance follows it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SymbolEdit {
pub symbol: String,
pub layer: NodeId,
/// The instance editing was entered from (a double-click on it), selected again on leaving after an edit;
/// `None` when it was entered from the Symbols panel.
pub instance: Option<NodeId>,
pub xf: Affine,
/// The symbol's art is a group whose members are on the layer: the group takes them back.
pub group: bool,
/// An edit changed the art since editing was entered.
pub changed: bool,
/// The isolated object from before editing was entered, isolated again on leaving.
#[serde(default)]
pub back_isolation: Option<NodeId>,
/// The current layer from before editing was entered, current again on leaving.
#[serde(default)]
pub back_layer: Option<NodeId>,
}
/// A saved view (View → New View…): zoom, centre and rotation, listed at the bottom of the View menu.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SavedView {
+1 -1
View File
@@ -15,7 +15,7 @@ mod tabs_lists;
pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape};
use char_style::yes;
pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, ScriptMetrics};
pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, ScriptMetrics};
pub use para_style::{
Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, LeadingModel, Mojikumi, ParaDirection,
ParaStyle, SingleWordJustify,
+20
View File
@@ -116,6 +116,10 @@ pub struct CharStyle {
/// these characters are drawn in, whatever digits were typed.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub digits: Digits,
/// Font height reference (Character panel menu › Show Font Height Options): the height of the
/// font that [`Self::size`] measures, the em box by default.
#[serde(default, rename = "fontHeightReference", skip_serializing_if = "crate::skip::is_default")]
pub font_height_reference: FontHeightReference,
/// No Break (Character panel menu): these characters are not broken across lines, including
/// at a space or a hyphenation point inside the run. A run longer than the line stays together
/// and can overflow it.
@@ -130,6 +134,21 @@ pub struct CharStyle {
pub language: Option<String>,
}
/// What a character's font size measures ([`CharStyle::font_height_reference`]): the em square
/// of the font, the height of its capitals or of its lowercase letters (the cap height and x
/// height of its OS/2 table), or its ideographic character face (ICF, OpenType's `icfb` and
/// `icft` baselines). Layout sets the characters at the em size at which the chosen height equals
/// the font size; with a font that has no such value, the size measures the em box.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum FontHeightReference {
#[default]
EmBox,
CapHeight,
XHeight,
IcfBox,
}
/// The numeral system digits are drawn in ([`CharStyle::digits`]): as typed, or every digit of
/// the Western, Arabic-Indic and Eastern Arabic-Indic sets drawn in one of them or in the digits
/// of an Indic script. The text keeps the digits that were typed; only the glyphs change (like
@@ -380,6 +399,7 @@ impl Default for CharStyle {
standard_vertical_roman: false,
small_caps: None,
digits: Digits::Default,
font_height_reference: FontHeightReference::EmBox,
no_break: false,
language: None,
}
+18
View File
@@ -60,6 +60,24 @@ fn digits_map_between_the_numeral_systems() {
assert_eq!(serde_json::from_value::<CharStyle>(serde_json::to_value(&farsi).unwrap()).unwrap(), farsi);
}
/// The font height reference is the em box by default and is saved only when it is another.
#[test]
fn font_height_reference_is_saved_only_when_not_the_em_box() {
let st = CharStyle::default();
assert_eq!(st.font_height_reference, FontHeightReference::EmBox);
assert!(serde_json::to_value(&st).unwrap().get("fontHeightReference").is_none());
for (r, key) in
[(FontHeightReference::CapHeight, "capHeight"), (FontHeightReference::XHeight, "xHeight"), (FontHeightReference::IcfBox, "icfBox")]
{
let set = CharStyle { font_height_reference: r, ..CharStyle::default() };
assert_eq!(serde_json::to_value(&set).unwrap()["fontHeightReference"], key);
assert_eq!(serde_json::from_value::<CharStyle>(serde_json::to_value(&set).unwrap()).unwrap(), set);
}
// Styles written before the attribute existed read as the em box.
let old = serde_json::to_value(&st).unwrap();
assert_eq!(serde_json::from_value::<CharStyle>(old).unwrap().font_height_reference, FontHeightReference::EmBox);
}
/// Each Indic numeral system draws 0–9 (and the Arabic-Indic sets) as the ten digits of its
/// script's block; every system saves under its id, which reads back, and other names are refused.
#[test]
+9 -4
View File
@@ -84,6 +84,7 @@ mod style;
pub mod stylelib;
mod swatch;
pub mod swatchlib;
pub(crate) mod symboledit;
pub mod symbollib;
pub(crate) mod tabs;
mod textedit;
@@ -183,11 +184,15 @@ pub fn has_multi(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
if st.selection.len() < 2 { Err("select at least two objects".into()) } else { Ok(()) }
}
/// The isolated group, layer or object in isolation mode. Pattern editing mode and opacity mask
/// editing isolate their own layer, which does not count here; an object isolated inside them
/// counts.
/// The isolated group, layer or object in isolation mode. Pattern editing mode, opacity mask
/// editing and symbol editing mode isolate their own layer, which does not count here; an object
/// isolated inside them counts.
pub(crate) fn ordinary_isolation(st: &crate::DocState) -> Option<vectorcraft_doc::NodeId> {
st.isolation.filter(|l| !st.doc.pattern_edit.as_ref().is_some_and(|e| e.layer == *l) && !st.doc.mask_edit.as_ref().is_some_and(|e| e.layer == *l))
st.isolation.filter(|l| {
!st.doc.pattern_edit.as_ref().is_some_and(|e| e.layer == *l)
&& !st.doc.mask_edit.as_ref().is_some_and(|e| e.layer == *l)
&& !st.doc.symbol_edit.as_ref().is_some_and(|e| e.layer == *l)
})
}
/// Objects are selected ([`has_selection`]), and in isolation mode ([`ordinary_isolation`]) at
/// least one of them is inside the isolated object: Object › Lock's and Object › Hide's items.
+1 -1
View File
@@ -120,7 +120,7 @@ fn number_in(p: &Value, key: &str, default: f64, (lo, hi): (f64, f64), cmd: &str
}
/// Whole-number param `key` within `lo..=hi`, `None` when left out.
fn whole_in(p: &Value, key: &str, (lo, hi): (u64, u64), cmd: &str) -> Result<Option<u64>> {
pub(super) fn whole_in(p: &Value, key: &str, (lo, hi): (u64, u64), cmd: &str) -> Result<Option<u64>> {
let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) };
let n = v.as_u64().ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number")))?;
if (lo..=hi).contains(&n) { Ok(Some(n)) } else { Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))) }
+22 -2
View File
@@ -2,9 +2,11 @@
//! Duotone and Invert Colors, in Effect › Color Effects) are the effects crate's; here, Apply as
//! Filter writes any colour adjustment into the colours themselves, Select by Color Kind selects the
//! objects that use spot colours, carry a live colour adjustment, or are images of a colour model,
//! and Print Ink Fixes ([`print_fixes`]) prepares a CMYK document's inks for print. Its panel and
//! its submenu, Object › Plug-ins › Color Effects, are the UI's.
//! Print Ink Fixes ([`print_fixes`]) prepares a CMYK document's inks for print, and Find Nearest
//! Swatch ([`nearest`]) matches colours to a swatch library's. Its panel and its submenu, Object ›
//! Plug-ins › Color Effects, are the UI's.
mod nearest;
mod print_fixes;
#[cfg(test)]
mod tests;
@@ -50,6 +52,24 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
print_fixes::run
),
cmd!(
query "addon.color.nearestSwatch",
"Find Nearest Swatch",
["Object", "Plug-ins", "Color Effects"],
None,
"{library?: a swatch library's id or name (swatch.library.list: the built-in ones, and the colour books and libraries the user saved or loaded) or \"document\" (the document's swatches; the default), color?: colour (look this colour up; default: the selection's), fill?: true, stroke?: true (the selected objects' fills and strokes, their type's and those of the objects inside them: solid colours and gradient stops, once each, the first 100), count?: 1..50 (5), ids?: [id…] (default: the selection)} list the library's colour swatches closest to each colour, closest first, by CIEDE2000 difference (ΔE; under 1 hardly tells apart, about 2 to 3 close). Tint swatches, None and Registration aren't matched → {library, colors: [{values, hex, matches: [{name, values, hex, spot, deltaE}]}]}",
has_doc,
nearest::find
),
cmd!(
"addon.color.applyNearestSwatch",
"Apply Nearest Swatch",
["Object", "Plug-ins", "Color Effects"],
None,
"{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step: a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}",
has_selection,
nearest::apply
),
]
}
@@ -0,0 +1,198 @@
//! Find Nearest Swatch (`addon.color.nearestSwatch`, `addon.color.applyNearestSwatch`; Object ›
//! Plug-ins › Color Effects › Find Nearest Swatch…): the swatches of a library, a colour book the
//! user loaded or the document closest to each colour of the selection (CIEDE2000), and the
//! colours replaced by them as one undo step (#4923).
use serde_json::{Value, json};
use vectorcraft_color::cms::{Lab, delta_e2000};
use vectorcraft_color::{Color, Paint, Swatch};
use vectorcraft_doc::swatches::color_name;
use vectorcraft_doc::{Document, NodeId, NodeKind};
use super::super::super::colorcmds::recolor_links;
use super::super::super::menucmds::squash;
use super::super::super::swatchlib::{DOCUMENT_SWATCHES, library_swatches};
use super::super::super::{bad, checked_ids_or_selection, color_value, index, switch_or, text_param};
use super::super::arrange::whole_in;
use crate::{Result, Session};
const FIND: &str = "addon.color.nearestSwatch";
const APPLY: &str = "addon.color.applyNearestSwatch";
/// The most colours of the selection looked up.
const MAX_COLORS: usize = 100;
/// The `library` param ([`DOCUMENT_SWATCHES`] when left out) and its colour swatches with their Lab
/// values.
fn candidates(s: &Session, p: &Value, cmd: &str) -> Result<(String, Vec<(Swatch, Lab)>)> {
let key = text_param(p, "library", cmd)?.unwrap_or(DOCUMENT_SWATCHES).to_string();
let swatches = library_swatches(s, &key).ok_or_else(|| bad(cmd, format!("no swatch library `{key}` (see swatch.library.list)")))?;
let labs: Vec<(Swatch, Lab)> = swatches
.into_iter()
.filter(|w| !w.is_reserved())
.filter_map(|w| {
let lab = w.paint.color()?.to_lab();
Some((w, lab))
})
.collect();
if labs.is_empty() {
return Err(bad(cmd, format!("`{key}` has no colours")));
}
Ok((key, labs))
}
/// The `count` swatches of `among` closest to `c`, closest first, with their difference (ΔE 2000).
fn nearest(among: &[(Swatch, Lab)], c: Color, count: usize) -> Vec<(&Swatch, f32)> {
let lab = c.to_lab();
let mut v: Vec<(&Swatch, f32)> = among.iter().map(|(w, l)| (w, delta_e2000(*l, lab))).collect();
v.sort_by(|a, b| a.1.total_cmp(&b.1));
v.truncate(count);
v
}
/// The solid colours and gradient stops of the fills (`fill`) and strokes (`stroke`) of `ids`, of
/// everything inside them and of their type, once each and at most [`MAX_COLORS`].
fn art_colors(d: &Document, ids: &[NodeId], fill: bool, stroke: bool) -> Vec<Color> {
let mut out: Vec<Color> = vec![];
let mut add = |p: &Paint| {
let colors: Vec<Color> = match p {
Paint::Solid { color, .. } => vec![*color],
Paint::Gradient(g) => g.gradient.stops.iter().map(|s| s.color).collect(),
_ => vec![],
};
for c in colors {
if out.len() < MAX_COLORS && !out.contains(&c) {
out.push(c);
}
}
};
for n in ids.iter().filter_map(|id| d.node(*id)) {
n.walk(&mut |m| {
for it in &m.appearance.items {
if if it.is_fill() { fill } else { stroke } {
add(it.paint());
}
}
if let NodeKind::Text(t) = &m.kind {
for r in &t.runs {
if fill {
add(&r.style.fill);
}
if stroke {
add(&r.style.stroke);
}
}
}
});
}
out
}
/// A swatch's name and values as the results give them.
fn described(w: &Swatch) -> Value {
let c = w.paint.color().unwrap_or_default();
json!({ "name": w.name, "values": color_name(c), "hex": c.to_hex(), "spot": w.spot })
}
pub(super) fn find(s: &mut Session, p: &Value) -> Result<Value> {
let count = whole_in(p, "count", (1, 50), FIND)?.map_or(5, index);
let (fill, stroke) = (switch_or(p, "fill", true, FIND)?, switch_or(p, "stroke", true, FIND)?);
let colors = match p.get("color").filter(|v| !v.is_null()) {
Some(v) => vec![color_value(v).ok_or_else(|| bad(FIND, "`color` is not a colour"))?],
None => {
let ids = checked_ids_or_selection(s, p, FIND)?;
if ids.is_empty() {
return Err(bad(FIND, "select objects or give `color`"));
}
art_colors(&s.doc()?.doc, &ids, fill, stroke)
}
};
let (library, among) = candidates(s, p, FIND)?;
let found: Vec<Value> = colors
.iter()
.map(|c| {
let matches: Vec<Value> = nearest(&among, *c, count)
.into_iter()
.map(|(w, de)| {
let mut m = described(w);
m["deltaE"] = json!((f64::from(de) * 100.0).round() / 100.0);
m
})
.collect();
json!({ "values": color_name(*c), "hex": c.to_hex(), "matches": matches })
})
.collect();
Ok(json!({ "library": library, "colors": found }))
}
pub(super) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let (fill, stroke) = (switch_or(p, "fill", true, APPLY)?, switch_or(p, "stroke", true, APPLY)?);
let ids = checked_ids_or_selection(s, p, APPLY)?;
if ids.is_empty() {
return Err(bad(APPLY, "nothing selected"));
}
let (library, among) = candidates(s, p, APPLY)?;
// The swatch each colour takes, once each.
let colors = art_colors(&s.doc()?.doc, &ids, fill, stroke);
let mut used: Vec<&Swatch> = vec![];
for c in &colors {
if let Some((w, _)) = nearest(&among, *c, 1).first()
&& !used.iter().any(|u| u.name == w.name)
{
used.push(w);
}
}
if used.is_empty() {
return Ok(json!({ "changed": 0, "swatches": [], "added": [] }));
}
let undo = s.doc()?.history.undo.len();
// A library's swatches join the document's first, as Add to Swatches adds them, so spot and
// global colours stay linked to theirs.
let mut added = vec![];
let mut names: Vec<String> = used.iter().map(|w| w.name.clone()).collect();
if library != DOCUMENT_SWATCHES {
let r = s.execute("swatch.library.add", &json!({ "library": library, "names": names }))?;
let listed = |k: &str| r.get(k).and_then(Value::as_array).cloned().unwrap_or_default();
let in_doc: Vec<String> = listed("added").iter().chain(&listed("existing")).filter_map(Value::as_str).map(str::to_string).collect();
let d = &s.doc()?.doc;
// Each library swatch's copy: the document swatch with its paint among those added or kept.
names = used
.iter()
.map(|w| in_doc.iter().find(|n| d.swatch(n).is_some_and(|x| x.paint == w.paint)).cloned().unwrap_or_else(|| w.name.clone()))
.collect();
added = listed("added");
}
// Each used swatch's paint as art takes it: linked to a global or spot swatch.
let d = &s.doc()?.doc;
let paints: Vec<(Lab, Paint)> = used
.iter()
.zip(&names)
.filter_map(|(w, name)| {
let lab = w.paint.color()?.to_lab();
let paint = d.swatch(name).filter(|x| x.global).and_then(|_| d.tint_paint(name, 1.0)).unwrap_or_else(|| w.paint.clone());
Some((lab, paint))
})
.collect();
// shortcut: a colour past the MAX_COLORS looked up takes the closest of the swatches those
// took; look up every colour if documents with more colours need their own matches.
let to = |c: Color| -> Option<&Paint> {
let lab = c.to_lab();
paints.iter().min_by(|a, b| delta_e2000(a.0, lab).total_cmp(&delta_e2000(b.0, lab))).map(|(_, p)| p)
};
let links = |c: &mut Color, link: &mut Option<String>, tint: &mut f32| {
let Some(Paint::Solid { color, swatch, .. }) = to(*c) else { return false };
if *c == *color && *link == *swatch {
return false;
}
(*c, *link, *tint) = (*color, swatch.clone(), 1.0);
true
};
let given = json!({
"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "fill": fill, "stroke": stroke, "includeImages": false, "includePatterns": false,
});
let r = recolor_links(s, &given, "Apply Nearest Swatch", &|c| to(c).and_then(Paint::color).unwrap_or(c), &links)?;
squash(s, undo, "Apply Nearest Swatch");
Ok(json!({ "changed": r["changed"], "swatches": names, "added": added }))
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,115 @@
//! Find Nearest Swatch.
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::NodeId;
use crate::Session;
const FIND: &str = "addon.color.nearestSwatch";
const APPLY: &str = "addon.color.applyNearestSwatch";
/// A document with a global swatch "Brand" and a selected rectangle filled `fill`, stroked `stroke`.
fn doc(fill: &str, stroke: Option<&str>) -> (Session, NodeId) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
s.execute("swatch.new", &json!({"name": "Brand", "color": "#ff8000", "global": true})).unwrap();
let id = NodeId(s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap());
s.execute("paint.setFill", &json!({"color": fill})).unwrap();
match stroke {
Some(c) => s.execute("paint.setStroke", &json!({"color": c})).unwrap(),
None => s.execute("paint.setStroke", &json!({"none": true})).unwrap(),
};
(s, id)
}
fn paints(s: &Session, id: NodeId) -> (Paint, Paint) {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
(n.appearance.fill_paint(), n.appearance.items.iter().find(|i| !i.is_fill()).unwrap().paint().clone())
}
fn names(matches: &Value) -> Vec<&str> {
matches.as_array().unwrap().iter().map(|m| m["name"].as_str().unwrap()).collect()
}
#[test]
fn the_closest_swatches_of_a_library_are_listed_closest_first() {
let (mut s, _) = doc("#ff6634", None);
let v = s.execute(FIND, &json!({"library": "web-safe-216", "count": 3})).unwrap();
assert_eq!(v["library"], "web-safe-216");
let colors = v["colors"].as_array().unwrap();
assert_eq!(colors.len(), 1, "the fill; no stroke");
assert_eq!((colors[0]["hex"].as_str(), colors[0]["values"].as_str()), (Some("#ff6634"), Some("R=255 G=102 B=52")));
let m = &colors[0]["matches"];
assert_eq!(names(m)[0], "#FF6633");
let de: Vec<f64> = m.as_array().unwrap().iter().map(|x| x["deltaE"].as_f64().unwrap()).collect();
assert_eq!(de.len(), 3);
assert!(de[0] < 1.0 && de.windows(2).all(|w| w[0] <= w[1]), "{de:?}");
// A colour given is looked up instead of the selection's.
let v = s.execute(FIND, &json!({"library": "web-safe-216", "color": "#010203", "count": 1})).unwrap();
assert_eq!(names(&v["colors"][0]["matches"]), ["#000000"]);
}
#[test]
fn the_document_swatches_are_the_default_library() {
let (mut s, _) = doc("#ff8010", Some("#ff8010"));
let v = s.execute(FIND, &json!({})).unwrap();
assert_eq!(v["library"], "document");
assert_eq!(v["colors"].as_array().unwrap().len(), 1, "fill and stroke share a colour: looked up once");
assert_eq!(names(&v["colors"][0]["matches"])[0], "Brand");
assert!(!names(&v["colors"][0]["matches"]).contains(&"[Registration]"));
}
#[test]
fn applying_links_each_colour_to_its_nearest_document_swatch_as_one_undo_step() {
let (mut s, id) = doc("#ff8010", Some("#ff8010"));
let undo = s.doc().unwrap().history.undo.len();
let v = s.execute(APPLY, &json!({"stroke": false})).unwrap();
assert_eq!((v["changed"].as_u64(), v["swatches"].clone(), v["added"].clone()), (Some(1), json!(["Brand"]), json!([])));
let (fill, stroke) = paints(&s, id);
assert!(matches!(&fill, Paint::Solid { swatch: Some(n), color, .. } if n == "Brand" && color.to_hex() == "#ff8000"), "{fill:?}");
assert_eq!(stroke.color().unwrap().to_hex(), "#ff8010", "strokes left alone");
let st = s.doc().unwrap();
assert_eq!((st.history.undo.len(), st.history.undo.last().unwrap().label.as_str()), (undo + 1, "Apply Nearest Swatch"));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(paints(&s, id).0.color().unwrap().to_hex(), "#ff8010");
}
#[test]
fn a_library_spot_colour_joins_the_document_and_stays_spot() {
let (mut s, id) = doc("#e01525", None);
// A colour book of one spot colour, loaded as a user would load one they own.
s.execute("swatch.new", &json!({"name": "Ink Red", "color": "#e01020", "spot": true})).unwrap();
let data = s.execute("swatch.library.save", &json!({"names": ["Ink Red"]})).unwrap()["data"].as_str().unwrap().to_string();
s.execute("swatch.delete", &json!({"names": ["Ink Red"]})).unwrap();
let lib = s.execute("swatch.library.load", &json!({"data": data, "name": "Book.vcswatches"})).unwrap()["library"].as_str().unwrap().to_string();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
let v = s.execute(APPLY, &json!({"library": lib})).unwrap();
assert_eq!((v["swatches"].clone(), v["added"].clone()), (json!(["Ink Red"]), json!(["Ink Red"])));
let d = &s.doc().unwrap().doc;
assert!(d.swatch("Ink Red").is_some_and(|w| w.spot && w.global));
assert!(matches!(paints(&s, id).0, Paint::Solid { swatch: Some(n), .. } if n == "Ink Red"));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "adding and applying undo together");
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.swatch("Ink Red").is_none());
assert_eq!(paints(&s, id).0, Paint::solid(Color::from_hex("#e01525").unwrap()));
}
#[test]
fn bad_requests_are_refused() {
let (mut s, _) = doc("#ff8010", None);
for (p, why) in [
(json!({"library": "nope"}), "no swatch library `nope`"),
(json!({"library": "spectrum-gradients"}), "has no colours"),
(json!({"count": 0}), "`count` must be from 1 to 50"),
(json!({"color": "zzz"}), "`color` is not a colour"),
(json!({"fill": "yes"}), "`fill` must be true or false"),
] {
let e = s.execute(FIND, &p).unwrap_err().to_string();
assert!(e.contains(why), "{p}: {e}");
}
s.execute("select.none", &json!({})).unwrap();
assert!(s.execute(FIND, &json!({})).unwrap_err().to_string().contains("select objects or give `color`"));
assert!(s.execute(APPLY, &json!({})).is_err(), "nothing selected");
}
+25 -2
View File
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export All Open Documents",
["Object", "Plug-ins", "Document Tools"],
None,
"{folder, format?: vectorcraft|pdf|svg|svgz|ai|eps (default: pdf)} write a copy of every open document, in tab order, to `folder` as File › Save a Copy writes it, each named after its document (<name>.<ext>; -2, -3… added when that name is taken, so no file is replaced): every document keeps its path, title and modified state, and no undo step is made. A document that can't be written is listed in `failed` and the others are still written; the active document stays active → {files: [{title, path, warnings}], failed: [{title, error}], format}",
"{folder, format?: vectorcraft|pdf|svg|svgz|ai|eps (default: pdf)} write a copy of every open document, in tab order, to `folder` as File › Save a Copy writes it, each named after its document (<name>.<ext>; -2, -3… added when that name is taken, so no file is replaced), one self-contained file each with all of its art (images embedded or included, every artboard; no remembered save options apply): every document keeps its path, title and modified state, and no undo step is made. A document that can't be written is listed in `failed` and the others are still written; the active document stays active → {files: [{title, path, warnings}], failed: [{title, error}], format}",
has_doc,
export_all_open
),
@@ -98,6 +98,20 @@ fn save_timestamped_copy(s: &mut Session, p: &Value) -> Result<Value> {
fileio::save_document(s, SaveMode::Copy, &json!({ "path": path, "format": format.id }))
}
/// The save options of every copy, given in full so that no document's remembered options (linked
/// SVG images, a PDF page range, separate artboard files) apply: one self-contained file per
/// document with all of its art. Each format reads only the options it has, and `range` is left
/// out of SVG, where it would pick artboards over `useArtboards`.
fn copy_options(format: &str) -> Value {
match format {
"svg" | "svgz" => json!({ "images": "embed", "useArtboards": false }),
"pdf" => json!({ "range": "all" }),
"eps" => json!({ "includeLinkedFiles": true, "useArtboards": false }),
"ai" | "ait" => json!({ "range": "all", "includeLinked": true, "separateArtboards": false }),
_ => json!({ "includeLinked": true, "separateArtboards": false }),
}
}
fn export_all_open(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.document.exportAllOpen";
let format = fileio::save_format(Some(str_param(p, "format").unwrap_or("pdf")), None).map_err(|e| bad(C, e))?;
@@ -117,9 +131,18 @@ fn export_all_open(s: &mut Session, p: &Value) -> Result<Value> {
failed.push(json!({ "title": title, "error": "every numbered name for the copy is taken" }));
continue;
};
match fileio::save_document(s, SaveMode::Copy, &json!({ "path": path, "format": format.id })) {
let options = copy_options(format.id);
match fileio::save_document(s, SaveMode::Copy, &json!({ "path": path, "format": format.id, "options": options })) {
Ok(r) => {
written.push(path.clone());
// Any other file the save wrote is taken for the copies still to come.
for key in ["files", "linked"] {
for f in r.get(key).and_then(Value::as_array).into_iter().flatten() {
if let Some(f) = f.as_str().or_else(|| f.get("path").and_then(Value::as_str)) {
written.push(f.to_string());
}
}
}
files.push(json!({ "title": title, "path": r.get("path").cloned().unwrap_or(json!(path)), "warnings": r.get("warnings").cloned().unwrap_or(json!([])) }));
}
Err(e) => failed.push(json!({ "title": title, "error": e.to_string() })),
@@ -217,3 +217,62 @@ fn export_all_open_documents_writes_each_and_reports_failures() {
assert_eq!(s.active_index(), Some(0));
let _ = std::fs::remove_dir_all(&dir);
}
/// An SVG document last saved with linked images exports as one SVG with its images embedded: no
/// image file is written beside the copies (#4920 review).
#[test]
fn export_all_open_documents_writes_one_file_each() {
let dir = std::env::temp_dir().join(format!("vc-export-all-one-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("out")).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100, "name": "pic"})).unwrap();
let mut png = vec![];
image::RgbaImage::from_pixel(4, 4, image::Rgba([1, 2, 3, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
s.execute("file.place", &json!({"name": "a.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
s.execute("document.save", &json!({"path": dir.join("pic.svg"), "format": "svg", "options": {"images": "link"}})).unwrap();
let saved: Vec<_> = std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).filter(|e| e.path().is_file()).collect();
assert!(saved.len() > 1, "the linked save wrote its image beside it: {saved:?}");
let out = dir.join("out");
let r = s.execute("addon.document.exportAllOpen", &json!({"folder": out, "format": "svg"})).unwrap();
let names: Vec<String> = std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
assert_eq!(names, ["pic.svg"], "{r}");
assert!(std::fs::read_to_string(out.join("pic.svg")).unwrap().contains("data:image/png;base64,"), "the image is embedded");
let _ = std::fs::remove_dir_all(&dir);
}
/// A PDF document last saved with a page range exports with every page, and an SVG document with
/// two artboards exports as one SVG holding the art of both (#4920 review).
#[test]
fn export_all_open_documents_keeps_every_page_and_artboard() {
let dir = std::env::temp_dir().join(format!("vc-export-all-pages-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("out")).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100, "artboards": 2, "name": "pages"})).unwrap();
s.execute("document.save", &json!({"path": dir.join("pages.pdf"), "format": "pdf", "options": {"range": "1"}})).unwrap();
let pages = |b: &[u8]| {
String::from_utf8_lossy(b).matches("/Type /Page\n").count()
+ String::from_utf8_lossy(b).matches("/Type/Page/").count()
+ String::from_utf8_lossy(b).matches("/Type /Page/").count()
+ String::from_utf8_lossy(b).matches("/Type /Page ").count()
};
let first = pages(&std::fs::read(dir.join("pages.pdf")).unwrap());
let out = dir.join("out");
s.execute("addon.document.exportAllOpen", &json!({"folder": out, "format": "pdf"})).unwrap();
let copy = pages(&std::fs::read(out.join("pages.pdf")).unwrap());
assert!(copy > first, "the copy has every page: {first} saved, {copy} exported");
// SVG: one file with the art on both artboards.
let a = s.doc().unwrap().doc.artboards[1].rect;
s.execute("shape.rectangle", &json!({"x": a.x0 + 10.0, "y": a.y0 + 10.0, "width": 20, "height": 20})).unwrap();
s.execute("addon.document.exportAllOpen", &json!({"folder": out, "format": "svg"})).unwrap();
let svgs: Vec<String> =
std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).filter(|n| n.ends_with(".svg")).collect();
assert_eq!(svgs, ["pages.svg"]);
let svg = std::fs::read_to_string(out.join("pages.svg")).unwrap();
// Not the first artboard alone (its 100 × 100 box at the origin): the art's own bounds.
let view = svg.split("viewBox=\"").nth(1).and_then(|v| v.split('"').next()).unwrap();
let w: f64 = view.split_whitespace().nth(2).unwrap().parse().unwrap();
assert!(w > 19.0 && w < 30.0, "the SVG shows the art (the square on the second artboard), not the first artboard: viewBox {view}");
let _ = std::fs::remove_dir_all(&dir);
}
+15 -6
View File
@@ -35,10 +35,10 @@ use brushes::{
};
use dynamic::{symbol_instance_reset, symbol_instance_set_appearance};
use symbols::selected_instances;
pub(crate) use symbols::{instance_in_place, natural_size, normalize, place_xf, set_natural_size, store_symbol};
pub(crate) use symbols::{fit_instances_of, instance_in_place, instances_of, natural_size, normalize, place_xf, set_natural_size, store_symbol};
use symbols::{
symbol_adjust, symbol_break_link, symbol_delete, symbol_duplicate, symbol_edit, symbol_list, symbol_new, symbol_options, symbol_place,
symbol_replace, symbol_select_instances, symbol_set_current, symbol_spray, symbol_unused, symbol_update,
symbol_adjust, symbol_break_link, symbol_delete, symbol_duplicate, symbol_list, symbol_new, symbol_options, symbol_place, symbol_replace,
symbol_select_instances, symbol_set_current, symbol_spray, symbol_unused, symbol_update,
};
const SIZES_KEY: &str = vectorcraft_doc::SYMBOL_SIZES;
@@ -210,9 +210,18 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Symbol",
[],
None,
"{id?, ids?: [id…] (instead of id)} break the link of the selected instance so the symbol's art can be edited (without the instance's own appearance changes to a dynamic symbol); finish with symbol.update → {name, ids}",
has_selection,
symbol_edit
"{id?, ids?: [id…] (instead of id), name?, x?, y?, artboard?: index=0 (the active artboard; the app passes it), viewCenter?: [x, y] (the app passes the view's center)} edit a symbol's art in symbol editing mode: the instance `id` (default: the selected instances, which must share one symbol) where it shows the art, else the symbol `name` (default: the current symbol) centered on (x, y) (default: the center of artboard `artboard`, or of the first artboard when there is no such artboard; in a document without artboards, viewCenter, else (0, 0)); every instance follows each edit and the instance stays linked (symbol.breakLink expands one); refused while a pattern or an opacity mask is edited → {name, layer, id?}",
has_doc,
super::symboledit::enter
),
cmd!(
"symbol.exitEditing",
"Exit Symbol Editing Mode",
[],
None,
"{} leave symbol editing mode; after an edit, select the instance it was entered from; leaving without having changed the art adds no undo step and restores the selection from before entry (object.exitIsolation also leaves it) → {name?}",
has_doc,
super::symboledit::leave
),
cmd!(
"symbol.update",
+50 -18
View File
@@ -95,7 +95,14 @@ pub(crate) fn normalize(mut art: Node) -> Result<(Node, (f64, f64), Point)> {
Ok((art, (w, h), c))
}
fn instances_of(d: &Document, name: Option<&str>) -> Vec<NodeId> {
/// After the art of symbol `name` changed: each of its instances keeps its appearance changes to
/// the objects the art still has.
pub(crate) fn fit_instances_of(d: &mut Document, name: &str) {
let ids = instances_of(d, Some(name));
fit_overrides(d, &ids);
}
pub(crate) fn instances_of(d: &Document, name: Option<&str>) -> Vec<NodeId> {
let mut v = vec![];
d.walk(|n| {
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind
@@ -274,6 +281,14 @@ pub(super) fn symbol_options(s: &mut Session, p: &Value) -> Result<Value> {
if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) {
d.unknown.insert(CURRENT_SYMBOL.into(), json!(n));
}
// Symbol editing mode follows the new name, and so does its layer.
if let Some(e) = d.symbol_edit.as_mut().filter(|e| e.symbol == name) {
e.symbol = n.clone();
let layer = e.layer;
if let Some(l) = d.node_mut(layer) {
l.name = Some(n.clone());
}
}
}
if let Some(r) = reg {
d.set_symbol_registration(&final_name, r);
@@ -398,34 +413,35 @@ pub(super) fn symbol_break_link(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": ids_json(&out) }))
}
pub(super) fn symbol_edit(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_instances(s, p)?;
let first = *ids.first().ok_or_else(|| EngineError::Other("select a symbol instance".into()))?;
let name = match &s.doc()?.doc.node(first).map(|n| n.kind.clone()) {
Some(NodeKind::SymbolInstance { symbol, .. }) => symbol.clone(),
_ => return Err(EngineError::Other("select a symbol instance".into())),
};
// The symbol's own art: one instance's appearance changes would become every instance's.
let out = s.edit("Edit Symbol", |d, sel| {
let out = break_links(d, &[first], false)?;
sel.set(out.iter().copied());
Ok(out)
})?;
Ok(json!({ "name": name, "ids": ids_json(&out) }))
}
pub(super) fn symbol_update(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "symbol.update";
let name = name_param(p, C)?.to_string();
if !s.doc()?.doc.symbols.iter().any(|x| x.name == name) {
return Err(bad(C, format!("no symbol named `{name}`")));
}
// Redefining the symbol being edited leaves symbol editing mode in the same step.
let session = s.doc()?.doc.symbol_edit.clone().filter(|e| e.symbol == name);
if session.is_some() {
crate::cmd::settle_interaction(s)?;
s.end_text_edit();
}
let ids = roots(s, p)?;
// The art on the editing layer can't redefine it: the symbol would hold an instance of itself.
if let Some(se) = &session {
let d = &s.doc()?.doc;
if ids.iter().any(|i| d.ancestry(*i).is_some_and(|a| a.contains(&se.layer))) {
return Err(EngineError::Other("a symbol can't hold an instance of itself".into()));
}
}
let mut art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?;
crate::cmd::threads::keep_shown_text(&s.doc()?.doc, &mut art);
let (art, size, c) = normalize(art)?;
let old = natural_size(&s.doc()?.doc, &name);
let editing = session.is_some();
let id = s.edit("Redefine Symbol", |d, sel| {
if editing {
crate::cmd::symboledit::finish(d, sel);
}
let art = d.reid(&art);
if let Some(sym) = d.symbols.iter_mut().find(|x| x.name == name) {
sym.art = Arc::new(art);
@@ -445,6 +461,9 @@ pub(super) fn symbol_update(s: &mut Session, p: &Value) -> Result<Value> {
sel.set([id]);
Ok(id)
})?;
if let Some(se) = &session {
crate::cmd::symboledit::leave_editing(s, se)?;
}
Ok(json!({ "name": name, "id": id.0 }))
}
@@ -476,8 +495,18 @@ pub(super) fn symbol_delete(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, format!("no symbol named `{name}`")));
}
let expand = switch_or(p, "expandInstances", true, C)?;
// Deleting the symbol being edited leaves symbol editing mode in the same step.
let session = s.doc()?.doc.symbol_edit.clone().filter(|e| names.contains(&e.symbol));
let editing = session.is_some();
if editing {
crate::cmd::settle_interaction(s)?;
s.end_text_edit();
}
let label = if names.len() == 1 { "Delete Symbol" } else { "Delete Symbols" };
s.edit(label, |d, _| {
s.edit(label, |d, sel| {
if editing {
crate::cmd::symboledit::finish(d, sel);
}
// Every instance goes before any definition, whatever the order of `names`: expanding an
// instance of one symbol can bring out instances of another being deleted (nested in its
// art), which expand in turn while their definitions are still there. Each round expands
@@ -519,6 +548,9 @@ pub(super) fn symbol_delete(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(())
})?;
if let Some(se) = &session {
crate::cmd::symboledit::leave_editing(s, se)?;
}
ok()
}
+4
View File
@@ -481,6 +481,7 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
let label = e.label.clone();
let costs = e.costs();
let editing = st.doc.mask_edit.map(|m| m.layer);
let editing_symbol = st.doc.symbol_edit.clone();
let doc = std::mem::replace(&mut st.doc, e.doc);
let selection = std::mem::replace(&mut st.selection, Arc::unwrap_or_clone(e.selection));
let selection = shared_selection(selection, st.history.redo.last());
@@ -494,6 +495,7 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
crate::deselect_hidden(&menu, &st.doc, &mut st.selection);
st.sync_new_art();
super::maskedit::follow_history(st, editing);
super::symboledit::follow_history(st, editing_symbol.as_ref());
leave_isolation_if_gone(st);
super::globaledit::follow_history(st);
Ok(json!({ "undone": label }))
@@ -507,6 +509,7 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
let label = e.label.clone();
let costs = e.costs();
let editing = st.doc.mask_edit.map(|m| m.layer);
let editing_symbol = st.doc.symbol_edit.clone();
let doc = std::mem::replace(&mut st.doc, e.doc);
let selection = std::mem::replace(&mut st.selection, Arc::unwrap_or_clone(e.selection));
let selection = shared_selection(selection, st.history.undo.last());
@@ -519,6 +522,7 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
crate::deselect_hidden(&menu, &st.doc, &mut st.selection);
st.sync_new_art();
super::maskedit::follow_history(st, editing);
super::symboledit::follow_history(st, editing_symbol.as_ref());
leave_isolation_if_gone(st);
super::globaledit::follow_history(st);
Ok(json!({ "redone": label }))
+1 -1
View File
@@ -121,7 +121,7 @@ use access::{may_read, may_write};
pub use commands::specs;
pub use export_names::artboard_file_names;
pub(crate) use export_names::{default_name, merge, unique_file_names, with_black_output, write_or_return};
use export_names::{distinct_names, write_encoded};
use export_names::{distinct_names_by, write_encoded};
use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, writable};
pub use formats::{
DOC_EXTS, DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, export_extensions, extension,
+2 -2
View File
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export for Screens",
["File", "Export"],
None,
"{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)",
"{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N; a row whose file would take a name another row's file has, in any case, gets -2, -3… too, and identical rows write one file; row folders that differ only in case are one folder, spelled as the first row spells it) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)",
has_doc,
screens::export_for_screens
),
@@ -90,7 +90,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Batch",
[],
None,
"{label?, commands: [{command, params}], artboard?: index (the active artboard; the app passes it: each edit.pasteInPlace, edit.pasteInFront, edit.pasteInBack, object.align, object.distribute, object.distributeSpacing or select.allOnArtboard step that names none gets it, and each artboard.fitToArt or artboard.fitToSelection step that names no index gets it as index, or the last artboard of a document with fewer)} run several commands as ONE undo step (one in each document they edit: steps may open, switch, close or revert documents); stops at the first error and rolls everything back, documents included",
"{label?, commands: [{command, params}], artboard?: index (the active artboard; the app passes it: each edit.pasteInPlace, edit.pasteInFront, edit.pasteInBack, object.align, object.distribute, object.distributeSpacing, select.allOnArtboard or symbol.edit step that names none gets it, and each artboard.fitToArt or artboard.fitToSelection step that names no index gets it as index, or the last artboard of a document with fewer)} run several commands as ONE undo step (one in each document they edit: steps may open, switch, close or revert documents); stops at the first error and rolls everything back, documents included",
has_doc,
batch::batch
),
+8 -2
View File
@@ -20,6 +20,12 @@ pub(crate) fn unique_file_names<'a>(names: impl IntoIterator<Item = (&'a str, St
/// `names` with `-2`, `-3`… after a name already used (compared without case).
pub(super) fn distinct_names(names: impl IntoIterator<Item = String>) -> Vec<String> {
distinct_names_by(names, str::to_lowercase)
}
/// `names` with `-2`, `-3`… after a name already used, two names matching when `key` gives the same
/// key for them.
pub(super) fn distinct_names_by(names: impl IntoIterator<Item = String>, key: impl Fn(&str) -> String) -> Vec<String> {
let mut taken = std::collections::HashSet::new();
// The next number to try for each base: a name repeated many times doesn't search the numbers
// taken before it.
@@ -28,8 +34,8 @@ pub(super) fn distinct_names(names: impl IntoIterator<Item = String>) -> Vec<Str
.into_iter()
.map(|base| {
let mut name = base.clone();
while !taken.insert(name.to_lowercase()) {
let n = next.entry(base.to_lowercase()).or_insert(1);
while !taken.insert(key(&name)) {
let n = next.entry(key(&base)).or_insert(1);
*n += 1;
name = format!("{base}-{n}");
}
+45 -19
View File
@@ -21,7 +21,7 @@ use super::encode::single_artboard;
use super::export::{export_crop, isolated, selected_artwork, without_artboards};
use super::naming::{self, NameParts};
use super::{
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, distinct_names, encode_with_warnings, merge, unique_file_names,
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, distinct_names_by, encode_with_warnings, merge, unique_file_names,
with_black_output, writable, write_file,
};
@@ -490,13 +490,31 @@ pub(crate) fn check_settings(s: &Session, p: &Value) -> Result<()> {
rows_of(s, p, ScreenParams::of(p)?.preset.as_deref()).map(|_| ())
}
/// `name` the way a case-insensitive file system compares names: lower-cased, then each character
/// upper-cased where its upper case is one character. Names with the same lower case give the
/// same key, and so do `ΟΣ`, `ος` and `οσ` (`ΟΣ`); `ᾈ` and `ᾀ` give `ᾀ`, and `ß` stays `ß`.
fn fold(name: &str) -> String {
name.to_lowercase()
.chars()
.map(|c| {
let mut upper = c.to_uppercase();
match (upper.next(), upper.next()) {
(Some(u), None) => u,
_ => c,
}
})
.collect()
}
/// The file names (and bytes) an export wrote, or the files it returns.
#[derive(Default)]
struct Output {
/// Lower-case relative names already used, with what wrote each (two identical rows write
/// one file; different files that would share a name get `-2`, `-3`…).
/// Relative names already used, by their [`fold`]ed spelling, with what wrote each (two
/// identical rows write one file; different files that would share a name get `-2`, `-3`…).
taken: std::collections::HashMap<String, String>,
/// Sub-folders already made.
/// Each sub-folder as the first file in it spells it, by its [`fold`]ed name.
folders: std::collections::HashMap<String, String>,
/// Sub-folders already made, by their [`fold`]ed names.
made: HashSet<String>,
/// `(relative name, bytes)` kept to return or zip (empty when written to a folder).
kept: Vec<(String, Vec<u8>)>,
@@ -505,12 +523,19 @@ struct Output {
}
impl Output {
/// The name the file `key` writes as `name` takes: `name`, else (with `number`) `name-2`,
/// `name-3`… when another file has it; `None` when the same file (`key`) was written as `name`
/// already, or, without `number`, when any file was (the first row wins, as Export for Screens
/// always did when no name pattern is given).
fn claim(&mut self, name: &str, key: &str, number: bool) -> Option<String> {
let (base, ext) = name.rsplit_once('.').map_or((name, ""), |(b, e)| (b, e));
/// The name the file `key` writes as `name` takes: `name`, else `name-2`, `name-3`… when
/// another file has it, with or without a name pattern; `None` when the same file (`key`) was
/// written as `name` already. Names are compared without case, and a sub-folder keeps the
/// spelling of the first file put in it.
fn claim(&mut self, name: &str, key: &str) -> Option<String> {
let name = match name.rsplit_once('/') {
Some((dir, file)) => {
let dir = self.folders.entry(fold(dir)).or_insert_with(|| dir.to_string());
format!("{dir}/{file}")
}
None => name.to_string(),
};
let (base, ext) = name.rsplit_once('.').map_or((name.as_str(), ""), |(b, e)| (b, e));
for n in 1.. {
let candidate = if n == 1 {
name.to_string()
@@ -519,11 +544,11 @@ impl Output {
} else {
format!("{base}-{n}.{ext}")
};
match self.taken.get(&candidate.to_lowercase()) {
Some(k) if k == key || !number => return None,
match self.taken.get(&fold(&candidate)) {
Some(k) if k == key => return None,
Some(_) => continue,
None => {
self.taken.insert(candidate.to_lowercase(), key.to_string());
self.taken.insert(fold(&candidate), key.to_string());
return Some(candidate);
}
}
@@ -543,7 +568,7 @@ impl Output {
match folder {
Some(dir) if !keep => {
if let Some((sub, _)) = name.rsplit_once('/')
&& self.made.insert(sub.to_lowercase())
&& self.made.insert(fold(sub))
{
create_dir(&format!("{dir}/{sub}"))?;
}
@@ -686,8 +711,8 @@ pub(super) fn presets_delete(s: &mut Session, p: &Value) -> Result<Value> {
/// Every chosen artboard (asset, layer, selected object) in every format row, one file each (a PDF
/// holds its artboard alone), or with `fullDocument` one file per row (a PDF of every artboard,
/// else the bounds of all art). Items that share a name get `-2`, `-3`… instead of overwriting, and
/// so do different files the `name` pattern gives one name. The reply's `warnings` lists every
/// warning the encoders give for the files, each once.
/// so do different files that would share a name, with or without the `name` pattern. The reply's
/// `warnings` lists every warning the encoders give for the files, each once.
pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
let o = ScreenParams::of(p)?;
let rows = rows_of(s, p, o.preset.as_deref())?;
@@ -818,7 +843,7 @@ fn encode_and_write(export: ScreenExport, writer: &mut dyn FnMut(&str, &[u8]) ->
});
let (sub, file) = naming.file(document.as_ref().unwrap_or(item), index, files, r);
let name = sub.map_or_else(|| file.clone(), |d| format!("{d}/{file}"));
let Some(name) = out.claim(&name, &format!("{}|{}", item.stem, r.key), o.name.is_some()) else { continue };
let Some(name) = out.claim(&name, &format!("{}|{}", item.stem, r.key)) else { continue };
let bytes = if let Some(boards) = single {
let mut options = r.options.clone();
options["range"] = json!(boards.iter().map(|b| (b + 1).to_string()).collect::<Vec<_>>().join(", "));
@@ -938,8 +963,9 @@ fn items(
}
isolated(doc, &doc.paint_order(ids.iter().copied()), name).map(|(d, r)| (Arc::new(d), r))
};
// Each item's stem follows Windows' file-naming rules, and no two items share one.
let safe = |stems: Vec<String>| distinct_names(stems.iter().map(|s| safe_file_name(s)));
// Each item's stem follows Windows' file-naming rules, and no two items share one, compared as
// the item folders are (`fold`).
let safe = |stems: Vec<String>| distinct_names_by(stems.iter().map(|s| safe_file_name(s)), fold);
let named = |pieces: Vec<(String, Arc<Document>, Option<usize>, Rect)>, fallback: &str| -> Vec<Item> {
let stems = safe(unique_file_names(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string()))));
pieces
@@ -410,6 +410,33 @@ fn an_ai_file_with_a_variable_library_says_its_variables_werent_read() {
assert!(notes(&r).is_empty(), "{r}");
}
/// An Objects on Path group in a `.ai` file opens as one: its record names the group's children by
/// their ids in the opened document.
#[test]
fn an_ai_file_opens_its_objects_on_path_group_live() {
let ids = ["9b1c0e7a-0000-4000-8000-000000000001", "9b1c0e7a-0000-4000-8000-000000000002", "9b1c0e7a-0000-4000-8000-000000000003"];
let options = ai::on_path_options(2, 4, std::f64::consts::FRAC_PI_2, "");
let record = ai::on_path_record("Adobe Align On Path", &ids, &options); // brand-ok: the record's name in the files
let group = format!(
"0 Ae\nu\n/ConstraintGroup :\n128 /GroupType ,\n;\n0 0 1 0 0 0 1 XA\n10 50 m\n190 50 L\nS\n{}0 1 1 0 1 0 0 Xa\n{}f\n{}{}f\n{}U\n",
ai::on_path_member(ids[0], None, 0.0),
ai::rect(5.0, 45.0, 15.0, 55.0),
ai::on_path_member(ids[1], Some(0), 0.0),
ai::rect(185.0, 45.0, 195.0, 55.0),
ai::on_path_member(ids[2], Some(1), 0.0),
);
let data = ai::editing_data(200.0, 100.0, &format!("{record}{}", ai::layer("Art", &group)));
let mut s = Session::new();
let r = open(&mut s, "on-path.ai", &ai::ai(&compressed(data.as_bytes()), "1 0 0 rg 5 45 10 10 re f 185 45 10 10 re f"), json!({})).unwrap();
assert_eq!(r["format"], json!("ai"), "{r}");
let doc = &s.doc().unwrap().doc;
let g = &doc.layers[0].children().unwrap()[0];
let rec = g.extras().objects_on_path.as_deref().expect("an Objects on Path group");
let kids: Vec<NodeId> = g.children().unwrap().iter().map(|c| c.id).collect();
assert!(kids.contains(&rec.path) && rec.members.iter().all(|m| kids.contains(&m.id)), "{rec:?} {kids:?}");
assert_eq!((rec.members.len(), rec.pivot, rec.rotate.round()), (2, 4, 90.0));
}
/// A page showing only a placeholder line, as a `.ai` saved without PDF compatibility has.
const PLACEHOLDER: &str = "BT /F1 12 Tf 10 50 Td (Saved without its PDF part) Tj ET";
+86 -2
View File
@@ -533,13 +533,97 @@ fn png_8_rows_write_indexed_colour() {
let f = files(&r);
// The IHDR colour type: 3 (indexed) for PNG 8, 6 (RGBA) for PNG; both are .png, the PNG 8 row
// written first.
assert_eq!(names(&r), ["Artboard-1.png"], "the same file name: the first row wins");
assert_eq!(f[0].1[25], 3);
assert_eq!(names(&r), ["Artboard-1.png", "Artboard-1-2.png"], "the same file name: the second row's file is numbered");
assert_eq!((f[0].1[25], f[1].1[25]), (3, 6));
let r = export(&mut s, json!({"formats": [{"format": "png8", "suffix": "-8"}, {"format": "png"}]}));
let f = files(&r);
assert_eq!((f[0].1[25], f[1].1[25]), (3, 6));
}
/// Without a name pattern, a row that writes a different file under a name another row's file
/// already has gets `-2`, `-3`… as a name pattern's files do, compared without case; the first
/// file keeps its name, and two identical rows still write one file.
#[test]
fn a_second_file_with_a_taken_name_is_numbered() {
let mut s = session(1);
let px = |png: &[u8]| image::load_from_memory(png).unwrap().to_rgba8().get_pixel(1, 1).0;
let rows = json!([{"format": "png", "background": "black"}, {"format": "png", "background": "white"}, {"format": "png", "background": "black"}]);
let r = export(&mut s, json!({ "formats": rows }));
assert_eq!(names(&r), ["Artboard-1.png", "Artboard-1-2.png"]);
let f = files(&r);
assert_eq!((px(&f[0].1), px(&f[1].1)), ([0, 0, 0, 255], [255, 255, 255, 255]));
// Row folders that differ only in case are one folder, spelled as the first row spells it.
let rows = json!([{"format": "png", "folder": "Print"}, {"format": "png", "folder": "print", "scale": 2, "suffix": ""}, {"format": "svg", "folder": "PRINT"}]);
let r = export(&mut s, json!({"subfolders": true, "formats": rows}));
assert_eq!(names(&r), ["Print/Artboard-1.png", "Print/Artboard-1-2.png", "Print/Artboard-1.svg"]);
assert_eq!(size(&files(&r)[1].1), (80, 60));
}
/// Names and folders that differ only in case match when a letter's lower case depends on where
/// it stands: Greek `ΟΣ` lower-cases to `ος`, which a case-insensitive file system treats as `οσ`.
#[test]
fn names_that_differ_only_in_greek_sigmas_are_numbered() {
let mut s = session(2);
s.execute("artboard.setProps", &json!({"index": 0, "name": "ΟΣ"})).unwrap();
s.execute("artboard.setProps", &json!({"index": 1, "name": "οσ"})).unwrap();
let r = export(&mut s, json!({"name": "<name>", "formats": [{"format": "svg"}]}));
assert_eq!(names(&r), ["ΟΣ.svg", "οσ-2.svg"]);
let rows = json!([{"format": "png", "folder": "ΟΣ"}, {"format": "svg", "folder": "οσ"}]);
let r = export(&mut s, json!({"range": "1", "subfolders": true, "formats": rows}));
assert_eq!(names(&r), ["ΟΣ/ΟΣ.png", "ΟΣ/ΟΣ.svg"]);
// Each item still gets a folder of its own.
let r = export(&mut s, json!({"folders": "item", "formats": [{"format": "svg"}]}));
assert_eq!(names(&r), ["ΟΣ/ΟΣ.svg", "οσ-2/οσ-2.svg"]);
// Letters whose full upper case is two characters still match their other case.
s.execute("artboard.setProps", &json!({"index": 0, "name": "ᾀ"})).unwrap();
s.execute("artboard.setProps", &json!({"index": 1, "name": "ᾈ"})).unwrap();
let r = export(&mut s, json!({"name": "<name>", "formats": [{"format": "svg"}]}));
assert_eq!(names(&r), ["ᾀ.svg", "ᾈ-2.svg"]);
// So do names whose lower case is longer than the name: `İ` lower-cases to `i̇`.
s.execute("artboard.setProps", &json!({"index": 0, "name": "İ"})).unwrap();
s.execute("artboard.setProps", &json!({"index": 1, "name": "i\u{307}"})).unwrap();
let r = export(&mut s, json!({"name": "<name>", "formats": [{"format": "svg"}]}));
assert_eq!(names(&r), ["İ.svg", "i\u{307}-2.svg"]);
}
/// A fresh folder for one test, emptied first and removed when dropped (also when an assertion
/// fails).
#[cfg(not(target_arch = "wasm32"))]
struct Folder(std::path::PathBuf);
#[cfg(not(target_arch = "wasm32"))]
impl Folder {
fn new(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!("vc-screens-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
Self(dir)
}
}
#[cfg(not(target_arch = "wasm32"))]
impl Drop for Folder {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// The numbered file is written beside the first one, and the reply lists both paths.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_numbered_file_is_written_into_the_first_rows_folder() {
let mut s = session(1);
let tmp = Folder::new("taken");
let dir = &tmp.0;
let folder = dir.to_string_lossy().replace('\\', "/");
let rows = json!([{"format": "png", "folder": "Print"}, {"format": "png", "folder": "print", "background": "white"}]);
let r = export(&mut s, json!({"folder": folder, "subfolders": true, "formats": rows}));
let written: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect();
assert_eq!(written, [format!("{folder}/Print/Artboard-1.png"), format!("{folder}/Print/Artboard-1-2.png")]);
let entries: Vec<String> = std::fs::read_dir(dir.join("Print")).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
assert_eq!(entries.len(), 2, "{entries:?}");
assert_eq!(std::fs::read_dir(dir).unwrap().count(), 1, "one sub-folder");
}
#[test]
fn format_settings_apply_to_their_rows_and_rows_win() {
let mut s = session(1);
+1 -1
View File
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Gradient",
["Window", "Gradient"],
None,
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even, which SVG and PDF exports draw as an image, except that PDF writes one made of tints of one spot color in that ink; freeform gradients mix their own way), stops?: [{offset 0..1, color? (needed without swatch), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg, aspect?: %, reverse?: bool, dither?: bool (fills only: a linear or radial gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even, which SVG and PDF exports draw as an image, except that PDF writes one with spot color stops in its inks when every other stop is paper white, CMYK or gray; freeform gradients mix their own way), stops?: [{offset 0..1, color? (needed without swatch), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg, aspect?: %, reverse?: bool, dither?: bool (fills only: a linear or radial gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
has_doc,
edit_gradient
),
+16 -2
View File
@@ -334,13 +334,27 @@ pub(super) fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
}
pub(super) fn exit_isolation(s: &mut Session, p: &Value) -> Result<Value> {
// Opacity mask editing and pattern editing isolate their editing layer. Leaving isolation mode
// then leaves that mode, as Stop Editing Opacity Mask and the isolation bar's Done do.
// Opacity mask editing, pattern editing and symbol editing isolate their editing layer. Leaving
// isolation mode then leaves that mode, as Stop Editing Opacity Mask and the isolation bar's
// Done do. A group isolated inside the symbol's art goes back to the symbol's layer.
let st = s.doc()?;
if let Some(layer) = st.doc.symbol_edit.as_ref().map(|e| e.layer)
&& let Some(inner) = st.isolation.filter(|i| *i != layer)
{
crate::cmd::settle_interaction(s)?;
let st = s.doc_mut()?;
crate::cmd::distortcmds::finish_edit_text(st, inner);
st.isolation = Some(layer);
st.selection.set([inner]);
st.revision += 1;
return ok();
}
let leave_mode: Option<fn(&mut Session, &Value) -> Result<Value>> = if st.doc.mask_edit.is_some() {
Some(crate::cmd::maskedit::leave)
} else if st.doc.pattern_edit.is_some() {
Some(crate::cmd::patterncmds::pattern_done)
} else if st.doc.symbol_edit.is_some() {
Some(crate::cmd::symboledit::leave)
} else {
None
};
File diff suppressed because one or more lines are too long
+11 -4
View File
@@ -161,12 +161,18 @@ fn in_pattern_edit(s: &Session) -> std::result::Result<(), String> {
editing(s).map(|_| ()).ok_or_else(|| "not in pattern editing mode".into())
}
/// Make: a selection, and no opacity mask is being edited
/// ([`super::maskedit::outside_mask_editing`]), since the tile layer would replace the mask editing
/// layer as the isolated layer.
/// Make: a selection, and no opacity mask or symbol is being edited
/// ([`super::maskedit::outside_mask_editing`], [`outside_symbol_editing`]), since the tile layer
/// would replace that mode's editing layer as the isolated layer.
fn can_make_pattern(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
super::maskedit::outside_mask_editing(s)
super::maskedit::outside_mask_editing(s)?;
outside_symbol_editing(s)
}
/// No symbol is being edited (symbol editing mode shows the symbol in isolation mode).
fn outside_symbol_editing(s: &Session) -> std::result::Result<(), String> {
if s.active().is_some_and(|st| st.doc.symbol_edit.is_some()) { Err("exit isolation mode first".into()) } else { Ok(()) }
}
fn can_edit_pattern(s: &Session) -> std::result::Result<(), String> {
@@ -175,6 +181,7 @@ fn can_edit_pattern(s: &Session) -> std::result::Result<(), String> {
return Err("already editing a pattern".into());
}
super::maskedit::outside_mask_editing(s)?;
outside_symbol_editing(s)?;
if s.active().is_some_and(|st| st.doc.patterns.is_empty()) { Err("the document has no patterns".into()) } else { Ok(()) }
}
+3 -2
View File
@@ -773,9 +773,10 @@ fn select(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(json!({"id": target.0}))
}
/// The layer an opacity mask or a pattern is edited in, which stays isolated while that mode is on.
/// The layer an opacity mask, a pattern or a symbol is edited in, which stays isolated while that
/// mode is on.
fn editing_layer(d: &Document) -> Option<NodeId> {
d.mask_edit.map(|m| m.layer).or_else(|| d.pattern_edit.as_ref().map(|e| e.layer))
d.mask_edit.map(|m| m.layer).or_else(|| d.pattern_edit.as_ref().map(|e| e.layer)).or_else(|| d.symbol_edit.as_ref().map(|e| e.layer))
}
/// Leave isolation mode, Construction mode included. While an opacity mask or a pattern is edited,
/// its editing layer stays isolated.
+445
View File
@@ -0,0 +1,445 @@
//! Symbol editing mode (Edit Symbol, a double-click on an instance or on a symbol in the Symbols
//! panel).
//!
//! The symbol's art moves onto a temporary isolated layer, placed where the instance it was
//! entered from shows it (from the panel: at the center of the active artboard, or of the view in a
//! document without artboards), where every tool and command can edit it while the rest of the
//! document is dimmed. After each edit [`sync`] writes the layer's art back into the symbol, so
//! every instance updates live. Leaving the mode
//! (`symbol.exitEditing`, Escape, the isolation bar's back button, a double-click outside the art)
//! removes the layer, and saving or exporting never writes it ([`Document::without_edit_modes`]).
//! Symbol editing is not entered while a pattern or an opacity mask is edited, and neither of those
//! is entered while a symbol is edited.
use std::collections::HashSet;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection, SymbolEdit, SymbolRegistration};
use vectorcraft_geom::{Affine, Point};
use super::brushsym::{natural_size, place_xf};
use super::{EngineError, Result, Session, bad, id_param, number_or, ok, str_param};
/// The name of the undo step that enters symbol editing mode.
const ENTER: &str = "Edit Symbol";
/// The symbol instances among the selected objects, members of selected groups included.
fn selected_instances(st: &crate::DocState) -> Vec<NodeId> {
let mut out = vec![];
for id in &st.selection.objects {
if let Some(n) = st.doc.node(*id) {
n.walk(&mut |c| {
if matches!(c.kind, NodeKind::SymbolInstance { .. }) && !out.contains(&c.id) {
out.push(c.id);
}
});
}
}
out
}
/// The symbol and transform of instance `id`.
fn instance_of(d: &Document, id: NodeId) -> Option<(String, Affine)> {
match &d.node(id)?.kind {
NodeKind::SymbolInstance { symbol, xf } => Some((symbol.clone(), *xf)),
_ => None,
}
}
/// Where editing places the art: the instance's own transform, or for one that flattens the art,
/// a placement at the symbol's natural size around the same point.
fn editing_xf(d: &Document, name: &str, xf: Affine) -> Affine {
let det = xf.determinant();
if det.is_finite() && det.abs() > 1e-12 { xf } else { place_xf(xf * Point::ZERO, natural_size(d, name), SymbolRegistration::CENTER) }
}
/// Enter symbol editing mode (`symbol.edit`).
pub(crate) fn enter(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "symbol.edit";
let st = s.doc()?;
if st.doc.mask_edit.is_some() {
return Err(bad(C, "stop editing the opacity mask first"));
}
if st.doc.pattern_edit.is_some() {
return Err(bad(C, "finish editing the pattern first"));
}
let given = id_param(p, "id").map(|i| vec![i]).or_else(|| super::ids_param(p, "ids").filter(|v| !v.is_empty()));
let (name, xf, instance) = match given {
Some(ids) => {
let symbols = ids.iter().map(|id| instance_of(&st.doc, *id)).collect::<Option<Vec<_>>>();
let symbols = symbols.ok_or_else(|| bad(C, "select a symbol instance"))?;
let (name, xf) = symbols.first().cloned().ok_or_else(|| bad(C, "select a symbol instance"))?;
if symbols.iter().any(|(n, _)| *n != name) {
return Err(bad(C, "select instances of one symbol"));
}
let xf = editing_xf(&st.doc, &name, xf);
(name, xf, ids.first().copied())
}
None => {
let picked = if str_param(p, "name").is_some() { vec![] } else { selected_instances(st) };
let symbols: Vec<(String, Affine)> = picked.iter().filter_map(|id| instance_of(&st.doc, *id)).collect();
match symbols.first() {
Some((first, xf)) => {
if symbols.iter().any(|(n, _)| n != first) {
return Err(bad(C, "select instances of one symbol"));
}
(first.clone(), editing_xf(&st.doc, first, *xf), picked.first().copied())
}
None => {
let d = &st.doc;
let name = match str_param(p, "name") {
Some(n) if d.symbols.iter().any(|x| x.name == n) => n.to_string(),
Some(n) => return Err(bad(C, format!("no symbol named `{n}`"))),
None => d
.unknown
.get(super::brushsym::CURRENT_SYMBOL)
.and_then(Value::as_str)
.filter(|n| d.symbols.iter().any(|x| x.name == *n))
.or_else(|| d.symbols.first().map(|x| x.name.as_str()))
.map(str::to_string)
.ok_or_else(|| EngineError::Other("the document has no symbols".into()))?,
};
let artboard = p.get("artboard").and_then(Value::as_u64).map_or(0, super::index);
let center = match d.artboards.get(artboard).or(d.artboards.first()) {
Some(a) => a.rect.center(),
None => super::point_param(p, "viewCenter").unwrap_or(Point::ZERO),
};
let at = Point::new(number_or(p, "x", center.x, C)?, number_or(p, "y", center.y, C)?);
(name.clone(), place_xf(at, natural_size(d, &name), SymbolRegistration::CENTER), None)
}
}
}
};
// Editing another symbol (one from the Symbols panel, or through the API): the current edit
// ends first.
if st.doc.symbol_edit.is_some() {
if instance.is_some_and(|i| st.doc.symbol_edit.as_ref().is_some_and(|e| st.doc.ancestry(i).unwrap_or_default().contains(&e.layer))) {
return Err(bad(C, "exit isolation mode first"));
}
leave(s, &json!({}))?;
}
if let Some(i) = s.doc()?.isolation {
super::settle_interaction(s)?;
super::distortcmds::finish_edit_text(s.doc_mut()?, i);
}
s.end_text_edit();
let st = s.doc()?;
let back = Return { redo: st.history.redo.clone(), before: Some(st.doc.clone()) };
let (isolation, active_layer) = (st.isolation, st.active_layer);
let layer = s.edit(ENTER, |d, sel| {
let layer = begin(d, sel, &name, xf, instance)?;
if let Some(e) = d.symbol_edit.as_mut() {
e.back_isolation = isolation;
e.back_layer = active_layer;
}
Ok(layer)
})?;
s.doc_mut()?.symbol_edit_return = back;
show_editing(s, layer)?;
let mut out = json!({ "name": name, "layer": layer.0 });
if let Some(i) = instance {
out["id"] = json!(i.0);
}
Ok(out)
}
/// A group whose members editing spreads out on the layer: nothing of its own that they would lose
/// while they are edited.
fn spreads(art: &Node) -> bool {
matches!(art.kind, NodeKind::Group { clip: false, .. })
&& art.has_default_transparency()
&& art.appearance.items.is_empty()
&& art.appearance.effects.is_empty()
}
/// Enter symbol editing for `name` inside an edit: copy its art, placed by `xf`, onto a new
/// editing layer and select it. Returns the layer.
fn begin(d: &mut Document, sel: &mut Selection, name: &str, xf: Affine, instance: Option<NodeId>) -> Result<NodeId> {
let art =
d.symbols.iter().find(|x| x.name == name).map(|x| x.art.clone()).ok_or_else(|| EngineError::Other(format!("no symbol named `{name}`")))?;
let group = spreads(&art);
let parts = match art.children() {
Some(ch) if group => ch.clone(),
_ => vec![art],
};
let layer = d.add_layer(Some(name));
let mut ids = Vec::with_capacity(parts.len());
for part in &parts {
let mut copy = d.reid(part);
copy.transform(xf, false);
ids.push(d.insert(Some(layer), usize::MAX, copy)?);
}
// Copied type whose layout caches `reid` dropped has them again.
super::typecmd::refresh_bounds_of(d, &ids);
d.symbol_edit = Some(SymbolEdit { symbol: name.to_string(), layer, instance, xf, group, changed: false, back_isolation: None, back_layer: None });
sel.set(ids);
Ok(layer)
}
/// Leave symbol editing mode (`symbol.exitEditing`); a no-op outside it. `object.exitIsolation` runs
/// it too while a symbol is edited. Isolation and the current layer go back to what they were
/// before. When nothing changed the art since editing was entered, the entering step is taken back
/// instead of a step being added, so the document and its redo steps are as they were.
pub(crate) fn leave(s: &mut Session, _: &Value) -> Result<Value> {
let Some(se) = s.doc()?.doc.symbol_edit.clone() else { return ok() };
super::settle_interaction(s)?;
super::distortcmds::finish_edit_text(s.doc_mut()?, se.layer);
s.end_text_edit();
let st = s.doc_mut()?;
let unchanged = !st.doc.symbol_edit.as_ref().is_some_and(|e| e.changed);
let Return { redo, before } = std::mem::take(&mut st.symbol_edit_return);
// Saved while editing: the saved file holds no editing layer, so leaving keeps it saved.
let saved_here = st.saved_doc.symbol_edit.as_ref().is_some_and(|e| e.layer == se.layer);
let clean = !st.is_dirty();
// Not after edits that were undone (their redo steps hold the editing layer), nor after a save
// (taking the entering step back would undo what the save changed). The last step is the
// entering step only when it starts from the document entering started from: a batch step
// that entered after other edits starts from before them.
if unchanged
&& !saved_here
&& st.history.redo.is_empty()
&& let Some(entry) = st.history.undo.last()
&& before.as_ref().is_some_and(|b| Arc::ptr_eq(b, &entry.doc))
&& let Some(entry) = st.history.undo.pop()
{
let now = std::mem::replace(&mut st.doc, entry.doc);
// What changed outside the undo history while editing (the current symbol and brush, the
// perspective grid's view, export settings, the grid preferences) stays as it is now.
if st.doc.unknown != now.unknown || st.doc.export_settings != now.export_settings || st.doc.grid != now.grid {
let d = Arc::make_mut(&mut st.doc);
d.unknown = now.unknown.clone();
d.export_settings = now.export_settings.clone();
d.grid = now.grid.clone();
}
st.selection = Arc::unwrap_or_clone(entry.selection);
st.selection.prune(&st.doc);
st.history.redo = redo;
st.revision += 1;
} else {
s.edit("Exit Symbol Editing Mode", |d, sel| {
finish(d, sel);
Ok(())
})?;
if saved_here && clean {
s.doc_mut()?.mark_saved();
}
}
leave_editing(s, &se)?;
Ok(json!({ "name": se.symbol }))
}
/// Leave symbol editing inside an edit (no-op when not editing): drop the editing layer and select
/// the instance editing was entered from. The symbol already holds the layer's art ([`sync`]).
pub(crate) fn finish(d: &mut Document, sel: &mut Selection) {
let Some(se) = d.symbol_edit.take() else { return };
let _ = d.remove(se.layer);
match se.instance.filter(|i| d.node(*i).is_some()) {
Some(i) => sel.set([i]),
None => sel.clear(),
}
}
/// The redo steps entering symbol editing mode cleared, which leaving without an edit gives back,
/// and the document entering started from, which tells the entering step apart from any other.
#[derive(Clone, Debug, Default)]
pub(crate) struct Return {
redo: Vec<crate::HistoryEntry>,
before: Option<Arc<Document>>,
}
/// Isolate the editing layer `layer` and draw into it.
fn show_editing(s: &mut Session, layer: NodeId) -> Result<()> {
let st = s.doc_mut()?;
st.isolation = Some(layer);
st.active_layer = Some(layer);
st.revision += 1;
Ok(())
}
/// Return to the document after leaving the editing session `se`: to the isolation and current
/// layer from before it, while they are still there.
pub(crate) fn leave_editing(s: &mut Session, se: &SymbolEdit) -> Result<()> {
let st = s.doc_mut()?;
st.symbol_edit_return = Return::default();
go_back(st, se);
st.revision += 1;
Ok(())
}
/// Out of the editing session `se`: the isolation and current layer from before it, else none and
/// the default layer.
pub(crate) fn go_back(st: &mut crate::DocState, se: &SymbolEdit) {
st.isolation = se.back_isolation.filter(|i| st.doc.node(*i).is_some());
st.active_layer = se.back_layer.filter(|l| st.doc.node(*l).is_some_and(|n| n.is_layer())).or_else(|| st.doc.default_layer());
}
/// After undo or redo, isolation follows the restored document into or out of symbol editing
/// (`was` is the editing session before, if any), so it never names a layer that is gone. A group
/// isolated inside the symbol's art stays isolated while it is there.
pub(crate) fn follow_history(st: &mut crate::DocState, was: Option<&SymbolEdit>) {
match st.doc.symbol_edit.as_ref().map(|e| e.layer) {
Some(layer) => {
let inside = st.isolation.is_some_and(|i| i != layer && st.doc.ancestry(i).is_some_and(|a| a.contains(&layer)));
if !inside {
st.isolation = Some(layer);
}
st.active_layer = Some(layer);
}
None => {
// Out of the mode: isolation on the editing layer or inside it, and a current layer
// that is gone, go back to what they were before it.
let Some(was) = was else { return };
let gone = |id: Option<NodeId>| id.is_some_and(|i| i == was.layer || st.doc.node(i).is_none());
if gone(st.isolation) || gone(st.active_layer) {
go_back(st, was);
}
}
}
}
/// Does `n` hold an instance of symbol `name`, directly or through the art of the symbols it holds
/// instances of?
fn holds_symbol(d: &Document, n: &Node, name: &str, seen: &mut HashSet<String>) -> bool {
let mut found = false;
let mut nested = vec![];
n.walk(&mut |c| {
if let NodeKind::SymbolInstance { symbol, .. } = &c.kind {
if symbol == name {
found = true;
} else if !seen.contains(symbol) {
nested.push(symbol.clone());
}
}
});
if found {
return true;
}
for symbol in nested {
if seen.insert(symbol.clone())
&& let Some(art) = d.symbols.iter().find(|x| x.name == symbol).map(|x| x.art.clone())
&& holds_symbol(d, &art, name, seen)
{
return true;
}
}
false
}
/// The object at `path` (child indices, paint order) among `nodes` and the groups they hold.
fn at_path<'a>(nodes: &'a [Arc<Node>], path: &[u32]) -> Option<&'a Node> {
let (first, rest) = path.split_first()?;
let mut n: &Node = nodes.get(*first as usize)?;
for i in rest {
n = n.children()?.get(*i as usize)?;
}
Some(n)
}
/// The path of object `id` among `nodes` and the groups they hold, at most `depth` levels down.
fn path_of(nodes: &[Arc<Node>], id: NodeId, depth: usize) -> Option<Vec<u32>> {
if depth == 0 {
return None;
}
for (i, n) in nodes.iter().enumerate() {
let i = u32::try_from(i).ok()?;
if n.id == id {
return Some(vec![i]);
}
if let Some(mut p) = n.children().and_then(|ch| path_of(ch, id, depth - 1)) {
p.insert(0, i);
return Some(p);
}
}
None
}
/// Each instance's appearance changes to objects of symbol `name`'s art follow those objects
/// through an edit of the editing layer: `old` and `new` are the layer's objects before and after
/// with whether the symbol's art is a group of them (else it is the one object). A change to an
/// object the edit removed names no object afterwards.
fn follow_objects(d: &mut Document, name: &str, old: (&[Arc<Node>], bool), new: (&[Arc<Node>], bool)) {
let depth = vectorcraft_doc::symbol_overrides::MAX_PATH_DEPTH + 1;
for id in super::brushsym::instances_of(d, Some(name)) {
let Some(n) = d.node_mut(id) else { continue };
if n.extras().symbol_overrides.is_empty() {
continue;
}
let mut e = n.extras_mut();
e.symbol_overrides.retain_mut(|o| {
// The art's own group stays the art's root.
if old.1 && o.path.is_empty() {
return true;
}
let on_layer: Vec<u32> = if old.1 { o.path.clone() } else { std::iter::once(0).chain(o.path.iter().copied()).collect() };
let moved = at_path(old.0, &on_layer).and_then(|obj| path_of(new.0, obj.id, depth)).and_then(|p| match new.1 {
true => Some(p),
false => p.split_first().filter(|(first, _)| **first == 0).map(|(_, rest)| rest.to_vec()),
});
match moved {
Some(p) => {
o.path = p;
true
}
None => false,
}
});
}
}
/// Write the editing layer's art back into the symbol (called after every edit, with the document
/// as it was before); each instance of a dynamic symbol keeps its appearance changes to the objects
/// the art still has. Leaves the mode when the symbol or the layer is gone. Refuses art that holds
/// an instance of the symbol itself.
pub(crate) fn sync(before: &Document, d: &mut Document) -> Result<()> {
let Some(se) = d.symbol_edit.clone() else { return Ok(()) };
let art: Option<Vec<Arc<Node>>> = d.node(se.layer).and_then(|l| l.children().cloned());
let (Some(art), Some(index)) = (art, d.symbols.iter().position(|x| x.name == se.symbol)) else {
d.symbol_edit = None;
let _ = d.remove(se.layer);
return Ok(());
};
// Entering, and edits that left the layer's art alone, write nothing.
let same = |b: &[Arc<Node>]| b.len() == art.len() && b.iter().zip(&art).all(|(a, b)| Arc::ptr_eq(a, b));
let (old, old_group) = match before.symbol_edit.as_ref().filter(|b| b.layer == se.layer) {
None => return Ok(()),
Some(_) if before.node(se.layer).and_then(|l| l.children()).is_some_and(|b| same(b)) => return Ok(()),
Some(b) => (before.node(se.layer).and_then(|l| l.children().cloned()).unwrap_or_default(), b.group),
};
if art.iter().any(|n| holds_symbol(d, n, &se.symbol, &mut HashSet::new())) {
return Err(EngineError::Other("a symbol can't hold an instance of itself".into()));
}
let inv = se.xf.inverse();
let parts: Vec<Arc<Node>> = art
.iter()
.map(|n| {
let mut n = (**n).clone();
n.transform(inv, false);
Arc::new(n)
})
.collect();
// Once the layer's objects make a group, later edits give that group its members.
let (new_art, group) = if se.group {
let mut g = (*d.symbols[index].art).clone();
if let Some(ch) = g.children_mut() {
*ch = parts;
}
(g, true)
} else {
match <[Arc<Node>; 1]>::try_from(parts) {
Ok([one]) => (Arc::unwrap_or_clone(one), false),
Err(parts) => {
let id = d.alloc_id();
(Node::group(id, parts), true)
}
}
};
d.symbols[index].art = Arc::new(new_art);
follow_objects(d, &se.symbol, (&old, old_group), (&art, group));
super::brushsym::fit_instances_of(d, &se.symbol);
if let Some(e) = d.symbol_edit.as_mut() {
e.changed = true;
e.group = group;
}
Ok(())
}
+29 -6
View File
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, 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_range_style
),
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Touch Type",
[],
None,
"{id, start: byte (a character of it), dx?: pt, dy?: pt, hScale?: %, vScale?: %, rotation?: deg} change one character (a ligature, one glyph for several characters, as a whole) as a Touch Type tool drag does, as one undo step: dx moves it along its line in reading order by changing the kerning of the character before it on the line and its own kerning, and the characters after it stay where they are (an error for the first character of a line and for a right-to-left character; limited to what kerning from -1000 to 10000 can give); dy raises it (baseline shift, along its own up axis when it is rotated); dx and dy are document points; hScale (including the object transform), vScale and rotation set its scales and rotation as text.setRangeStyle does. The kerning of the two characters also keeps the character and the ones after it where they were along the line when the font kerned them with their neighbors; a wider or narrower character moves the ones after it by the change in its width → {id, start, end} (the character's bytes)",
"{id, start: byte (a character of it), dx?: pt, dy?: pt, hScale?: %, vScale?: %, rotation?: deg} change one character (a ligature, one glyph for several characters, as a whole) as a Touch Type tool drag does, as one undo step: dx moves it along its line in reading order by changing the kerning of the character before it on the line and its own kerning, and the characters after it stay where they are (an error for the first character of a line and for a right-to-left character; limited to what kerning from -1000 to 10000 can give); dy raises it (baseline shift, up from its line whatever its rotation, also for the first character of a line and a right-to-left character); dx and dy are document points; hScale (including the object transform), vScale and rotation set its scales and rotation as text.setRangeStyle does. The kerning of the two characters also keeps the character and the ones after it where they were along the line when the font kerned them with their neighbors; a wider or narrower character moves the ones after it by the change in its width → {id, start, end} (the character's bytes)",
has_doc,
touch_type
),
@@ -325,6 +325,7 @@ pub(crate) struct CharChange {
/// The language to set: a tag, or `None` for the default.
language: Option<Option<String>>,
font_axes: Option<AxisChange>,
font_height_reference: Option<vectorcraft_doc::FontHeightReference>,
}
/// `charDirection: "default"|"leftToRight"|"rightToLeft"` → the Character Direction to set (`None`
@@ -452,6 +453,20 @@ pub(crate) fn char_align_param(p: &Value, cmd: &str) -> Result<Option<vectorcraf
}))
}
/// `fontHeightReference: "emBox"|"capHeight"|"xHeight"|"icfBox"` (Character panel › Set Font Height
/// Reference): the height of the font that the font size measures.
pub(crate) fn font_height_reference_param(p: &Value, cmd: &str) -> Result<Option<vectorcraft_doc::FontHeightReference>> {
use vectorcraft_doc::FontHeightReference;
let Some(v) = p.get("fontHeightReference").filter(|v| !v.is_null()) else { return Ok(None) };
Ok(Some(match v.as_str() {
Some("emBox") => FontHeightReference::EmBox,
Some("capHeight") => FontHeightReference::CapHeight,
Some("xHeight") => FontHeightReference::XHeight,
Some("icfBox") => FontHeightReference::IcfBox,
_ => return Err(bad(cmd, "`fontHeightReference` must be \"emBox\", \"capHeight\", \"xHeight\" or \"icfBox\"")),
}))
}
fn paint_param(p: &Value, k: &str, cmd: &str) -> Result<Option<Paint>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
@@ -523,6 +538,7 @@ impl CharChange {
digits: digits_param(p, cmd)?,
language: language_param(p, cmd)?,
font_axes: font_axes_param(p, cmd)?,
font_height_reference: font_height_reference_param(p, cmd)?,
};
if c.size.is_some_and(|v| v <= 0.0) {
return Err(bad(cmd, "size must be positive"));
@@ -570,6 +586,7 @@ impl CharChange {
&& self.digits.is_none()
&& self.language.is_none()
&& self.font_axes.is_none()
&& self.font_height_reference.is_none()
}
fn localize(&mut self, t: &TextObject, cmd: &str) -> Result<()> {
@@ -680,6 +697,9 @@ impl CharChange {
if let Some(v) = self.digits {
st.digits = v;
}
if let Some(v) = self.font_height_reference {
st.font_height_reference = v;
}
if let Some(v) = &self.language {
st.language.clone_from(v);
}
@@ -896,9 +916,8 @@ fn touch_type(s: &mut Session, p: &Value) -> Result<Value> {
});
};
// The baseline shift that raises the character `dy` document points. Baseline shift moves it
// along its own up axis, turned with its rotation (the new one, when the call sets it), by a
// length that the object transform, Shrink Text to Fit and a path effect such as Skew scale:
// two layouts with the call's other changes and shifts one step apart, both within the range,
// up from its line, whatever its rotation, by a length that the object transform, Shrink Text
// to Fit and a path effect such as Skew scale: two layouts with the call's other changes and shifts one step apart, both within the range,
// measure that length. Neither shift is one that a run already has, so the character's run
// splits off alike in both.
let raise = if dy == 0.0 {
@@ -1217,7 +1236,11 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> {
match step {
Step::Size => st.size = super::typecmd::local_type_value(t, st.size * points + inc, (0.1, 1296.0), false, C)?,
Step::Leading => {
let leading = t.para_at(paras.get(i).copied().unwrap_or(0)).effective_leading(st);
// With Auto leading the step starts from the leading the lines are set with:
// the paragraph's Auto Leading share of the em size the font height reference
// gives the characters. An inline graphic keeps its size.
let em = if run.inline.is_some() { st.size } else { vectorcraft_text::FontDb::global().em_size(st, t.vertical) };
let leading = t.para_at(paras.get(i).copied().unwrap_or(0)).effective_leading(&CharStyle { size: em, ..st.clone() });
st.leading = Some(super::typecmd::local_type_value(t, leading * points + inc, (0.1, 5000.0), false, C)?);
}
Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0),
+1
View File
@@ -178,6 +178,7 @@ pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
("standardVerticalRoman", json!(v.standard_vertical_roman)),
("noBreak", json!(v.no_break)),
("digits", json!(v.digits)),
("fontHeightReference", json!(v.font_height_reference)),
("language", json!(v.language)),
] {
m.insert(k.into(), x);
@@ -1306,6 +1306,97 @@ fn digits_are_a_character_attribute_saved_only_when_set() {
assert_eq!(set, [("صفحة ".to_string(), Digits::Default), ("12".to_string(), Digits::Farsi)]);
}
/// Font height reference (Character panel › Set Font Height Reference): a character attribute,
/// saved only when not the em box; the type is laid out again at the em size it gives, so 20 pt
/// type measuring a 0.7 em cap height grows by 1 / 0.7, and a leading step from Auto starts at
/// 120% of that em size.
#[test]
fn font_height_reference_is_a_character_attribute_saved_only_when_not_the_em_box() {
use vectorcraft_doc::FontHeightReference as R;
use vectorcraft_text::test_fonts::{HEIGHT_CAP, HEIGHT_FAMILY, height_font};
vectorcraft_text::FontDb::global().add_font(height_font(true).unwrap());
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id =
s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hx Hx", "font": HEIGHT_FAMILY, "size": 20})).unwrap()["id"].as_u64().unwrap();
let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.clone(),
_ => panic!("text"),
};
let height = |s: &Session| text(s).cached_bounds.unwrap().height();
let em_box = height(&s);
assert_eq!(text(&s).runs[0].style.font_height_reference, R::EmBox);
s.execute("select.set", &json!({"ids": [id]})).unwrap();
for (key, want) in [("capHeight", R::CapHeight), ("xHeight", R::XHeight), ("icfBox", R::IcfBox), ("emBox", R::EmBox)] {
s.execute("text.setFormat", &json!({"fontHeightReference": key})).unwrap();
assert!(text(&s).runs.iter().all(|r| r.style.font_height_reference == want && r.style.size == 20.0));
let saved = serde_json::to_value(&text(&s).runs[0].style).unwrap();
assert_eq!(saved.get("fontHeightReference").and_then(|v| v.as_str()), (want != R::EmBox).then_some(key));
}
s.execute("text.setFormat", &json!({"fontHeightReference": "capHeight"})).unwrap();
let grown = height(&s) / em_box;
assert!((grown - 1000.0 / f64::from(HEIGHT_CAP)).abs() < 1e-6, "{grown}");
// A leading step from Auto starts at the leading the lines are set with: 120% of the em size
// (the paragraph's Auto Leading).
s.execute("type.step", &json!({"attribute": "leading"})).unwrap();
let stepped = text(&s).runs[0].style.leading.unwrap();
assert!((stepped - (1.2 * 20.0 * 1000.0 / f64::from(HEIGHT_CAP) + 2.0)).abs() < 1e-9, "{stepped}");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(text(&s).runs[0].style.font_height_reference, R::EmBox);
assert!((height(&s) - em_box).abs() < 1e-9);
let err = s.execute("text.setFormat", &json!({"fontHeightReference": "baseline"})).unwrap_err().to_string();
assert!(err.contains("`fontHeightReference` must be"), "{err}");
// Characters selected with the Type tool: only the range takes it.
s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 5, "fontHeightReference": "xHeight"})).unwrap();
let set: Vec<_> = text(&s).runs.iter().map(|r| (r.text.clone(), r.style.font_height_reference)).collect();
assert_eq!(set, [("Hx ".to_string(), R::EmBox), ("Hx".to_string(), R::XHeight)]);
}
/// A leading step from Auto starts at the leading each paragraph's lines are set with, its
/// Auto Leading share of the em size: 20 pt type in paragraphs at 120% and 150% steps from 24 pt
/// and from 30 pt.
#[test]
fn a_leading_step_from_auto_starts_at_each_paragraphs_auto_leading() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab\nCd", "size": 20})).unwrap()["id"].as_u64().unwrap();
s.execute("text.setFormat", &json!({"id": id, "start": 3, "end": 5, "autoLeading": 150})).unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("type.step", &json!({"attribute": "leading"})).unwrap();
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else {
panic!("text");
};
let leading: Vec<_> = t.runs.iter().map(|r| (r.text.as_str(), r.style.leading)).collect();
assert_eq!(leading, [("Ab\n", Some(26.0)), ("Cd", Some(32.0))]);
}
/// An inline graphic keeps its size whatever the font height reference, so a leading step from
/// Auto starts at 120% of that size: 20 pt Cap Height type steps from 120% of 20 / 0.7 pt and
/// the graphic in it from 24 pt.
#[test]
fn a_leading_step_from_auto_starts_at_an_inline_graphics_own_size() {
use vectorcraft_text::test_fonts::{HEIGHT_CAP, HEIGHT_FAMILY, height_font};
vectorcraft_text::FontDb::global().add_font(height_font(true).unwrap());
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
s.execute("shape.ellipse", &json!({"x": 300, "y": 300, "width": 10, "height": 10})).unwrap();
s.execute("symbol.new", &json!({"name": "Dot"})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hx", "font": HEIGHT_FAMILY, "size": 20})).unwrap()["id"].as_u64().unwrap();
s.execute("text.insertInline", &json!({"id": id, "at": 2, "symbol": "Dot"})).unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("text.setFormat", &json!({"fontHeightReference": "capHeight"})).unwrap();
s.execute("type.step", &json!({"attribute": "leading"})).unwrap();
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else {
panic!("text");
};
let leading: Vec<_> = t.runs.iter().map(|r| (r.inline.is_some(), r.style.leading.unwrap())).collect();
let text = 1.2 * 20.0 * 1000.0 / f64::from(HEIGHT_CAP) + 2.0;
assert_eq!(leading.len(), 2, "{leading:?}");
assert!(leading.iter().all(|&(inline, l)| (l - if inline { 26.0 } else { text }).abs() < 1e-9), "{leading:?}");
}
/// The Indic digits (#4942): each set by its id through text.setFormat and text.setRangeStyle,
/// saved under it, kept by save and reopen and by character styles; the text stays as typed.
#[test]
+10 -7
View File
@@ -2,10 +2,10 @@ use super::{NodeId, Value, cmd};
/// The commands that act on the active artboard (#693), which the app passes as their `artboard`:
/// Paste in Place, in Front and in Back paste onto it, Align to Artboard aligns to it, Distribute
/// Objects and Distribute Spacing with Align to Artboard distribute objects across it, and All on
/// Active Artboard selects on it. A `command.batch` given `artboard` passes it on to each of their
/// steps that names none.
pub const ON_ACTIVE_ARTBOARD: [&str; 7] = [
/// Objects and Distribute Spacing with Align to Artboard distribute objects across it, All on
/// Active Artboard selects on it, and Edit Symbol from the Symbols panel centers the symbol's art
/// on it. A `command.batch` given `artboard` passes it on to each of their steps that names none.
pub const ON_ACTIVE_ARTBOARD: [&str; 8] = [
"edit.pasteInPlace",
"edit.pasteInFront",
"edit.pasteInBack",
@@ -13,6 +13,7 @@ pub const ON_ACTIVE_ARTBOARD: [&str; 7] = [
"object.distribute",
"object.distributeSpacing",
"select.allOnArtboard",
"symbol.edit",
];
/// The commands that resize the artboards the app passes (Object › Artboards): the app passes the
@@ -23,9 +24,9 @@ pub const FIT_ACTIVE_ARTBOARD: [&str; 2] = ["artboard.fitToArt", "artboard.fitTo
/// `(command, param)` pairs the journal records only so a replay reproduces the original run
/// (dates from the clock: `cmd::clock_date`; the artboards the app passes to the
/// [`ON_ACTIVE_ARTBOARD`] and [`FIT_ACTIVE_ARTBOARD`] commands and to a batch); an action leaves
/// them out ([`Session::journal_for_action`]).
const REPLAY_ONLY: [(&str, &str); 14] = [
/// [`ON_ACTIVE_ARTBOARD`] and [`FIT_ACTIVE_ARTBOARD`] commands and to a batch; the view's center
/// the app passes to Edit Symbol); an action leaves them out ([`Session::journal_for_action`]).
const REPLAY_ONLY: [(&str, &str); 16] = [
("file.new", "created"),
("document.save", "modified"),
("file.saveAs", "modified"),
@@ -39,6 +40,8 @@ const REPLAY_ONLY: [(&str, &str); 14] = [
("artboard.fitToArt", "indices"),
("artboard.fitToSelection", "index"),
("select.allOnArtboard", "artboard"),
("symbol.edit", "artboard"),
("symbol.edit", "viewCenter"),
("command.batch", "artboard"),
];
+3
View File
@@ -34,6 +34,8 @@ pub struct DocState {
/// Select › Start Global Edit: the objects edited together until Stop Global Edit or Escape
/// ([`cmd::globaledit`]). Editing state: not saved, not undoable.
pub global_edit: Option<cmd::globaledit::GlobalEdit>,
/// What leaving symbol editing mode goes back to ([`cmd::symboledit`]).
pub(crate) symbol_edit_return: cmd::symboledit::Return,
pub interaction: Option<Interaction>,
/// Edits made while this is open are one undo step (a scrubbed numeric field).
pub undo_group: Option<UndoGroup>,
@@ -246,6 +248,7 @@ impl DocState {
variables_highlight: None,
isolation: None,
global_edit: None,
symbol_edit_return: Default::default(),
interaction: None,
undo_group: None,
last_transform: None,
+22 -1
View File
@@ -37,6 +37,10 @@ impl Session {
if shaper_changed || dim_changed {
cmd::objects_on_path::refresh(&before, Arc::make_mut(&mut st.doc));
}
// Symbol editing: the symbol follows its art on the editing layer, before
// inline graphics follow their symbols.
let symbol_synced =
if st.doc.symbol_edit.is_some() { cmd::symboledit::sync(&before, Arc::make_mut(&mut st.doc)) } else { Ok(()) };
// Inline graphics in text follow their symbols.
cmd::inline::refresh(&before, Arc::make_mut(&mut st.doc));
// Text Wrap: area type follows its wrap objects; then threads re-flow.
@@ -60,7 +64,9 @@ impl Session {
if !st.doc.variables.bindings.is_empty() {
Arc::make_mut(&mut st.doc).prune_variable_bindings();
}
if st.doc.too_deep(Some(&before)) {
if let Err(e) = symbol_synced {
Err(e)
} else if st.doc.too_deep(Some(&before)) {
// Deeper trees overflow the stack of what walks them.
Err(EngineError::Other(TOO_DEEP.into()))
} else if doc_sane(&st.doc, &st.selection) && objects_in_range(&st.doc, &spread) {
@@ -82,6 +88,21 @@ impl Session {
// The art on a locked artboard can't stay selected, however it got there, except
// the new art this edit made and new art still selected.
st.protect_locked_artboards(&before_sel.objects, &before_sel.new_art, Some(&*before));
// Symbol or opacity mask editing ended because the edit deleted its layer:
// isolation on the layer or inside it leaves too, out of symbol editing to what it
// was before.
let mode_layer = before.symbol_edit.as_ref().map(|e| e.layer).or(before.mask_edit.map(|m| m.layer));
if let Some(layer) = mode_layer
&& st.doc.node(layer).is_none()
&& st.isolation.is_some_and(|i| i == layer || st.doc.node(i).is_none())
{
if let Some(se) = &before.symbol_edit {
cmd::symboledit::go_back(st, se);
} else {
st.isolation = None;
st.active_layer = st.doc.default_layer();
}
}
st.revision += 1;
if st.interaction.is_none() {
let selection = match st.history.undo.last() {
+1
View File
@@ -158,6 +158,7 @@ mod swatchcmds;
mod swatches;
mod swatchlib;
mod symbolbounds;
mod symboledit;
mod symbollib;
mod targeting;
mod textcombos;
+2 -3
View File
@@ -1117,13 +1117,12 @@ fn place_and_break_link_round_trip_geometry() {
}
#[test]
fn edit_then_update_redefines_all_instances() {
fn break_link_then_update_redefines_all_instances() {
let mut s = session();
let (inst, name, _) = make_symbol(&mut s);
let other = NodeId(s.execute("symbol.place", &json!({"x": 300, "y": 200})).unwrap()["id"].as_u64().unwrap());
select(&mut s, &[inst]);
let r = s.execute("symbol.edit", &json!({})).unwrap();
assert_eq!(r["name"], name);
let r = s.execute("symbol.breakLink", &json!({})).unwrap();
let art = NodeId(r["ids"][0].as_u64().unwrap());
// Make the art twice as wide, then redefine.
s.execute("object.transform", &json!({"ids": [art.0], "matrix": [2, 0, 0, 1, -140, 0]})).ok();
+8 -2
View File
@@ -144,8 +144,14 @@ fn break_link_bakes_the_changes_and_edit_symbol_shows_the_symbols_own_art() {
};
let art = NodeId(s.execute("symbol.breakLink", &json!({"ids": [inst.0]})).unwrap()["ids"][0].as_u64().unwrap());
assert_eq!(fills(&s, art), ["#0000ff", "#ff0000"]);
let art = NodeId(s.execute("symbol.edit", &json!({"ids": [other.0]})).unwrap()["ids"][0].as_u64().unwrap());
assert_eq!(fills(&s, art), ["#0000ff", "#0000ff"], "the symbol's art, without this instance's changes");
// Symbol editing mode shows the symbol's art, without this instance's changes.
s.execute("symbol.edit", &json!({"ids": [other.0]})).unwrap();
let st = s.doc().unwrap();
let layer = st.doc.symbol_edit.as_ref().unwrap().layer;
let parts: Vec<String> =
st.doc.node(layer).unwrap().children().unwrap().iter().map(|c| c.appearance.fill_paint().color().unwrap().to_hex()).collect();
assert_eq!(parts, ["#0000ff", "#0000ff"], "the symbol's art, without this instance's changes");
assert_eq!(overrides(&s, other), 1, "the instance keeps its changes");
}
#[test]
+30 -2
View File
@@ -236,11 +236,39 @@ fn perceptual_spot_gradients_export_as_separation_shadings() {
assert!(pdf.contains("/C0[1]") && pdf.contains("/C1[0.4"), "white ink at 100% to 40%: {}", &pdf[at..(at + 200).min(pdf.len())]);
}
#[test]
fn perceptual_spot_gradients_mixed_with_other_inks_export_as_device_n_shadings() {
let (mut s, _) = setup();
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": {"c": 1, "m": 0, "y": 1, "k": 0}, "spot": true}));
let ink_to_cyan = json!({"stops": [{"offset": 0, "swatch": "Ink"}, {"offset": 1, "color": {"c": 1, "m": 0, "y": 0, "k": 0}}]});
let ink_to_leaf = json!({"stops": [{"offset": 0, "swatch": "Ink", "midpoint": 30}, {"offset": 1, "swatch": "Leaf", "tint": 50}]});
for (what, edit, space) in [
("Ink → cyan", with(ink_to_cyan.clone(), json!({"method": "perceptual"})), "/DeviceN[/Cyan/Ink]/DeviceCMYK"),
("Ink → Leaf 50 %", with(ink_to_leaf, json!({"method": "perceptual"})), "/DeviceN[/Ink/Leaf]/DeviceCMYK"),
] {
run(&mut s, "paint.editGradient", edit);
let (pdf, warnings) = pdf_of(&s);
assert!(!pdf.contains("/Subtype/Image"), "{what}: no image");
assert!(pdf.contains("/ShadingType 2") && pdf.contains(space), "{what}: a DeviceN shading");
// The curve's stops: a stitching function of more than the two ends.
assert!(pdf.contains("/FunctionType 3"), "{what}: inks on the curve");
assert!(!warnings.iter().any(|w| w.contains("spot")), "{what}: {warnings:?}");
}
// The curve starts at the first stop's inks: no cyan, Ink at 100 %.
run(&mut s, "paint.editGradient", with(ink_to_cyan.clone(), json!({"method": "perceptual"})));
let (pdf, _) = pdf_of(&s);
assert!(pdf.contains("/C0[0 1]"), "the first stop's inks");
// A radial one too.
run(&mut s, "paint.editGradient", with(ink_to_cyan, json!({"method": "perceptual", "kind": "radial"})));
let (pdf, _) = pdf_of(&s);
assert!(!pdf.contains("/Subtype/Image") && pdf.contains("/ShadingType 3") && pdf.contains("/DeviceN[/Cyan/Ink]/DeviceCMYK"), "radial");
}
#[test]
fn spot_gradients_drawn_as_images_warn() {
let (mut s, _) = setup();
let spot_warning = |w: &[String]| w.iter().any(|w| w.contains("spot colors in gradients exported as images"));
let ink_to_cyan = json!({"stops": [{"offset": 0, "swatch": "Ink"}, {"offset": 1, "color": {"c": 1, "m": 0, "y": 0, "k": 0}}]});
let ink_to_green = json!({"stops": [{"offset": 0, "swatch": "Ink"}, {"offset": 1, "color": "#00ff00"}]});
// More stops than the export finds tints for: an image, as before.
let n = 4097;
let stops: Vec<Value> = (0..n)
@@ -250,7 +278,7 @@ fn spot_gradients_drawn_as_images_warn() {
})
.collect();
for (what, edit) in [
("Perceptual Ink → cyan", with(ink_to_cyan, json!({"method": "perceptual"}))),
("Perceptual Ink → RGB green", with(ink_to_green, json!({"method": "perceptual"}))),
("dithered Classic Ink → paper", with(ink_to_white(100.0), json!({"dither": true, "method": "classic"}))),
("dithered Perceptual Ink → paper", with(ink_to_white(100.0), json!({"dither": true, "method": "perceptual"}))),
("Perceptual Ink and paper, 4,097 stops", json!({"stops": stops, "dither": false, "method": "perceptual"})),
+491
View File
@@ -0,0 +1,491 @@
//! Symbol editing mode (`symbol.edit`, `symbol.exitEditing`).
use serde_json::{Value, json};
use vectorcraft_doc::NodeKind;
use vectorcraft_geom::Rect;
use crate::*;
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn id(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().expect("an id"))
}
/// A 400 × 300 document with the 80 × 40 symbol "Box" made from a rectangle at (100, 50), its
/// instance there and a second instance centered on (300, 200).
fn two_instances() -> (Session, NodeId, NodeId) {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 400, "height": 300}));
run(&mut s, "shape.rectangle", json!({"x": 100, "y": 50, "width": 80, "height": 40}));
let first = id(&run(&mut s, "symbol.new", json!({"name": "Box"})));
let second = id(&run(&mut s, "symbol.place", json!({"name": "Box", "x": 300, "y": 200})));
(s, first, second)
}
/// The bounds of `id` as the document measures them: an instance's by its symbol's art.
fn bounds(s: &Session, id: NodeId) -> Rect {
s.doc().unwrap().doc.bounds_of(&[id], false).expect("bounds")
}
fn close(a: Rect, b: Rect) -> bool {
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() < 1e-6)
}
fn is_instance(s: &Session, id: NodeId) -> bool {
s.doc().unwrap().doc.node(id).is_some_and(|n| matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if symbol == "Box"))
}
/// The objects on the editing layer.
fn layer_art(s: &Session) -> Vec<NodeId> {
let st = s.doc().unwrap();
let layer = st.doc.symbol_edit.as_ref().expect("editing a symbol").layer;
st.doc.node(layer).and_then(|l| l.children()).map(|c| c.iter().map(|n| n.id).collect()).unwrap_or_default()
}
#[test]
fn edit_symbol_edits_the_art_where_the_instance_shows_it_and_every_instance_follows() {
let (mut s, first, second) = two_instances();
run(&mut s, "select.set", json!({"ids": [first.0]}));
let r = run(&mut s, "symbol.edit", json!({}));
assert_eq!((r["name"].as_str(), r["id"].as_u64()), (Some("Box"), Some(first.0)));
let layer = NodeId(r["layer"].as_u64().unwrap());
let st = s.doc().unwrap();
assert_eq!(st.isolation, Some(layer));
assert_eq!(st.active_layer, Some(layer));
// The instance stays linked: editing the symbol doesn't expand it.
assert!(is_instance(&s, first) && is_instance(&s, second));
let art = layer_art(&s);
assert_eq!(art.len(), 1);
assert!(close(bounds(&s, art[0]), Rect::new(100.0, 50.0, 180.0, 90.0)), "{:?}", bounds(&s, art[0]));
assert_eq!(s.doc().unwrap().selection.objects, art);
// An edit of the art reaches both instances at once, each at its own place.
run(&mut s, "object.move", json!({"dx": 10, "dy": 0}));
assert!(close(bounds(&s, first), Rect::new(110.0, 50.0, 190.0, 90.0)), "{:?}", bounds(&s, first));
assert!(close(bounds(&s, second), Rect::new(270.0, 180.0, 350.0, 220.0)), "{:?}", bounds(&s, second));
// New art goes into the symbol.
run(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 10, "height": 10}));
assert_eq!(layer_art(&s).len(), 2);
assert!(close(bounds(&s, second), Rect::new(260.0, 180.0, 350.0, 240.0)), "{:?}", bounds(&s, second));
let r = run(&mut s, "symbol.exitEditing", json!({}));
assert_eq!(r["name"], "Box");
let st = s.doc().unwrap();
assert!(st.doc.symbol_edit.is_none() && st.isolation.is_none());
assert!(st.doc.node(layer).is_none(), "the editing layer is gone");
assert_eq!(st.selection.objects, vec![first]);
assert!(is_instance(&s, first));
assert!(close(bounds(&s, second), Rect::new(260.0, 180.0, 350.0, 240.0)), "{:?}", bounds(&s, second));
}
#[test]
fn an_instance_transform_places_the_art_and_edits_go_back_through_it() {
let (mut s, first, second) = two_instances();
// The second instance twice as large: a 20 pt move there is a 10 pt move of the symbol's art.
run(&mut s, "select.set", json!({"ids": [second.0]}));
run(&mut s, "object.scale", json!({"sx": 200, "sy": 200, "origin": [300, 200]}));
let big = bounds(&s, second);
assert!(close(big, Rect::new(220.0, 160.0, 380.0, 240.0)), "{big:?}");
run(&mut s, "symbol.edit", json!({"id": second.0}));
let art = layer_art(&s);
assert!(close(bounds(&s, art[0]), big), "{:?}", bounds(&s, art[0]));
run(&mut s, "object.move", json!({"dx": 20, "dy": 0}));
assert!(close(bounds(&s, second), Rect::new(240.0, 160.0, 400.0, 240.0)), "{:?}", bounds(&s, second));
assert!(close(bounds(&s, first), Rect::new(110.0, 50.0, 190.0, 90.0)), "{:?}", bounds(&s, first));
}
#[test]
fn the_symbols_panel_edits_a_symbol_without_an_instance_at_the_artboard_center() {
let (mut s, first, second) = two_instances();
run(&mut s, "edit.clear", json!({"ids": [first.0, second.0]}));
run(&mut s, "select.none", json!({}));
let r = run(&mut s, "symbol.edit", json!({"name": "Box"}));
assert!(r.get("id").is_none());
let art = layer_art(&s);
assert!(close(bounds(&s, art[0]), Rect::new(160.0, 130.0, 240.0, 170.0)), "{:?}", bounds(&s, art[0]));
run(&mut s, "object.move", json!({"dx": 0, "dy": 5}));
run(&mut s, "symbol.exitEditing", json!({}));
// No instance was added, and a new one shows the edited art.
let mut instances = 0;
s.doc().unwrap().doc.walk(|n| instances += usize::from(matches!(n.kind, NodeKind::SymbolInstance { .. })));
assert_eq!(instances, 0);
let placed = id(&run(&mut s, "symbol.place", json!({"name": "Box", "x": 200, "y": 150})));
assert!(close(bounds(&s, placed), Rect::new(160.0, 135.0, 240.0, 175.0)), "{:?}", bounds(&s, placed));
assert!(s.execute("symbol.edit", &json!({"name": "Nope"})).is_err());
}
#[test]
fn the_symbols_panel_edits_a_symbol_at_the_center_of_the_active_artboard_or_the_view() {
let (mut s, _, _) = two_instances();
run(&mut s, "artboard.new", json!({"x": 500, "y": 0, "width": 200, "height": 100}));
run(&mut s, "select.none", json!({}));
run(&mut s, "symbol.edit", json!({"name": "Box", "artboard": 1}));
let art = layer_art(&s);
assert!(close(bounds(&s, art[0]), Rect::new(560.0, 30.0, 640.0, 70.0)), "{:?}", bounds(&s, art[0]));
run(&mut s, "symbol.exitEditing", json!({}));
// An artboard the document doesn't have: the first one.
run(&mut s, "symbol.edit", json!({"name": "Box", "artboard": 7, "viewCenter": [1000, 1000]}));
let art = layer_art(&s);
assert!(close(bounds(&s, art[0]), Rect::new(160.0, 130.0, 240.0, 170.0)), "{:?}", bounds(&s, art[0]));
run(&mut s, "symbol.exitEditing", json!({}));
// Without artboards, the view's center.
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).artboards.clear();
run(&mut s, "symbol.edit", json!({"name": "Box", "viewCenter": [1000, 1000]}));
let art = layer_art(&s);
assert!(close(bounds(&s, art[0]), Rect::new(960.0, 980.0, 1040.0, 1020.0)), "{:?}", bounds(&s, art[0]));
}
#[test]
fn leaving_without_an_edit_leaves_the_document_and_its_history_as_they_were() {
let (mut s, first, _) = two_instances();
let steps = s.doc().unwrap().history.undo.len();
let art = s.doc().unwrap().doc.symbols[0].art.clone();
run(&mut s, "symbol.edit", json!({"id": first.0}));
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
run(&mut s, "object.exitIsolation", json!({}));
let st = s.doc().unwrap();
assert_eq!(st.history.undo.len(), steps);
assert!(std::sync::Arc::ptr_eq(&st.doc.symbols[0].art, &art));
assert!(st.doc.symbol_edit.is_none() && st.isolation.is_none());
// Outside the mode, leaving it does nothing.
run(&mut s, "symbol.exitEditing", json!({}));
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
// A step undone before editing can still be redone after leaving without an edit.
run(&mut s, "shape.rectangle", json!({"x": 300, "y": 20, "width": 10, "height": 10}));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "symbol.edit", json!({"id": first.0}));
assert!(s.doc().unwrap().history.redo.is_empty());
run(&mut s, "symbol.exitEditing", json!({}));
assert_eq!(s.doc().unwrap().history.redo.len(), 1);
run(&mut s, "edit.redo", json!({}));
// Leaving after an edit that was undone adds a step, keeping the redo step that holds the
// editing layer apart from the document outside the mode.
run(&mut s, "symbol.edit", json!({"id": first.0}));
run(&mut s, "object.move", json!({"dx": 10, "dy": 0}));
run(&mut s, "edit.undo", json!({}));
let steps = s.doc().unwrap().history.undo.len();
run(&mut s, "symbol.exitEditing", json!({}));
let st = s.doc().unwrap();
assert_eq!(st.history.undo.len(), steps + 1);
assert!(st.doc.symbol_edit.is_none() && st.isolation.is_none());
}
#[test]
fn leaving_without_an_edit_keeps_the_current_symbol_picked_while_editing() {
let (mut s, first, _) = two_instances();
run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20}));
run(&mut s, "symbol.new", json!({"name": "Small"}));
run(&mut s, "symbol.setCurrent", json!({"name": "Box"}));
let steps = s.doc().unwrap().history.undo.len();
run(&mut s, "symbol.edit", json!({"id": first.0}));
// A click in the Symbols panel while editing: not an undo step.
run(&mut s, "symbol.setCurrent", json!({"name": "Small"}));
run(&mut s, "symbol.exitEditing", json!({}));
let st = s.doc().unwrap();
assert_eq!(st.history.undo.len(), steps);
assert_eq!(st.doc.unknown.get("currentSymbol").and_then(Value::as_str), Some("Small"));
}
#[test]
fn leaving_right_after_a_batch_that_only_entered_takes_its_step_back() {
let (mut s, first, _) = two_instances();
let steps = s.doc().unwrap().history.undo.len();
run(&mut s, "command.batch", json!({"commands": [{"command": "symbol.edit", "params": {"id": first.0}}]}));
assert!(s.doc().unwrap().doc.symbol_edit.is_some());
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
run(&mut s, "symbol.exitEditing", json!({}));
let st = s.doc().unwrap();
assert_eq!(st.history.undo.len(), steps);
assert!(st.doc.symbol_edit.is_none() && st.isolation.is_none());
}
#[test]
fn leaving_right_after_a_batch_that_edited_and_entered_keeps_its_edits() {
let (mut s, first, second) = two_instances();
let steps = s.doc().unwrap().history.undo.len();
// A batch named like the entering step that moves an instance before entering: leaving keeps
// the move and adds a step of its own.
let moved = Rect::new(310.0, 180.0, 390.0, 220.0);
let commands = json!([
{"command": "select.set", "params": {"ids": [second.0]}},
{"command": "object.move", "params": {"dx": 50, "dy": 0}},
{"command": "symbol.edit", "params": {"id": first.0}},
]);
run(&mut s, "command.batch", json!({"label": "Edit Symbol", "commands": commands}));
assert!(close(bounds(&s, second), moved), "{:?}", bounds(&s, second));
run(&mut s, "symbol.exitEditing", json!({}));
let st = s.doc().unwrap();
assert!(st.doc.symbol_edit.is_none() && st.isolation.is_none());
assert_eq!(st.history.undo.len(), steps + 2);
assert!(close(bounds(&s, second), moved), "{:?}", bounds(&s, second));
// Undo takes back the exit, then the batch's move and entry together.
run(&mut s, "edit.undo", json!({}));
assert!(s.doc().unwrap().doc.symbol_edit.is_some());
run(&mut s, "edit.undo", json!({}));
let st = s.doc().unwrap();
assert!(st.doc.symbol_edit.is_none() && st.isolation.is_none());
assert!(close(bounds(&s, second), Rect::new(260.0, 180.0, 340.0, 220.0)), "{:?}", bounds(&s, second));
}
#[test]
fn redefining_the_symbol_being_edited_leaves_symbol_editing_mode_in_the_same_step() {
let (mut s, first, second) = two_instances();
let other = id(&run(&mut s, "shape.rectangle", json!({"x": 10, "y": 200, "width": 30, "height": 30})));
let layer = NodeId(run(&mut s, "symbol.edit", json!({"id": first.0}))["layer"].as_u64().unwrap());
// The art on the editing layer can't redefine the symbol.
let e = s.execute("symbol.update", &json!({"name": "Box"})).unwrap_err().to_string();
assert!(e.contains("can't hold an instance of itself"), "{e}");
let steps = s.doc().unwrap().history.undo.len();
run(&mut s, "symbol.update", json!({"name": "Box", "ids": [other.0]}));
let st = s.doc().unwrap();
assert!(st.doc.symbol_edit.is_none() && st.isolation.is_none() && st.doc.node(layer).is_none());
assert_eq!(st.history.undo.len(), steps + 1);
// Every instance shows the new 30 × 30 art, and a later edit can't bring the old art back.
assert!(close(bounds(&s, second), Rect::new(285.0, 185.0, 315.0, 215.0)), "{:?}", bounds(&s, second));
run(&mut s, "shape.rectangle", json!({"x": 300, "y": 10, "width": 10, "height": 10}));
assert!(close(bounds(&s, second), Rect::new(285.0, 185.0, 315.0, 215.0)), "{:?}", bounds(&s, second));
// Undo goes back into the edit as it was.
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
let st = s.doc().unwrap();
assert_eq!(st.isolation, Some(layer));
assert!(st.doc.symbol_edit.is_some());
}
#[test]
fn leaving_goes_back_to_the_isolation_before_and_undo_keeps_a_group_isolated_inside() {
let (mut s, first, _) = two_instances();
// The instance in an isolated group.
let other = id(&run(&mut s, "shape.rectangle", json!({"x": 10, "y": 200, "width": 20, "height": 20})));
run(&mut s, "select.set", json!({"ids": [first.0, other.0]}));
let group = id(&run(&mut s, "object.group", json!({})));
run(&mut s, "object.isolate", json!({"id": group.0}));
run(&mut s, "symbol.edit", json!({"id": first.0}));
run(&mut s, "symbol.exitEditing", json!({}));
assert_eq!(s.doc().unwrap().isolation, Some(group));
// A group isolated inside the symbol's art stays isolated through an undo.
let layer = NodeId(run(&mut s, "symbol.edit", json!({"id": first.0}))["layer"].as_u64().unwrap());
run(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 10, "height": 10}));
let art: Vec<u64> = layer_art(&s).iter().map(|i| i.0).collect();
run(&mut s, "select.set", json!({ "ids": art }));
let inner = id(&run(&mut s, "object.group", json!({})));
run(&mut s, "object.isolate", json!({"id": inner.0}));
run(&mut s, "select.all", json!({}));
run(&mut s, "object.move", json!({"dx": 5, "dy": 0}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc().unwrap().isolation, Some(inner));
run(&mut s, "object.exitIsolation", json!({}));
assert_eq!(s.doc().unwrap().isolation, Some(layer));
run(&mut s, "object.exitIsolation", json!({}));
assert_eq!(s.doc().unwrap().isolation, Some(group));
// Deleting the editing layer goes back there too.
let layer = NodeId(run(&mut s, "symbol.edit", json!({"id": first.0}))["layer"].as_u64().unwrap());
run(&mut s, "layer.delete", json!({"ids": [layer.0]}));
assert_eq!(s.doc().unwrap().isolation, Some(group));
}
#[test]
fn leaving_after_a_save_keeps_the_document_saved() {
let (mut s, first, _) = two_instances();
for edit in [false, true] {
run(&mut s, "symbol.edit", json!({"id": first.0}));
if edit {
run(&mut s, "object.move", json!({"dx": 10, "dy": 0}));
}
// The save leaves the editing layer out of the file.
s.doc_mut().unwrap().mark_saved();
run(&mut s, "symbol.exitEditing", json!({}));
let st = s.doc().unwrap();
assert!(!st.is_dirty(), "edit: {edit}");
assert!(st.doc.symbol_edit.is_none() && st.isolation.is_none(), "edit: {edit}");
}
}
#[test]
fn deleting_the_editing_layer_leaves_symbol_editing_mode() {
let (mut s, first, _) = two_instances();
let layer = NodeId(run(&mut s, "symbol.edit", json!({"id": first.0}))["layer"].as_u64().unwrap());
run(&mut s, "layer.delete", json!({"ids": [layer.0]}));
let st = s.doc().unwrap();
assert!(st.doc.symbol_edit.is_none() && st.doc.node(layer).is_none());
assert_eq!(st.isolation, None);
assert!(st.active_layer.is_some_and(|l| st.doc.node(l).is_some()));
assert!(is_instance(&s, first));
}
#[test]
fn undo_and_redo_restore_the_art_every_instance_shows_and_the_mode() {
let (mut s, first, second) = two_instances();
run(&mut s, "symbol.edit", json!({"id": first.0}));
let layer = s.doc().unwrap().doc.symbol_edit.as_ref().unwrap().layer;
run(&mut s, "object.move", json!({"dx": 10, "dy": 0}));
run(&mut s, "symbol.exitEditing", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc().unwrap().isolation, Some(layer), "back in symbol editing mode");
run(&mut s, "edit.undo", json!({}));
assert!(close(bounds(&s, second), Rect::new(260.0, 180.0, 340.0, 220.0)), "{:?}", bounds(&s, second));
assert_eq!(s.doc().unwrap().isolation, Some(layer));
run(&mut s, "edit.undo", json!({}));
let st = s.doc().unwrap();
assert!(st.doc.symbol_edit.is_none() && st.isolation.is_none());
run(&mut s, "edit.redo", json!({}));
run(&mut s, "edit.redo", json!({}));
assert!(close(bounds(&s, second), Rect::new(270.0, 180.0, 350.0, 220.0)), "{:?}", bounds(&s, second));
assert_eq!(s.doc().unwrap().isolation, Some(layer));
}
#[test]
fn instances_of_one_symbol_only_and_no_instance_of_itself() {
let (mut s, first, _) = two_instances();
run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20}));
let other = id(&run(&mut s, "symbol.new", json!({"name": "Small"})));
run(&mut s, "select.set", json!({"ids": [first.0, other.0]}));
let e = s.execute("symbol.edit", &json!({})).unwrap_err().to_string();
assert!(e.contains("select instances of one symbol"), "{e}");
let e = s.execute("symbol.edit", &json!({"ids": [first.0, other.0]})).unwrap_err().to_string();
assert!(e.contains("select instances of one symbol"), "{e}");
assert!(s.execute("symbol.edit", &json!({"ids": [first.0, 999_999]})).is_err());
assert!(s.doc().unwrap().doc.symbol_edit.is_none());
run(&mut s, "symbol.edit", json!({"id": first.0}));
let e = s.execute("symbol.place", &json!({"name": "Box"})).unwrap_err().to_string();
assert!(e.contains("can't hold an instance of itself"), "{e}");
assert_eq!(layer_art(&s).len(), 1);
// An instance of another symbol may go in.
run(&mut s, "symbol.place", json!({"name": "Small"}));
assert_eq!(layer_art(&s).len(), 2);
}
#[test]
fn other_editing_modes_and_symbol_editing_do_not_overlap() {
let (mut s, first, second) = two_instances();
run(&mut s, "symbol.edit", json!({"id": first.0}));
assert!(s.execute("object.pattern.make", &json!({})).is_err());
assert!(s.execute("transparency.editOpacityMask", &json!({})).is_err());
// Editing another symbol from the panel ends this edit first.
run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20}));
run(&mut s, "symbol.exitEditing", json!({}));
run(&mut s, "select.set", json!({"ids": [second.0]}));
run(&mut s, "symbol.edit", json!({}));
run(&mut s, "symbol.edit", json!({"name": "Box", "x": 50, "y": 50}));
assert_eq!(s.doc().unwrap().doc.symbol_edit.as_ref().unwrap().instance, None);
assert_eq!(s.doc().unwrap().doc.layers.iter().filter(|l| l.name.as_deref() == Some("Box")).count(), 1);
}
#[test]
fn renaming_deleting_and_saving_while_a_symbol_is_edited() {
let (mut s, first, _) = two_instances();
run(&mut s, "symbol.edit", json!({"id": first.0}));
run(&mut s, "object.move", json!({"dx": 10, "dy": 0}));
let layer = s.doc().unwrap().doc.symbol_edit.as_ref().unwrap().layer;
// Saving leaves the editing layer out; the symbol already holds the edit.
let saved = s.doc().unwrap().doc.without_edit_modes().into_owned();
assert!(saved.symbol_edit.is_none() && saved.node(layer).is_none());
assert!(saved.symbols[0].art.geometric_bounds().is_some());
run(&mut s, "symbol.options", json!({"name": "Box", "newName": "Crate"}));
let st = s.doc().unwrap();
assert_eq!(st.doc.symbol_edit.as_ref().unwrap().symbol, "Crate");
assert_eq!(st.doc.node(layer).and_then(|l| l.name.clone()).as_deref(), Some("Crate"));
run(&mut s, "object.move", json!({"dx": 10, "dy": 0}));
// Deleting the symbol being edited leaves the mode in the same step.
run(&mut s, "symbol.delete", json!({"name": "Crate"}));
let st = s.doc().unwrap();
assert!(st.doc.symbol_edit.is_none() && st.isolation.is_none() && st.doc.node(layer).is_none());
assert!(st.doc.symbols.is_empty());
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc().unwrap().isolation, Some(layer));
}
/// Deleting several symbols, the one being edited among them, leaves the mode in the same step.
#[test]
fn deleting_several_symbols_with_the_edited_one_leaves_the_mode() {
let (mut s, first, _) = two_instances();
run(&mut s, "shape.rectangle", json!({"x": 10, "y": 200, "width": 20, "height": 20}));
run(&mut s, "symbol.new", json!({"name": "Dot"}));
run(&mut s, "symbol.edit", json!({"id": first.0}));
let layer = s.doc().unwrap().doc.symbol_edit.as_ref().unwrap().layer;
run(&mut s, "symbol.delete", json!({"names": ["Dot", "Box"]}));
let st = s.doc().unwrap();
assert!(st.doc.symbol_edit.is_none() && st.isolation.is_none() && st.doc.node(layer).is_none());
assert!(st.doc.symbols.is_empty());
}
/// Editing a dynamic symbol keeps each instance's appearance changes to the objects its art still
/// has, and drops the changes to an object the edit deleted.
#[test]
fn editing_a_dynamic_symbol_keeps_the_instances_changes_to_the_objects_still_there() {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 400, "height": 300}));
let a = id(&run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})));
let b = id(&run(&mut s, "shape.rectangle", json!({"x": 60, "y": 10, "width": 40, "height": 40})));
let inst = id(&run(&mut s, "symbol.new", json!({"name": "Pair", "ids": [a.0, b.0], "type": "dynamic"})));
run(&mut s, "symbol.instance.setAppearance", json!({"id": inst.0, "path": [0], "fill": "#00ff00"}));
run(&mut s, "symbol.instance.setAppearance", json!({"id": inst.0, "path": [1], "fill": "#ff0000"}));
// Each change by the path it names and the color it paints.
let changes = |s: &Session| -> Vec<(Vec<u32>, String)> {
let n = s.doc().unwrap().doc.node(inst).unwrap().clone();
n.extras().symbol_overrides.iter().map(|o| (o.path.clone(), o.fill.as_ref().and_then(|p| p.color()).unwrap().to_hex())).collect()
};
let other = id(&run(&mut s, "symbol.place", json!({"name": "Pair", "x": 200, "y": 200})));
run(&mut s, "symbol.edit", json!({"id": other.0}));
let art = layer_art(&s);
assert_eq!(art.len(), 2);
// Deleting the first object: the change to the second follows it to the first place.
run(&mut s, "select.set", json!({"ids": [art[0].0]}));
run(&mut s, "edit.clear", json!({}));
assert_eq!(changes(&s), vec![(vec![0], "#ff0000".to_string())], "the change to the deleted object goes");
run(&mut s, "symbol.exitEditing", json!({}));
assert_eq!(changes(&s), vec![(vec![0], "#ff0000".to_string())]);
assert!(s.doc().unwrap().doc.node(inst).is_some_and(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })));
}
#[test]
fn undo_back_into_an_earlier_edit_leaves_it_to_its_own_isolation() {
let (mut s, first, second) = two_instances();
let other = id(&run(&mut s, "shape.rectangle", json!({"x": 10, "y": 200, "width": 20, "height": 20})));
run(&mut s, "select.set", json!({"ids": [second.0, other.0]}));
let group = id(&run(&mut s, "object.group", json!({})));
// The first edit is entered with nothing isolated.
run(&mut s, "symbol.edit", json!({"id": first.0}));
run(&mut s, "object.move", json!({"dx": 10, "dy": 0}));
run(&mut s, "symbol.exitEditing", json!({}));
assert_eq!(s.doc().unwrap().isolation, None);
// The second edit is entered with the group isolated.
run(&mut s, "object.isolate", json!({"id": group.0}));
run(&mut s, "symbol.edit", json!({"id": second.0}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc().unwrap().isolation, Some(group));
// Back in the first edit, leaving it goes back to what it was entered from.
run(&mut s, "edit.undo", json!({}));
assert!(s.doc().unwrap().doc.symbol_edit.is_some());
run(&mut s, "symbol.exitEditing", json!({}));
assert_eq!(s.doc().unwrap().isolation, None);
}
#[test]
fn undoing_entry_with_a_group_isolated_inside_the_art_goes_back_to_before() {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 400, "height": 300}));
let a = id(&run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})));
let b = id(&run(&mut s, "shape.rectangle", json!({"x": 40, "y": 10, "width": 20, "height": 20})));
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
let inner = id(&run(&mut s, "object.group", json!({})));
let c = id(&run(&mut s, "shape.rectangle", json!({"x": 10, "y": 40, "width": 20, "height": 20})));
run(&mut s, "select.set", json!({"ids": [inner.0, c.0]}));
let instance = id(&run(&mut s, "symbol.new", json!({"name": "Pair"})));
let before = s.doc().unwrap().active_layer;
assert!(before.is_some());
run(&mut s, "symbol.edit", json!({"id": instance.0}));
let layer = s.doc().unwrap().doc.symbol_edit.as_ref().unwrap().layer;
let group = {
let st = s.doc().unwrap();
layer_art(&s).into_iter().find(|i| st.doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. }))).expect("a group")
};
run(&mut s, "object.isolate", json!({"id": group.0}));
assert_eq!(s.doc().unwrap().isolation, Some(group));
run(&mut s, "edit.undo", json!({}));
let st = s.doc().unwrap();
assert!(st.doc.symbol_edit.is_none() && st.doc.node(layer).is_none());
assert_eq!(st.isolation, None);
assert_eq!(st.active_layer, before);
}
+71 -3
View File
@@ -325,6 +325,16 @@ fn type_commands_refuse_a_number_or_boolean_given_as_text() {
("text.setRangeStyle", json!({"id": id.0, "allCaps": 1}), "allCaps"),
("text.setRangeStyle", json!({"id": id.0, "noBreak": "true"}), "noBreak"),
("text.setRangeStyle", json!({"id": id.0, "size": 30, "tateChuYoko": 1}), "tateChuYoko"),
("text.setFormat", json!({"ids": [id.0], "underline": "true"}), "underline"),
("text.setFormat", json!({"ids": [id.0], "strikethrough": 1}), "strikethrough"),
("text.setFormat", json!({"ids": [id.0], "allCaps": "false"}), "allCaps"),
("text.setFormat", json!({"ids": [id.0], "proportionalMetrics": 1}), "proportionalMetrics"),
("text.setFormat", json!({"ids": [id.0], "tateChuYoko": "true"}), "tateChuYoko"),
("text.setFormat", json!({"ids": [id.0], "standardVerticalRoman": "true"}), "standardVerticalRoman"),
("text.setFormat", json!({"ids": [id.0], "rotation": 10, "noBreak": 1}), "noBreak"),
("text.setFormat", json!({"ids": [id.0], "hyphenate": "false"}), "hyphenate"),
("text.setFormat", json!({"ids": [id.0], "hyphenateCapitalized": 0}), "hyphenateCapitalized"),
("text.setFormat", json!({"ids": [id.0], "romanHangingPunctuation": "true"}), "romanHangingPunctuation"),
("text.getRange", json!({"id": id.0, "effective": "true"}), "effective"),
("type.opticalMarginAlignment", json!({"on": "true"}), "on"),
("type.systemLayout", json!({"on": "true"}), "on"),
@@ -338,12 +348,13 @@ fn type_commands_refuse_a_number_or_boolean_given_as_text() {
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
let style = json!({"id": id.0, "size": 30, "underline": true, "tracking": null, "start": null, "noBreak": true});
s.execute("text.setRangeStyle", &style).unwrap();
s.execute("text.setFormat", &json!({"ids": [id.0], "strikethrough": true, "hyphenate": null})).unwrap();
s.execute("type.size.increase", &json!({"by": null})).unwrap();
s.execute("type.opticalMarginAlignment", &json!({"on": true})).unwrap();
s.execute("type.ruby", &json!({"text": "a", "size": null, "overhang": false})).unwrap();
let t = obj(&s, id);
let st = &t.runs[0].style;
assert!(st.underline && st.no_break && t.optical_margin && st.size > 30.0, "{st:?}");
assert!(st.underline && st.strikethrough && st.no_break && t.optical_margin && st.size > 30.0, "{st:?}");
assert!(!t.ruby.is_empty() && t.ruby.iter().all(|r| !r.overhang && r.size_percent == 50.0), "{:?}", t.ruby);
}
@@ -507,7 +518,7 @@ fn touch_type_moves_one_character_in_centered_and_right_aligned_lines() {
}
}
/// `dy` moves a rotated character that many document points along its own up axis, also in type
/// `dy` moves a rotated character that many document points up from its line, also in type
/// scaled unevenly.
#[test]
fn touch_type_raises_a_rotated_character_in_unevenly_scaled_type() {
@@ -518,9 +529,66 @@ fn touch_type_raises_a_rotated_character_in_unevenly_scaled_type() {
let before = glyph_pens(&s, id);
s.execute("text.touchType", &json!({"id": id.0, "start": 1, "dy": 10})).unwrap();
let (x, y) = moved_by(&before, &glyph_pens(&s, id), 1);
assert!(((x * x + y * y).sqrt() - 10.0).abs() < 1e-6, "({x}, {y})");
assert!(near((x, y), (0.0, -10.0)), "({x}, {y})");
}
/// A character rotated near or at a quarter turn moves across its line and along it: in "ABCD"
/// with the B at 85, 90 and 95 degrees, `dx` 10 and `dy` 4 move the B 10 pt right and 4 pt up and
/// leave the C and D where they are, through baseline shift and kerning only, with Auto and
/// Optical kerning and in type scaled unevenly. Undo, redo and the native format keep the place.
#[test]
fn touch_type_moves_a_character_turned_near_a_quarter_turn_across_its_line() {
for rotation in [85.0, 90.0, 95.0, -90.0] {
for (kerning, scaled) in [("auto", false), ("optical", false), ("auto", true)] {
let case = format!("{rotation}°, {kerning}, scaled {scaled}");
let mut s = session();
let id = NodeId(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "ABCD", "size": 40})).unwrap()["id"].as_u64().unwrap());
s.execute("text.setRangeStyle", &json!({"id": id.0, "kerning": kerning})).unwrap();
if scaled {
s.execute("object.transform", &json!({"ids": [id.0], "matrix": [2, 0, 0, 0.5, 0, 0]})).unwrap();
}
s.execute("text.touchType", &json!({"id": id.0, "start": 1, "rotation": rotation})).unwrap();
let before = glyph_pens(&s, id);
let runs_before = obj(&s, id).runs;
s.execute("text.touchType", &json!({"id": id.0, "start": 1, "dx": 10, "dy": 4})).unwrap();
let after = glyph_pens(&s, id);
assert!(near(moved_by(&before, &after, 1), (10.0, -4.0)), "{case}: {:?}", moved_by(&before, &after, 1));
for byte in [0, 2, 3] {
assert!(near(moved_by(&before, &after, byte), (0.0, 0.0)), "{case}, byte {byte}: {:?}", moved_by(&before, &after, byte));
}
// Only kerning and baseline shift change.
let t = obj(&s, id);
let style = |byte: usize| vectorcraft_text::edit::slice_runs(&t.runs, byte, byte + 1)[0].style.clone();
let plain = |mut st: CharStyle| {
st.kerning = None;
st.baseline_shift = 0.0;
st
};
for byte in 0..4 {
let was = vectorcraft_text::edit::slice_runs(&runs_before, byte, byte + 1)[0].style.clone();
assert_eq!(plain(style(byte)), plain(was), "{case}, byte {byte}");
}
let shift = style(1).baseline_shift;
assert!(shift > 0.0, "{case}: {shift}");
// A drag straight across the line is baseline shift alone.
let before = glyph_pens(&s, id);
s.execute("text.touchType", &json!({"id": id.0, "start": 1, "dy": -6})).unwrap();
assert!(near(moved_by(&before, &glyph_pens(&s, id), 1), (0.0, 6.0)), "{case}: {:?}", moved_by(&before, &glyph_pens(&s, id), 1));
s.execute("edit.undo", &json!({})).unwrap();
let moved = glyph_pens(&s, id);
assert!(near(moved_by(&after, &moved, 1), (0.0, 0.0)), "{case}: undone");
s.execute("edit.redo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
// The native format keeps both attributes and the place they give.
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
let NodeKind::Text(reopened) = &back.node(id).unwrap().kind else { panic!("not text") };
assert_eq!(reopened.runs, obj(&s, id).runs, "{case}");
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), reopened);
let pens: Vec<(usize, Point)> = lay.glyphs.iter().map(|g| (g.byte, reopened.xf * (g.xf * Point::ZERO))).collect();
assert!(near(moved_by(&moved, &pens, 1), (0.0, 0.0)), "{case}: reopened");
}
}
}
/// `dy` moves a character that many document points in type scaled up 2000 times, where baseline
/// shift is limited to 0.648 pt of text space.
#[test]
+5
View File
@@ -434,6 +434,11 @@ fn structured_junk() {
("addon.color.printFixes", json!({"inkLimit": 1e308, "overprintBlack": -1e308})),
("addon.color.printFixes", json!({"inkLimit": 1, "overprintBlack": 1, "removeWhiteOverprint": true, "scope": "document", "textOnly": true})),
("addon.color.printFixes", json!({"inkLimit": [300], "scope": 3, "ids": "x"})),
("addon.color.nearestSwatch", json!({"library": 7, "count": u64::MAX, "color": [1e308, -1e308, "x"]})),
("addon.color.nearestSwatch", json!({"library": "web-safe-216", "color": {"l": 1e30, "a": -1e30, "b": 0}, "count": 50})),
("addon.color.nearestSwatch", json!({"library": "spectrum-gradients", "fill": "x", "ids": [u64::MAX]})),
("addon.color.applyNearestSwatch", json!({"library": "web-safe-216", "fill": true, "stroke": true})),
("addon.color.applyNearestSwatch", json!({"library": "nope", "ids": "x", "stroke": 3})),
("effect.apply", json!({"effect": "no.such.effect"})),
("effect.remove", json!({"index": 99})),
("effect.setParams", json!({"index": 0, "params": null})),
+190
View File
@@ -2954,6 +2954,40 @@ proptest! {
survive("mutated EPS editing data", || vectorcraft_eps::import(&bytes).ok().map(|r| r.document))?;
}
/// A `.ai` whose envelopes are damaged or hostile: any kind, warp style, options word,
/// Fidelity and grid size, corners out of range or repeated, the contents block cut short.
#[test]
fn ai_envelope_never_panics(
kind in prop::sample::select(vec![0i64, 1, 2, 3, 4, -1, 1 << 40]),
style in prop::sample::select(vec![0i64, 1, 8, 15, 16, -3, i64::MAX]),
word in prop::sample::select(vec![0i64, 1, 131073, 65536, 0x2_0007, 1 << 18, -1, i64::MAX]),
fidelity in prop::sample::select(vec!["50", "0", "100", "101", "-1", "1e308", "nan"]),
size in prop::sample::select(vec!["[1 1]", "[3 2]", "[0 0]", "[51 1]", "[100000 100000]", "[-1 2]", "[1e308 1]"]),
corner in prop::sample::select(vec!["10 10", "1e308 -1e308", "nan 0", "10 10 10 10", "0"]),
edit in 0i64..2,
cut in 0usize..2_000,
edits in prop::collection::vec((0usize..2_000, prop::sample::select(vec!['0', '9', '-', ' ', '\n', '(', ')', '/', ':', ';', ',', '[', ']', '%', '_', 'X', 'u', 'U'])), 0..8),
) {
use vectorcraft_testkit::ai;
let n = |x: &str, y: &str| format!("%_[0.5 1 {x} {y} {x} {y} 1 {x} {y} 1] /N X#\n");
let mesh = format!(
"%_/Mesh X!\n%_2 /Version X#\n%_/Cartesian /Type X#\n%_{size} /Size X#\n%_/Data X#\n%_[0 0] /P X#\n{}{}{}%_[{corner}] /N X#\n%_/E X#\n%_[0 0] /P X#\n%_/E X#\n%_/End X!\n",
n("10", "10"),
n("10", "90"),
n("90", "90")
);
let content = "%_u\n%_1 0 0 1 0 0 Xa\n%_10 10 m\n%_10 90 L\n%_90 90 L\n%_90 10 L\n%_f\n%_U\n";
let keys = format!(
"%_{word} /Int (AIDeformOptionsKey) ,\n%_{fidelity} /Int (AIDeformFidelityKey) ,\n%_{edit} /Bool (AIDeformEditModeKey) ,\n%_{style} /Int (DeformStyle) ,\n%_0.5 /Real (DeformValue) ,\n%_1 /Bool (Rotate) ,\n"
);
let art = format!(
"%_u\n{mesh}%_U\n%_/ArtDictionary :\n{keys}%_X=\n{content}%_X+\n%_ (AIDeformArtKey) ,\n%_{kind} /Int (AIDeformStyle) ,\n%_;\n%_\nu\n1 0 0 1 0 0 Xa\n12 12 m\n12 88 L\n88 88 L\nf\nU\n(Adobe Deform Plugin) 1 0 0 XP\n0 0 1 1 0 0 Xa\n0 0 m\n5 5 L\nf\n"
);
let data = mutate_text(&ai::editing_data(100.0, 100.0, &ai::layer("L", &art)), cut, &edits);
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&data)].concat();
survive("hostile .ai envelope", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?;
}
/// A `.ai` whose editing data is damaged or hostile.
#[test]
fn ai_editing_data_never_panics(cut in 0usize..3_000, edits in prop::collection::vec((0usize..3_000, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', '[', ']', '%', 'q', 'Q', 'W', 'u', 'U', 'L', 'k', 'x'])), 0..12), damage in prop::collection::vec((0usize..4_000, any::<u8>()), 0..4)) {
@@ -2980,6 +3014,101 @@ proptest! {
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat();
survive("mutated .ai variable library", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?;
}
/// A `.ai` whose Objects on Path record and group are damaged or hostile.
#[test]
fn ai_objects_on_path_never_panics(cut in 0usize..4_000, edits in prop::collection::vec((0usize..4_000, prop::sample::select(vec!['0', '1', '9', '-', '.', 'e', ' ', '\n', '(', ')', '/', ':', ';', ',', '%', '_', 'u', 'U'])), 0..12)) {
let text = mutate_text(&on_path_data(&ON_PATH_IDS, &ON_PATH_IDS, 4, "4", "0.5", "", ["0", "1", "2"], true, 0), cut, &edits);
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat();
// Trusted, so that the layers read are built whatever the empty page shows.
survive("mutated .ai Objects on Path", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?;
}
/// Objects on Path records of any shape: ids in any order, repeated or missing, any number of
/// objects, pivots and angles out of range or not finite, spacings, order tags that disagree,
/// groups nested or cut short, and versioned content left open.
#[test]
fn ai_objects_on_path_records_never_panic(
listed in prop::collection::vec(prop::sample::select(ON_PATH_IDS.to_vec()), 0..7),
drawn in prop::collection::vec(prop::sample::select(ON_PATH_IDS.to_vec()), 0..7),
members in 0usize..7,
pivot in prop::sample::select(vec!["0", "4", "8", "9", "-1", "2.5", "1e400"]),
rotation in prop::sample::select(vec!["0", "0.5235987755982988", "-3.2", "1e400", "-1e400", "1e300"]),
spacing in prop::sample::select(vec!["", "%_56.5 /Real (Spacing) ,\n", "%_0 /Bool (IsEquiSpaced) ,\n", "%_1 /Bool (Flip) ,\n"]),
tags in prop::array::uniform3(prop::sample::select(vec!["0", "1", "2", "-1", "7", "(x)", "1e400"])),
versioned in any::<bool>(),
nest in 0usize..3,
cut in 0usize..2_000,
) {
let data = on_path_data(&listed, &drawn, members, pivot, rotation, spacing, tags, versioned, nest);
let cut_at = data.len().saturating_sub(cut);
let cut_at = (0..=cut_at).rev().find(|i| data.is_char_boundary(*i)).unwrap_or(0);
for data in [data.as_str(), &data[..cut_at]] {
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(data)].concat();
survive("hostile Objects on Path", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?;
}
}
}
/// The data [`ai_objects_on_path_never_panics`] damages, as it is, opens with its Objects on Path group.
#[test]
fn the_objects_on_path_fuzz_data_opens_live() {
let text = on_path_data(&ON_PATH_IDS, &ON_PATH_IDS, 4, "4", "0.5", "", ["0", "1", "2"], true, 0);
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat();
// Trusted: the empty page it is read beside isn't compared with its art.
let (doc, warnings, _) = vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0);
let g = doc.layers.first().and_then(|l| l.children()).and_then(|c| c.first()).unwrap_or_else(|| panic!("{warnings:?}"));
assert!(g.extras().objects_on_path.is_some(), "{g:?}");
}
/// The ids of the objects of an Objects on Path group in the fuzz cases.
const ON_PATH_IDS: [&str; 5] = ["f-0001", "f-0002", "f-0003", "f-0004", "f-0005"];
/// Editing data with an Objects on Path record naming `listed` for `members` objects (Pivot
/// `pivot`, Rotation `rotation`, then the entries `more`) and a group of a path and squares whose
/// dictionaries give the ids `drawn` (the first the path's, the others with the order tags `tags`
/// in turn), inside `nest` more groups, in versioned content with a copy after it when `versioned`
/// is set.
#[allow(clippy::too_many_arguments)]
fn on_path_data(
listed: &[&str],
drawn: &[&str],
members: usize,
pivot: &str,
rotation: &str,
more: &str,
tags: [&str; 3],
versioned: bool,
nest: usize,
) -> String {
use vectorcraft_testkit::ai;
let options = ai::on_path_options(members, 0, 0.0, more)
.replace("%_0 /Int (PivotPosition)", &format!("%_{pivot} /Int (PivotPosition)"))
.replace("%_0 /Real (Rotation)", &format!("%_{rotation} /Real (Rotation)"));
let record = ai::on_path_record("Adobe Align On Path", listed, &options); // brand-ok: the record's name in the files
let mut group = String::from("0 Ae\nu\n/ConstraintGroup :\n128 /GroupType ,\n;\n0 0 1 0 0 0 1 XA\n20 20 m\n100 80 L\n180 20 L\nS\n");
for (i, id) in drawn.iter().enumerate() {
if i == 0 {
group += &ai::on_path_member(id, None, 0.0);
} else {
let x = 10.0 + 30.0 * i as f64;
let tag = tags.get(i - 1).map_or_else(|| (i - 1).to_string(), |t| t.to_string());
group += &format!(
"0 1 1 0 1 0 0 Xa\n{}f\n%_/ArtDictionary :\n%_{tag} /Int (ConstraintArtOrderId) ,\n%_({id}) /String (Persistent ID) ,\n%_({rotation}) /String (BBAccumRotation) ,\n%_;\n%_\n",
ai::rect(x, 40.0, x + 10.0, 46.0)
);
}
}
group += "U\n";
for _ in 0..nest {
group = format!("0 Ae\nu\n{group}U\n");
}
let art = if versioned {
format!("%AI17_Begin_Content_if_version_gt:24 14\n{group}%AI17_Alternate_Content\n{group}%AI17_End_Versioned_Content\n")
} else {
group
};
ai::editing_data(200.0, 100.0, &format!("{record}{}", ai::layer("L", &art)))
}
/// A `.ai` in PostScript form as other apps write it: the procsets that define its operators named,
@@ -3011,6 +3140,22 @@ proptest! {
}
}
/// An operand of a layer's options (`Lb`): a flag, a colour index or component, a percentage, or
/// anything else.
fn arb_layer_operand() -> impl Strategy<Value = String> {
prop_oneof![Just("0".to_string()), Just("1".to_string()), (-2i32..300).prop_map(|v| v.to_string()), arb_num()]
}
/// A coordinate of a guide path: the ends of the canvas around the template box's centre (100 50),
/// the edges of the artboards, near them, or anything else.
fn arb_guide_coord() -> impl Strategy<Value = String> {
prop_oneof![
prop::sample::select(vec!["-8091.5", "8291.5", "-8141.5", "8241.5", "0", "50", "100", "200", "-8091.505", "8291.5001", "1e308"])
.prop_map(str::to_string),
arb_num(),
]
}
/// The operators, operands and section comments of hostile editing data.
fn arb_ai_token() -> impl Strategy<Value = String> {
prop_oneof![
@@ -3037,6 +3182,9 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
"b",
"W",
"(b) *",
"(N) *",
"(S) *",
"-8091.5 50 m 8291.5 50 L (N) *",
"u",
"U",
"*u",
@@ -3214,6 +3362,12 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
"[1 1] /P X#",
"(Adobe Path Blends) 1 0 92 XP",
"(Adobe Path Blends) 1 0 999999 XP",
"(Adobe Deform Plugin) 1 0 0 XP",
"%_/ArtDictionary :\n%_131073 /Int (AIDeformOptionsKey) ,\n%_X=\n",
"%_X+\n%_ (AIDeformArtKey) ,\n%_2 /Int (AIDeformStyle) ,\n%_;\n",
"%_1 /Int (AIDeformStyle) ,\n%_9 /Int (DeformStyle) ,\n%_1e308 /Real (DeformValue) ,\n%_;\n",
"%_1 /Bool (AIDeformEditModeKey) ,",
"[1e308 1e308] /Size X#",
"\n%44434241010000000000000000000000\n",
"\n%0200000041000000000000000000000000000000\n",
"\n%4G\n",
@@ -3297,6 +3451,42 @@ proptest! {
survive("hostile editing data in an EPS of its art's box", || vectorcraft_engine::cmd::fileio::load("x.eps", &boxed).ok().map(|l| l.doc))?;
}
/// Layer options (`Lb`) of any number of operands with any values, and guide paths (`(N) *`,
/// `(S) *`) of any points, among them ones across the whole canvas or from edge to edge of
/// artboards that overlap or are the same, on layers and sublayers.
#[test]
fn ai_layer_options_and_ruler_guides_never_panic(
options in prop::collection::vec(arb_layer_operand(), 0..17),
guides in prop::collection::vec((prop::collection::vec((arb_guide_coord(), arb_guide_coord()), 0..5), any::<bool>(), any::<bool>()), 0..12),
boards in prop::collection::vec(prop::sample::select(vec![[0.0, 100.0, 200.0, 0.0], [0.0, 100.0, 100.0, 0.0], [50.0, 100.0, 200.0, 0.0]]), 0..4),
) {
use vectorcraft_testkit::ai;
let board = |b: &[f64; 4]| {
format!(
"%_/Dictionary :\n%_{} {} /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_{} {} /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n",
b[0], b[1], b[2], b[3]
)
};
let array: String = boards.iter().map(board).collect();
let document = format!("%_/Document :\n%_/Dictionary :\n%_/Array :\n{array}%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n");
let paths: Vec<String> = guides
.iter()
.map(|(points, ruler, sub)| {
let ops: String = points.iter().enumerate().map(|(i, (x, y))| format!("{x} {y} {}\n", if i == 0 { "m" } else { "L" })).collect();
let path = format!("{ops}({}) *\n", if *ruler { "N" } else { "S" });
if *sub { format!("%AI5_BeginLayer\n{} Lb\n(Sub) Ln\n{path}LB\n%AI5_EndLayer--\n", options.join(" ")) } else { path }
})
.collect();
let body = format!("{document}%AI5_BeginLayer\n{} Lb\n(L) Ln\n{}\nLB\n%AI5_EndLayer--\n", options.join(" "), paths.concat());
let data = ai::editing_data(200.0, 100.0, &body).replacen("%%EndComments", "%AI3_TemplateBox: 100 50 100 50\n%%EndComments", 1);
survive("layer options and ruler guides", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?;
survive("layer options and ruler guides in units of 10 points", || {
Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(2000.0, 1000.0), vec![], false, false, 10.0).0)
})?;
let eps = ai::eps(data.as_bytes(), ai::page_ps());
survive("layer options and ruler guides in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?;
}
/// The testkit's sample damaged, as it is and compressed, in EPS, `.ai` and PDF files.
#[test]
fn ai_mutated_sample_never_panics(cut in 0usize..4_000, edits in prop::collection::vec((0usize..4_000, any::<u8>()), 0..12)) {
+2
View File
@@ -18,6 +18,7 @@
//! did before (the printed page, or the PDF), with a warning saying why.
mod blend;
mod envelope;
mod frames;
mod lex;
mod mesh;
@@ -32,6 +33,7 @@ use std::io::Read as _;
use super::lex::find;
use crate::ps;
pub(crate) use envelope::DRAWN as ENVELOPE_DRAWN;
pub(crate) use frames::text_frames;
pub use read::{IMAGE_TOO_LARGE, PaletteSwatches, Structure, artboards, palette, read, variables};
pub(crate) use read::{join_and, slot_frame, slot_of};
+361
View File
@@ -0,0 +1,361 @@
//! Envelopes from the editing data (`(Adobe Deform Plugin) 1 0 0 XP`), read as live envelopes.
//!
//! What an envelope is and does is Illustrator's public documentation: Make with Warp, Make with
//! Mesh (rows and columns) and Make with Top Object
//! (<https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-objects-with-envelopes.html>),
//! Edit Contents
//! (<https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/edit-the-contents-of-envelopes.html>),
//! Envelope Options
//! (<https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/envelope-panel-options.html>)
//! and the warp styles with their bend and distortions
//! (<https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html>).
//!
//! An envelope is written in three parts. On `%_` lines, a group holding the mesh that shapes it,
//! then its `/ArtDictionary`: Envelope Options as a bit word (`AIDeformOptionsKey`), Fidelity
//! (`AIDeformFidelityKey`), Edit Contents (`AIDeformEditModeKey`), a warp's style (`DeformStyle`,
//! numbered as Effect › Warp lists them), bend and distortions (`DeformValue`, `DeformHoriz`,
//! `DeformVert`, fractions) and orientation (`Rotate`), the contents (`X=` … `X+`, then
//! `(AIDeformArtKey)`) and the kind (`AIDeformStyle`: 1 a warp, 2 a mesh, 3 a top object). Then the
//! distorted art on plain lines, and the plugin's `XP`. While the contents are edited, the group
//! holds the contents and the dictionary the mesh. A top object's outline is the edge of its
//! one-patch mesh. An envelope whose parts don't read, or that doesn't come out as drawn, keeps its
//! drawn art.
use std::collections::BTreeMap;
use std::sync::Arc;
use kurbo::{BezPath, Point, Rect, Vec2};
use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions, GradientMesh, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, default_fidelity};
use vectorcraft_doc::{Document, ImageBlob, LayerColor, Node, NodeId, NodeKind, PatternDef};
use vectorcraft_geom::PathData;
use super::obj::Obj;
use crate::import::native::compare::{MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, compare_scale, differ, differ_in_colour};
/// The plugin that writes envelopes.
pub(super) const PLUGIN: &str = "Adobe Deform Plugin"; // brand-ok: the plugin name the files use
/// The note for an envelope that keeps its drawn art.
pub(crate) const DRAWN: &str = "an envelope VectorCraft couldn't read as a live envelope comes in as its drawn art";
/// The most envelopes waiting for their drawn art at once.
pub(super) const MAX_PENDING: usize = 64;
/// The most envelopes one import draws to check; more keep their drawn art.
pub(super) const MAX_CHECKED: usize = 256;
/// The most rows or columns of a mesh read (Make with Mesh's own limit).
const MAX_GRID: u32 = 50;
/// The bits of the options word that are read: Distort Appearance, Distort Pattern Fills, Distort
/// Linear Gradients, Anti-Alias (set: off) and Preserve Shape Using (set: Clipping Mask).
const KNOWN_BITS: i64 = 0b111 | 1 << 16 | 1 << 17;
/// More of the inked pixels than this differing, each with all its neighbours, and the envelope
/// doesn't come out as drawn.
const MAX_SOLID: f64 = 0.03;
/// More of the inked pixels than this differing at all, and the envelope doesn't come out as drawn.
const MAX_ANY: f64 = 0.1;
/// The whole number of entry `key`: `None` when it is missing, `Some(None)` when it isn't one.
fn whole(o: &Obj, key: &str) -> Option<Option<i64>> {
o.get(key)?;
let n = o.nums(key);
Some(match n.as_slice() {
[v] if v.is_finite() && v.fract() == 0.0 && v.abs() < 1e12 => Some(*v as i64),
_ => None,
})
}
/// A switch (`0` or `1`), off when missing.
fn switch(o: &Obj, key: &str) -> Option<bool> {
match whole(o, key) {
None | Some(Some(0)) => Some(false),
Some(Some(1)) => Some(true),
_ => None,
}
}
/// Envelope Options and Fidelity.
pub(super) fn options(o: &Obj) -> Option<(EnvelopeOptions, f64)> {
let word = whole(o, "AIDeformOptionsKey")??;
if word < 0 || word & !KNOWN_BITS != 0 {
return None;
}
let fidelity = match whole(o, "AIDeformFidelityKey") {
None => default_fidelity(),
Some(Some(f)) if (0..=100).contains(&f) => f as f64,
Some(_) => return None,
};
let bit = |n: u32| word & (1 << n) != 0;
let options = EnvelopeOptions {
anti_alias: !bit(16),
preserve_shape: if bit(17) { PreserveShape::ClippingMask } else { PreserveShape::Transparency },
distort_appearance: bit(0),
distort_pattern_fills: bit(1),
distort_linear_gradients: bit(2),
};
Some((options, fidelity))
}
/// A warp: its style, bend, distortions and orientation.
fn warp(o: &Obj) -> Option<EnvelopeKind> {
let style = whole(o, "DeformStyle")??;
let id = super::style::warp_style(style)?;
let percent = |key: &str| -> Option<f64> {
if o.get(key).is_none() {
return Some(0.0);
}
match o.nums(key).as_slice() {
[v] if v.is_finite() && (-1.0..=1.0).contains(v) => Some(v * 100.0),
_ => None,
}
};
o.get("DeformValue")?;
let vertical = switch(o, "Rotate")?;
Some(EnvelopeKind::Warp {
style: id.into(),
bend: percent("DeformValue")?,
h: percent("DeformHoriz")?,
v: percent("DeformVert")?,
horizontal: !vertical,
})
}
/// The gradient mesh `n` is, or the one its plain groups hold alone.
fn only_mesh(n: &Node) -> Option<&GradientMesh> {
match &n.kind {
NodeKind::Mesh(m) => Some(m),
NodeKind::Group { children, clip: false } => match children.as_slice() {
[c] => only_mesh(c),
_ => None,
},
_ => None,
}
}
/// A grid point and its handles.
type GridPoint = (Point, [Vec2; 4]);
/// The points of mesh `m` as a `rows`×`cols` grid with its first row at the top and its first
/// column at the left (y down), as an envelope's mesh lays them out; `None` for a grid out of range
/// or with points that aren't finite.
fn upright(m: &GradientMesh) -> Option<(u32, u32, Vec<GridPoint>)> {
let (mut rows, mut cols) = (m.rows, m.cols);
if rows == 0 || cols == 0 || rows > MAX_GRID || cols > MAX_GRID {
return None;
}
let len = (rows as usize + 1) * (cols as usize + 1);
if m.points.len() != len {
return None;
}
let finite = |v: Vec2| v.x.is_finite() && v.y.is_finite();
if !m.points.iter().all(|q| finite(q.p.to_vec2()) && q.handles.iter().all(|h| finite(*h))) {
return None;
}
let mut pts: Vec<GridPoint> = m.points.iter().map(|q| (q.p, q.handles)).collect();
let at = |pts: &[GridPoint], cols: u32, r: u32, c: u32| pts.get((r * (cols + 1) + c) as usize).map_or(Point::ZERO, |q| q.0);
let remap = |h: [Vec2; 4], slots: [usize; 4]| slots.map(|s| h.get(s).copied().unwrap_or(Vec2::ZERO));
// Columns that run down the page: the grid goes the other way round.
let along = at(&pts, cols, 0, cols) - at(&pts, cols, 0, 0);
if along.y.abs() > along.x.abs() {
let mut t = Vec::with_capacity(len);
for r in 0..=cols {
for c in 0..=rows {
let (p, h) = pts.get((c * (cols + 1) + r) as usize).copied()?;
// The new next column is the old next row.
let mut slots = [0; 4];
(slots[H_RIGHT], slots[H_LEFT], slots[H_DOWN], slots[H_UP]) = (H_DOWN, H_UP, H_RIGHT, H_LEFT);
t.push((p, remap(h, slots)));
}
}
(rows, cols) = (cols, rows);
pts = t;
}
if (at(&pts, cols, 0, cols) - at(&pts, cols, 0, 0)).x < 0.0 {
for row in pts.chunks_mut(cols as usize + 1) {
row.reverse();
for (_, h) in row.iter_mut() {
h.swap(H_RIGHT, H_LEFT);
}
}
}
if (at(&pts, cols, rows, 0) - at(&pts, cols, 0, 0)).y < 0.0 {
let mut flipped: Vec<GridPoint> = pts.chunks(cols as usize + 1).rev().flatten().copied().collect();
for (_, h) in &mut flipped {
h.swap(H_DOWN, H_UP);
}
pts = flipped;
}
Some((rows, cols, pts))
}
/// The outline of an upright grid: its edge, clockwise from the top left corner.
fn outline(rows: u32, cols: u32, pts: &[GridPoint]) -> Option<PathData> {
let at = |r: u32, c: u32| pts.get((r * (cols + 1) + c) as usize).copied();
// Each edge: from a point by one of its handles to the next point by one of its handles.
type Edge = ((u32, u32), usize, (u32, u32), usize);
let mut ring: Vec<Edge> = vec![];
ring.extend((0..cols).map(|c| ((0, c), H_RIGHT, (0, c + 1), H_LEFT)));
ring.extend((0..rows).map(|r| ((r, cols), H_DOWN, (r + 1, cols), H_UP)));
ring.extend((1..=cols).rev().map(|c| ((rows, c), H_LEFT, (rows, c - 1), H_RIGHT)));
ring.extend((1..=rows).rev().map(|r| ((r, 0), H_UP, (r - 1, 0), H_DOWN)));
let mut bp = BezPath::new();
bp.move_to(at(0, 0)?.0);
for ((ar, ac), out, (br, bc), into) in ring {
let ((a, ha), (b, hb)) = (at(ar, ac)?, at(br, bc)?);
bp.curve_to(a + *ha.get(out)?, b + *hb.get(into)?, b);
}
bp.close_path();
Some(PathData::from_bezpath(&bp))
}
/// Has group `n` nothing of its own: no name, lock, transparency, appearance or other properties,
/// shown?
fn plain_group(n: &Node) -> bool {
let plain = Node::new(n.id, NodeKind::Group { children: vec![], clip: false });
matches!(n.kind, NodeKind::Group { clip: false, .. })
&& (&n.name, n.visible, n.locked, n.opacity, n.blend, n.isolate, n.knockout, n.knockout_shape)
== (&plain.name, plain.visible, plain.locked, plain.opacity, plain.blend, plain.isolate, plain.knockout, plain.knockout_shape)
&& n.appearance == plain.appearance
&& n.extras() == plain.extras()
}
/// `nodes` without the plain group the editing data puts round an envelope's contents.
fn unwrapped(mut nodes: Vec<Node>) -> Vec<Arc<Node>> {
if nodes.len() == 1
&& let Some(n) = nodes.first()
&& plain_group(n)
&& let Some(n) = nodes.pop()
&& let NodeKind::Group { children, .. } = n.kind
{
return children;
}
nodes.into_iter().map(Arc::new).collect()
}
/// The live envelope of dictionary `o`, the group on `%_` lines before it (`before`) and the art
/// in its `AIDeformArtKey` block (`inner`); `None` where they don't make one.
pub(super) fn live(o: &Obj, before: Option<Node>, inner: Vec<Node>) -> Option<NodeKind> {
let editing = switch(o, "AIDeformEditModeKey")?;
let (options, fidelity) = options(o)?;
let before = before?;
// While the contents are edited, they are the group before the dictionary and the mesh is in it.
let (content, shape): (Vec<Arc<Node>>, Option<&GradientMesh>) = if editing {
let NodeKind::Group { children, clip: false } = &before.kind else { return None };
let shape = match inner.as_slice() {
[n] => only_mesh(n),
_ => None,
};
(children.clone(), shape)
} else {
let shape = only_mesh(&before);
(unwrapped(inner), shape)
};
if content.is_empty() {
return None;
}
let kind = match whole(o, "AIDeformStyle")?? {
1 => warp(o)?,
2 => {
let (rows, cols, pts) = upright(shape?)?;
let (points, handles) = pts.into_iter().unzip();
EnvelopeKind::Mesh { rows, cols, points, handles }
}
3 => {
let (rows, cols, pts) = upright(shape?)?;
EnvelopeKind::TopObject { path: outline(rows, cols, &pts)? }
}
_ => return None,
};
Some(NodeKind::Envelope { content, kind: Box::new(kind), fidelity, editing, options, frame: Box::default() })
}
/// What the import's documents hold that art refers to: the patterns and image blobs.
pub(super) struct Shared<'a> {
pub(super) patterns: &'a [PatternDef],
pub(super) images: &'a BTreeMap<String, ImageBlob>,
}
/// A document of `node` alone, on a layer with id `layer`, with the patterns and images it shows.
fn alone(node: &Node, layer: NodeId, shared: &Shared<'_>) -> Document {
let mut doc = Document::new(1.0, 1.0);
let mut l = Node::layer(layer, "", LayerColor::Preset(0));
if let Some(c) = l.children_mut() {
c.push(Arc::new(node.clone()));
}
doc.layers = vec![Arc::new(l)];
doc.patterns = shared.patterns.to_vec();
let mut keys = vec![];
doc.visit_images(|_, im| keys.push(im.key.clone()));
for k in keys {
if let Some(blob) = shared.images.get(&k) {
doc.images.insert(k, blob.clone());
}
}
doc
}
/// How `live` and `drawn`, drawn over `region` at `scale`, differ: the pixels that differ in
/// lightness or colour, those whose neighbours all differ too (where one draws something the other
/// doesn't, not an edge drawn a little apart), and the pixels either inks. `None` when they can't
/// be drawn.
fn differing(live: &Document, drawn: &Document, region: Rect, scale: f64) -> Option<(usize, usize, usize)> {
let x = vectorcraft_render::Renderer::new().render_region(live, region, scale, false);
let y = vectorcraft_render::Renderer::new().render_region(drawn, region, scale, false);
if x.width == 0 || x.height == 0 || x.width != y.width || x.height != y.height {
return None;
}
let (w, h) = (x.width as usize, x.height as usize);
let (xs, ys) = (x.pixels.as_chunks::<4>().0, y.pixels.as_chunks::<4>().0);
let differs: Vec<bool> = xs.iter().zip(ys).map(|(p, q)| differ(p, q) || differ_in_colour(p, q)).collect();
let inked = xs.iter().zip(ys).filter(|(p, q)| p[3] > 8 || q[3] > 8).count();
let at = |i: usize, j: usize| differs.get(j * w + i).copied().unwrap_or(false);
let any = differs.iter().filter(|d| **d).count();
let solid = (1..h.saturating_sub(1))
.flat_map(|j| (1..w.saturating_sub(1)).map(move |i| (i, j)))
.filter(|&(i, j)| (j - 1..=j + 1).all(|n| (i - 1..=i + 1).all(|m| at(m, n))))
.count();
Some((any, solid, inked))
}
/// The differences from their drawn art that the live envelopes of one import may add up to, as
/// shares of the page drawn as the page check draws it: half of what that check allows
/// ([`MAX_DIFFERENCE`], [`MAX_SOLID_DIFFERENCE`]), so live envelopes never make the file open from
/// its PDF part instead.
#[derive(Debug, Default)]
pub(super) struct Spent {
any: f64,
solid: f64,
}
/// Do `live` and `drawn` draw alike: few of the pixels either inks differ, and fewer still where a
/// whole patch differs; and, on `page` (when the file has one), do they differ little enough that
/// with the differences in `spent` the page check still takes the layers' art (it is added to
/// `spent` when they do)? Both are drawn on `layer`, a layer id no other node has.
pub(super) fn drawn_alike(live: &Node, drawn: &Node, layer: NodeId, shared: &Shared<'_>, page: Option<Rect>, spent: &mut Spent) -> bool {
let (Some(a), Some(b)) = (live.visual_bounds(), drawn.visual_bounds()) else { return false };
let region = a.union(b).inflate(2.0, 2.0);
if !(region.is_finite() && region.width() > 0.0 && region.height() > 0.0) || region.width().max(region.height()) > 1e6 {
return false;
}
let (x, y) = (alone(live, layer, shared), alone(drawn, layer, shared));
let Some((any, solid, inked)) = differing(&x, &y, region, compare_scale(region)) else { return false };
if inked == 0 {
return false;
}
let share = |n: usize| n as f64 / inked as f64;
if share(solid) > MAX_SOLID || share(any) > MAX_ANY {
return false;
}
let Some(page) = page.filter(|p| p.is_finite() && p.width() > 0.0 && p.height() > 0.0) else { return true };
// Only what is on the page counts, drawn as the page check draws it (about 400 pixels along the
// page's longer side).
let on_page = region.intersect(page);
let scale = compare_scale(page);
if on_page.width() * scale < 1.0 || on_page.height() * scale < 1.0 {
return true;
}
let Some((any, solid, _)) = differing(&x, &y, on_page, scale) else { return false };
let pixels = (page.width() * scale * page.height() * scale).max(1.0);
let (any, solid) = (spent.any + any as f64 / pixels, spent.solid + solid as f64 / pixels);
if any > MAX_DIFFERENCE / 2.0 || solid > MAX_SOLID_DIFFERENCE / 2.0 {
return false;
}
*spent = Spent { any, solid };
true
}
+36 -4
View File
@@ -1,9 +1,12 @@
//! The reader: runs the editing data's operators into a document.
//!
//! - Art space has y up; the first artboard's top left corner is the document's origin.
//! - Layers: `%AI5_BeginLayer`, `visible preview unlocked printing dimmed … colorIndex r g b …
//! dimPercent … Lb` (ten operands in the legacy format, without the one before the colour
//! index), `(name) Ln`, its art, `LB`.
//! - Layers: `%AI5_BeginLayer`, `visible preview unlocked printing dimmed … colorIndex r g b
//! template dimPercent … Lb` (ten operands in the legacy format, without the one before the
//! colour index; see `Reader::layer_attrs`), `(name) Ln`, its art, `LB`.
//! - Ruler guides: a straight `(N) *` guide path, directly on a layer, that runs across the whole
//! canvas (16,383 points around the template box's centre) or from edge to edge of one artboard
//! is a ruler guide on that layer ([`Document::guides`]); other guide paths stay paths.
//! - Groups `u` … `U`, compound paths `*u` … `*U`, clipping groups `q` … `Q` (the path painted
//! with `W` clips); a group's transparency and name follow its end.
//! - Paths: `m`, `l`/`L`, `c`/`C`, `v`/`V`, `y`/`Y`, painted by `N n F f S s B b` (lower case
@@ -15,6 +18,8 @@
//! app wrote) the bounding box.
//! - Variables: the variable library, XML nodes of the `/Document` dictionary, isn't read; a note
//! gives how many variables and data sets it has ([`VariableLibrary`]).
//! - Objects on Path: a group whose objects' dictionaries carry ids, and a record of the
//! `/Document` dictionary naming them, read by [`on_path`].
//! - Images: `%AI5_BeginRaster`, the colour space `XN`, `[matrix] bounds w h bits type alpha …`
//! and the samples after `XI`. 8-bit CMYK samples keep their ink amounts in a CMYK TIFF, and
//! an alpha channel that hides part of such an image becomes its opacity mask; other samples
@@ -70,6 +75,7 @@ mod art;
mod containers;
mod directives;
mod document;
mod on_path;
mod ops;
mod types;
@@ -78,7 +84,7 @@ use art::preview_png;
pub use document::{PaletteSwatches, VariableLibrary, artboards, palette, variables};
pub(crate) use document::{slot_frame, slot_of};
pub use types::IMAGE_TOO_LARGE;
use types::{BLEND_BUDGET, FLIP, Frame, GState, Kind, PatternHead, Place, Raster};
use types::{BLEND_BUDGET, FLIP, Frame, GState, Kind, PatternHead, PendingEnvelope, Place, Raster};
#[cfg(test)]
pub use types::{NON_NATIVE_ART, NON_NATIVE_ART_UNREAD, VERSIONED_UNENDED};
pub(super) use types::{blend_mode, blend_name};
@@ -115,6 +121,9 @@ pub struct Structure {
/// move, in its pixels, that places them there (the corner of the box they are written with,
/// in place of the canvas point of art space's origin).
pub placed_elsewhere: Vec<(NodeId, Affine)>,
/// The live envelopes read from the editing data, by id, with the drawn art each took the place
/// of: the importer puts that art back when the layers with the envelopes don't draw as the page.
pub envelopes_drawn: Vec<(NodeId, Node)>,
pub warnings: Vec<String>,
}
@@ -145,6 +154,8 @@ struct Reader<'a> {
artboards: Vec<(String, Rect)>,
/// The variables and data sets of the `/Document` dictionary's variable library.
variables: VariableLibrary,
/// The Objects on Path records of the `/Document` dictionary and the ids of the objects.
on_path: on_path::OnPath,
gradients: HashMap<String, Gradient>,
gradient_def: Option<GradientDef>,
instance: Option<Instance>,
@@ -192,6 +203,17 @@ struct Reader<'a> {
blend_record: Option<(usize, Vec<u8>)>,
/// How many more objects blends may lay out in this import ([`super::blend::live_blend`]).
blend_budget: usize,
/// The group on `%_` lines an envelope's dictionary just took (see [`Reader::drop_envelope_art`]).
envelope_art: Option<Node>,
/// Envelopes waiting for their drawn art, innermost last.
envelopes: Vec<PendingEnvelope>,
/// How many more envelopes this import may draw to check ([`super::envelope::drawn_alike`]).
envelope_checks: usize,
/// How far the live envelopes read so far draw from their drawn art ([`super::envelope::Spent`]).
envelope_spent: super::envelope::Spent,
/// The live envelopes made so far and the drawn art each took the place of
/// ([`Structure::envelopes_drawn`]).
envelopes_drawn: Vec<(NodeId, Node)>,
/// A group just ended, its state (`Xy`, `Xd`, `XW`) not yet read.
group_ended: bool,
/// A gradient mesh just ended (`/End X!`), its own state (`Xy`) not yet read: also after its
@@ -206,6 +228,9 @@ struct Reader<'a> {
clip_fills: HashSet<NodeId>,
/// Groups closed as an object's drawn look (`9 () XW`).
looks: HashSet<NodeId>,
/// Straight `(N) *` guide paths painted directly on a layer, by id: their two points in art
/// space. [`Reader::finish`] makes the ruler guides among them guides of their layer.
ruler_guides: HashMap<NodeId, (Point, Point)>,
/// Legacy compound member groups, even when followed by the generic `9 () XW` marker.
compound_groups: HashSet<NodeId>,
/// Groups of a text object and its look (see [`Self::style_marker`]), which aren't a look.
@@ -274,6 +299,7 @@ impl<'a> Reader<'a> {
cropmarks: None,
artboards: Vec::new(),
variables: VariableLibrary::default(),
on_path: on_path::OnPath::default(),
gradients: HashMap::new(),
gradient_def: None,
instance: None,
@@ -306,8 +332,14 @@ impl<'a> Reader<'a> {
blend_roles: HashMap::new(),
blend_record: None,
blend_budget: BLEND_BUDGET,
envelope_art: None,
envelopes: Vec::new(),
envelope_checks: super::envelope::MAX_CHECKED,
envelope_spent: super::envelope::Spent::default(),
envelopes_drawn: Vec::new(),
clip_fills: HashSet::new(),
looks: HashSet::new(),
ruler_guides: HashMap::new(),
compound_groups: HashSet::new(),
text_looks: HashSet::new(),
group_ended: false,
+20 -2
View File
@@ -1,11 +1,12 @@
use super::containers::close;
use super::document::TEXT_SLOT;
use super::types::{
FLIP, IMAGE_TOO_LARGE, LINKED_PREVIEWS, MAX_PIXELS, MAX_PLACED_ELSEWHERE, MAX_POINTS, MAX_SIDE, NON_NATIVE_ART, NON_NATIVE_ART_UNREAD, Place,
FLIP, Frame, IMAGE_TOO_LARGE, Kind, LINKED_PREVIEWS, MAX_PIXELS, MAX_PLACED_ELSEWHERE, MAX_POINTS, MAX_SIDE, NON_NATIVE_ART,
NON_NATIVE_ART_UNREAD, Place,
};
use super::{
Affine, Appearance, AppearanceItem, Arc, BezPath, CANVAS_CENTRE, FillLayer, ImageBlob, ImageObject, Inks, Instance, LinkInfo, Node, NodeId,
NodeKind, Obj, OpacityMask, Paint, PathData, Point, Reader, Rect, StrokeLayer, V, ps,
NodeKind, Obj, OpacityMask, Paint, PathData, PathEl, Point, Reader, Rect, StrokeLayer, V, ps,
};
impl<'a> Reader<'a> {
@@ -162,6 +163,10 @@ impl<'a> Reader<'a> {
if op.chars().next().is_some_and(|c| c.is_ascii_lowercase()) {
close(&mut bp);
}
let line = match bp.elements() {
[PathEl::MoveTo(a), PathEl::LineTo(b)] => Some((*a, *b)),
_ => None,
};
let to_doc = self.space();
let path = PathData::from_bezpath(&(to_doc * bp));
let gs = self.gs.clone();
@@ -191,6 +196,19 @@ impl<'a> Reader<'a> {
}
let kind = NodeKind::Path { path, rule: gs.rule, live: None, clipping: clip, guide };
let mut node = self.new_node(kind, &gs);
// A ruler guide is written as a straight `(N) *` guide path on its layer.
if guide
&& op == "N"
&& !hidden
&& !gs.hidden
&& !clip
&& self.pattern.is_none()
&& matches!(self.frames.last(), Some(Frame { kind: Kind::Layer(_), .. }))
&& let Some((a, b)) = line
&& [a.x, a.y, b.x, b.y].iter().all(|v| v.is_finite())
{
self.ruler_guides.insert(node.id, (a, b));
}
node.appearance = Appearance { items, ..Appearance::default() }.into();
node.opacity = gs.opacity;
node.blend = gs.blend;
+99 -3
View File
@@ -1,6 +1,6 @@
use super::document::slot_of;
use super::ops::{paints_apart, path_rule};
use super::types::{Frame, GState, Kind, MAX_DEPTH, MAX_NODES};
use super::types::{Frame, GState, Kind, MAX_DEPTH, MAX_NODES, PendingEnvelope};
use super::{AppearanceItem, Arc, BezPath, BlendMode, FillRule, HashSet, LayerColor, Node, NodeId, NodeKind, Obj, Paint, PathEl, Rc, Reader, V, obj};
impl<'a> Reader<'a> {
@@ -222,7 +222,7 @@ impl<'a> Reader<'a> {
let color = a.color.unwrap_or(LayerColor::Preset(0));
let mut layer = Node::new(
self.doc.alloc_id(),
NodeKind::Layer { color, template: false, printable: a.printable, children, clip, preview: a.preview, dim_images: a.dim },
NodeKind::Layer { color, template: a.template, printable: a.printable, children, clip, preview: a.preview, dim_images: a.dim },
);
layer.name = Some(a.name.clone());
layer.visible = a.visible;
@@ -324,17 +324,30 @@ impl<'a> Reader<'a> {
"ArtDictionary" => {
let envelope = o.get("AIDeformOptionsKey").is_some();
self.art_dictionary(&o);
let before = self.envelope_art.take();
if envelope {
let mut slots = vec![];
for child in &children {
envelope_text_slots(child, &mut slots);
}
let typed = !slots.is_empty() || before.as_ref().is_some_and(has_text_slot);
for mut slot in slots {
// The editing data keeps the source text inside the envelope's options;
// its visible result is the outlined art the PDF page carries.
slot.visible = false;
self.add(slot, false)?;
}
// An envelope of type keeps its drawn art and its type, hidden, as above: the
// type is filled in after reading, which the envelope can't wait for.
if typed {
return Ok(());
}
if self.envelopes.len() >= crate::import::ai::envelope::MAX_PENDING {
self.warn(crate::import::ai::envelope::DRAWN);
} else {
let siblings = self.frames.last().map_or(0, |f| f.children.len());
self.envelopes.push(PendingEnvelope { obj: *o, before, inner: children, depth: self.frames.len(), siblings });
}
}
}
"Document" => self.document(&o),
@@ -467,7 +480,8 @@ impl<'a> Reader<'a> {
/// envelope's dictionary. It holds the mesh that shapes the envelope, or, while the envelope's
/// contents are being edited, those contents (the mesh then is in the dictionary); either way the
/// distorted art follows on plain lines. The source text in the contents stays, hidden, as the
/// dictionary's does. Whether it was dropped.
/// dictionary's does. The group is kept in [`Reader::envelope_art`] for the envelope to be made
/// of. Whether it was dropped.
pub(super) fn drop_envelope_art(&mut self) -> bool {
let Some(n) = self.last_object() else { return false };
let id = n.id;
@@ -483,6 +497,7 @@ impl<'a> Reader<'a> {
slot.visible = false;
f.children.push(slot);
}
self.envelope_art = Some(group);
}
self.hidden_ids.remove(&id);
self.after_object = false;
@@ -543,6 +558,87 @@ impl<'a> Reader<'a> {
// The blend's own state block follows its record, as a group's follows its end.
self.after_object = true;
}
/// An envelope's plugin `XP`: the envelope whose dictionary was read last in this container
/// and its drawn art, the one object read since, become one live envelope, or, where they
/// can't, the drawn art stays (see [`crate::import::ai::envelope`]). Envelopes left waiting
/// in containers that closed keep their drawn art.
pub(super) fn end_envelope(&mut self) {
let depth = self.frames.len();
while self.envelopes.last().is_some_and(|p| p.depth > depth) {
self.envelopes.pop();
self.warn(crate::import::ai::envelope::DRAWN);
}
if self.envelopes.last().is_none_or(|p| p.depth != depth) {
return;
}
let Some(p) = self.envelopes.pop() else { return };
let drawn = self.frames.last().filter(|f| f.children.len() == p.siblings + 1).and_then(|f| f.children.last());
let Some(drawn) = drawn else {
self.warn(crate::import::ai::envelope::DRAWN);
return;
};
let live = (self.envelope_checks > 0).then(|| crate::import::ai::envelope::live(&p.obj, p.before, p.inner)).flatten();
let Some(kind) = live else {
self.warn(crate::import::ai::envelope::DRAWN);
return;
};
// Objects the reader couldn't read come in as the page's art in their place: the envelope's
// drawn art stays round them.
let mut ids = HashSet::new();
fn collect(n: &Node, ids: &mut HashSet<NodeId>) {
ids.insert(n.id);
for c in n.children().into_iter().flatten() {
collect(c, ids);
}
}
collect(drawn, &mut ids);
if let NodeKind::Envelope { content, .. } = &kind {
content.iter().for_each(|c| collect(c, &mut ids));
}
if self.unread.iter().any(|(id, _)| ids.contains(id)) {
self.warn(crate::import::ai::envelope::DRAWN);
return;
}
self.envelope_checks -= 1;
// The drawn art's own properties (name, visibility, lock, transparency, appearance, mask,
// attributes) become the envelope's.
let mut envelope = drawn.clone();
(envelope.id, envelope.kind) = (self.doc.alloc_id(), kind);
let layer = self.doc.alloc_id();
let shared = crate::import::ai::envelope::Shared { patterns: &self.doc.patterns, images: &self.doc.images };
let mut shown = envelope.clone();
(shown.visible, shown.opacity) = (true, 1.0);
let mut plain = drawn.clone();
(plain.visible, plain.opacity) = (true, 1.0);
let page = self.page_in_doc();
if !crate::import::ai::envelope::drawn_alike(&shown, &plain, layer, &shared, page, &mut self.envelope_spent) {
self.warn(crate::import::ai::envelope::DRAWN);
return;
}
// The contents are the envelope's own now, not a copy on `%_` lines.
fn forget(n: &Node, hidden: &mut HashSet<NodeId>) {
hidden.remove(&n.id);
for c in n.children().into_iter().flatten() {
forget(c, hidden);
}
}
forget(&envelope, &mut self.hidden_ids);
if let Some(f) = self.frames.last_mut()
&& let Some(slot) = f.children.last_mut()
{
let id = envelope.id;
let drawn = std::mem::replace(slot, envelope);
self.envelopes_drawn.push((id, drawn));
}
}
}
/// Does `node` hold a text slot?
fn has_text_slot(node: &Node) -> bool {
let mut slots = vec![];
envelope_text_slots(node, &mut slots);
!slots.is_empty()
}
/// The source text inside an envelope options dictionary, carried through its containing metadata.
+96 -6
View File
@@ -1,8 +1,11 @@
use super::art::{element_name, elements, join_and, sane};
use super::containers::drop_commented_copies;
use super::types::{FLIP, Kind, MAX_DEPTH};
use super::{Affine, Artboard, ColorMode, Document, NodeKind, Obj, OpacityMask, Paint, Point, Reader, Rect, Structure, Swatch, Unit, V, obj};
use vectorcraft_doc::{ImageBlob, PatternDef};
use super::{
Affine, Arc, Artboard, CANVAS_CENTRE, ColorMode, Document, Node, NodeId, NodeKind, Obj, OpacityMask, Paint, Point, Reader, Rect, Structure,
Swatch, Unit, V, obj,
};
use vectorcraft_doc::{Guide, ImageBlob, PatternDef};
/// `%AI5_RulerUnits` → [`Unit`] (Illustrator 7 spec table 3; centimeters is 4; pixels is 6 in later files).
fn ruler_unit(v: u8) -> Option<Unit> {
@@ -221,6 +224,7 @@ impl<'a> Reader<'a> {
/// An object's dictionary: its name (`AIArtName`, or the XML id `AI10_ArtUID`), and the
/// opacity mask it may carry.
pub(super) fn art_dictionary(&mut self, o: &Obj) {
self.constraint_art(o);
// A blend's key object or spine.
if let Some(kind) = o.text("ShapeBlendPathType")
&& let Some(id) = self.last_object().map(|n| n.id)
@@ -231,7 +235,7 @@ impl<'a> Reader<'a> {
// An envelope (a distortion, `AIDeformStyle` / `DeformValue`) is written on `%_` lines as
// a group holding the mesh that shapes it (or its contents, while they are being edited);
// the distorted art the file's PDF part draws follows on plain lines. That group isn't
// art, so it goes (#2405).
// art, so it goes (#2405), kept aside for the live envelope ([`Reader::end_envelope`]).
if (o.get("AIDeformStyle").is_some() || o.get("DeformValue").is_some()) && self.drop_envelope_art() {
return;
}
@@ -275,10 +279,11 @@ impl<'a> Reader<'a> {
}
}
/// The `/Document` dictionary: the artboards, and how many variables and data sets its variable
/// library has.
/// The `/Document` dictionary: the artboards, how many variables and data sets its variable
/// library has, and its Objects on Path records.
pub(super) fn document(&mut self, o: &Obj) {
variable_library(o, 0, &mut self.variables);
self.constraint_data(o);
let Some(list) = find_entry(o, "ArtboardArray", 0) else { return };
for (i, ab) in list.items().enumerate().take(1000) {
let (p1, p2) = (ab.nums("PositionPoint1"), ab.nums("PositionPoint2"));
@@ -312,6 +317,12 @@ impl<'a> Reader<'a> {
self.artboards.first().map(|(_, r)| *r).or(self.cropmarks).or_else(|| self.sized_board())
}
/// The page the art is compared with in the document's space, once the art has started.
pub(super) fn page_in_doc(&self) -> Option<Rect> {
let board = self.first_board().or_else(|| self.bbox_board())?;
Some(self.to_doc?.transform_rect_bbox(board))
}
/// The page of a file that says nothing of its artboard (the legacy format as other apps write
/// it): its art's bounding box.
fn bbox_board(&self) -> Option<Rect> {
@@ -375,6 +386,11 @@ impl<'a> Reader<'a> {
}
pub(super) fn finish(mut self) -> Result<Structure, String> {
// Envelopes whose plugin never closed them keep their drawn art.
if !self.envelopes.is_empty() {
self.envelopes.clear();
self.warn(crate::import::ai::envelope::DRAWN);
}
if !self.unknown.is_empty() {
let ops: Vec<String> = self.unknown.iter().map(|o| format!("`{o}`")).collect();
self.unsupported.insert(format!("operators VectorCraft doesn't know ({})", ops.join(", ")));
@@ -389,12 +405,14 @@ impl<'a> Reader<'a> {
let to_doc = self.ensure_space();
let artboard = self.first_board();
drop_commented_copies(&mut self.doc.layers, &self.hidden_ids);
super::on_path::apply(&mut self.doc, &self.on_path);
let mut doc = std::mem::replace(&mut self.doc, Document::new(1.0, 1.0));
let boards: Vec<(String, Rect)> = if self.artboards.is_empty() {
artboard.or_else(|| self.bbox_board()).map(|r| vec![("Artboard 1".to_string(), r)]).unwrap_or_default()
} else {
self.artboards.clone()
};
let board_rects: Vec<Rect> = boards.iter().map(|(_, r)| *r).collect();
doc.artboards = boards
.into_iter()
.enumerate()
@@ -424,6 +442,22 @@ impl<'a> Reader<'a> {
color: None,
}];
}
if !self.ruler_guides.is_empty() {
let ids: Vec<u32> = doc.artboards.iter().map(|a| a.id).collect();
let (candidates, centre) = (std::mem::take(&mut self.ruler_guides), self.template_center);
let pick = |id: NodeId| -> Option<Guide> {
let (a, b) = candidates.get(&id)?;
let (vertical, board) = ruler_guide(*a, *b, centre, &board_rects)?;
let p = to_doc * *a;
let artboard = match board {
Some(i) => Some(*ids.get(i)?),
None => None,
};
let pos = if vertical { p.x } else { p.y };
pos.is_finite().then_some(Guide { vertical, pos, artboard, layer: None })
};
take_ruler_guides(&mut doc.layers, &pick, &mut doc.guides, 0);
}
if self.color_model.unwrap_or(self.cmyk_colors > self.rgb_colors) {
doc.color_mode = ColorMode::Cmyk;
let (swatches, groups) = vectorcraft_color::default_swatches(ColorMode::Cmyk.model());
@@ -468,13 +502,69 @@ impl<'a> Reader<'a> {
slot_names: self.slot_names,
restyled: self.restyled,
placed_elsewhere: self.placed_elsewhere,
envelopes_drawn: self.envelopes_drawn,
warnings,
})
}
}
/// The dictionary of entry `key` in `o` or the dictionaries it holds.
fn find_entry<'o>(o: &'o Obj, key: &str, depth: usize) -> Option<&'o Obj> {
/// How far (points) a ruler guide's ends may lie from the canvas's or an artboard's edges.
const GUIDE_TOLERANCE: f64 = 0.01;
/// Whether the straight guide path from `a` to `b` (art space) is a ruler guide → whether it is
/// vertical, and the index in `boards` of the artboard it belongs to (`None`: a canvas guide). A
/// canvas guide runs across the whole canvas, 16,383 points centred on the template box's
/// centre `centre`; an artboard guide runs from edge to edge of one artboard of `boards`, inside it.
fn ruler_guide(a: Point, b: Point, centre: Option<(f64, f64)>, boards: &[Rect]) -> Option<(bool, Option<usize>)> {
let near = |u: f64, v: f64| (u - v).abs() <= GUIDE_TOLERANCE;
let vertical = match (near(a.x, b.x), near(a.y, b.y)) {
(true, false) => true,
(false, true) => false,
_ => return None,
};
let (along, across) = if vertical { ((a.y.min(b.y), a.y.max(b.y)), a.x) } else { ((a.x.min(b.x), a.x.max(b.x)), a.y) };
if let Some((cx, cy)) = centre {
let c = if vertical { cy } else { cx };
if near(along.0, c - CANVAS_CENTRE) && near(along.1, c + CANVAS_CENTRE) {
return Some((vertical, None));
}
}
let mut on = boards.iter().enumerate().filter(|(_, r)| {
let (ends, sides) =
if vertical { ((r.min_y(), r.max_y()), (r.min_x(), r.max_x())) } else { ((r.min_x(), r.max_x()), (r.min_y(), r.max_y())) };
near(along.0, ends.0) && near(along.1, ends.1) && (sides.0..=sides.1).contains(&across)
});
match (on.next(), on.next()) {
(Some((i, _)), None) => Some((vertical, Some(i))),
_ => None,
}
}
/// Moves the children of the layers and sublayers of `nodes` that `pick` makes a guide of into
/// `guides`, each on its layer.
fn take_ruler_guides(nodes: &mut [Arc<Node>], pick: &dyn Fn(NodeId) -> Option<Guide>, guides: &mut Vec<Guide>, depth: usize) {
if depth > MAX_DEPTH {
return;
}
for n in nodes {
if !matches!(n.kind, NodeKind::Layer { .. }) {
continue;
}
let id = n.id;
let Some(kids) = Arc::make_mut(n).children_mut() else { continue };
kids.retain(|k| match pick(k.id) {
Some(g) => {
guides.push(Guide { layer: Some(id), ..g });
false
}
None => true,
});
take_ruler_guides(kids, pick, guides, depth + 1);
}
}
pub(super) fn find_entry<'o>(o: &'o Obj, key: &str, depth: usize) -> Option<&'o Obj> {
if depth > 8 {
return None;
}
+176
View File
@@ -0,0 +1,176 @@
//! Objects on Path groups: a `u` … `U` group whose children's dictionaries carry a `Persistent ID`
//! (the path's with `KeyPath` 1, each object's with its `ConstraintArtOrderId` and the angle of its
//! frame, `BBAccumRotation`: radians, counterclockwise in art space), and a dictionary of the
//! `/Document` dictionary's `Constraint Data` array naming them: `Constraint Name`, `Constraint
//! Verison` 1, `Constraint IDs` (the path's id, then the objects' in their order along the path)
//! and `Constraint Dict` with the options (`PivotPosition` 0 to 8, `Rotation` in radians,
//! `IsEquiSpaced`, `StartRangeFraction`, `ObjectsOrder`, `ConstraintArtData`). The group opens
//! with its [`ObjectsOnPath`] record and its art where it is drawn. A record with options the
//! record can't hold (a `Spacing`, uneven spacing, a start other than 0, keys this reader doesn't
//! know), or one that doesn't match one group exactly, leaves the group a plain group.
use std::collections::{HashMap, HashSet};
use vectorcraft_doc::objects_on_path::{Member, ObjectsOnPath, attachable_path};
use super::{Affine, Document, NodeId, NodeKind, Obj, Reader, V};
/// The constraint name of an Objects on Path record.
const NAME: &str = "Adobe Align On Path"; // brand-ok: the record's name in the files
/// Most records read from one file.
const MAX_RECORDS: usize = 1000;
/// Most objects one record names.
const MAX_IDS: usize = 10_000;
/// Most objects whose ids are kept.
const MAX_TAGGED: usize = 100_000;
/// The keys of `Constraint Dict` this reader reads; any other one leaves the group plain.
const KNOWN: [&str; 6] = ["StartRangeFraction", "ConstraintArtData", "ObjectsOrder", "PivotPosition", "IsEquiSpaced", "Rotation"];
/// An Objects on Path record of the `/Document` dictionary.
#[derive(Debug)]
pub(super) struct Record {
/// The path's id, then the objects' in their order along the path.
ids: Vec<String>,
pivot: u8,
/// Degrees, counterclockwise.
rotate: f64,
}
/// What an object's dictionary says of its part in an Objects on Path group.
#[derive(Clone, Copy, Debug, Default)]
struct Tag {
key_path: bool,
order: Option<f64>,
/// `BBAccumRotation`: radians, counterclockwise in art space.
angle: Option<f64>,
}
/// The records and object ids read, for [`apply`].
#[derive(Debug, Default)]
pub(super) struct OnPath {
records: Vec<Record>,
/// The objects with each `Persistent ID`.
ids: HashMap<String, Vec<NodeId>>,
tags: HashMap<NodeId, Tag>,
}
/// The first number of entry `key`.
fn num(o: &Obj, key: &str) -> Option<f64> {
o.nums(key).first().copied()
}
/// An Objects on Path record from a dictionary of the `Constraint Data` array: `None` for another
/// constraint, another version, or options the record can't hold.
fn record(o: &Obj) -> Option<Record> {
if o.text("Constraint Name").as_deref() != Some(NAME) || num(o, "Constraint Verison") != Some(1.0) {
return None;
}
let list = o.obj("Constraint IDs")?;
if list.entries.len() > MAX_IDS || !list.content.is_empty() {
return None;
}
let ids = list.entries.iter().map(|(_, v)| v.iter().find(|v| matches!(v, V::Str(_))).and_then(V::text)).collect::<Option<Vec<String>>>()?;
let members = ids.len().checked_sub(1).filter(|n| *n > 0)?;
let options = o.obj("Constraint Dict")?;
if options.entries.iter().any(|(k, _)| !k.as_deref().is_some_and(|k| KNOWN.contains(&k))) || !options.content.is_empty() {
return None;
}
if num(options, "IsEquiSpaced") != Some(1.0)
|| !matches!(options.get("StartRangeFraction").map(|_| num(options, "StartRangeFraction")), None | Some(Some(0.0)))
{
return None;
}
let pivot = num(options, "PivotPosition").filter(|p| p.fract() == 0.0 && (0.0..=8.0).contains(p))? as u8;
let rotate = num(options, "Rotation").map(f64::to_degrees).filter(|r| r.is_finite())?;
let order = options.obj("ObjectsOrder")?;
let order: Vec<f64> = order.entries.iter().flat_map(|(_, v)| v.iter().filter_map(V::num)).collect();
if order.len() != members || order.iter().enumerate().any(|(i, n)| *n != i as f64) {
return None;
}
let data = options.obj("ConstraintArtData")?;
if data.entries.len() != members || data.items().count() != members || data.items().any(|d| !d.entries.is_empty() || !d.content.is_empty()) {
return None;
}
Some(Record { ids, pivot, rotate })
}
impl Reader<'_> {
/// The Objects on Path records of a `Constraint Data` array in the `/Document` dictionary `o`.
pub(super) fn constraint_data(&mut self, o: &Obj) {
let Some(list) = super::document::find_entry(o, "Constraint Data", 0) else { return };
for d in list.items().take(MAX_RECORDS) {
if self.on_path.records.len() >= MAX_RECORDS {
return;
}
if let Some(r) = record(d) {
self.on_path.records.push(r);
}
}
}
/// The `Persistent ID` an object's dictionary `o` gives the last object, with its part in an
/// Objects on Path group.
pub(super) fn constraint_art(&mut self, o: &Obj) {
let Some(uuid) = o.text("Persistent ID") else { return };
let Some(id) = self.last_object().filter(|n| !matches!(n.kind, NodeKind::Layer { .. })).map(|n| n.id) else { return };
if self.on_path.tags.len() >= MAX_TAGGED {
return;
}
let angle = o.text("BBAccumRotation").and_then(|s| s.trim().parse::<f64>().ok()).filter(|a| a.is_finite());
let tag = Tag { key_path: num(o, "KeyPath") == Some(1.0), order: num(o, "ConstraintArtOrderId"), angle };
self.on_path.tags.insert(id, tag);
self.on_path.ids.entry(uuid).or_default().push(id);
}
}
/// Give each group that a record of `found` names exactly its [`ObjectsOnPath`] record.
pub(super) fn apply(doc: &mut Document, found: &OnPath) {
if found.records.is_empty() {
return;
}
// Where every object with an id is, found in one walk of the tree (setting a record doesn't
// move anything).
let at = doc.index_paths(found.tags.keys().copied());
for r in &found.records {
// A record that doesn't match leaves the group as it was read.
let _ = apply_one(doc, found, &at, r);
}
}
fn apply_one(doc: &mut Document, found: &OnPath, at: &HashMap<NodeId, Vec<usize>>, r: &Record) -> Option<()> {
// The one object still in the document with the id `uuid` (a commented copy may have gone).
let resolve = |uuid: &str| -> Option<(NodeId, &Vec<usize>)> {
let mut live = found.ids.get(uuid)?.iter().filter_map(|id| at.get(id).map(|p| (*id, p)));
let one = live.next()?;
live.next().is_none().then_some(one)
};
let (first, rest) = r.ids.split_first()?;
let (path, path_at) = resolve(first)?;
if !found.tags.get(&path)?.key_path {
return None;
}
let (_, group_at) = path_at.split_last()?;
let mut seen = HashSet::from([path]);
let mut members = Vec::with_capacity(rest.len());
for (i, uuid) in rest.iter().enumerate() {
let (id, place) = resolve(uuid)?;
let tag = found.tags.get(&id)?;
if !seen.insert(id) || place.split_last().map(|(_, up)| up) != Some(group_at) || tag.key_path || tag.order != Some(i as f64) {
return None;
}
// The frame the object is drawn in: turned by its angle (the document's y points down)
// about the center of its bounds.
let angle = -tag.angle?;
let center = doc.node_at(place)?.geometric_bounds()?.center();
members.push(Member { id, frame: Affine::rotate_about(angle, center), angle });
}
let g = doc.node_at(group_at)?;
let NodeKind::Group { children, clip: false } = &g.kind else { return None };
if g.extras().objects_on_path.is_some() || children.len() != seen.len() || children.iter().any(|c| !seen.contains(&c.id)) {
return None;
}
attachable_path(children.iter().find(|c| c.id == path)?)?;
let on_path = ObjectsOnPath { path, members, pivot: r.pivot, rotate: r.rotate };
doc.node_mut_at(group_at)?.extras_mut().objects_on_path = Some(Box::new(on_path));
Some(())
}
+10 -4
View File
@@ -298,6 +298,8 @@ impl<'a> Reader<'a> {
let len = Self::nums(&vals).last().copied().unwrap_or(0.0);
if name == crate::import::ai::blend::PLUGIN && len.is_finite() && len > 0.0 && len <= crate::import::ai::blend::MAX_RECORD as f64 {
self.blend_record = Some((len as usize, vec![]));
} else if name == crate::import::ai::envelope::PLUGIN {
self.end_envelope();
}
}
_ if IGNORED.contains(&w) => {}
@@ -308,8 +310,11 @@ impl<'a> Reader<'a> {
Ok(())
}
/// `… Lb`: a layer's options. The legacy format's has ten operands, its colour index one place
/// earlier (`visible preview unlocked printing dimmed masks colorIndex r g b`).
/// `… Lb`: a layer's options, `visible preview unlocked printing dimmed`, then the colour index
/// and its RGB. The colour index is at 5 with fewer than ten operands, 6 with ten to twelve
/// (`masks` before it) and 7 with thirteen or more (one more flag before it). Four places after
/// the colour index comes the template flag (eleven operands or more), then the dimming
/// percentage (twelve or more, else 50). A fourteenth operand changes nothing here.
fn layer_attrs(&mut self, vals: &[V]) {
let n = Self::nums(vals);
let Some(Frame { kind: Kind::Layer(a), .. }) = self.frames.last_mut() else { return };
@@ -318,9 +323,10 @@ impl<'a> Reader<'a> {
a.preview = flag(1, true);
a.locked = !flag(2, true);
a.printable = flag(3, true);
let dim_pct = n.get(12).copied().filter(|v| v.is_finite()).unwrap_or(50.0).clamp(0.0, 100.0);
let at = 5 + usize::from(n.len() >= 10) + usize::from(n.len() >= 13);
a.template = flag(at + 4, false);
let dim_pct = n.get(at + 5).copied().filter(|v| v.is_finite()).unwrap_or(50.0).clamp(0.0, 100.0);
a.dim = flag(4, false).then_some(dim_pct.round() as u8);
let at = if n.len() <= 10 { 6 } else { 7 };
let index = n.get(at).copied().unwrap_or(0.0);
let rgb = |i: usize| n.get(at + i).map_or(0, |v| v.clamp(0.0, 255.0) as u8);
a.color = Some(if (0.0..vectorcraft_doc::LAYER_COLORS.len() as f64).contains(&index) && index.fract() == 0.0 {
+16
View File
@@ -123,6 +123,7 @@ pub(super) struct LayerAttrs {
pub(super) preview: bool,
pub(super) locked: bool,
pub(super) printable: bool,
pub(super) template: bool,
pub(super) dim: Option<u8>,
pub(super) color: Option<LayerColor>,
}
@@ -165,6 +166,21 @@ pub(super) struct Frame {
pub(super) state: Option<(f32, BlendMode, bool, Knockout, bool)>,
}
/// An envelope whose dictionary was read, waiting for its drawn art and its plugin's `XP` (see
/// [`super::super::envelope`]).
#[derive(Debug)]
pub(super) struct PendingEnvelope {
pub(super) obj: Obj,
/// The group on `%_` lines before the dictionary.
pub(super) before: Option<Node>,
/// The art in the dictionary's `AIDeformArtKey` block.
pub(super) inner: Vec<Node>,
/// How many frames were open when the dictionary closed, and how many children the innermost
/// had: its drawn art is the one child after them.
pub(super) depth: usize,
pub(super) siblings: usize,
}
/// What a reader of a pattern's definition has of its head, `(name) llx lly urx ury`: the operands
/// before its first operator.
#[derive(Debug, Default)]
+6
View File
@@ -82,6 +82,12 @@ const WARPS: [&str; 15] = [
"warp.twist",
];
/// The warp style (`arc`, `arcLower`, …) numbered `n` in `DeformStyle`.
pub(super) fn warp_style(n: i64) -> Option<&'static str> {
let id = WARPS.get(usize::try_from(n.checked_sub(1)?).ok()?)?;
id.strip_prefix("warp.")
}
/// A plain style's paint, as the object comes in.
#[derive(Clone, Debug, Default, PartialEq)]
pub(super) struct ArtStyle {
+741 -1
View File
@@ -5,7 +5,7 @@ use std::sync::Arc;
use kurbo::{Point, Rect};
use vectorcraft_color::gradient::GradientKind;
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{AppearanceItem, ColorMode, Document, Knockout, LayerColor, LineCap, LineJoin, Node, NodeKind, Unit};
use vectorcraft_doc::{AppearanceItem, ColorMode, Document, Guide, Knockout, LayerColor, LineCap, LineJoin, Node, NodeKind, Unit};
use super::*;
@@ -1997,6 +1997,488 @@ fn an_envelope_s_contents_being_edited_are_not_read_as_art() {
assert_eq!(fill(&a[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
}
/// `art` on `%_` lines.
fn commented(art: &str) -> String {
art.lines().map(|l| format!("%_{l}\n")).collect()
}
/// Envelope Options as new envelopes have them (Distort Appearance, Anti-Alias, Clipping Mask),
/// Fidelity 50, the contents not being edited.
const ENVELOPE_KEYS: &str = "131073 /Int (AIDeformOptionsKey) ,\n50 /Int (AIDeformFidelityKey) ,\n0 /Bool (AIDeformEditModeKey) ,\n";
/// An envelope as the editing data writes it: on `%_` lines a group holding `before`, then the
/// dictionary with `keys`, the art `block` in its `AIDeformArtKey` block and the kind `kind`; then
/// `drawn` in a group on plain lines, and the plugin's `XP`.
fn envelope_parts(before: &str, keys: &str, block: &str, kind: u32, drawn: &str) -> String {
format!(
"{}%_/ArtDictionary :\n{}%_X=\n{}%_X+\n%_ (AIDeformArtKey) ,\n%_{kind} /Int (AIDeformStyle) ,\n%_;\n%_\n%_9 () XW\nu\n{drawn}U\n9 () XW\n(Adobe Deform Plugin) 1 0 0 XP\n",
commented(&format!("u\n{before}U\n")),
commented(keys),
commented(block),
)
}
/// An envelope of `content` (in the plain group the editing data puts round it) shaped by the mesh
/// `shape`.
fn envelope_of(shape: &str, keys: &str, content: &str, kind: u32, drawn: &str) -> String {
envelope_parts(shape, keys, &format!("u\n{content}U\n9 () XW\n"), kind, drawn)
}
/// A grid point's place in art space and its handle towards a neighbour.
type MeshAt<'a> = &'a dyn Fn((usize, usize)) -> (f64, f64);
type MeshHandle<'a> = &'a dyn Fn((usize, usize), (usize, usize)) -> (f64, f64);
/// A grey `/Mesh` of `cols`×`rows` patches (`/P [i j]`, `i` along x, column by column), each
/// corner of patch `(i, j)` at `at`, from the one nearest the origin up, across and down, with its
/// handles towards the next corner and the previous one at `handle`; `anchor`, written after the
/// second corner of the first patch, puts an anchor on its second edge.
fn mesh_text(cols: usize, rows: usize, at: MeshAt<'_>, handle: MeshHandle<'_>, anchor: Option<[f64; 6]>) -> String {
let mut s = format!("/Mesh X!\n2 /Version X#\n/Cartesian /Type X#\n[{cols} {rows}] /Size X#\n/Data X#\n/DeviceGray /CS X#\n");
for i in 0..cols {
for j in 0..rows {
s += &format!("[{i} {j}] /P X#\n");
let (a, b, c, d) = ((i, j), (i, j + 1), (i + 1, j + 1), (i + 1, j));
for (k, (p, next, prev)) in [(a, b, d), (b, c, a), (c, d, b), (d, a, c)].into_iter().enumerate() {
let ((x, y), (nx, ny), (px, py)) = (at(p), handle(p, next), handle(p, prev));
s += &format!("[0.8 1 {x} {y} {nx} {ny} 1 {px} {py} 1] /N X#\n");
if let (1, Some([x, y, ax, ay, bx, by])) = (k, anchor.filter(|_| (i, j) == (0, 0))) {
s += &format!("[{x} {y} {ax} {ay} {bx} {by} 1] /A X#\n");
}
}
s += "/E X#\n";
}
}
s + "/End X!\n"
}
/// A grid in art space, its points `step` apart from (40, 20), handles a third of the way.
fn straight_mesh(cols: usize, rows: usize, step: (f64, f64)) -> String {
let at = move |(i, j): (usize, usize)| (40.0 + i as f64 * step.0, 20.0 + j as f64 * step.1);
let third = move |p: (usize, usize), q: (usize, usize)| {
let ((x, y), (u, v)) = (at(p), at(q));
(x + (u - x) / 3.0, y + (v - y) / 3.0)
};
mesh_text(cols, rows, &at, &third, None)
}
/// The red rectangle 40 20 160 80 in art space, which is the same box in the document's.
fn envelope_content() -> String {
format!("{RED}{}f\n", rect_path(40.0, 20.0, 160.0, 80.0))
}
/// What envelope `kind` draws of [`envelope_content`]: the rectangle's edges, many points to a
/// side, mapped as VectorCraft maps them, as a red path in art space.
fn drawn_content(kind: &vectorcraft_doc::live::EnvelopeKind) -> String {
let rect = Rect::new(40.0, 20.0, 160.0, 80.0);
let map = vectorcraft_doc::live::envelope_mapper(kind, rect);
let corners = [Point::new(40.0, 20.0), Point::new(160.0, 20.0), Point::new(160.0, 80.0), Point::new(40.0, 80.0)];
let mut s = RED.to_string();
for (k, a) in corners.iter().enumerate() {
let b = corners[(k + 1) % 4];
for t in 0..64 {
let q = map(a.lerp(b, f64::from(t) / 64.0));
s += &format!("{} {} {}\n", q.x, 100.0 - q.y, if k == 0 && t == 0 { "m" } else { "L" });
}
}
s + "f\n"
}
/// The live envelope `n` is: its content, kind, fidelity, editing and options.
fn live_envelope(n: &Node) -> (&[Arc<Node>], &vectorcraft_doc::live::EnvelopeKind, f64, bool, vectorcraft_doc::live::EnvelopeOptions) {
match &n.kind {
NodeKind::Envelope { content, kind, fidelity, editing, options, frame } => {
assert_eq!(**frame, kurbo::Affine::IDENTITY);
(content, kind, *fidelity, *editing, *options)
}
other => panic!("not a live envelope: {other:#?}"),
}
}
/// A warp's dictionary keys: style number `style`, bend, horizontal and vertical distortion as
/// fractions, and the orientation (vertical with `rotate` 1).
fn warp_keys(style: u32, bend: f64, h: f64, v: f64, rotate: u32) -> String {
format!(
"{ENVELOPE_KEYS}{style} /Int (DeformStyle) ,\n{h} /Real (DeformHoriz) ,\n{bend} /Real (DeformValue) ,\n{v} /Real (DeformVert) ,\n{rotate} /Bool (Rotate) ,\n(Warp) /UnicodeString (DisplayString) ,\n"
)
}
/// An envelope made with a warp opens as that live envelope of its contents, its style, bend,
/// distortions (fractions in the file) and orientation read; the warp's drawn art goes.
#[test]
fn a_warp_envelope_opens_live() {
use vectorcraft_doc::live::EnvelopeKind;
for (style, bend, h, v, rotate, id) in
[(1, 0.5, 0.0, 0.0, 0, "arc"), (1, 0.5, -0.2, 0.3, 1, "arc"), (8, 0.5, 0.0, 0.0, 0, "flag"), (15, 0.5, 0.0, 0.0, 0, "twist")]
{
let want = EnvelopeKind::Warp { style: id.into(), bend: bend * 100.0, h: h * 100.0, v: v * 100.0, horizontal: rotate == 0 };
let body =
envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(style, bend, h, v, rotate), &envelope_content(), 1, &drawn_content(&want));
let r = read_ok(&layer(&body));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
let (content, kind, fidelity, editing, options) = live_envelope(&a[0]);
let EnvelopeKind::Warp { style: s, bend: b, h: dh, v: dv, horizontal } = kind else { panic!("{kind:?}") };
assert_eq!((s.as_str(), *horizontal), (id, rotate == 0));
assert!([(*b, bend), (*dh, h), (*dv, v)].iter().all(|(got, f)| (got - f * 100.0).abs() < 1e-9), "{kind:?}");
assert_eq!((fidelity, editing, options), (50.0, false, vectorcraft_doc::live::EnvelopeOptions::NEW));
assert_eq!(content.len(), 1, "{content:#?}");
assert!(matches!(content[0].kind, NodeKind::Path { .. }), "{content:#?}");
assert_eq!(fill(&content[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
}
/// Every warp style number reads as its style, in Effect › Warp's order.
#[test]
fn warp_envelopes_read_every_style() {
use vectorcraft_doc::live::EnvelopeKind;
let ids = [
"arc",
"arcLower",
"arcUpper",
"arch",
"bulge",
"shellLower",
"shellUpper",
"flag",
"wave",
"fish",
"rise",
"fisheye",
"inflate",
"squeeze",
"twist",
];
for (n, id) in ids.iter().enumerate() {
let want = EnvelopeKind::Warp { style: (*id).into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
let keys = warp_keys(n as u32 + 1, 0.3, 0.0, 0.0, 0);
let r = read_ok(&layer(&envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &keys, &envelope_content(), 1, &drawn_content(&want))));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{id}: {a:#?}");
assert_eq!(live_envelope(&a[0]).1, &want, "{id}");
}
}
/// An envelope made with a mesh opens as a live mesh envelope: its rows and columns, its points
/// row by row from the top left, and each point's four handles, a moved point and an uneven handle
/// included.
#[test]
fn a_mesh_envelope_opens_live_with_its_points_and_handles() {
use vectorcraft_doc::live::{EnvelopeKind, H_DOWN, H_LEFT, H_RIGHT, H_UP};
// 3 columns 40 apart and 2 rows 30 apart from (40, 20); the inner point (1, 1) moved, and its
// handle towards (2, 1) longer and off the line.
let at = |(i, j): (usize, usize)| if (i, j) == (1, 1) { (90.0, 55.0) } else { (40.0 + i as f64 * 40.0, 20.0 + j as f64 * 30.0) };
let handle = |p: (usize, usize), q: (usize, usize)| {
if (p, q) == ((1, 1), (2, 1)) {
return (108.0, 62.0);
}
let ((x, y), (u, v)) = (at(p), at(q));
(x + (u - x) / 3.0, y + (v - y) / 3.0)
};
let shape = mesh_text(3, 2, &at, &handle, None);
// Row r of the envelope is row 2 - r of the file (art space has y up).
let doc_pt = |(x, y): (f64, f64)| Point::new(x, 100.0 - y);
let mut points = vec![];
let mut handles = vec![];
for r in 0..=2usize {
for c in 0..=3usize {
let g = (c, 2 - r);
let p = doc_pt(at(g));
points.push(p);
let mut h = [kurbo::Vec2::ZERO; 4];
let towards = |q: Option<(usize, usize)>| q.filter(|q| q.0 <= 3 && q.1 <= 2).map_or(kurbo::Vec2::ZERO, |q| doc_pt(handle(g, q)) - p);
h[H_RIGHT] = towards(Some((g.0 + 1, g.1)));
h[H_LEFT] = towards(g.0.checked_sub(1).map(|i| (i, g.1)));
h[H_UP] = towards(Some((g.0, g.1 + 1)));
h[H_DOWN] = towards(g.1.checked_sub(1).map(|j| (g.0, j)));
handles.push(h);
}
}
let want = EnvelopeKind::Mesh { rows: 2, cols: 3, points: points.clone(), handles: handles.clone() };
let r = read_ok(&layer(&envelope_of(&shape, ENVELOPE_KEYS, &envelope_content(), 2, &drawn_content(&want))));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
let EnvelopeKind::Mesh { rows, cols, points: got, handles: got_h } = live_envelope(&a[0]).1 else { panic!("{a:#?}") };
assert_eq!((*rows, *cols, got.len(), got_h.len()), (2, 3, 12, 12));
for (k, (p, q)) in got.iter().zip(&points).enumerate() {
assert!((*p - *q).hypot() < 1e-6, "point {k}: {p:?}, not {q:?}");
}
// The moved inner point (row 1, column 1): each of its handles, the uneven one towards the right.
let inner = 4 + 1;
assert!((got[inner] - Point::new(90.0, 45.0)).hypot() < 1e-6, "{:?}", got[inner]);
for slot in [H_RIGHT, H_LEFT, H_DOWN, H_UP] {
assert!((got_h[inner][slot] - handles[inner][slot]).hypot() < 1e-6, "slot {slot}: {:?} vs {:?}", got_h[inner], handles[inner]);
}
assert!((got_h[inner][H_RIGHT] - kurbo::Vec2::new(18.0, -7.0)).hypot() < 1e-6, "{:?}", got_h[inner]);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
/// An envelope made with a top object opens as a live envelope whose top path is the edge of its
/// one-patch mesh, an anchor on the edge included; its contents stay the rectangle.
#[test]
fn a_top_object_envelope_opens_live_with_its_outline() {
use vectorcraft_doc::live::EnvelopeKind;
// Corners (40, 20) (40, 80) (160, 80) (160, 20); the top edge bows up through an anchor at (100, 95).
let at = |(i, j): (usize, usize)| (40.0 + i as f64 * 120.0, 20.0 + j as f64 * 60.0);
let handle = |p: (usize, usize), q: (usize, usize)| match (p, q) {
((0, 1), (1, 1)) => (60.0, 88.0),
((1, 1), (0, 1)) => (140.0, 88.0),
_ => {
let ((x, y), (u, v)) = (at(p), at(q));
(x + (u - x) / 3.0, y + (v - y) / 3.0)
}
};
let shape = mesh_text(1, 1, &at, &handle, Some([100.0, 95.0, 70.0, 95.0, 130.0, 95.0]));
let mut bp = kurbo::BezPath::new();
bp.move_to((40.0, 20.0));
bp.curve_to((60.0, 12.0), (70.0, 5.0), (100.0, 5.0));
bp.curve_to((130.0, 5.0), (140.0, 12.0), (160.0, 20.0));
bp.curve_to((160.0, 40.0), (160.0, 60.0), (160.0, 80.0));
bp.curve_to((120.0, 80.0), (80.0, 80.0), (40.0, 80.0));
bp.curve_to((40.0, 60.0), (40.0, 40.0), (40.0, 20.0));
bp.close_path();
let want = EnvelopeKind::TopObject { path: vectorcraft_geom::PathData::from_bezpath(&bp) };
let r = read_ok(&layer(&envelope_of(&shape, ENVELOPE_KEYS, &envelope_content(), 3, &drawn_content(&want))));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
let (content, kind, ..) = live_envelope(&a[0]);
let EnvelopeKind::TopObject { path } = kind else { panic!("{kind:?}") };
let anchors: Vec<Point> = path.subpaths.iter().flat_map(|s| s.anchors.iter().map(|a| a.p)).collect();
for p in [(40.0, 20.0), (100.0, 5.0), (160.0, 20.0), (160.0, 80.0), (40.0, 80.0)] {
assert!(anchors.iter().any(|q| (*q - Point::from(p)).hypot() < 1e-6), "{p:?} not in {anchors:?}");
}
// The curve through the anchor: its handles there run level.
let mid = path.subpaths.iter().flat_map(|s| s.anchors.iter()).find(|a| (a.p - Point::new(100.0, 5.0)).hypot() < 1e-6).unwrap();
assert!((mid.h_in.y - 5.0).abs() < 1e-6 && (mid.h_out.y - 5.0).abs() < 1e-6, "{mid:?}");
assert_eq!(content.len(), 1, "{content:#?}");
assert_eq!(fill(&content[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
/// Envelope Options come from the options word, Fidelity from its own key.
#[test]
fn an_envelope_reads_its_options_and_fidelity() {
use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions, PreserveShape};
let shape = straight_mesh(1, 1, (120.0, 60.0));
// A flat 1 × 1 mesh over the box draws the box as it is.
let drawn = drawn_content(&EnvelopeKind::Mesh {
rows: 1,
cols: 1,
points: vec![Point::new(40.0, 20.0), Point::new(160.0, 20.0), Point::new(40.0, 80.0), Point::new(160.0, 80.0)],
handles: vec![],
});
for (word, fidelity, options) in [
(
65536,
80,
EnvelopeOptions {
anti_alias: false,
preserve_shape: PreserveShape::Transparency,
distort_appearance: false,
distort_linear_gradients: false,
distort_pattern_fills: false,
},
),
(
0x20007,
100,
EnvelopeOptions {
anti_alias: true,
preserve_shape: PreserveShape::ClippingMask,
distort_appearance: true,
distort_linear_gradients: true,
distort_pattern_fills: true,
},
),
(
0x20002,
0,
EnvelopeOptions {
anti_alias: true,
preserve_shape: PreserveShape::ClippingMask,
distort_appearance: false,
distort_linear_gradients: false,
distort_pattern_fills: true,
},
),
] {
let keys = format!("{word} /Int (AIDeformOptionsKey) ,\n{fidelity} /Int (AIDeformFidelityKey) ,\n0 /Bool (AIDeformEditModeKey) ,\n");
let r = read_ok(&layer(&envelope_of(&shape, &keys, &envelope_content(), 2, &drawn)));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{word}: {a:#?}");
let (_, _, got_fidelity, _, got) = live_envelope(&a[0]);
assert_eq!((got_fidelity, got), (f64::from(fidelity), options), "{word}");
}
}
/// An envelope's contents keep their groups: a group of two rectangles stays that group.
#[test]
fn an_envelope_s_contents_keep_their_groups() {
use vectorcraft_doc::live::EnvelopeKind;
let group = format!(
"u\n{RED}{}f\n{}f\nU\n/ArtDictionary :\n(Pair) /String (AIArtName) ,\n;\n",
rect_path(40.0, 20.0, 90.0, 80.0),
rect_path(110.0, 20.0, 160.0, 80.0)
);
let mut drawn = RED.to_string();
drawn += &format!("{}f\n{}f\n", rect_path(40.0, 20.0, 90.0, 80.0), rect_path(110.0, 20.0, 160.0, 80.0));
let r = read_ok(&layer(&envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), ENVELOPE_KEYS, &group, 2, &drawn)));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
let (content, kind, ..) = live_envelope(&a[0]);
assert!(matches!(kind, EnvelopeKind::Mesh { rows: 1, cols: 1, .. }), "{kind:?}");
assert_eq!(content.len(), 1, "{content:#?}");
assert_eq!(content[0].name.as_deref(), Some("Pair"));
let kids = content[0].children().unwrap();
assert_eq!(kids.len(), 2, "{kids:#?}");
assert!(kids.iter().all(|k| matches!(k.kind, NodeKind::Path { .. })), "{kids:#?}");
}
/// While an envelope's contents are edited, the contents come first and the mesh is in the
/// dictionary: the envelope opens live all the same, in Edit Contents.
#[test]
fn an_envelope_whose_contents_are_edited_opens_live_in_edit_contents() {
use vectorcraft_doc::live::EnvelopeKind;
let at = |(i, j): (usize, usize)| (40.0 + i as f64 * 120.0, 20.0 + j as f64 * 60.0);
let handle = |p: (usize, usize), q: (usize, usize)| match (p, q) {
((0, 1), (1, 1)) => (70.0, 95.0),
((1, 1), (0, 1)) => (130.0, 95.0),
_ => {
let ((x, y), (u, v)) = (at(p), at(q));
(x + (u - x) / 3.0, y + (v - y) / 3.0)
}
};
let mesh = mesh_text(1, 1, &at, &handle, None);
let mut bp = kurbo::BezPath::new();
bp.move_to((40.0, 20.0));
bp.curve_to((70.0, 5.0), (130.0, 5.0), (160.0, 20.0));
bp.curve_to((160.0, 40.0), (160.0, 60.0), (160.0, 80.0));
bp.curve_to((120.0, 80.0), (80.0, 80.0), (40.0, 80.0));
bp.curve_to((40.0, 60.0), (40.0, 40.0), (40.0, 20.0));
bp.close_path();
let want = EnvelopeKind::TopObject { path: vectorcraft_geom::PathData::from_bezpath(&bp) };
let keys = ENVELOPE_KEYS.replace("0 /Bool (AIDeformEditModeKey)", "1 /Bool (AIDeformEditModeKey)");
let body = envelope_parts(&envelope_content(), &keys, &format!("0 g\n{mesh}"), 3, &drawn_content(&want));
let r = read_ok(&layer(&body));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
let (content, kind, _, editing, _) = live_envelope(&a[0]);
assert!(editing);
assert!(matches!(kind, EnvelopeKind::TopObject { .. }), "{kind:?}");
assert_eq!(content.len(), 1, "{content:#?}");
assert!(matches!(content[0].kind, NodeKind::Path { .. }), "{content:#?}");
assert_eq!(fill(&content[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
}
/// An envelope that doesn't read (a warp style number past the list, a mesh envelope whose shape
/// isn't a mesh, one whose plugin never closes it) keeps its drawn art, with one note; the art
/// round it is read as ever.
#[test]
fn an_envelope_that_doesnt_read_keeps_its_drawn_art() {
let blue = "0 0 1 1 0 0 1 Xa\n";
let drawn = format!("{blue}{}f\n", rect_path(40.0, 20.0, 160.0, 80.0));
let mesh = straight_mesh(1, 1, (120.0, 60.0));
let cases = [
envelope_of(&mesh, &warp_keys(99, 0.5, 0.0, 0.0, 0), &envelope_content(), 1, &drawn),
envelope_of(&format!("{}f\n", rect_path(40.0, 20.0, 160.0, 80.0)), ENVELOPE_KEYS, &envelope_content(), 2, &drawn),
envelope_of(&mesh, ENVELOPE_KEYS, &envelope_content(), 7, &drawn),
envelope_of(&mesh, &ENVELOPE_KEYS.replace("131073", "131081"), &envelope_content(), 2, &drawn),
envelope_of(&mesh, ENVELOPE_KEYS, &envelope_content(), 2, &drawn).replace("(Adobe Deform Plugin) 1 0 0 XP\n", ""),
];
for case in cases {
let body = format!("{RED}{}f\n{case}{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0), rect_path(190.0, 0.0, 200.0, 10.0));
let r = read_ok(&layer(&body));
let a = art(&r.doc);
assert_eq!(a.len(), 3, "{case}\n{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Path { .. }) && matches!(a[2].kind, NodeKind::Path { .. }), "{a:#?}");
let NodeKind::Group { children, .. } = &a[1].kind else { panic!("{a:#?}") };
assert_eq!(fill(&children[0]).color(), Some(Color::rgb(0.0, 0.0, 1.0)), "{case}");
assert_eq!(r.warnings, [super::envelope::DRAWN], "{case}");
}
}
/// An envelope whose contents don't come out as its drawn art (its warp bends them another way)
/// keeps the drawn art.
#[test]
fn an_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art() {
use vectorcraft_doc::live::EnvelopeKind;
let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true };
let body = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(1, 0.5, 0.0, 0.0, 0), &envelope_content(), 1, &drawn_content(&other));
let r = read_ok(&layer(&body));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Group { .. }), "{a:#?}");
assert_eq!(r.warnings, [super::envelope::DRAWN]);
}
/// An envelope that comes out close to its drawn art, but with more of the page differing than the
/// page check can take from the live envelopes (its drawn art 2 pt to the side on a 200 × 100
/// page), keeps the drawn art, so the file still opens from its layers.
#[test]
fn an_envelope_that_differs_from_its_drawn_art_over_the_page_keeps_it() {
let shifted = format!("{RED}{}f\n", rect_path(42.0, 20.0, 162.0, 80.0));
let body = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), ENVELOPE_KEYS, &envelope_content(), 2, &shifted);
let r = read_ok(&layer(&body));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Group { .. }), "{a:#?}");
assert_eq!(r.warnings, [super::envelope::DRAWN]);
}
/// An envelope whose drawn art holds an object the reader can't read keeps that drawn art, which
/// the page's art replaces.
#[test]
fn an_envelope_whose_drawn_art_holds_an_unread_object_keeps_it() {
let flat = vectorcraft_doc::live::EnvelopeKind::Mesh {
rows: 1,
cols: 1,
points: vec![Point::new(40.0, 20.0), Point::new(160.0, 20.0), Point::new(40.0, 80.0), Point::new(160.0, 80.0)],
handles: vec![],
};
let drawn = format!("{}frobnicate\n", drawn_content(&flat));
let r = read_ok(&layer(&envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), ENVELOPE_KEYS, &envelope_content(), 2, &drawn)));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Group { .. }), "{a:#?}");
assert_eq!(r.unread.iter().map(|(id, _)| *id).collect::<Vec<_>>(), [a[0].id]);
assert_eq!(r.warnings, [super::envelope::DRAWN]);
}
/// A live envelope takes its drawn art's own properties: its name here.
#[test]
fn a_live_envelope_keeps_its_drawn_art_s_name() {
let flat = vectorcraft_doc::live::EnvelopeKind::Mesh {
rows: 1,
cols: 1,
points: vec![Point::new(40.0, 20.0), Point::new(160.0, 20.0), Point::new(40.0, 80.0), Point::new(160.0, 80.0)],
handles: vec![],
};
let body = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), ENVELOPE_KEYS, &envelope_content(), 2, &drawn_content(&flat))
.replace("U\n9 () XW\n(Adobe Deform Plugin)", "U\n/ArtDictionary :\n(Bent) /String (AIArtName) ,\n;\n9 () XW\n(Adobe Deform Plugin)");
let r = read_ok(&layer(&body));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
live_envelope(&a[0]);
assert_eq!(a[0].name.as_deref(), Some("Bent"));
}
/// The same envelope beside the page opens live: what differs off the page isn't counted.
#[test]
fn an_envelope_off_the_page_opens_live_when_it_draws_close_to_its_drawn_art() {
let at = |(i, j): (usize, usize)| (300.0 + i as f64 * 120.0, 20.0 + j as f64 * 60.0);
let third = |p: (usize, usize), q: (usize, usize)| {
let ((x, y), (u, v)) = (at(p), at(q));
(x + (u - x) / 3.0, y + (v - y) / 3.0)
};
let content = format!("{RED}{}f\n", rect_path(300.0, 20.0, 420.0, 80.0));
let shifted = format!("{RED}{}f\n", rect_path(302.0, 20.0, 422.0, 80.0));
let r = read_ok(&layer(&envelope_of(&mesh_text(1, 1, &at, &third, None), ENVELOPE_KEYS, &content, 2, &shifted)));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
live_envelope(&a[0]);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
#[test]
fn a_gradient_mesh_reads_as_a_patch() {
let corner = |rgb: &str, x: i32, y: i32, next: (i32, i32), prev: (i32, i32)| {
@@ -2133,6 +2615,122 @@ fn stroked_path_guides_from_the_star_operator() {
assert!(matches!(n.appearance.items[0], AppearanceItem::Stroke(_)));
}
/// [`stream`] with a template box centred on (306.5, 395.5): the canvas runs 8,191.5 points each
/// way from there.
fn canvas_stream(body: &str) -> String {
stream(body).replacen("%%EndComments", "%AI3_TemplateBox: 306.5 395.5 306.5 395.5\n%%EndComments", 1)
}
/// The straight guide path from (`x0`, `y0`) to (`x1`, `y1`), `(op) *`.
fn guide_line(x0: f64, y0: f64, x1: f64, y1: f64, op: &str) -> String {
format!("{x0} {y0} m\n{x1} {y1} L\n({op}) *\n")
}
/// #779: a straight `(N) *` guide path across the whole canvas, directly on a layer or sublayer, is
/// a ruler guide on that layer, at its place in the document; it leaves no path behind.
#[test]
fn canvas_wide_guide_paths_are_ruler_guides_of_their_layer() {
let body = format!(
"{}{}{RED}{}f\n%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(Sub) Ln\n{}LB\n%AI5_EndLayer--\n",
guide_line(-7885.0, 40.0, 8498.0, 40.0, "N"),
guide_line(25.0, 8587.0, 25.0, -7796.0, "N"),
rect_path(0.0, 0.0, 10.0, 10.0),
guide_line(-7885.0, 90.0, 8498.0, 90.0, "N"),
);
let doc = read(canvas_stream(&layer(&body)).as_bytes()).unwrap().doc;
let top = &doc.layers[0];
let kids = top.children().unwrap();
assert_eq!(kids.len(), 2, "the square and the sublayer");
assert!(matches!(kids[0].kind, NodeKind::Path { guide: false, .. }));
let sub = &kids[1];
assert!(sub.children().unwrap().is_empty());
let want = [
Guide { vertical: false, pos: 60.0, artboard: None, layer: Some(top.id) },
Guide { vertical: true, pos: 25.0, artboard: None, layer: Some(top.id) },
Guide { vertical: false, pos: 10.0, artboard: None, layer: Some(sub.id) },
];
assert_eq!(doc.guides, want);
}
/// #779: a straight `(N) *` guide path from edge to edge of one artboard is a ruler guide of that
/// artboard.
#[test]
fn artboard_wide_guide_paths_are_ruler_guides_of_their_artboard() {
// The crop marks' artboard, 0 0 200 100.
let doc = read(canvas_stream(&layer(&guide_line(0.0, 30.0, 200.0, 30.0, "N"))).as_bytes()).unwrap().doc;
assert_eq!(doc.guides, [Guide { vertical: false, pos: 70.0, artboard: Some(1), layer: Some(doc.layers[0].id) }]);
assert!(art(&doc).is_empty());
// The second of two artboards: art space x 400 to 600, y -150 to 0.
let boards = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\
%_/Dictionary :\n%_0 200 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_300 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\
%_/Dictionary :\n%_400 0 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_600 -150 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\
%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n";
let doc = read_ok(&format!("{boards}{}", layer(&guide_line(500.0, 0.0, 500.0, -150.0, "N")))).doc;
assert_eq!(doc.artboards[1].id, 2);
assert_eq!(doc.guides, [Guide { vertical: true, pos: 500.0, artboard: Some(2), layer: Some(doc.layers[0].id) }]);
}
/// Guide paths that aren't ruler guides stay guide paths: `(S) *` ones (guides made from objects),
/// even across the whole canvas or an artboard, short ones, slanted ones, and ones in a group.
#[test]
fn other_guide_paths_stay_guide_paths() {
let cases = [
guide_line(-7885.0, 40.0, 8498.0, 40.0, "S"),
guide_line(20.0, 40.0, 80.0, 40.0, "S"),
guide_line(20.0, 40.0, 80.0, 40.0, "N"),
guide_line(0.0, 30.0, 200.0, 30.0, "S"),
guide_line(-7885.0, -7796.0, 8498.0, 8587.0, "N"),
format!("u\n{}U\n", guide_line(-7885.0, 40.0, 8498.0, 40.0, "N")),
format!("1 Xw\n{}0 Xw\n", guide_line(-7885.0, 40.0, 8498.0, 40.0, "N")),
"-7885 40 m\n306 40 L\n8498 40 L\n(N) *\n".to_string(),
];
for body in cases {
let doc = read(canvas_stream(&layer(&body)).as_bytes()).unwrap().doc;
assert!(doc.guides.is_empty(), "{body}");
assert_eq!(art(&doc).len(), 1, "{body}");
}
}
/// The layer whose `Lb` has `ops` as its options (the first one in the file).
fn layer_with(ops: &str) -> Node {
let doc = read_ok(&format!("%AI5_BeginLayer\n{ops} Lb\n(L) Ln\nLB\n%AI5_EndLayer--\n")).doc;
Node::clone(&doc.layers[0])
}
/// #779: the template flag four places after the colour index makes a template layer; locking,
/// printing and dimming keep their own flags. In thirteen and fourteen operands the flag before
/// the colour index and the last one change nothing.
#[test]
fn the_template_flag_of_a_layer() {
let l = layer_with("1 1 0 0 1 0 0 4 255 79 255 1 50 0");
let NodeKind::Layer { template, printable, dim_images, color, .. } = l.kind else { panic!() };
assert!(template && !printable && l.locked);
assert_eq!((dim_images, color), (Some(50), LayerColor::Preset(4)));
for ops in ["1 1 1 1 1 0 1 5 1 2 3 1 30 0", "1 1 1 1 1 0 0 5 1 2 3 1 30 1", "1 1 1 1 1 0 1 5 1 2 3 1 30"] {
let l = layer_with(ops);
let NodeKind::Layer { template, printable, dim_images, color, .. } = l.kind else { panic!() };
assert!(template && printable && !l.locked, "{ops}");
assert_eq!((dim_images, color), (Some(30), LayerColor::Preset(5)), "{ops}");
}
let l = layer_with("1 1 1 1 0 0 1 0 79 128 255 0 50 0");
assert!(matches!(l.kind, NodeKind::Layer { template: false, .. }));
}
/// The colour index is one place earlier in eleven and twelve operands, as in ten, and the twelfth
/// is the dimming percentage; with nine it is one place earlier still.
#[test]
fn layer_options_of_nine_to_twelve_operands() {
let options = |l: Node| match l.kind {
NodeKind::Layer { template, dim_images, color, .. } => (template, dim_images, color),
_ => panic!(),
};
assert_eq!(options(layer_with("1 1 1 1 1 0 3 1 2 3 1")), (true, Some(50), LayerColor::Preset(3)));
assert_eq!(options(layer_with("1 1 1 1 1 0 3 1 2 3 0 25")), (false, Some(25), LayerColor::Preset(3)));
assert_eq!(options(layer_with("1 1 1 1 1 0 -1 10 20 30 1 25")), (true, Some(25), LayerColor::Custom([10, 20, 30])));
assert_eq!(options(layer_with("1 1 1 1 0 0 3 1 2 3")), (false, None, LayerColor::Preset(3)));
assert_eq!(options(layer_with("1 1 1 1 0 2 1 2 3")), (false, None, LayerColor::Preset(2)));
}
/// A blend's record as the editing data writes it after `(Adobe Path Blends) 1 0 n XP`: `DCBA`,
/// version 1, then (as far as the reader reads it) the number of keys at word 10 and a record of
/// six words per key starting with its id. Hand-written; the other words are zero.
@@ -2362,3 +2960,145 @@ fn crop_marks_after_the_layers_are_not_art() {
assert_eq!(art(&r.doc).len(), 1);
assert_eq!(r.doc.layers.len(), 1);
}
/// The name of an Objects on Path record.
const ALIGN_ON_PATH: &str = "Adobe Align On Path"; // brand-ok: the record's name in the files
/// The ids of an Objects on Path group's path and its four objects.
const ON_PATH_IDS: [&str; 5] = [
"0a6d1e52-0000-4000-8000-000000000001",
"0a6d1e52-0000-4000-8000-000000000002",
"0a6d1e52-0000-4000-8000-000000000003",
"0a6d1e52-0000-4000-8000-000000000004",
"0a6d1e52-0000-4000-8000-000000000005",
];
const OPEN_PATH: &str = "20 20 m\n100 80 L\n180 20 L\nS\n";
const CLOSED_PATH: &str =
"40 50 m\n40 66.57 66.86 80 100 80 c\n133.14 80 160 66.57 160 50 c\n160 33.43 133.14 20 100 20 c\n66.86 20 40 33.43 40 50 c\ns\n";
/// An Objects on Path group: the stroked `path` with the id `ids[0]`, then a 10 × 6 red square for
/// each other id, 50 pt apart from x 20, whose frame is turned `angle` radians.
fn on_path_group(path: &str, ids: &[&str], angle: f64) -> String {
use vectorcraft_testkit::ai::on_path_member;
let (first, rest) = ids.split_first().unwrap();
let mut group = format!("0 Ae\nu\n/ConstraintGroup :\n128 /GroupType ,\n;\n0 0 1 0 0 0 1 XA\n2 w\n{path}{}", on_path_member(first, None, 0.0));
for (i, id) in rest.iter().enumerate() {
let x = 20.0 + 50.0 * i as f64;
group += &format!("{RED}{}f\n{}", rect_path(x, 40.0, x + 10.0, 46.0), on_path_member(id, Some(i), angle));
}
group + "U\n"
}
/// The group the layer of `body` holds first, and its Objects on Path record.
fn on_path(body: &str) -> (Arc<Node>, Option<vectorcraft_doc::objects_on_path::ObjectsOnPath>) {
let doc = read_ok(body).doc;
let g = art(&doc).first().cloned().unwrap();
let rec = g.extras().objects_on_path.as_deref().cloned();
(g, rec)
}
/// An Objects on Path group opens as one, with its path, its objects in their order along the path,
/// Pivot and Rotate, and its art where the file draws it; the same art without the record opens as a
/// plain group.
#[test]
fn an_objects_on_path_group_opens_with_its_path_objects_and_options() {
use vectorcraft_testkit::ai::{on_path_options, on_path_record};
let group = on_path_group(OPEN_PATH, &ON_PATH_IDS, 0.5);
let record = on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &on_path_options(4, 0, std::f64::consts::FRAC_PI_6, ""));
let (g, rec) = on_path(&format!("{record}{}", layer(&group)));
let rec = rec.expect("an Objects on Path group");
let kids = g.children().unwrap();
assert_eq!(kids.len(), 5);
let NodeKind::Path { path, .. } = &kids.iter().find(|c| c.id == rec.path).unwrap().kind else { panic!("the path") };
assert!(path.subpaths.first().is_some_and(|s| !s.closed), "the open path");
let child = |id| kids.iter().find(|c| c.id == id).unwrap();
let xs: Vec<f64> = rec.members.iter().map(|m| child(m.id).geometric_bounds().unwrap().x0).collect();
assert_eq!(xs, [20.0, 70.0, 120.0, 170.0], "the objects in the file's order");
for m in &rec.members {
let center = child(m.id).geometric_bounds().unwrap().center();
assert_eq!((m.angle, m.frame), (-0.5, kurbo::Affine::rotate_about(-0.5, center)), "the frame turned counterclockwise");
}
assert!((rec.rotate - 30.0).abs() < 1e-9, "{}", rec.rotate);
assert_eq!(rec.pivot, 0);
let (plain, none) = on_path(&layer(&group));
assert!(none.is_none());
let kinds = |g: &Node| g.children().unwrap().iter().map(|c| c.kind.clone()).collect::<Vec<_>>();
assert_eq!(kinds(&g), kinds(&plain), "the art stays where it is drawn");
}
/// A group on a closed path opens as an Objects on Path group too.
#[test]
fn an_objects_on_path_group_on_a_closed_path_opens_live() {
use vectorcraft_testkit::ai::{on_path_options, on_path_record};
let record = on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &on_path_options(4, 4, 0.0, ""));
let (g, rec) = on_path(&format!("{record}{}", layer(&on_path_group(CLOSED_PATH, &ON_PATH_IDS, 0.0))));
let rec = rec.expect("an Objects on Path group");
let NodeKind::Path { path, .. } = &g.children().unwrap().iter().find(|c| c.id == rec.path).unwrap().kind else { panic!("the path") };
assert!(path.subpaths.first().is_some_and(|s| s.closed), "the closed path");
assert_eq!((rec.members.len(), rec.pivot, rec.rotate), (4, 4, 0.0));
}
/// A record the group can't be read from, or with options an Objects on Path group doesn't hold,
/// leaves the group a plain group with its art.
#[test]
fn an_objects_on_path_record_that_doesnt_fit_leaves_a_plain_group() {
use vectorcraft_testkit::ai::{on_path_options, on_path_record};
let [p, a, b, c, d] = ON_PATH_IDS;
let options = |more: &str| on_path_options(4, 4, 0.0, more);
let good = options("");
let group = on_path_group(OPEN_PATH, &ON_PATH_IDS, 0.0);
assert!(on_path(&format!("{}{}", on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good), layer(&group))).1.is_some());
let cases: Vec<(&str, String, String)> = vec![
("the path's id is missing", on_path_record(ALIGN_ON_PATH, &["0a6d1e52-ffff", a, b, c, d], &good), group.clone()),
("an object's id is missing", on_path_record(ALIGN_ON_PATH, &[p, a, b, c], &good), group.clone()),
("an id twice", on_path_record(ALIGN_ON_PATH, &[p, a, b, b, d], &good), group.clone()),
("objects out of order", on_path_record(ALIGN_ON_PATH, &[p, b, a, c, d], &good), group.clone()),
("a spacing", on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &options("%_56.5 /Real (Spacing) ,\n")), group.clone()),
("an option this reader doesn't know", on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &options("%_1 /Bool (Flip) ,\n")), group.clone()),
(
"uneven spacing",
on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good.replace("1 /Bool (IsEquiSpaced)", "0 /Bool (IsEquiSpaced)")),
group.clone(),
),
(
"a later start",
on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good.replace("0 /Real (StartRangeFraction)", "0.25 /Real (StartRangeFraction)")),
group.clone(),
),
("a pivot past 8", on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &on_path_options(4, 9, 0.0, "")), group.clone()),
("another constraint", on_path_record("Some Other Constraint", &ON_PATH_IDS, &good), group.clone()),
(
"another version",
on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good).replace("%_1 /Int (Constraint Verison)", "%_2 /Int (Constraint Verison)"),
group.clone(),
),
("an object without its angle", on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good), group.replace("%_(0) /String (BBAccumRotation) ,\n", "")),
(
"another child in the group",
on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good),
group.replace("U\n", &format!("{}f\nU\n", rect_path(0.0, 0.0, 5.0, 5.0))),
),
("a path without its key", on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good), group.replace("%_1 /Bool (KeyPath) ,\n", "")),
];
for (why, record, group) in cases {
let (g, rec) = on_path(&format!("{record}{}", layer(&group)));
assert!(rec.is_none(), "{why}");
assert!(matches!(g.kind, NodeKind::Group { clip: false, .. }), "{why}");
assert!(g.children().unwrap().len() >= 5, "{why}: the art is kept");
}
}
/// The copy of the drawn group that follows it as versioned content is skipped: the group opens once,
/// live.
#[test]
fn an_objects_on_path_group_in_versioned_content_opens_once() {
use vectorcraft_testkit::ai::{on_path_options, on_path_record};
let record = on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &on_path_options(4, 4, 0.0, ""));
let group = on_path_group(OPEN_PATH, &ON_PATH_IDS, 0.0);
let copy = group.replace("/ConstraintGroup :\n128 /GroupType ,\n;\n", "");
let versioned = format!("%AI17_Begin_Content_if_version_gt:24 14\n{group}%AI17_Alternate_Content\n{copy}%AI17_End_Versioned_Content\n");
let doc = read_ok(&format!("{record}{}", layer(&versioned))).doc;
let kids = art(&doc);
assert_eq!(kids.len(), 1);
assert_eq!(kids[0].children().unwrap().len(), 5);
assert!(kids[0].extras().objects_on_path.is_some());
}
+1 -1
View File
@@ -40,7 +40,7 @@ use super::ai::{self, Structure, slot_of};
use super::ate::{Story, Texts};
mod build;
mod compare;
pub(super) mod compare;
mod layers;
mod plan;
mod slots;
+56 -5
View File
@@ -71,6 +71,7 @@ pub(super) fn build_from(
slot_names,
restyled,
placed_elsewhere,
envelopes_drawn,
warnings,
} = structure;
// Editing data in the units of the page's user space ([`in_units`]) is compared with the page
@@ -190,6 +191,7 @@ pub(super) fn build_from(
if let Some(a) = doc.artboards.first_mut() {
a.rect = rect;
}
drop_guides_off_boards(&mut doc);
}
frame = Some(Frame { page: visible, page_rect, rect, whole, pages: page_moves });
}
@@ -310,10 +312,10 @@ pub(super) fn build_from(
doc = other;
notes.extend(unseen_note(unseen).into_iter().chain(note));
}
_ if trust => {
notes.extend(unseen_note(unseen).into_iter().chain([format!("{why}: they come from its editing data all the same")]))
_ => {
let more = drawn_or_trusted(&frame, &mut view, &mut doc, &envelopes_drawn, &notes, unseen, trust, why, reflow)?;
notes.extend(more);
}
_ => return Err(why),
},
}
}
@@ -754,6 +756,46 @@ fn slot_content(
Ok((plan(infos, stories, made, template, to_doc, pages, notes), false))
}
/// The layers `doc` don't draw as the page (`why`), `unseen` text objects of them taken out: the
/// notes to add when `doc` with each live envelope `drawn` lists replaced by the drawn art it took
/// the place of (see [`ai::Structure::envelopes_drawn`]) draws as the page, `doc` then being that;
/// else, with `trust`, the notes for `doc` as it is. An error, `why`, otherwise. The envelope note
/// is added unless `notes` has it.
#[allow(clippy::too_many_arguments)]
fn drawn_or_trusted(
frame: &Frame<'_>,
view: &mut PageView,
doc: &mut Document,
drawn: &[(NodeId, Node)],
notes: &[String],
unseen: usize,
trust: bool,
why: String,
reflow: bool,
) -> Result<Vec<String>, String> {
let unseen_note = |n: usize| (n > 0).then(|| format!("{n} {UNSEEN_TEXT}"));
let mut put_back = doc.clone();
let mut any = false;
for (id, art) in drawn {
if let Some(n) = put_back.node_mut(*id) {
*n = art.clone();
any = true;
}
}
if any {
let more = prune_unseen(frame, view, &mut put_back, reflow);
if let Ok(note) = compare(frame, view, &put_back, reflow) {
*doc = put_back;
let envelope = (!notes.iter().any(|n| n == ai::ENVELOPE_DRAWN)).then(|| ai::ENVELOPE_DRAWN.to_string());
return Ok(envelope.into_iter().chain(unseen_note(unseen + more)).chain(note).collect());
}
}
if trust {
return Ok(unseen_note(unseen).into_iter().chain([format!("{why}: they come from its editing data all the same")]).collect());
}
Err(why)
}
/// `doc` with the placed files `placed` lists at their other placement (see
/// [`ai::Structure::placed_elsewhere`]): each image's transform followed by its move. `None` when
/// no listed image survives replacement.
@@ -933,8 +975,14 @@ fn page_boards<'a>(boards: &'a [vectorcraft_doc::Artboard], first: usize, pages:
((first > 0 || pages.len() == boards.len()) && shown.iter().zip(pages).all(same)).then_some(shown)
}
/// Draw `doc`'s layers and artboards `k` times as large, from the origin, with their strokes,
/// effects and pattern tiles.
/// Drops the guides of artboards `doc` no longer has: an artboard guide goes with its artboard.
fn drop_guides_off_boards(doc: &mut Document) {
let kept: Vec<u32> = doc.artboards.iter().map(|a| a.id).collect();
doc.guides.retain(|g| g.artboard.is_none_or(|id| kept.contains(&id)));
}
/// Draw `doc`'s layers, artboards and guides `k` times as large, from the origin, with their
/// strokes, effects and pattern tiles.
fn scale_document(doc: &mut Document, k: f64) {
let a = Affine::scale(k);
let scaling = Scaling { strokes: true, effects: Some(vectorcraft_effects::scale_effect), patterns: true, ..Default::default() };
@@ -944,6 +992,9 @@ fn scale_document(doc: &mut Document, k: f64) {
for board in &mut doc.artboards {
board.rect = a.transform_rect_bbox(board.rect);
}
for guide in &mut doc.guides {
guide.pos *= k;
}
}
/// Marks (in a node's name, until [`prune_unseen`] has looked) the text objects made from the file's
+5 -5
View File
@@ -47,7 +47,7 @@ const WHITE: u32 = 8;
/// Do two pixels look different: in lightness, or one inked (as a light yellow is) where the other
/// is white?
pub(super) fn differ(p: &[u8; 4], q: &[u8; 4]) -> bool {
pub(in crate::import) fn differ(p: &[u8; 4], q: &[u8; 4]) -> bool {
let (p, q) = (on_white(p), on_white(q));
let (a, b) = (ink(p), ink(q));
lightness(p).abs_diff(lightness(q)) > SAME_PIXEL || (a.min(b) <= WHITE && a.abs_diff(b) > SAME_PIXEL)
@@ -59,7 +59,7 @@ const SAME_COLOUR: u32 = 64;
/// Are two pixels different colours ([`differ`] also takes a red and a dark red of about the same
/// lightness to be alike)?
pub(super) fn differ_in_colour(p: &[u8; 4], q: &[u8; 4]) -> bool {
pub(in crate::import) fn differ_in_colour(p: &[u8; 4], q: &[u8; 4]) -> bool {
let (p, q) = (on_white(p), on_white(q));
p.iter().zip(q).any(|(a, b)| a.abs_diff(b) > SAME_COLOUR)
}
@@ -84,7 +84,7 @@ impl Difference {
}
/// The scale the comparisons draw a region at: about 400 pixels along its longer side.
fn compare_scale(r: Rect) -> f64 {
pub(in crate::import) fn compare_scale(r: Rect) -> f64 {
(400.0 / r.width().max(r.height()).max(1.0)).min(4.0)
}
@@ -362,14 +362,14 @@ const MARKED: f64 = 0.05;
/// More of the page than this differing between the layers' art and the page's own, and the
/// layers aren't used.
pub(super) const MAX_DIFFERENCE: f64 = 0.05;
pub(in crate::import) const MAX_DIFFERENCE: f64 = 0.05;
/// More than this and the import says so.
const NOTABLE_DIFFERENCE: f64 = 0.002;
/// More of the page than this where one draws an object that the other doesn't (see
/// [`Difference`]), and the layers aren't used: about a centimetre square on a letter page.
pub(super) const MAX_SOLID_DIFFERENCE: f64 = 0.001;
pub(in crate::import) const MAX_SOLID_DIFFERENCE: f64 = 0.001;
/// How far (points) the art the layers print may reach past a page that holds it all.
const PAST_THE_PAGE: f64 = 1.0;
+74
View File
@@ -1135,6 +1135,46 @@ fn editing_data_in_the_units_of_a_user_unit_page_comes_in_at_the_pages_size() {
assert!(near(first(&d, 0), [100.0, 860.0, 140.0, 900.0]), "{:?}", first(&d, 0));
}
/// #779: a `.ai`'s ruler guides come in as guides of their layers, at the page's size when the
/// editing data is in the units of a `UserUnit` page.
#[test]
fn an_ai_files_ruler_guides_come_in_on_their_layers() {
// Across the canvas (16,383 points around the template box's centre, 50 50) at y 40, and
// across the artboard at x 20.
let guides = "-8141.5 40 m\n8241.5 40 L\n(N) *\n20 0 m\n20 100 L\n(N) *\n";
let art = native(&layer("Jig", true, &format!("{}\n{guides}", square(10, 10))));
let art = art.replacen("%AI5_NumLayers", "%AI3_TemplateBox: 50 50 50 50\n%AI5_NumLayers", 1);
for k in [1.0, 10.0] {
let page = in_points(pdf_part(10.0, 86.0), k);
let (d, w, read) = crate::layered_ai(&private(&art), page, vec![], false, false, k);
assert!(read, "{w:?}");
let jig = d.layers[0].id;
let want = [
vectorcraft_doc::Guide { vertical: false, pos: 60.0 * k, artboard: None, layer: Some(jig) },
vectorcraft_doc::Guide { vertical: true, pos: 20.0 * k, artboard: Some(d.artboards[0].id), layer: Some(jig) },
];
assert_eq!(d.guides, want);
assert_eq!(d.layers[0].children().map_or(0, |c| c.len()), 1, "the square alone");
}
}
/// An EPS whose page is its art's box keeps only its first artboard, and the guides of that one:
/// the ruler guides of the artboards left out go with them.
#[test]
fn an_eps_of_its_art_box_keeps_the_guides_of_its_first_artboard() {
let point = |x: u32, y: u32, n: u8| format!("%_{x} {y} /RealPointRelToROrigin\n%_ (PositionPoint{n}) ,\n");
let board = |x0: u32, x1: u32| format!("%_/Dictionary :\n{}{}%_; ,\n", point(x0, 50, 1), point(x1, 0, 2));
let array = format!("%_/Array :\n{}{}%_; (ArtboardArray) ,\n", board(0, 50), board(50, 100));
let boards = format!("%_/Document :\n%_/Dictionary :\n{array}%_; /NotRecorded ,\n%_;\n");
let guides = "25 0 m\n25 50 L\n(N) *\n75 0 m\n75 50 L\n(N) *\n";
let art = native(&format!("{boards}{}", layer("Art", true, &format!("{}\n{guides}", square(10, 10)))));
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
assert_eq!(r.document.artboards.len(), 1);
let (board, layer) = (r.document.artboards[0].id, r.document.layers[0].id);
assert_eq!(r.document.guides, [vectorcraft_doc::Guide { vertical: true, pos: 25.0, artboard: Some(board), layer: Some(layer) }]);
}
/// A PDF part read from a `UserUnit` page cropped to the art: the art's box, not the artboard, is
/// the page in the editing data's units, so the layers are read at the page's size, with their
/// artboard.
@@ -2821,6 +2861,40 @@ fn envelope_options_keep_the_source_text_hidden_under_its_outlined_appearance()
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
/// A live envelope that draws a little apart from its drawn art, in layers that already differ
/// from the page almost as much as the page check takes, would make them differ too much: its
/// drawn art is put back, so the file still opens from its layers.
#[test]
fn a_live_envelope_that_would_tip_the_page_check_keeps_its_drawn_art() {
let rect = |x0: f64, y0: f64, x1: f64, y1: f64| format!("{x0} {y0} m\n{x0} {y1} L\n{x1} {y1} L\n{x1} {y0} L\n{x0} {y0} L\n");
let hidden = |s: &str| -> String { s.lines().map(|l| format!("%_{l}\n")).collect() };
let corner = |x: f64, y: f64, nx: f64, ny: f64, px: f64, py: f64| format!("[0.8 1 {x} {y} {nx} {ny} 1 {px} {py} 1] /N X#\n");
let mesh = format!(
"/Mesh X!\n2 /Version X#\n/Cartesian /Type X#\n[1 1] /Size X#\n/Data X#\n/DeviceGray /CS X#\n[0 0] /P X#\n{}{}{}{}/E X#\n/End X!\n",
corner(20.0, 5.0, 20.0, 35.0, 40.0, 5.0),
corner(20.0, 95.0, 40.0, 95.0, 20.0, 65.0),
corner(80.0, 95.0, 80.0, 65.0, 60.0, 95.0),
corner(80.0, 5.0, 60.0, 5.0, 80.0, 35.0),
);
let red = "0 1 1 0 1 0 0 Xa\n";
let envelope = format!(
"{}%_/ArtDictionary :\n%_131073 /Int (AIDeformOptionsKey) ,\n%_50 /Int (AIDeformFidelityKey) ,\n%_0 /Bool (AIDeformEditModeKey) ,\n%_X=\n{}%_X+\n%_ (AIDeformArtKey) ,\n%_2 /Int (AIDeformStyle) ,\n%_;\n%_\nu\n{red}{}f\nU\n(Adobe Deform Plugin) 1 0 0 XP\n",
hidden(&format!("u\n{mesh}U\n")),
hidden(&format!("u\n{red}{}f\nU\n", rect(20.0, 5.0, 80.0, 95.0))),
rect(20.5, 5.0, 80.5, 95.0),
);
// Hairlines the page doesn't draw: about 4.5% of it differs before the envelope.
let lines: String = [3, 6, 9, 12, 15, 85, 88, 91, 94].iter().map(|x| format!("0 0 0 1 K\n0.5 w\n{x} 0 m\n{x} 100 L\nS\n")).collect();
let art = native(&layer("Art", true, &format!("{lines}{envelope}")));
let page = "1 0 0 setrgbcolor 20.5 5 moveto 80.5 5 lineto 80.5 95 lineto 20.5 95 lineto closepath fill";
let r = import(&eps(page, &art)).unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
let mut envelopes = 0;
r.document.walk(|n| envelopes += usize::from(matches!(n.kind, NodeKind::Envelope { .. })));
assert_eq!(envelopes, 0, "{:?}", r.warnings);
assert!(r.warnings.iter().any(|w| w == crate::import::ai::ENVELOPE_DRAWN), "{:?}", r.warnings);
}
/// An art style section with style `Comic`: a white fill offset and repeated by Transform under a
/// red fill with its own blend mode, the characters at the bottom.
fn comic_style() -> String {
+33
View File
@@ -1017,6 +1017,39 @@ fn variable_font_settings_are_warned_as_left_out() {
assert!(String::from_utf8(out.bytes).unwrap().contains("(H\\351llo \\2005\\r) Tx\n"), "still written as text");
}
/// Type whose font size measures the cap height is written at the em size it is drawn at, with
/// no warning: 14 pt measuring a 0.7 em cap height reads back as 20 pt type.
#[test]
fn a_font_height_reference_is_written_as_the_size_drawn() {
use vectorcraft_text::test_fonts::{HEIGHT_FAMILY, height_font};
vectorcraft_text::FontDb::global().add_font(height_font(true).unwrap());
let mut doc = typed();
let id = texts(&doc)[0].0.id;
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
let st = &mut t.runs[0].style;
(st.font_family, st.size) = (HEIGHT_FAMILY.into(), 14.0);
st.font_height_reference = vectorcraft_doc::FontHeightReference::CapHeight;
}
let out = write(&doc);
assert!(!out.warnings.iter().any(|w| w == TYPE_ATTRIBUTES || w == TYPE), "{:?}", out.warnings);
let imported = crate::import(&out.bytes).unwrap();
let size = texts(&imported.document)[0].1.runs[0].style.size;
assert!((size - 20.0).abs() < 1e-6, "{size}");
}
/// Characters with Auto leading are written with their paragraph's Auto Leading share of the
/// size: 18 pt type in paragraphs at 150% is written with 27 pt leading.
#[test]
fn auto_leading_is_written_at_the_paragraphs_share() {
let mut doc = typed();
let id = texts(&doc)[0].0.id;
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
t.edit_paras(None, |p| p.justification.auto_leading = 150.0);
}
let text = String::from_utf8(write(&doc).bytes).unwrap();
assert!(text.contains("\n27 0 Tl\n"), "{text}");
}
/// A legacy `.ai` paragraph has no list: type with a bulleted or numbered list is written as its
/// text, with the warning that the bullets and numbers are left out.
#[test]
+7 -2
View File
@@ -719,11 +719,15 @@ impl<'a> Writer<'a> {
// ---------- type ----------
/// Type: a text object when the format can hold it as text, else its outlines.
/// Type: a text object when the format can hold it as text, else its outlines. A run whose
/// font size measures a height other than the em box is written at the em size it is drawn
/// at ([`vectorcraft_text::FontDb::font_heights_resolved`]).
fn text(&mut self, t: &TextObject) {
if !invertible(t.xf) {
return;
}
let resolved = vectorcraft_text::FontDb::global().font_heights_resolved(t);
let t = &*resolved;
if !self.outline_text {
if writable(t) {
return self.text_object(t);
@@ -849,7 +853,8 @@ impl<'a> Writer<'a> {
ps::push_num(&mut self.body, finite(st.tracking, 0.0));
self.line(" Tt");
self.line(if st.kerning.is_none() { "1 TA" } else { "0 TA" });
ps::push_nums(&mut self.body, &[finite(st.effective_leading(), size * 1.2), finite(p.space_before, 0.0)]);
let leading = st.leading.unwrap_or_else(|| p.justification.auto_leading_for(st.size));
ps::push_nums(&mut self.body, &[finite(leading, size * 1.2), finite(p.space_before, 0.0)]);
self.line("Tl");
}
+2 -2
View File
@@ -348,8 +348,8 @@ impl Exporter<'_> {
/// Is `p` a paint PDF has no paint for when it paints a fill (`fill`) or a stroke, which
/// [`Self::area`] draws as art: a pattern swatch or a gradient drawn as pixels
/// ([`vectorcraft_color::GradientPaint::draws_as_pixels`]), but not a Perceptual one of tints
/// of one spot ink ([`Self::perceptual_spot`])?
/// ([`vectorcraft_color::GradientPaint::draws_as_pixels`]), but not a Perceptual one of spot
/// inks ([`Self::perceptual_spot`])?
pub(super) fn area_paint(&self, p: &Paint, fill: bool) -> bool {
match p {
Paint::Pattern { pattern, .. } => self.doc.pattern(pattern).is_some(),
+42 -9
View File
@@ -100,9 +100,10 @@ impl Exporter<'_> {
/// The stops of a gradient that mixes spot inks with process colors or with each other, in
/// a DeviceN space of the process inks the stops use (in CMYK order) and the spot inks (in
/// the order the stops name them), with midpoints as explicit stops (the tints and opacity
/// there are halfway). Paper white adds no ink. `None`, with a warning, when a stop is
/// written in RGB or Lab or the spot inks can't share a DeviceN space; the gradient is then
/// written in the colors of its stops.
/// there are halfway), or for a Perceptual gradient the stops on its curve
/// ([`vectorcraft_color::Gradient::ink_mixes`]). Paper white adds no ink. `None`, with a
/// warning, when a stop is written in RGB or Lab or the spot inks can't share a DeviceN space;
/// the gradient is then written in the colors of its stops.
fn device_n_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option<Vec<(f32, krilla::color::Color, f32)>> {
use krilla::color::device_n::{Color as DnColor, Colorant, DeviceNSpace, Process};
let paper = |c: &Color| c.to_rgba8(1.0)[..3] == [255; 3];
@@ -151,6 +152,17 @@ impl Exporter<'_> {
let tints = process.iter().map(|&i| q(v[i])).chain(v[4..].iter().map(|&t| q(t))).collect();
Some(DnColor::new(tints, space.clone())?.into())
};
// A Perceptual gradient: the stops on its curve, each a mix of two stops' inks.
if let Some(curve) = g.ink_mixes() {
return curve
.into_iter()
.map(|(o, i, w, opacity)| {
let next = inks.get(i + 1).unwrap_or(&inks[i]);
let mixed: Vec<f32> = inks[i].iter().zip(next).map(|(a, b)| a + (b - a) * w).collect();
Some((o, color(&mixed)?, opacity))
})
.collect();
}
let mut out = vec![];
for (i, s) in g.stops.iter().enumerate() {
out.push((s.offset, color(&inks[i])?, s.opacity));
@@ -345,13 +357,32 @@ impl Exporter<'_> {
g.stops.iter().all(|s| spot(s) == Some(ink) || paper(s)).then_some(ink)
}
/// Is `g` a Perceptual linear or radial gradient of tints of one spot ink that paints a fill
/// (`fill`) without dither or a stroke, with a curve short enough for
/// [`vectorcraft_color::Gradient::fits_ink_tints`], which [`Self::perceptual_spot_stops`]
/// writes as a Separation shading rather than an image?
/// Can every stop of `g` be inks of a DeviceN space ([`Self::device_n_stops`]): is a stop
/// linked to a spot swatch, and is every other stop paper white, unlinked, or written in CMYK
/// or gray?
fn device_n_inks_only(&self, g: &vectorcraft_color::Gradient) -> bool {
let doc = self.doc;
let cmyk_doc = doc.color_mode == vectorcraft_doc::ColorMode::Cmyk;
let spot = |s: &vectorcraft_color::GradientStop| s.swatch.as_deref().is_some_and(|n| doc.swatch(n).is_some_and(|w| w.spot));
let inks = |s: &vectorcraft_color::GradientStop| {
(s.swatch.is_none() && s.color.to_rgba8(1.0)[..3] == [255; 3])
|| matches!(self.out.color(&s.color, self.intent, cmyk_doc, self.convert), Color::Cmyk { .. } | Color::Gray { .. })
};
g.stops.iter().any(spot) && g.stops.iter().all(|s| spot(s) || inks(s))
}
/// Is `g` a Perceptual linear or radial gradient of spot inks, alone or mixed with process
/// inks, that paints a fill (`fill`) without dither or a stroke, with a curve short enough for
/// [`vectorcraft_color::Gradient::fits_ink_tints`]: one that [`Self::perceptual_spot_stops`]
/// writes as a Separation shading, or [`Self::device_n_stops`] as a DeviceN shading, rather
/// than an image?
pub(super) fn perceptual_spot(&self, g: &vectorcraft_color::GradientPaint, fill: bool) -> bool {
let gr = &g.gradient;
!gr.method.is_classic() && gr.kind != GradientKind::Freeform && !(fill && gr.dither) && self.one_spot_ink(gr).is_some() && gr.fits_ink_tints()
!gr.method.is_classic()
&& gr.kind != GradientKind::Freeform
&& !(fill && gr.dither)
&& (self.one_spot_ink(gr).is_some() || self.device_n_inks_only(gr))
&& gr.fits_ink_tints()
}
/// The stops of a Perceptual gradient of tints of one spot ink (or paper white, 0% of it) as
@@ -376,7 +407,9 @@ impl Exporter<'_> {
let doc = self.doc;
let from_stops = g.gradient.kind != GradientKind::Freeform || g.freeform.is_none();
if from_stops && g.gradient.stops.iter().any(|s| s.swatch.as_deref().is_some_and(|n| doc.swatch(n).is_some_and(|w| w.spot))) {
self.warn("spot colors in gradients exported as images (freeform, dithered or Perceptual gradients that mix inks) are written in process colors");
self.warn(
"spot colors in gradients exported as images (freeform or dithered gradients, and Perceptual ones with RGB or Lab stops or too many stops) are written in process colors",
);
}
}
+5 -1
View File
@@ -567,8 +567,12 @@ impl Rules<'_> {
}
}
/// Type: its first character style's font, colour and spacing, its paragraph's alignment.
/// Type: its first character style's font, colour and spacing, its paragraph's alignment. The
/// font size is the em size the characters are drawn at
/// ([`vectorcraft_text::FontDb::font_heights_resolved`]).
fn type_art(&mut self, n: &Node, t: &vectorcraft_doc::TextObject, art: &mut Art) {
let resolved = vectorcraft_text::FontDb::global().font_heights_resolved(t);
let t = &*resolved;
if matches!(t.kind, TextKind::OnPath { .. }) {
art.unsupported("type on a path");
}
+5 -2
View File
@@ -73,11 +73,14 @@ impl Writer<'_> {
/// instances of their symbols (a `<use>` of the symbol's def where it can be shared), all in
/// one group carrying the object's transparency. The live text skips their characters and
/// places the text after each one; type on a path with inline graphics is written as outlines
/// (a `<textPath>` couldn't leave room for the art).
/// (a `<textPath>` couldn't leave room for the art). A run whose font size measures a height
/// other than the em box is written at the em size it is drawn at
/// ([`vectorcraft_text::FontDb::font_heights_resolved`]).
pub(super) fn text_node(&mut self, n: &Node, t: &TextObject) {
let doc = self.doc;
let resolved = doc.inline_resolved(t);
let t = &*resolved;
let heights = vectorcraft_text::FontDb::global().font_heights_resolved(&resolved);
let t = &*heights;
let inline = t.runs.iter().any(|r| r.inline.is_some());
// Ruby is written as outlines with its base text: a reader's <text> can't place it. So are
// small capitals, superscript and subscript and tab leaders, which SVG text doesn't draw (#3869).
+20
View File
@@ -148,6 +148,26 @@ fn ellipses_round_fully_and_options_change_units_and_names() {
assert!(prop(&r, "width").is_none() && prop(&r, "height").is_none());
}
/// Type whose font size measures the cap height gives the em size it is drawn at: 14 pt measuring
/// a 0.7 em cap height is `font-size: 20px`, and its Auto leading is not written.
#[test]
fn type_gives_the_font_size_it_is_drawn_at() {
use vectorcraft_text::test_fonts::{HEIGHT_FAMILY, height_font};
vectorcraft_text::FontDb::global().add_font(height_font(true).unwrap());
let mut d = doc();
let st = CharStyle {
font_family: HEIGHT_FAMILY.into(),
size: 14.0,
font_height_reference: vectorcraft_doc::FontHeightReference::CapHeight,
..CharStyle::default()
};
let n = text(&mut d, "Hello", st);
let id = add(&mut d, n);
let r = one(&d, id, &CssOptions::default());
assert_eq!(prop(&r, "font-size"), Some("20px"));
assert!(prop(&r, "line-height").is_none());
}
#[test]
fn type_gives_font_properties() {
let mut d = doc();
+17
View File
@@ -176,6 +176,23 @@ fn digits_are_written_as_drawn() {
assert!(!text.contains("page 4") && !text.contains("page ٥"), "{svg}");
}
/// A run whose font size measures the cap height is written at the em size it is drawn at: 14 pt
/// measuring a 0.7 em cap height is `font-size="20"`, after a 12 pt run measuring the em box,
/// which takes the `<text>` element's 12.
#[test]
fn font_height_reference_writes_the_size_drawn() {
use vectorcraft_doc::FontHeightReference;
use vectorcraft_text::test_fonts::{HEIGHT_FAMILY, height_font};
vectorcraft_text::FontDb::global().add_font(height_font(true).unwrap());
let em_box = CharStyle { font_family: HEIGHT_FAMILY.into(), ..style(12.0) };
let cap = CharStyle { size: 14.0, font_height_reference: FontHeightReference::CapHeight, ..em_box.clone() };
let d = area_doc(vec![run("Hx ", em_box), run("Hx", cap)], 280.0, Justify::Left);
let svg = export(&d, &ExportOptions::default());
assert!(svg.contains("font-size=\"12\" fill"), "{svg}");
let sizes: Vec<_> = tspans(&svg).iter().map(|t| attr(t, "font-size")).collect();
assert_eq!(sizes, [None, Some(20.0)], "{svg}");
}
#[test]
fn fewer_tspans_writes_one_per_line() {
let bold = CharStyle { font_style: "Bold".into(), ..style(12.0) };
+37 -1
View File
@@ -1,6 +1,7 @@
//! Hand-written Illustrator editing data (the format `vectorcraft_eps`'s reader documents) and the
//! EPS, `.ai` and PDF files that carry it, for import tests. Nothing here comes from files another
//! app wrote, except the key names that [`pdf`] uses and the XML node keys of [`variable_library`].
//! app wrote, except the key names that [`pdf`] uses, the XML node keys of [`variable_library`] and
//! the keys of [`on_path_record`] and [`on_path_member`].
/// Editing data of a `width` × `height` page: header comments (the art's bounding box and the crop
/// marks are the page), `body` (setup and layers), the trailer.
@@ -77,6 +78,41 @@ pub fn variable_library(variables: &[&str], data_sets: &[&str]) -> String {
)
}
/// The document data with an Objects on Path record: a `/Document` dictionary whose `Constraint
/// Data` array holds a record named `name` (version 1) for the objects with the ids `ids` (the
/// path's first), with the options `options` ([`on_path_options`]).
pub fn on_path_record(name: &str, ids: &[&str], options: &str) -> String {
let ids: String = ids.iter().map(|id| format!("%_({id}) /UnicodeString ,\n")).collect();
format!(
"%AI9_BeginDocumentData\n%_/Document :\n%_/Dictionary :\n%_/Array :\n%_/Dictionary :\n%_1 /Int (Constraint Verison) ,\n\
%_({name}) /UnicodeString (Constraint Name) ,\n%_/Array :\n{ids}%_; (Constraint IDs) ,\n%_/Dictionary :\n{options}%_; (Constraint Dict) ,\n\
%_; ,\n%_; (Constraint Data) ,\n%_; /Recorded ,\n%_;\n%AI9_EndDocumentData\n"
)
}
/// The options of an Objects on Path record for `members` objects: Pivot `pivot` (0 to 8),
/// Rotate `rotation` (radians), spaced evenly from the start of the path, then `more` entries.
pub fn on_path_options(members: usize, pivot: u8, rotation: f64, more: &str) -> String {
let data = "%_/Dictionary :\n%_; ,\n".repeat(members);
let order: String = (0..members).map(|i| format!("%_{i} /Int ,\n")).collect();
format!(
"%_0 /Real (StartRangeFraction) ,\n%_/Array :\n{data}%_; (ConstraintArtData) ,\n%_/Array :\n{order}%_; (ObjectsOrder) ,\n\
%_{pivot} /Int (PivotPosition) ,\n%_1 /Bool (IsEquiSpaced) ,\n%_{rotation} /Real (Rotation) ,\n{more}"
)
}
/// The dictionary of an object of an Objects on Path group, with the id `id`: the path's
/// (`order` `None`), or the object's at `order` along the path whose frame is turned `angle`
/// radians counterclockwise.
pub fn on_path_member(id: &str, order: Option<usize>, angle: f64) -> String {
match order {
None => format!("%_/ArtDictionary :\n%_({id}) /String (Persistent ID) ,\n%_1 /Bool (KeyPath) ,\n%_;\n%_\n"),
Some(i) => format!(
"%_/ArtDictionary :\n%_{i} /Int (ConstraintArtOrderId) ,\n%_({id}) /String (Persistent ID) ,\n%_({angle}) /String (BBAccumRotation) ,\n%_;\n%_\n"
),
}
}
/// A document of 200 × 100 pt with a bit of everything the reader reads. A layer `Art` that shows:
/// a group `Pair` (a red square `Red` and a hidden one), a compound path `Ring`, a clipping group
/// `Window` (a grey square seen through a smaller one), what [`page_ps`] and [`page_pdf`] draw. A
+20
View File
@@ -279,6 +279,26 @@ impl FontFace {
*self.icf_margins.get_or_init(|| self.icf_from_base().or_else(|| self.icf_from_glyphs()).unwrap_or_default())
}
/// The height of the face that a font size measures with `reference`, in ems: 1 for the em
/// box; the cap height or x height of its OS/2 table; the height of its ideographic character
/// face ([`Self::icf_margins`]), or across a `vertical` line its width. None for a face whose
/// font has no such value (an OS/2 table without the heights, no ICF), or one outside 0.1 to
/// 2 ems.
pub fn font_height(&self, reference: vectorcraft_doc::FontHeightReference, vertical: bool) -> Option<f64> {
use vectorcraft_doc::FontHeightReference as R;
let units = |v: Option<f32>| v.map(f64::from).map(|v| v / self.upem);
let h = match reference {
R::EmBox => Some(1.0),
R::CapHeight => units(self.skrifa()?.metrics(Size::unscaled(), self.location()).cap_height),
R::XHeight => units(self.skrifa()?.metrics(Size::unscaled(), self.location()).x_height),
R::IcfBox => {
let m = self.icf_margins();
(m != IcfMargins::default()).then_some(if vertical { 1.0 - m.left - m.right } else { 1.0 - m.top - m.bottom })
}
};
h.filter(|h| (0.1..=2.0).contains(h))
}
/// [`Self::icf_margins`] from the BASE table: the ICF's bottom edge `icfb` (top edge `icft`, else
/// as far below the em box top) against the em box `ideo` .. `idtp` (else the OS/2 typographic
/// descender, and one em above it); across vertical lines, `icfb` and `icft` of the vertical
+31
View File
@@ -247,6 +247,37 @@ impl FontDb {
Some(self.instance(&face, &st.font_axes))
}
/// The em size characters of style `st` are set at, in `vertical` type or not: its size, or
/// with a font height reference other than the em box ([`vectorcraft_doc::FontHeightReference`])
/// the size at which its face ([`Self::style_face`]) has that height
/// ([`FontFace::font_height`]). The size itself when the face has no such height.
pub fn em_size(&self, st: &vectorcraft_doc::CharStyle, vertical: bool) -> f64 {
let reference = st.font_height_reference;
if reference == vectorcraft_doc::FontHeightReference::EmBox {
return st.size;
}
self.style_face(st).and_then(|f| f.font_height(reference, vertical)).map_or(st.size, |h| st.size / h)
}
/// `t` as layout sets it: each run takes its [`Self::em_size`] and the em box as its font
/// height reference; an inline graphic keeps its size. Explicit leading and baseline shift
/// stay in points; Auto leading, tracking and kerning follow the em size. `t` itself when every
/// run measures the em box.
pub fn font_heights_resolved<'a>(&self, t: &'a vectorcraft_doc::TextObject) -> std::borrow::Cow<'a, vectorcraft_doc::TextObject> {
use vectorcraft_doc::FontHeightReference;
if t.runs.iter().all(|r| r.style.font_height_reference == FontHeightReference::EmBox) {
return std::borrow::Cow::Borrowed(t);
}
let mut out = t.clone();
for r in &mut out.runs {
if r.inline.is_none() {
r.style.size = self.em_size(&r.style, t.vertical);
}
r.style.font_height_reference = FontHeightReference::EmBox;
}
std::borrow::Cow::Owned(out)
}
/// `face` with the axis settings `axes` (by tag, in the axes' own units) in place of its own,
/// each clamped to its axis's range: its outlines, metrics and advances taken there, and its
/// file embedded instanced there. Tags the font has no axis for are left out. `face` itself
+13 -2
View File
@@ -227,12 +227,22 @@ impl Ctx<'_> {
let local = match &g.inline {
// Inline graphics: art space to glyph space (no font outline).
Some(ib) => Affine::translate((-lead, -g.bshift)) * ib.xf,
// Baseline shift moves the glyph across its line, and Character Rotation then turns it
// about its shifted pen position, so the shift is across the line at any rotation.
None => {
Affine::rotate(-g.rotation.to_radians()) * Affine::translate((g.dx - lead, g.dy - g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy)
Affine::translate((0.0, -g.bshift))
* Affine::rotate(-g.rotation.to_radians())
* Affine::translate((g.dx - lead, g.dy))
* Affine::scale_non_uniform(g.sx, g.sy)
}
};
// The turning point in font units, which later moves of the glyph's transform keep.
let pivot = match local.determinant() {
det if det != 0.0 && det.is_finite() => local.inverse() * Point::new(0.0, -g.bshift),
_ => Point::ZERO,
};
let unturned = if g.inline.is_none() && g.rotation != 0.0 && g.sx != 0.0 && g.sy != 0.0 {
let placed = Affine::translate((g.dx - lead, g.dy - g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy);
let placed = Affine::translate((g.dx - lead, g.dy)) * Affine::scale_non_uniform(g.sx, g.sy);
placed.inverse() * Affine::rotate(g.rotation.to_radians()) * placed
} else {
Affine::IDENTITY
@@ -306,6 +316,7 @@ impl Ctx<'_> {
colour,
bend: None,
unturned,
pivot,
});
}
+1 -1
View File
@@ -224,7 +224,7 @@ pub(super) fn leader_outline(db: &FontDb, g: &SGlyph, leader: &str, origin: f64,
}
let first = ((from - origin) / width - EPS).ceil();
let copies = (((to - origin) / width + EPS).floor() - first).clamp(0.0, MAX_LEADER_COPIES) as usize;
let local = Affine::rotate(-g.rotation.to_radians()) * Affine::translate((0.0, -g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy);
let local = Affine::translate((0.0, -g.bshift)) * Affine::rotate(-g.rotation.to_radians()) * Affine::scale_non_uniform(g.sx, g.sy);
for k in 0..copies {
let mut pen = origin + (first + k as f64) * width;
for &(gid, adv) in &glyphs {
+4 -1
View File
@@ -106,8 +106,11 @@ pub fn decorations(layout: &TextLayout, db: &FontDb, t: &TextObject) -> Vec<(usi
const SHRINK_PASSES: usize = 11;
/// Lay out a text object with explicit options (area type rows/columns, inset, first baseline,
/// fit, composer, OpenType features).
/// fit, composer, OpenType features). Each run is set at the em size its font height reference
/// gives it ([`FontDb::font_heights_resolved`]).
pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout {
let resolved = db.font_heights_resolved(t);
let t = &*resolved;
let first = layout_once(db, t, opts);
match (opts.fit.min_scale(), &t.kind) {
(Some(min), TextKind::Area { .. }) if first.overflow => shrink_to_fit(db, t, opts, min).unwrap_or(first),
+1
View File
@@ -368,5 +368,6 @@ fn ruby_glyph(g: &PositionedGlyph, m: Affine, run: usize, byte: usize, line: usi
colour: None,
bend: None,
unturned: Affine::IDENTITY,
pivot: m * g.origin,
}
}
+3
View File
@@ -142,6 +142,9 @@ pub struct PositionedGlyph {
/// Font units → font units: `xf * unturned` places the glyph as it would be placed without
/// its Character Rotation. The identity for a glyph without one.
pub unturned: Affine,
/// Font units: `xf * pivot` is the point its Character Rotation turns it about, its pen
/// position on the baseline moved by its baseline shift.
pub pivot: Point,
}
/// How a glyph on a path is bent along it: a point of its pen space (x along its advance from its
+2
View File
@@ -718,6 +718,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
tate_chu_yoko,
standard_vertical_roman: _,
digits,
font_height_reference,
no_break,
language,
} = a;
@@ -735,6 +736,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
&& *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
&& ligatures_suppressed_by(*tracking) == ligatures_suppressed_by(b.tracking)
+80
View File
@@ -254,6 +254,86 @@ pub fn vertical_font(vorg: bool) -> Option<Vec<u8>> {
Some(builder.build())
}
/// The family of [`height_font`] with its heights.
pub const HEIGHT_FAMILY: &str = "Heighttest Sans";
/// The family of [`height_font`] without them.
pub const HEIGHTLESS_FAMILY: &str = "Heightlesstest Sans";
/// The cap height and x height [`height_font`] gives, in font units (1,000 to the em).
pub const HEIGHT_CAP: i16 = 700;
pub const HEIGHT_X: i16 = 500;
/// The height (and the width across a vertical line) of [`height_font`]'s ideographic character
/// face, in ems.
pub const HEIGHT_ICF: f64 = 0.9;
/// The family of [`height_outside_font`].
pub const HEIGHT_OUTSIDE_FAMILY: &str = "Heightoutsidetest Sans";
/// The cap height and x height [`height_outside_font`] gives, in font units (1,000 to the em):
/// 2.5 em and 0.05 em, outside the 0.1 to 2 ems a font size measures.
pub const HEIGHT_OUTSIDE_CAP: i16 = 2500;
pub const HEIGHT_OUTSIDE_X: i16 = 50;
/// The bundled Source Sans 3 Regular as family [`HEIGHT_FAMILY`], with the cap height
/// [`HEIGHT_CAP`] and x height [`HEIGHT_X`] in its OS/2 table and a `BASE` table whose Han
/// baselines put the ideographic character face [`HEIGHT_ICF`] em high inside the em box (`ideo`
/// -120, `idtp` 880, `icfb` -70, `icft` 830). Without `heights`: family [`HEIGHTLESS_FAMILY`],
/// with a version 1 OS/2 table, which holds neither height, and no `BASE` table. `None` when it
/// can't be built.
pub fn height_font(heights: bool) -> Option<Vec<u8>> {
if heights { heights_font(HEIGHT_FAMILY, Some((HEIGHT_X, HEIGHT_CAP)), true) } else { heights_font(HEIGHTLESS_FAMILY, None, false) }
}
/// The bundled Source Sans 3 Regular as family [`HEIGHT_OUTSIDE_FAMILY`], with the cap height
/// [`HEIGHT_OUTSIDE_CAP`] and x height [`HEIGHT_OUTSIDE_X`] in its OS/2 table and no `BASE`
/// table. `None` when it can't be built.
pub fn height_outside_font() -> Option<Vec<u8>> {
heights_font(HEIGHT_OUTSIDE_FAMILY, Some((HEIGHT_OUTSIDE_X, HEIGHT_OUTSIDE_CAP)), false)
}
/// The bundled Source Sans 3 Regular as `family`: with the x height and cap height `heights` in
/// its OS/2 table, else a version 1 OS/2 table, which holds neither; with `base`, the `BASE`
/// table of [`height_font`].
fn heights_font(family: &str, heights: Option<(i16, i16)>, base: bool) -> Option<Vec<u8>> {
use write_fonts::tables::base::{Axis, Base, BaseCoord, BaseScript, BaseScriptList, BaseScriptRecord, BaseTagList, BaseValues};
const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
let font = skrifa::FontRef::new(BASE).ok()?;
let full = format!("{family} Regular");
let postscript = format!("{}-Regular", family.replace(' ', ""));
let name = name_table(&[(1, family), (2, "Regular"), (4, &full), (6, &postscript)])?;
let mut builder = FontBuilder::new();
builder.add_raw(Tag::new(b"name"), name);
if base {
let tags = BaseTagList::new([b"icfb", b"icft", b"ideo", b"idtp"].map(Tag::new).to_vec());
let values = BaseValues::new(2, [-70, 830, -120, 880].map(BaseCoord::format_1).to_vec());
let hani = BaseScriptRecord::new(Tag::new(b"hani"), BaseScript::new(Some(values), None, vec![]));
let axis = Axis::new(Some(tags), BaseScriptList::new(vec![hani]));
builder.add_table(&Base::new(Some(axis), None)).ok()?;
}
for r in font.table_directory.table_records() {
let tag = Tag::new(&r.tag().to_be_bytes());
if builder.contains(tag) {
continue;
}
let mut data = font.table_data(r.tag())?.as_bytes().to_vec();
if tag == Tag::new(b"OS/2") {
if let Some((x, cap)) = heights {
// sxHeight and sCapHeight, at bytes 86 and 88 of a version 2 or later table.
data.get_mut(86..90)?.copy_from_slice(&[x.to_be_bytes(), cap.to_be_bytes()].concat());
} else {
// Version 1 ends before sxHeight.
data.truncate(86);
data.get_mut(0..2)?.copy_from_slice(&1_u16.to_be_bytes());
}
}
builder.add_raw(tag, data);
}
Some(builder.build())
}
/// The family of [`vertical_punctuation_font`].
pub const VERTICAL_PUNCTUATION_FAMILY: &str = "Punctuationtest Sans";
+56
View File
@@ -1347,6 +1347,62 @@ fn character_alignment_on_the_icf_lines_small_characters_up_with_the_largest_fac
}
}
/// The font height reference: 20 pt type that measures the cap height is set at the em size at
/// which the font's cap height (OS/2) is 20 pt, and the same for the x height and for the ideographic
/// character face's height (its width in vertical type); the em box keeps 20 pt to the em, and
/// so does every reference in a font without these values. Auto leading follows the em size,
/// and two runs that measure different heights each take their own em size.
#[test]
fn font_size_measures_the_font_height_reference() {
use crate::test_fonts::{HEIGHT_CAP, HEIGHT_FAMILY, HEIGHT_ICF, HEIGHT_X, HEIGHTLESS_FAMILY, height_font};
use vectorcraft_doc::FontHeightReference as R;
let db = FontDb::with_font_dirs(vec![]);
db.add_font(height_font(true).unwrap());
db.add_font(height_font(false).unwrap());
let st = |family: &str, r: R| CharStyle { font_family: family.into(), font_height_reference: r, ..style(20.0) };
// The em size each glyph of `t` is drawn at (its transform's scale times 1,000 units).
let ems = |t: &TextObject| layout(&db, t).glyphs.iter().map(|g| g.xf.determinant().abs().sqrt() * 1000.0).collect::<Vec<_>>();
let near = |a: f64, b: f64| (a - b).abs() < 1e-6;
let (cap, x) = (f64::from(HEIGHT_CAP) / 1000.0, f64::from(HEIGHT_X) / 1000.0);
for vertical_type in [false, true] {
for (r, h) in [(R::EmBox, 1.0), (R::CapHeight, cap), (R::XHeight, x), (R::IcfBox, HEIGHT_ICF)] {
let mut t = point("Hx", st(HEIGHT_FAMILY, r));
t.vertical = vertical_type;
assert!(ems(&t).iter().all(|e| near(*e, 20.0 / h)), "{r:?} vertical {vertical_type}: {:?}", ems(&t));
t.runs[0].style.font_family = HEIGHTLESS_FAMILY.into();
assert!(ems(&t).iter().all(|e| near(*e, 20.0)), "{r:?} without the value, vertical {vertical_type}: {:?}", ems(&t));
}
}
let face = db.face(HEIGHT_FAMILY, "Regular").unwrap();
assert_eq!(face.font_height(R::CapHeight, false), Some(cap));
assert_eq!(db.face(HEIGHTLESS_FAMILY, "Regular").unwrap().font_height(R::XHeight, false), None);
// Auto leading: 120% of the em size the line is set at.
let lines = layout(&db, &point("H\nH", st(HEIGHT_FAMILY, R::CapHeight))).lines;
assert!(near(lines[1].baseline - lines[0].baseline, 1.2 * 20.0 / cap), "{lines:?}");
// One run at the em box, the next at the cap height.
let mut t = point("H", st(HEIGHT_FAMILY, R::EmBox));
t.runs.push(TextRun { text: "H".into(), style: st(HEIGHT_FAMILY, R::CapHeight), inline: None });
let set = ems(&t);
assert!(set.len() == 2 && near(set[0], 20.0) && near(set[1], 20.0 / cap), "{set:?}");
}
/// A font height outside 0.1 to 2 ems is not used: 20 pt type that measures a font's 2.5 em cap
/// height or its 0.05 em x height is set at 20 pt to the em.
#[test]
fn a_font_height_outside_a_tenth_to_two_ems_measures_the_em_box() {
use crate::test_fonts::{HEIGHT_OUTSIDE_FAMILY, height_outside_font};
use vectorcraft_doc::FontHeightReference as R;
let db = FontDb::with_font_dirs(vec![]);
db.add_font(height_outside_font().unwrap());
let face = db.face(HEIGHT_OUTSIDE_FAMILY, "Regular").unwrap();
for r in [R::CapHeight, R::XHeight] {
assert_eq!(face.font_height(r, false), None, "{r:?}");
let st = CharStyle { font_family: HEIGHT_OUTSIDE_FAMILY.into(), font_height_reference: r, ..style(20.0) };
let ems = layout(&db, &point("Hx", st)).glyphs.iter().map(|g| g.xf.determinant().abs().sqrt() * 1000.0).collect::<Vec<_>>();
assert!(!ems.is_empty() && ems.iter().all(|e| (e - 20.0).abs() < 1e-6), "{r:?}: {ems:?}");
}
}
/// Burasagari leaves Latin punctuation alone: in a measure of exactly "abcd", the full stop of
/// "abcd. ef" doesn't hang with Standard or Forced (the line breaks as it does with None), and a
/// Latin line ending in a full stop isn't shortened by Forced.
+1
View File
@@ -164,6 +164,7 @@ fn lines_of_clusters(lines: &[&[(usize, usize, f64)]], rtl: bool) -> TextLayout
colour: None,
bend: None,
unturned: Affine::IDENTITY,
pivot: Point::new(x, baseline),
});
x += advance;
}
+45
View File
@@ -1430,6 +1430,51 @@ fn restyling_one_character_keeps_its_kerning_with_its_neighbors() {
assert!((l.lines[1].baseline - l.lines[0].baseline - 60.0).abs() < 1e-9, "{:?}", l.lines);
}
/// Baseline shift moves a rotated character up from its line by the shift, at any rotation, in
/// point type and on a slanted path, and leaves its neighbors where they are. It turns about its
/// pen position moved by the shift (`xf * pivot`), and `xf * unturned` still places the shifted
/// glyph without its rotation.
#[test]
fn baseline_shift_moves_a_rotated_character_across_its_line() {
let slanted = vectorcraft_geom::PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (300.0, -300.0)).to_path(1e-3));
for on_path in [false, true] {
for rotation in [0.0, 45.0, 85.0, 90.0, 95.0, -90.0] {
let at = |shift: f64| {
let mut t = avav(1, |s| {
s.rotation = rotation;
s.baseline_shift = shift;
});
if on_path {
t.kind = TextKind::OnPath { path: slanted.clone(), start: 0.0, end: None };
}
layout(db(), &t)
};
let (plain, shifted) = (at(0.0), at(10.0));
let pen = |l: &TextLayout, byte: usize| l.glyphs.iter().find(|g| g.byte == byte).map(|g| g.xf * Point::ZERO).unwrap();
let v = plain.glyphs.iter().find(|g| g.byte == 1).unwrap();
let up = kurbo::Vec2::new(v.angle.sin(), -v.angle.cos());
let moved = pen(&shifted, 1) - pen(&plain, 1);
assert!((moved - up * 10.0).hypot() < 1e-6, "on a path {on_path}, {rotation}°: {moved:?}, up {up:?}");
for byte in [0, 2, 3] {
assert!((pen(&shifted, byte) - pen(&plain, byte)).hypot() < 1e-6, "on a path {on_path}, {rotation}°: byte {byte} moved");
}
let g = shifted.glyphs.iter().find(|g| g.byte == 1).unwrap();
let turn_at = g.xf * g.pivot;
assert!((turn_at - (v.origin + up * 10.0)).hypot() < 1e-6, "on a path {on_path}, {rotation}°: turns about {turn_at:?}");
let unrotated = layout(db(), &{
let mut t = avav(1, |s| s.baseline_shift = 10.0);
if on_path {
t.kind = TextKind::OnPath { path: slanted.clone(), start: 0.0, end: None };
}
t
});
let u = unrotated.glyphs.iter().find(|g| g.byte == 1).unwrap();
let (a, b) = ((g.xf * g.unturned).as_coeffs(), u.xf.as_coeffs());
assert!(a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-6), "on a path {on_path}, {rotation}°: {a:?} vs {b:?}");
}
}
}
#[test]
fn tracking_or_kerning_on_one_character_keeps_the_kerning_before_it() {
let plain = pens(&TextObject::point(Point::ZERO, "AVAV", style(40.0)));
+5
View File
@@ -1170,6 +1170,11 @@ impl SelectionTool {
if cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Text(_))) {
return vec![Action::SwitchTool("type".into())];
}
// A symbol instance: its symbol is edited in symbol editing mode, except one inside
// the art of a symbol already being edited.
if cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::SymbolInstance { .. })) {
return if cx.doc.symbol_edit.is_some() { vec![] } else { vec![Action::Exec("symbol.edit".into(), json!({ "id": top.0 }))] };
}
// An object that [`isolates`], not the one already isolated.
if cx.double_click_isolate && cx.doc.node(top).is_some_and(|n| isolates(&n.kind)) && cx.isolation != Some(top) {
return vec![Action::Exec("object.isolate".into(), json!({ "id": top.0 }))];
+34
View File
@@ -1964,3 +1964,37 @@ fn the_preview_memo_measures_once_per_revision_selection_and_preview_bounds() {
let c = cx(&moved, &s, &p);
assert_eq!(selection_bounds(&c).map(|r| r.x1), Some(250.0));
}
/// A double-click on a symbol instance edits its symbol in symbol editing mode (`symbol.edit`), and
/// one outside the symbol's art in that mode leaves it, as it leaves isolation mode. An instance
/// inside the art of the symbol being edited is not entered.
#[test]
fn a_double_click_on_a_symbol_instance_edits_its_symbol() {
use vectorcraft_doc::{Appearance, Node, NodeKind, Symbol, SymbolEdit};
let (mut d, rect) = doc_with_rect();
let art = Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(Rect::new(-10.0, -10.0, 10.0, 10.0)), Appearance::default_art());
d.symbols.push(Symbol { dynamic: false, name: "Dot".into(), art: std::sync::Arc::new(art) });
let (l, id) = (d.layers[0].id, d.alloc_id());
let xf = Affine::translate((300.0, 300.0)) * Affine::scale(2.0);
d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: "Dot".into(), xf })).unwrap();
let (s, p) = (Selection::default(), paint());
let double = |d: &vectorcraft_doc::Document, isolation: Option<NodeId>, x: f64, y: f64| {
let c = ToolContext { isolation, ..cx(d, &s, &p) };
SelectionTool::default().pointer(&c, &ev(PointerKind::DoubleClick, x, y))
};
assert_eq!(double(&d, None, 300.0, 300.0), vec![Action::Exec("symbol.edit".into(), json!({"id": id.0}))]);
assert_eq!(double(&d, None, 150.0, 150.0), vec![Action::Exec("object.isolate".into(), json!({"id": rect.0}))]);
// In symbol editing mode, with the instance on the editing layer.
d.symbol_edit = Some(SymbolEdit {
symbol: "Other".into(),
layer: l,
instance: None,
xf: Affine::IDENTITY,
group: false,
changed: false,
back_isolation: None,
back_layer: None,
});
assert_eq!(double(&d, Some(l), 300.0, 300.0), vec![]);
assert_eq!(double(&d, Some(l), 450.0, 50.0), vec![Action::Exec("object.exitIsolation".into(), json!({}))]);
}
+16 -12
View File
@@ -53,12 +53,14 @@ struct CharBox {
quad: [Point; 4],
/// The glyph's origin on its shifted baseline, which the scale handles measure from.
pen: Point,
/// Its pen position on the line's baseline, which character rotation turns it about.
/// The point character rotation turns it about: its pen position on the line's baseline,
/// moved by its baseline shift.
pivot: Point,
/// A unit vector along its line in reading order.
along: Vec2,
/// The glyph's own x axis and its up axis (with its rotation, which baseline shift moves it
/// along), unit vectors.
/// A unit vector up from its line, without its rotation: the way baseline shift moves it.
up: Vec2,
/// The glyph's own x axis and its up axis (with its rotation), unit vectors.
ex: Vec2,
ey: Vec2,
/// Whether the tool moves it along its line by kerning: a character comes before it on its
@@ -177,8 +179,9 @@ fn char_box(t: &TextObject, lay: &TextLayout, gi: usize) -> Option<CharBox> {
start: g.byte,
quad,
pen,
pivot: g.origin,
pivot: m * g.pivot,
along: if g.rtl { -d } else { d },
up: unit(linear(m * g.unturned, Vec2::new(0.0, -1.0))),
ex,
ey,
kerned: !g.rtl && lay.glyphs.iter().any(|h| h.line == g.line && h.len > 0 && h.byte + h.len == g.byte),
@@ -239,20 +242,21 @@ impl TouchTypeTool {
}
/// Document-space move for a character box (`b` of `t`): kerning along the line and baseline
/// shift along the character's up axis.
/// shift up from it, whatever the character's rotation. The move (text space) is split into
/// its parts along the line and up from it; a character that is not kerned only moves up or
/// down. When the two axes are parallel, the params hold no move.
fn move_params(id: NodeId, t: &TextObject, b: &CharBox, moved: Vec2) -> Value {
let doc_len = |v: Vec2| linear(t.xf, v).hypot();
let det = b.along.cross(b.ey);
let (along, rise) = if !b.kerned {
(0.0, moved.dot(b.ey))
} else if det.abs() > 0.1 {
(moved.cross(b.ey) / det, b.along.cross(moved) / det)
let det = b.along.cross(b.up);
let (along, rise) = if det.abs() <= 1e-9 || !det.is_finite() {
(0.0, 0.0)
} else {
(moved.dot(b.along), 0.0)
let (along, rise) = (moved.cross(b.up) / det, b.along.cross(moved) / det);
if b.kerned { (along, rise) } else { (0.0, rise) }
};
let mut out = json!({"id": id.0, "start": b.start});
let dx = along * doc_len(b.along);
let dy = rise * doc_len(b.ey);
let dy = rise * doc_len(b.up);
if dx != 0.0 {
out["dx"] = json!(dx);
}
+83
View File
@@ -140,6 +140,89 @@ fn dragging_the_box_moves_the_character_along_its_line_and_up() {
assert!(close(p3.get("dx").and_then(|v| v.as_f64()).unwrap_or(0.0), 0.0) && close(p3["dy"].as_f64().unwrap(), 4.0), "{p3}");
}
/// 40 pt point type "Type" at (100, 200) whose "y" is rotated by `rotation` degrees, selected.
fn doc_with_rotated_y(rotation: f64) -> (Document, NodeId, Selection) {
let (mut d, id, s) = doc_with_type();
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
edit::style_range(&mut t.runs, 1, 2, |st| st.rotation = rotation);
}
(d, id, s)
}
/// Near and at a quarter turn, a drag still splits into kerning along the line and baseline shift
/// across it: right 10 pt and up 4 pt is `dx` 10 and `dy` 4 at every rotation, and a drag straight
/// across the line is all `dy`. Arrow keys nudge the same way.
#[test]
fn a_character_rotated_near_a_quarter_turn_moves_across_its_line() {
for rotation in [45.0, 85.0, 90.0, 95.0, -90.0] {
let (d, id, s) = doc_with_rotated_y(rotation);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = TouchTypeTool { target: Some((id, 1)), ..Default::default() };
let q = quad(&t, &cx);
let at = q[0].midpoint(q[2]);
let acts = drag(&mut t, &cx, at, at + Vec2::new(10.0, -4.0));
let p1 = preview(&acts[1]);
assert!(close(p1["dx"].as_f64().unwrap(), 10.0) && close(p1["dy"].as_f64().unwrap(), 4.0), "{rotation}°: {p1}");
let acts = drag(&mut t, &cx, at, at + Vec2::new(0.0, -6.0));
let p2 = preview(&acts[1]);
assert!(p2.get("dx").is_none_or(|v| close(v.as_f64().unwrap(), 0.0)) && close(p2["dy"].as_f64().unwrap(), 6.0), "{rotation}°: {p2}");
let acts = t.key(&cx, ToolKey::Up, Mods::default());
let p3 = match &acts[0] {
Action::Exec(c, v) if c == "text.touchType" => v,
other => panic!("{other:?}"),
};
assert!(close(p3["dy"].as_f64().unwrap(), 1.0) && p3.get("dx").is_none_or(|v| close(v.as_f64().unwrap(), 0.0)), "{rotation}°: {p3}");
}
}
/// In type scaled unevenly, a drag's parts are measured in document points: right 10 pt and up 4
/// pt of a quarter-turned character is `dx` 10 and `dy` 4.
#[test]
fn a_quarter_turned_character_in_unevenly_scaled_type_follows_the_pointer() {
let (mut d, id, s) = doc_with_rotated_y(90.0);
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
t.xf = Affine::scale_non_uniform(2.0, 0.5) * t.xf;
}
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = TouchTypeTool { target: Some((id, 1)), ..Default::default() };
let q = quad(&t, &cx);
let at = q[0].midpoint(q[2]);
let acts = drag(&mut t, &cx, at, at + Vec2::new(10.0, -4.0));
let p1 = preview(&acts[1]);
assert!(close(p1["dx"].as_f64().unwrap(), 10.0) && close(p1["dy"].as_f64().unwrap(), 4.0), "{p1}");
}
/// A character with a baseline shift turns about its pen position moved up by the shift, rotated
/// or not, and the rotate handle measures its turn about that point.
#[test]
fn a_shifted_character_turns_about_its_shifted_pen_position() {
for rotation in [0.0, 30.0, 90.0] {
let (mut d, id, s) = doc_with_rotated_y(rotation);
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
edit::style_range(&mut t.runs, 1, 2, |st| st.baseline_shift = 10.0);
}
let p = paint();
let cx = cx(&d, &s, &p);
let t = TouchTypeTool { target: Some((id, 1)), ..Default::default() };
let (_, text, b) = t.current(&cx).expect("a character is selected");
let lay = vectorcraft_text::layout(FontDb::global(), text);
let y = lay.glyphs.iter().find(|g| g.byte == 1).unwrap();
let want = y.origin + Vec2::new(0.0, -10.0);
assert!((b.pivot - want).hypot() < 1e-6, "{rotation}°: {:?}, want {want:?}", b.pivot);
// The rotate handle turned a quarter turn about that point is a quarter turn more.
let mut t = t;
let top = text.xf * b.handle(Part::Rotate);
let centre = text.xf * want;
let r = top - centre;
let acts = drag(&mut t, &cx, top, centre + Vec2::new(r.y, -r.x));
let ps = preview(&acts[1]);
let turned = (ps["rotation"].as_f64().unwrap() - rotation - 90.0).rem_euclid(360.0);
assert!(turned.min(360.0 - turned) < 1e-6, "{rotation}°: {ps}");
}
}
#[test]
fn arrow_keys_nudge_the_selected_character() {
let (d, id, s) = doc_with_type();
+4 -1
View File
@@ -3,7 +3,9 @@
//! filter, into the colours themselves (`addon.color.filter`); below, under Inks, it opens Duotone's
//! dialog and Print Ink Fixes' (`addon.color.printFixes`). The submenu applies the add-on's live
//! effects, Duotone and Vector Halftone among them (Effect › Color Effects has them too), opens
//! Print Ink Fixes and opens the panel.
//! Print Ink Fixes and Find Nearest Swatch ([`nearest`]) and opens the panel.
pub mod nearest;
use egui::Ui;
use serde_json::{Map, Value, json};
@@ -97,6 +99,7 @@ pub fn menu() -> Vec<Item> {
Item::Cmd("Vector Halftone…", "effect.dialog", json!({ "effect": "addon.color.halftone" })),
Item::Sep,
cmd(print_fixes()),
Item::Cmd("Find Nearest Swatch…", nearest::COMMAND, Value::Null),
Item::Sep,
Item::Sub(
"Select by Color Kind",
+147
View File
@@ -0,0 +1,147 @@
//! Find Nearest Swatch in the interface (Object › Plug-ins › Color Effects › Find Nearest
//! Swatch…): a dialog that picks a library (the document's swatches, or a colour library or book)
//! and Fill and Stroke, and lists each colour of the selection with its closest swatches and their
//! difference, as `addon.color.nearestSwatch` finds them; Apply runs
//! `addon.color.applyNearestSwatch`.
use egui::Ui;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_engine::cmd::swatchlib::DOCUMENT_SWATCHES;
use crate::VectorcraftApp;
use crate::dialogs::{DialogSpec, form};
use crate::panels::swatches;
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::widgets;
/// The command Apply runs, and the dialog's kind.
pub const COMMAND: &str = "addon.color.applyNearestSwatch";
pub const KIND: &str = "nearestSwatch";
/// The query that lists the matches, and how many of each colour's the dialog shows.
const FIND: &str = "addon.color.nearestSwatch";
const MATCHES: u64 = 3;
const LABEL_W: f32 = 60.0;
const CHIP: f32 = 14.0;
/// The dialog's fields to start with: the document's swatches, fills and strokes.
pub(crate) fn fields() -> Value {
json!({ "library": DOCUMENT_SWATCHES, "fill": true, "stroke": true })
}
pub(crate) const SPEC: DialogSpec = DialogSpec {
heading: |_| tl!("Find Nearest Swatch").to_string(),
body,
confirm,
ok: Some("Apply"),
min_width: 340.0,
ok_enabled: Some(|app, _| app.session.active().is_some_and(|st| !st.selection.is_empty())),
..DialogSpec::FORM
};
/// A colour chip of `hex`, then `text`.
fn chip_label(ui: &mut Ui, hex: &str, text: &str, color: egui::Color32) {
let (r, _) = ui.allocate_exact_size(egui::vec2(CHIP, CHIP), egui::Sense::hover());
widgets::swatch_tile(ui, r, &Paint::solid(Color::from_hex(hex).unwrap_or_default()), false, false);
ui.label(egui::RichText::new(text).color(color));
}
fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
let key = d.str("library");
widgets::label_row(ui, tl!("Library:"), LABEL_W, |ui| {
if let Some(k) = widgets::combo(ui, "nearest-library", &swatches::library_label(app, &key), 220.0, |ui| swatches::library_list(app, ui, &key))
{
d.fields.insert("library".into(), json!(k));
}
});
ui.horizontal(|ui| {
form::check(ui, d, "fill", tl!("Fill"));
form::check(ui, d, "stroke", tl!("Stroke"));
});
ui.add_space(6.0);
let t = Tokens::get(ui.ctx());
if app.session.active().is_none_or(|st| st.selection.is_empty()) {
ui.label(egui::RichText::new(tl!("Select objects to find the swatches nearest their colors.")).color(t.text_dim));
return false;
}
let mut p = form::params(d);
p["count"] = json!(MATCHES);
let found = match app.session.execute(FIND, &p) {
Ok(v) => v,
Err(e) => {
ui.label(egui::RichText::new(crate::i18n::message(crate::i18n::current(), &e.to_string())).color(t.text_dim));
return false;
}
};
let colors = found["colors"].as_array().cloned().unwrap_or_default();
// Each colour, then its matches indented, closest first.
egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| {
for c in &colors {
ui.horizontal(|ui| chip_label(ui, c["hex"].as_str().unwrap_or_default(), c["values"].as_str().unwrap_or_default(), t.text));
for m in c["matches"].as_array().into_iter().flatten() {
ui.horizontal(|ui| {
ui.add_space(CHIP + 8.0);
let de = m["deltaE"].as_f64().unwrap_or_default();
chip_label(ui, m["hex"].as_str().unwrap_or_default(), m["name"].as_str().unwrap_or_default(), t.text);
ui.label(egui::RichText::new(format!("ΔE {de:.1}")).color(t.text_dim));
});
}
}
});
false
}
/// Apply: give each colour its nearest swatch and close.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
app.ui.dialog = None;
app.run(COMMAND, form::params(d))
}
#[cfg(test)]
mod tests {
use serde_json::json;
use vectorcraft_color::Paint;
use vectorcraft_engine::Session;
use crate::tests_labels::painted_text;
use crate::{VectorcraftApp, menus};
#[test]
fn find_nearest_swatch_lists_the_matches_and_applies_the_nearest() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
app.run("swatch.new", json!({"name": "Brand", "color": "#ff8000", "global": true})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap();
app.run("paint.setFill", json!({"color": "#ff8010"})).unwrap();
app.run("paint.setStroke", json!({"none": true})).unwrap();
let entries = menus::menu_entries(&app);
let item = entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Color Effects"] && e.label == "Find Nearest Swatch…").unwrap();
let (cmd, p) = menus::click_target(&item.label, item.command.as_deref().unwrap(), &item.params);
menus::invoke(&mut app, &cmd, p);
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(super::KIND));
let shown = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx()));
for s in ["Find Nearest Swatch", "Library:", "Document Swatches", "Fill", "Stroke", "R=255 G=128 B=16", "Brand", "ΔE", "Apply"] {
assert!(shown.contains(s), "{s} in {shown}");
}
// Another library lists its own swatches.
app.ui.dialog.as_mut().unwrap().fields.insert("library".into(), json!("web-safe-216"));
assert!(painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())).contains("#FF6600"));
app.ui.dialog.as_mut().unwrap().fields.insert("library".into(), json!("document"));
let undo = app.session.doc().unwrap().history.undo.len();
crate::dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let st = app.session.active().unwrap();
assert_eq!(st.history.undo.len(), undo + 1);
let fill = st.doc.node(st.selection.objects[0]).unwrap().appearance.fill_paint();
assert!(matches!(fill, Paint::Solid { swatch: Some(n), .. } if n == "Brand"));
// Nothing selected: the dialog says what to do and Apply is off.
app.run("select.none", json!({})).unwrap();
menus::invoke(&mut app, &cmd, serde_json::Value::Null);
assert!(
painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx()))
.contains("Select objects to find the swatches nearest their colors.")
);
assert!(!(super::SPEC.ok_enabled.unwrap())(&app, app.ui.dialog.as_ref().unwrap()), "Apply is off");
}
}
+26 -2
View File
@@ -38,8 +38,9 @@ impl VectorcraftApp {
}
let mut params = params;
// Paste in place, in front and in back (#693), Select › All on Active Artboard (#1006), and Align,
// Distribute Objects and Distribute Spacing with Align to Artboard act on the active artboard, the
// view's, which the app passes as `artboard`. A batch passes it on to those steps.
// Distribute Objects and Distribute Spacing with Align to Artboard, and Edit Symbol from the Symbols
// panel act on the active artboard, the view's, which the app passes as `artboard`. A batch passes
// it on to those steps.
if (vectorcraft_engine::ON_ACTIVE_ARTBOARD.contains(&id) || id == "command.batch")
&& params.get("artboard").is_none()
&& self.view().is_some()
@@ -48,6 +49,29 @@ impl VectorcraftApp {
{
p.insert("artboard".into(), serde_json::json!(panels::artboards::selected(self, n)));
}
// Edit Symbol from the Symbols panel in a document without artboards centers the symbol's
// art in the view, whose center the app passes as `viewCenter`, to a batch's symbol.edit
// steps too.
if matches!(id, "symbol.edit" | "command.batch")
&& let Some(c) = self.view().map(|v| v.center)
{
let fill = |p: &mut serde_json::Value| {
if let Some(p) = p.as_object_mut() {
p.entry("viewCenter").or_insert(serde_json::json!([c.x, c.y]));
}
};
if id == "symbol.edit" {
fill(&mut params);
} else if let Some(serde_json::Value::Array(steps)) = params.get_mut("commands") {
for step in steps {
if step.get("command").and_then(serde_json::Value::as_str) == Some("symbol.edit")
&& let Some(step) = step.as_object_mut()
{
fill(step.entry("params").or_insert_with(|| serde_json::json!({})));
}
}
}
}
// Object › Artboards › Fit to Artwork Bounds resizes the selected artboards (the Artboard
// tool's, else the active one), which the app passes as its `indices`, and Fit to Selected
// Art resizes the active artboard, which the app passes as its `index`.
+10 -5
View File
@@ -104,17 +104,22 @@ fn snapping_menu(app: &mut VectorcraftApp, ui: &mut Ui) {
}
/// The Control bar's isolation button. In isolation mode it is Exit Isolation Mode, except while a
/// pattern is edited (the isolation bar's Done and Cancel end that). Otherwise, with one object
/// selected that [`vectorcraft_tools::select::isolates`], it is Isolate Selected Object, which
/// also isolates a path inside a group that Direct Selection selected. An envelope's contents
/// get no button: the envelope's Edit Envelope and Edit Contents buttons show instead.
/// pattern is edited (the isolation bar's Done and Cancel end that), and Exit Symbol Editing Mode
/// while a symbol's art is the isolated layer. Otherwise, with one object selected that
/// [`vectorcraft_tools::select::isolates`], it is Isolate Selected Object, which also isolates a
/// path inside a group that Direct Selection selected. An envelope's contents get no button: the
/// envelope's Edit Envelope and Edit Contents buttons show instead.
fn isolation_button(app: &mut VectorcraftApp, ui: &mut Ui) {
let Some(st) = app.session.active() else { return };
let (icon, tip, id) = if st.isolation.is_some() {
if st.doc.pattern_edit.is_some() {
return;
}
("chevron-left", tl!("Exit Isolation Mode"), "object.exitIsolation")
if st.doc.symbol_edit.as_ref().is_some_and(|e| st.isolation == Some(e.layer)) {
("chevron-left", tl!("Exit Symbol Editing Mode"), "symbol.exitEditing")
} else {
("chevron-left", tl!("Exit Isolation Mode"), "object.exitIsolation")
}
} else if let [one] = st.selection.objects[..]
&& st.doc.node(one).is_some_and(|n| vectorcraft_tools::select::isolates(&n.kind))
&& vectorcraft_doc::live::envelope_of(&st.doc, one).is_none()
+1
View File
@@ -402,6 +402,7 @@ registry! {
RearrangeArtboards: [rearrange_artboards::KIND] => rearrange_artboards::SPEC,
PathToolOptions: [crate::addons::pathtools::KIND] => crate::addons::pathtools::OPTIONS_SPEC,
DivideDashes: [crate::addons::pathtools::dashes::KIND] => crate::addons::pathtools::dashes::SPEC,
NearestSwatch: [crate::addons::color::nearest::KIND] => crate::addons::color::nearest::SPEC,
ArrangeTools: [crate::addons::arrange::GRID, crate::addons::arrange::REPLACE, crate::addons::arrange::SIZE, crate::addons::arrange::OPACITY, crate::addons::arrange::SORT] => crate::addons::arrange::SPEC,
ShortcutImport: [shortcut_import::KIND] => DialogSpec::window(shortcut_import::show, shortcut_import::confirm),
}
+16
View File
@@ -914,6 +914,7 @@ font-technology Variable Променливи
Edit Selection Редактиране на селекцията
Edit Selection… Редактиране на селекцията…
Edit Symbol Редактиране на символ
Exit Symbol Editing Mode Изход от режим на редактиране на символ
Edit Text Редактиране на текст
Edit the applied effect, or add another one? Да се редактира ли приведеният ефект или да се добави друг?
Edit the point Редактиране на точката
@@ -4226,6 +4227,8 @@ axis Both И двете
@msg select an Intertwine group изберете група за преплитане (Intertwine)
@msg select a stroke with a variable width изберете щрих с променлива дебелина
@msg select a symbol instance изберете екземпляр на символ
@msg select instances of one symbol изберете екземпляри на един символ
@msg a symbol can't hold an instance of itself символ не може да съдържа свой собствен екземпляр
@msg select an Image Trace object изберете обект за трасиране на изображение (Image Trace)
@msg could not decode image: {0} изображението не може да бъде декодирано: {0}
@msg image is empty изображението е празно
@@ -4505,6 +4508,12 @@ axis Both И двете
Insert Inline Symbol Вграждане на вграден символ
ICF Top/Right ICF горе/дясно
ICF Bottom/Left ICF долу/ляво
Show Font Height Options Показване на опциите за височина на шрифта
Hide Font Height Options Скриване на опциите за височина на шрифта
Set Font Height Reference Задаване на референтна височина на шрифта
Em Box Квадратна касетка (Em Box)
x-Height Височина на x
ICF Box Касетка ICF
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` е референцията за стъпка {r}, която се разрешава само от `run` и `command_batch`
@msg `ids` must be an array of object ids, not {v} `ids` трябва да бъде масив от идентификатори на обекти, а не {v}
@msg `corners` must be an array of anchor indices, not {v} `corners` трябва да бъде масив от индекси на опорни точки, а не {v}
@@ -5825,6 +5834,13 @@ anchor Points: Точки:
@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` задайте `inkLimit`, `removeWhiteOverprint` или `overprintBlack`
@msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Корекциите на мастилата за печат работят върху CMYK документи: първо изберете Файл › Цветен режим на документа › CMYK цвят
@msg `{key}` must be 0..{max} `{key}` трябва да е 0..{max}
# Color Effects add-on: Find Nearest Swatch (Object › Plug-ins › Color Effects).
Find Nearest Swatch… Намиране на най-близкия образец…
Find Nearest Swatch Намиране на най-близкия образец
Apply Nearest Swatch Прилагане на най-близкия образец
Select objects to find the swatches nearest their colors. Изберете обекти, за да намерите най-близките до цветовете им образци.
@msg select objects or give `color` изберете обекти или задайте `color`
@msg `color` is not a colour `color` не е цвят
# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path.
Corner Tool Инструмент „Ъгли“
Slide Anchor Along Path Плъзгане на опорна точка по пътя
Can't render this file because it contains an unexpected character in line 2 and column 73.
+16
View File
@@ -1454,6 +1454,12 @@ digits Malayalam Malajalam
Axis Osa
ICF Top/Right ICF vrh/desno
ICF Bottom/Left ICF dno/lijevo
Show Font Height Options Prikaži opcije visine fonta
Hide Font Height Options Sakrij opcije visine fonta
Set Font Height Reference Postavi referentnu visinu fonta
Em Box Em okvir
x-Height Visina malih slova
ICF Box ICF okvir
Copy Color Value (Hex) Kopiraj vrijednost boje (hex)
Create New Swatch… Kreiraj novi uzorak boje…
Name 40% Naziv 40%
@@ -3086,6 +3092,7 @@ theme Appearance Izgled
Duplicate Brush Dupliciraj četkicu
Duplicate Symbol Dupliciraj simbol
Edit Symbol Uredi simbol
Exit Symbol Editing Mode Izađi iz načina uređivanja simbola
Expand Brush Strokes Proširi poteze četkicom
New Brush… Nova četkica…
New Brush Nova četkica
@@ -4618,6 +4625,8 @@ imageTrace Outline Obris
@msg a clipping path can't be attached to a path putanja isijecanja ne može se priložiti putanji
@msg select a stroke with a variable width odaberite potez s promjenjivom širinom
@msg select a symbol instance odaberite instancu simbola
@msg select instances of one symbol odaberite instance jednog simbola
@msg a symbol can't hold an instance of itself simbol ne može sadržavati instancu samog sebe
@msg select an Image Trace object odaberite objekat praćenja slike
@msg could not decode image: {0} nije moguće dekodirati sliku: {0}
@msg image is empty slika je prazna
@@ -5949,6 +5958,13 @@ shape Open Otvoren
Gap 4 Razmak 4
Dash 5 Crtica 5
Gap 5 Razmak 5
# Color Effects add-on: Find Nearest Swatch (Object › Plug-ins › Color Effects).
Find Nearest Swatch… Pronađi najbliži uzorak boje…
Find Nearest Swatch Pronađi najbliži uzorak boje
Apply Nearest Swatch Primijeni najbliži uzorak boje
Select objects to find the swatches nearest their colors. Odaberite objekte da pronađete uzorke boja najbliže njihovim bojama.
@msg select objects or give `color` odaberite objekte ili navedite `color`
@msg `color` is not a colour `color` nije boja
# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path.
Corner Tool Alat za uglove
Slide Anchor Along Path Klizanje sidra po putanji
Can't render this file because it contains an unexpected character in line 2 and column 73.
+17
View File
@@ -1317,6 +1317,7 @@
Edit Selection Edita la selecció
Edit Selection… Edita la selecció…
Edit Symbol Edita el símbol
Exit Symbol Editing Mode Surt del mode d’edició de símbols
Edit Text Edita el text
Edit the applied effect, or add another one? Vols editar l’efecte aplicat o afegir-ne un altre?
Edit the point Edita el punt
@@ -5381,6 +5382,9 @@ digits Malayalam Malaiàlam
@msg select a repeat selecciona una repetició
@msg select a stroke with a variable width selecciona un traç amb amplada variable
@msg select a symbol instance selecciona una instància de símbol
@msg select instances of one symbol selecciona instàncies d’un sol símbol
@msg a symbol can't hold an instance of itself un símbol no pot contenir una instància de si mateix
@msg exit isolation mode first surt primer del mode d’aïllament
@msg select an envelope selecciona un embolcall
@msg select an image selecciona una imatge
@msg select an Image Trace object selecciona un objecte d’Image Trace
@@ -5739,6 +5743,12 @@ theme Appearance Aparença
Glyph Bounds Límits del glif
Proximity Guides Guies de proximitat
Angular Guides Guies angulars
Show Font Height Options Mostra les opcions d’alçada de la font
Hide Font Height Options Amaga les opcions d’alçada de la font
Set Font Height Reference Estableix la referència d’alçada de la font
Em Box Caixa em
x-Height Alçada de la x
ICF Box Caixa ICF
# Average & Join (Object › Path, Cmd+Alt+Shift+J).
Average & Join… Mitjana i unió…
Average & Join Mitjana i unió
@@ -5930,6 +5940,13 @@ anchor Points: Punts:
@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` indiqueu `inkLimit`, `removeWhiteOverprint` o `overprintBlack`
@msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Les correccions de tinta per a impressió funcionen en documents CMYK: trieu primer Fitxer › Mode de color del document › Color CMYK
@msg `{key}` must be 0..{max} `{key}` ha de ser 0..{max}
# Color Effects add-on: Find Nearest Swatch (Object › Plug-ins › Color Effects).
Find Nearest Swatch… Cerca la mostra més propera…
Find Nearest Swatch Cerca la mostra més propera
Apply Nearest Swatch Aplica la mostra més propera
Select objects to find the swatches nearest their colors. Seleccioneu objectes per trobar les mostres més properes als seus colors.
@msg select objects or give `color` seleccioneu objectes o indiqueu `color`
@msg `color` is not a colour `color` no és un color
# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path.
Corner Tool Eina de cantonades
Slide Anchor Along Path Llisca l’ancoratge pel traçat
Can't render this file because it contains an unexpected character in line 2 and column 73.
+17
View File
@@ -1478,6 +1478,12 @@ digits Malayalam Malajálamské
Axis Osa
ICF Top/Right ICF horní/pravý
ICF Bottom/Left ICF dolní/levý
Show Font Height Options Zobrazit možnosti výšky písma
Hide Font Height Options Skrýt možnosti výšky písma
Set Font Height Reference Nastavit referenční výšku písma
Em Box Čtverčík
x-Height Výška x
ICF Box Rámeček ICF
Copy Color Value (Hex) Kopírovat hodnotu barvy (hex)
Create New Swatch… Vytvořit novou položku vzorníku…
Name 40% Název 40 %
@@ -3117,6 +3123,7 @@ theme Appearance Motiv
Duplicate Brush Duplikovat štětec
Duplicate Symbol Duplikovat symbol
Edit Symbol Upravit symbol
Exit Symbol Editing Mode Ukončit režim úprav symbolu
Expand Brush Strokes Rozložit tahy štětcem
New Brush… Nový štětec…
New Brush Nový štětec
@@ -4213,6 +4220,7 @@ imageTrace Outline Obrys
@msg the file's layers weren't read ({_1}): it comes in as its page, in one layer vrstvy souboru nebyly načteny ({_1}): soubor se otevře jako jeho stránka v jedné vrstvě
@msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part vrstvy souboru nebyly načteny z jeho dat pro úpravy ({_1}): pocházejí z jeho části PDF
@msg `to` must be "corner" or "smooth" `to` musí být "corner" nebo "smooth"
@msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference` musí být "emBox", "capHeight", "xHeight" nebo "icfBox"
@msg BMP files are RGB or grayscale: colorModel rgb or gray soubory BMP jsou RGB nebo ve stupních šedi: colorModel rgb nebo gray
@msg Blend colors: select at least three objects with filled colours Prolnout barvy: vyberte alespoň tři objekty s barevnou výplní
@msg Combine Slices: the slices have no bounds Spojit řezy: řezy nemají hranice
@@ -4657,6 +4665,8 @@ imageTrace Outline Obrys
@msg a clipping path can't be attached to a path ořezovou cestu nelze připojit na cestu
@msg select a stroke with a variable width vyberte tah s proměnnou šířkou
@msg select a symbol instance vyberte instanci symbolu
@msg select instances of one symbol vyberte instance jediného symbolu
@msg a symbol can't hold an instance of itself symbol nemůže obsahovat instanci sebe sama
@msg select an Image Trace object vyberte objekt vektorizace obrazu
@msg could not decode image: {0} obraz nelze dekódovat: {0}
@msg image is empty obraz je prázdný
@@ -5942,6 +5952,13 @@ anchor Points: Body:
@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` zadejte `inkLimit`, `removeWhiteOverprint` nebo `overprintBlack`
@msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Opravy tiskových barev fungují v dokumentech CMYK: nejprve zvolte Soubor › Barevný režim dokumentu › Barvy CMYK
@msg `{key}` must be 0..{max} `{key}` musí být 0..{max}
# Color Effects add-on: Find Nearest Swatch (Object › Plug-ins › Color Effects).
Find Nearest Swatch… Najít nejbližší položku vzorníku…
Find Nearest Swatch Najít nejbližší položku vzorníku
Apply Nearest Swatch Použít nejbližší položku vzorníku
Select objects to find the swatches nearest their colors. Vyberte objekty, k jejichž barvám chcete najít nejbližší položky vzorníku.
@msg select objects or give `color` vyberte objekty nebo zadejte `color`
@msg `color` is not a colour `color` není barva
# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path.
Corner Tool Nástroj Rohy
Slide Anchor Along Path Posunout kotevní bod po cestě
Can't render this file because it contains an unexpected character in line 2 and column 73.
+16
View File
@@ -1388,6 +1388,12 @@ digits Malayalam Malayalam
Axis Akse
ICF Top/Right ICF øverst/højre
ICF Bottom/Left ICF nederst/venstre
Show Font Height Options Vis indstillinger for skrifthøjde
Hide Font Height Options Skjul indstillinger for skrifthøjde
Set Font Height Reference Angiv reference for skrifthøjde
Em Box Em-felt
x-Height x-højde
ICF Box ICF-felt
Copy Color Value (Hex) Kopiér farveværdi (hex)
Create New Swatch… Opret ny farveprøve…
Name 40% Navn 40%
@@ -2870,6 +2876,7 @@ theme Appearance Udseende
Duplicate Brush Duplikér pensel
Duplicate Symbol Duplikér symbol
Edit Symbol Redigér symbol
Exit Symbol Editing Mode Afslut symbolredigeringstilstand
Expand Brush Strokes Udvid penselstrøg
New Brush… Ny pensel…
New Brush Ny pensel
@@ -4322,6 +4329,8 @@ imageTrace Outline Kontur
@msg select a repeat markér en gentagelse
@msg select a stroke with a variable width markér en streg med variabel bredde
@msg select a symbol instance markér en symbolforekomst
@msg select instances of one symbol markér forekomster af ét symbol
@msg a symbol can't hold an instance of itself et symbol kan ikke indeholde en forekomst af sig selv
@msg select an Image Trace object markér et Image Trace-objekt
@msg could not decode image: {0} kunne ikke afkode billedet: {0}
@msg image is empty billedet er tomt
@@ -5841,6 +5850,13 @@ anchor Points: Punkter:
@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` angiv `inkLimit`, `removeWhiteOverprint` eller `overprintBlack`
@msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Trykfarverettelser virker på CMYK-dokumenter: vælg først Filer › Dokumentets farvetilstand › CMYK-farve
@msg `{key}` must be 0..{max} `{key}` skal være 0..{max}
# Color Effects add-on: Find Nearest Swatch (Object › Plug-ins › Color Effects).
Find Nearest Swatch… Find nærmeste farveprøve…
Find Nearest Swatch Find nærmeste farveprøve
Apply Nearest Swatch Anvend nærmeste farveprøve
Select objects to find the swatches nearest their colors. Markér objekter for at finde de farveprøver, der ligger tættest på deres farver.
@msg select objects or give `color` markér objekter, eller angiv `color`
@msg `color` is not a colour `color` er ikke en farve
# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path.
Corner Tool Hjørneværktøj
Slide Anchor Along Path Skub ankerpunkt langs sti
Can't render this file because it contains an unexpected character in line 2 and column 73.
+18
View File
@@ -908,6 +908,7 @@ font-technology Variable Variabel
Edit Selection Auswahl bearbeiten
Edit Selection… Auswahl bearbeiten…
Edit Symbol Symbol bearbeiten
Exit Symbol Editing Mode Symbolbearbeitungsmodus beenden
Edit Text Text bearbeiten
Edit the applied effect, or add another one? Den angewendeten Effekt bearbeiten oder einen weiteren hinzufügen?
Edit the point Punkt bearbeiten
@@ -3825,6 +3826,7 @@ theme Appearance Erscheinungsbild
@msg the file's layers weren't read ({_1}): it comes in as its page, in one layer die Ebenen der Datei wurden nicht gelesen ({_1}): sie wird als ihre Seite in einer Ebene geöffnet
@msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part die Ebenen der Datei wurden nicht aus ihren Bearbeitungsdaten gelesen ({_1}): sie stammen aus ihrem PDF-Teil
@msg `to` must be "corner" or "smooth" `to` muss "corner" oder "smooth" sein
@msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference` muss "emBox", "capHeight", "xHeight" oder "icfBox" sein
@msg BMP files are RGB or grayscale: colorModel rgb or gray BMP-Dateien sind RGB oder Graustufen: colorModel rgb oder gray
@msg Blend colors: select at least three objects with filled colours Farben angleichen: Wählen Sie mindestens drei Objekte mit Flächenfarbe aus
@msg Combine Slices: the slices have no bounds Slices kombinieren: Die Slices haben keine Begrenzung
@@ -4272,6 +4274,8 @@ theme Appearance Erscheinungsbild
@msg a clipping path can't be attached to a path ein Schnittpfad kann nicht an einen Pfad angehängt werden
@msg select a stroke with a variable width wählen Sie eine Kontur mit variabler Breite aus
@msg select a symbol instance wählen Sie eine Symbolinstanz aus
@msg select instances of one symbol wählen Sie Instanzen eines einzigen Symbols aus
@msg a symbol can't hold an instance of itself ein Symbol kann keine Instanz von sich selbst enthalten
@msg select an Image Trace object wählen Sie ein Bildnachzeichner-Objekt aus
@msg could not decode image: {0} das Bild konnte nicht dekodiert werden: {0}
@msg image is empty das Bild ist leer
@@ -4560,6 +4564,13 @@ theme Appearance Erscheinungsbild
Insert Inline Symbol Inline-Symbol einfügen
ICF Top/Right ICF oben/rechts
ICF Bottom/Left ICF unten/links
Show Font Height Options Optionen für Schrifthöhe einblenden
Hide Font Height Options Optionen für Schrifthöhe ausblenden
Set Font Height Reference Bezugsgröße der Schrifthöhe festlegen
Em Box Geviert
Cap Height Versalhöhe
x-Height x-Höhe
ICF Box ICF-Rahmen
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` ist der Schrittverweis {r}, den nur `run` und `command_batch` auflösen
@msg `ids` must be an array of object ids, not {v} `ids` muss ein Array von Objekt-IDs sein, nicht {v}
@msg `corners` must be an array of anchor indices, not {v} `corners` muss ein Array von Ankerpunkt-Indizes sein, nicht {v}
@@ -5526,6 +5537,13 @@ anchor Points: Punkte:
@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` geben Sie `inkLimit`, `removeWhiteOverprint` oder `overprintBlack` an
@msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Druckfarbkorrekturen wirken in CMYK-Dokumenten: Wählen Sie zuerst Datei › Dokumentfarbmodus › CMYK-Farbe
@msg `{key}` must be 0..{max} `{key}` muss 0..{max} sein
# Color Effects add-on: Find Nearest Swatch (Object › Plug-ins › Color Effects).
Find Nearest Swatch… Ähnlichstes Farbfeld suchen…
Find Nearest Swatch Ähnlichstes Farbfeld suchen
Apply Nearest Swatch Ähnlichstes Farbfeld anwenden
Select objects to find the swatches nearest their colors. Wählen Sie Objekte aus, um die Farbfelder zu finden, die ihren Farben am nächsten kommen.
@msg select objects or give `color` wählen Sie Objekte aus oder geben Sie `color` an
@msg `color` is not a colour `color` ist keine Farbe
# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path.
Corner Tool Ecken-Werkzeug
Slide Anchor Along Path Ankerpunkt entlang des Pfads verschieben
Can't render this file because it contains an unexpected character in line 2 and column 73.

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