diff --git a/ROADMAP.md b/ROADMAP.md index c85be5aef..4d5918cba 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. | diff --git a/crates/cad/Cargo.toml b/crates/cad/Cargo.toml index 4886d9ea5..2ea6dea4f 100644 --- a/crates/cad/Cargo.toml +++ b/crates/cad/Cargo.toml @@ -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"] } diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 6ae29487a..ea3b8558d 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -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; diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index ba0a13b76..f1b537b70 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -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![]; diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index bde5fc809..e44b0be9e 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -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 { + 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> { + 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) { diff --git a/crates/color/src/gradient/tests.rs b/crates/color/src/gradient/tests.rs index c2d7d23d7..ab195b347 100644 --- a/crates/color/src/gradient/tests.rs +++ b/crates/color/src/gradient/tests.rs @@ -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); +} diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 478e54ca8..976d48485 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -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, diff --git a/crates/doc/src/document_ext.rs b/crates/doc/src/document_ext.rs index c0c85b124..b52e850c7 100644 --- a/crates/doc/src/document_ext.rs +++ b/crates/doc/src/document_ext.rs @@ -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(); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 26e2bfebf..b742a1dd7 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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, + /// 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, next_id: u64, /// Foreign data preserved on round-trip. #[serde(default, skip_serializing_if = "skip::is_empty_map")] diff --git a/crates/doc/src/records.rs b/crates/doc/src/records.rs index 434a85a8f..a6988f502 100644 --- a/crates/doc/src/records.rs +++ b/crates/doc/src/records.rs @@ -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, + 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, + /// The current layer from before editing was entered, current again on leaving. + #[serde(default)] + pub back_layer: Option, +} + /// 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 { diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 2b8875971..ef6da00e6 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -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, diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index dd84912f0..b5bc7e065 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -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, } +/// 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, } diff --git a/crates/doc/src/text/tests.rs b/crates/doc/src/text/tests.rs index bd53024b1..04deae8ee 100644 --- a/crates/doc/src/text/tests.rs +++ b/crates/doc/src/text/tests.rs @@ -60,6 +60,24 @@ fn digits_map_between_the_numeral_systems() { assert_eq!(serde_json::from_value::(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::(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::(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] diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 875826262..31d0ca2cb 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -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 { - 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. diff --git a/crates/engine/src/cmd/addons/arrange.rs b/crates/engine/src/cmd/addons/arrange.rs index 9b11a7115..735d72133 100644 --- a/crates/engine/src/cmd/addons/arrange.rs +++ b/crates/engine/src/cmd/addons/arrange.rs @@ -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> { +pub(super) fn whole_in(p: &Value, key: &str, (lo, hi): (u64, u64), cmd: &str) -> Result> { 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}"))) } diff --git a/crates/engine/src/cmd/addons/color.rs b/crates/engine/src/cmd/addons/color.rs index 4426624cc..3dc037a2f 100644 --- a/crates/engine/src/cmd/addons/color.rs +++ b/crates/engine/src/cmd/addons/color.rs @@ -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 { 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 + ), ] } diff --git a/crates/engine/src/cmd/addons/color/nearest.rs b/crates/engine/src/cmd/addons/color/nearest.rs new file mode 100644 index 000000000..5a9d0e7c7 --- /dev/null +++ b/crates/engine/src/cmd/addons/color/nearest.rs @@ -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 { + let mut out: Vec = vec![]; + let mut add = |p: &Paint| { + let colors: Vec = 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 { + 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 = colors + .iter() + .map(|c| { + let matches: Vec = 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 { + 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 = 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 = 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, 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::>(), "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; diff --git a/crates/engine/src/cmd/addons/color/nearest/tests.rs b/crates/engine/src/cmd/addons/color/nearest/tests.rs new file mode 100644 index 000000000..0436d19c8 --- /dev/null +++ b/crates/engine/src/cmd/addons/color/nearest/tests.rs @@ -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 = 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"); +} diff --git a/crates/engine/src/cmd/addons/document.rs b/crates/engine/src/cmd/addons/document.rs index faa02efc2..30548b80a 100644 --- a/crates/engine/src/cmd/addons/document.rs +++ b/crates/engine/src/cmd/addons/document.rs @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "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 (.; -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 (.; -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 { 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 { 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 { 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() })), diff --git a/crates/engine/src/cmd/addons/document/tests.rs b/crates/engine/src/cmd/addons/document/tests.rs index aef604eb3..562a389e8 100644 --- a/crates/engine/src/cmd/addons/document/tests.rs +++ b/crates/engine/src/cmd/addons/document/tests.rs @@ -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 = 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 = + 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); +} diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 7f78aa246..a1f03b6a3 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -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 { "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", diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index 556926309..0b69b7f8a 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -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 { +/// 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 { 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 { 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 { Ok(json!({ "ids": ids_json(&out) })) } -pub(super) fn symbol_edit(s: &mut Session, p: &Value) -> Result { - 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 { 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 { 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 { 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 { } Ok(()) })?; + if let Some(se) = &session { + crate::cmd::symboledit::leave_editing(s, se)?; + } ok() } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 3534d40ac..a00f90952 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -481,6 +481,7 @@ fn undo(s: &mut Session, _: &Value) -> Result { 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 { 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 { 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 { 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 })) diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 4079b4230..fb9419052 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -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, diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 4588b58aa..f80302eee 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "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 for the document, for the artboard, asset, layer or object, from 1, #### for the counter zero-padded to the run of #, , , (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, ,