diff --git a/AGENTS.md b/AGENTS.md index 0b6171cd3..61f141079 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. - **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`. - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`. - **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`). diff --git a/CLAUDE.md b/CLAUDE.md index 899f22e39..cbf9de129 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. - **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`. - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`. - **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`). diff --git a/Cargo.lock b/Cargo.lock index 4daf5fd2d..9cea5c99c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -712,6 +712,15 @@ dependencies = [ "rand_core", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + [[package]] name = "cipher" version = "0.4.4" @@ -4569,6 +4578,7 @@ version = "0.14.0" dependencies = [ "arboard", "block2 0.6.2", + "chrono", "clipboard-win", "craft-print", "craft-window", @@ -4678,6 +4688,7 @@ dependencies = [ name = "vectorcraft-doc" version = "0.14.0" dependencies = [ + "chrono", "hayro-ccitt", "image", "jpeg-encoder", @@ -4846,6 +4857,7 @@ name = "vectorcraft-pdf" version = "0.14.0" dependencies = [ "aes", + "chrono", "craft-pdf", "flate2", "image", diff --git a/Cargo.toml b/Cargo.toml index afd89a8d8..725ae7ce1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,8 @@ thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = { version = "1", features = ["float_roundtrip"] } log = "0.4" +# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). +chrono = { version = "0.4.45", default-features = false, features = ["std"] } proptest = "1" crc32fast = "1.5" # tests: a TCP port bound without listening (connections to it are refused) diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index e2ba95d64..196e1679c 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -27,6 +27,7 @@ eframe = { workspace = true, features = ["default_fonts", "wayland", "x11"] } egui = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +chrono = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { workspace = true } diff --git a/apps/vectorcraft/src/logging.rs b/apps/vectorcraft/src/logging.rs index cd2ea1eba..a9fded642 100644 --- a/apps/vectorcraft/src/logging.rs +++ b/apps/vectorcraft/src/logging.rs @@ -93,28 +93,36 @@ fn matches(name: &str, target: &str) -> bool { } /// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so +/// logging never panics and never silently resets to the epoch for a convertible instant. pub fn timestamp(t: SystemTime) -> String { let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); - let secs = since.as_secs(); - let (days, rem) = (secs / 86_400, secs % 86_400); - let (y, m, d) = civil_from_days(days); - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) + // Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value + // past `i64::MAX` seconds saturates rather than falling back to the epoch. + let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX); + let (y, t) = utc_parts(secs, since.subsec_nanos()); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ")) } -/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's -/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock -/// value can overflow it. -fn civil_from_days(days: u64) -> (u64, u64, u64) { - let z = days.saturating_add(719_468); - let era = z / 146_097; - let doe = z % 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2)); - (y, m, d) +/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into +/// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days), +/// so month, day and time of day come from chrono and only the year is carried, which keeps every +/// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic. +fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999))); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } } fn numbered(dir: &Path, n: usize) -> PathBuf { @@ -387,6 +395,20 @@ mod tests { assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); // Before the epoch (a clock set wrong) clamps instead of panicking. assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + // Past chrono's year range still converts via the 400-year cycle (the old code did too, + // through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows' + // SystemTime can't hold this instant, so it is only checked where it exists. + // `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output). + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { + assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); + } + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); + // 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857. + // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { + assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); + } } #[test] diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index fe957b22b..297f9f950 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -33,6 +33,9 @@ const WIDE_LINES: &str = const STROKE_EXTRAS: &str = "stroke alignment, width profiles, arrowheads and brushes are left out (Maximize Editability writes strokes as lines)"; const CLIPPING: &str = "clipping masks are not applied: the art they clip is written whole"; const TYPE_ITEMS: &str = "fills and strokes added to whole type objects are left out of text entities (outline the text to keep them)"; +/// The warning for an image left out because it can't be decoded: the note raster exports give +/// (`vectorcraft-engine`'s `rasterfx::undecoded_warning`). +const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture"; /// One segment of a subpath, cubics for every curve. #[derive(Clone, Copy)] @@ -973,8 +976,8 @@ impl<'a> Scene<'a> { /// Image `key` as a file in the chosen format, and its size in pixels. fn encode_image(&self, key: &str) -> Result<(DxfImage, (u32, u32)), String> { - let blob = self.doc.images.get(key).filter(|b| !b.bytes.is_empty()).ok_or_else(|| format!("image '{key}' has no pixels and was left out"))?; - let img = image::load_from_memory(&blob.bytes).map_err(|_| format!("image '{key}' could not be decoded and was left out"))?; + let blob = self.doc.images.get(key).filter(|b| !b.bytes.is_empty()).ok_or_else(|| "an image has no pixels and was left out".to_string())?; + let img = image::load_from_memory(&blob.bytes).map_err(|_| UNDECODED.to_string())?; let size = (img.width(), img.height()); let mut bytes = vec![]; let written = match self.o.raster { @@ -992,7 +995,7 @@ impl<'a> Scene<'a> { rgb.write_with_encoder(image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, 90)) } }; - written.map_err(|e| format!("image '{key}' could not be written: {e}"))?; + written.map_err(|e| format!("an image could not be written: {e}"))?; Ok((DxfImage { name: format!("{}.{}", blob.content_key(), self.o.raster.extension()), bytes }, size)) } } diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 9256d3473..95be8540e 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -526,6 +526,24 @@ fn images_link_png_or_jpeg_files() { assert!(r12.warnings.iter().any(|w| w.contains("no images"))); } +#[test] +fn images_without_pixels_are_left_out_with_one_note() { + // One image has no data in the document, and the other image's data is empty. + let mut d = doc_with(|d| { + ["raster-1", "raster-2"] + .into_iter() + .map(|key| { + let im = ImageObject { key: key.into(), width: 4, height: 2, xf: Affine::IDENTITY, link: None, placement: Default::default() }; + Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))) + }) + .collect() + }); + d.images.insert("raster-2".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(vec![]), proxy: None }); + let out = export(&d, &opts(&d)).unwrap(); + assert!(entities(&out).is_empty() && out.images.is_empty()); + assert_eq!(out.warnings, ["an image has no pixels and was left out"]); +} + #[test] fn crop_leaves_out_art_outside_the_region() { let d = shapes_doc(); diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml index 5260be910..0ee88bf3b 100644 --- a/crates/doc/Cargo.toml +++ b/crates/doc/Cargo.toml @@ -20,6 +20,7 @@ zune-jpeg = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } +chrono = { workspace = true } [dev-dependencies] jpeg-encoder = { workspace = true } diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 3c57411a1..8ba6fb174 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -579,11 +579,18 @@ impl Lerp { /// The object at `t` (0 = `a`, 1 = `b`), with id 0. pub fn at(&self, t: f64) -> Node { + self.at_eased(t, t) + } + + /// The object whose shape is at `t` and whose colours (fills, strokes, opacity, mesh and text + /// colours) are at `tc`: a blend with its colours eased apart from its spacing. + pub fn at_eased(&self, t: f64, tc: f64) -> Node { let (a, b) = (&self.a, &self.b); let base = if t < 0.5 { a } else { b }; let mut n = match &self.shape { Shape::Group { children, clip } => { - let mut g = Node::new(NodeId(0), NodeKind::Group { children: children.iter().map(|c| Arc::new(c.at(t))).collect(), clip: *clip }); + let mut g = + Node::new(NodeId(0), NodeKind::Group { children: children.iter().map(|c| Arc::new(c.at_eased(t, tc))).collect(), clip: *clip }); g.isolate = base.isolate; g.knockout = base.knockout; g @@ -593,8 +600,8 @@ impl Lerp { if let (NodeKind::Mesh(m), NodeKind::Mesh(ma), NodeKind::Mesh(mb)) = (&mut n.kind, &a.kind, &b.kind) { for ((p, x), y) in m.points.iter_mut().zip(&ma.points).zip(&mb.points) { p.p = x.p.lerp(y.p, t); - p.color = lerp_color(&x.color, &y.color, t as f32); - p.opacity = lerp_f32(x.opacity, y.opacity, t as f32); + p.color = lerp_color(&x.color, &y.color, tc as f32); + p.opacity = lerp_f32(x.opacity, y.opacity, tc as f32); for h in 0..4 { p.handles[h] = x.handles[h].lerp(y.handles[h], t); } @@ -620,7 +627,7 @@ impl Lerp { Shape::Placed => { let mut n = base.clone(); match (&mut n.kind, &a.kind, &b.kind) { - (NodeKind::Text(o), NodeKind::Text(x), NodeKind::Text(y)) => lerp_text(o, x, y, t), + (NodeKind::Text(o), NodeKind::Text(x), NodeKind::Text(y)) => lerp_text(o, x, y, t, tc), (NodeKind::SymbolInstance { xf: o, .. }, NodeKind::SymbolInstance { xf: x, .. }, NodeKind::SymbolInstance { xf: y, .. }) => { *o = lerp_affine(*x, *y, t) } @@ -657,8 +664,8 @@ impl Lerp { } }; n.id = NodeId(0); - n.appearance = lerp_appearance(&a.appearance, &b.appearance, t).into(); - n.opacity = lerp_f32(a.opacity, b.opacity, t as f32); + n.appearance = lerp_appearance(&a.appearance, &b.appearance, tc).into(); + n.opacity = lerp_f32(a.opacity, b.opacity, tc as f32); n.blend = base.blend; n.visible = true; n.locked = false; @@ -688,7 +695,7 @@ fn compound(path: PathData, rule: FillRule, counts: &[usize], total: usize) -> N /// Interpolate text `x` → `y` into `o` (a copy of the nearer): the transform by its parts, an /// area frame anchor by anchor, and each run's colours, stroke weight and size from the runs at /// the same place (by character) of both. -fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64) { +fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64, tc: f64) { o.xf = lerp_affine(x.xf, y.xf, t); if let (crate::text::TextKind::Area { frame: f }, crate::text::TextKind::Area { frame: fx }, crate::text::TextKind::Area { frame: fy }) = (&mut o.kind, &x.kind, &y.kind) @@ -710,7 +717,7 @@ fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64) { } let total: usize = o.runs.iter().map(|r| r.text.chars().count()).sum::().max(1); let mut start = 0usize; - let tf = t as f32; + let tf = tc as f32; let mut resized = false; for r in &mut o.runs { let len = r.text.chars().count(); @@ -1071,8 +1078,10 @@ pub fn blend_expand(keys: &[Arc], spec: &BlendSpec) -> Vec { let n = blend_step_count(a, b, spec.spacing, len); let lerp = Lerp::new(a, b, (spec.start(i), spec.start(i + 1))); for j in 1..=n { - let t = j as f64 / (n + 1) as f64; - let mut s = lerp.at(t); + // Even position of the step, then where the easing puts its shape and its colours. + let u = j as f64 / (n + 1) as f64; + let (t, tc) = (if rail.is_some() { spec.ease.apply(u) } else { u }, spec.color_position(u)); + let mut s = lerp.at_eased(t, tc); if let Some(r) = &rail { r.place(&mut s, i, t); } @@ -1134,6 +1143,55 @@ mod tests { assert_eq!(out[0].geometric_bounds().unwrap().center(), Point::new(5.0, 5.0), "keys stay put"); } + #[test] + fn easing_keeps_the_ends_and_stays_monotone() { + use crate::live::{BlendEase, BlendEaseType as K}; + for kind in [K::In, K::Out, K::InOut, K::OutIn] { + for ramp in [0.0, 0.5, 1.0, f64::NAN, f64::INFINITY] { + let e = BlendEase { kind, ramp }; + assert_eq!((e.apply(0.0), e.apply(1.0)), (0.0, 1.0)); + let ys: Vec = (0..=100).map(|i| e.apply(i as f64 / 100.0)).collect(); + assert!(ys.windows(2).all(|w| w[0] <= w[1])); + assert_eq!(e.apply(f64::NAN), 0.0); + if ramp == 0.0 { + assert_eq!(e.apply(0.25), 0.25); + } + } + } + let e = |kind| BlendEase { kind, ramp: 1.0 }; + assert!(e(K::In).apply(0.25) < 0.25); + assert!(e(K::Out).apply(0.25) > 0.25); + assert!(e(K::InOut).apply(0.25) < 0.25 && e(K::InOut).apply(0.75) > 0.75); + assert!(e(K::OutIn).apply(0.25) > 0.25 && e(K::OutIn).apply(0.75) < 0.75); + } + + #[test] + fn colour_easing_moves_colours_apart_from_positions() { + let rect = |x: f64, c: Color| { + let mut n = Node::path(NodeId(1), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::default()); + n.appearance = Appearance::basic(Paint::solid(c), Paint::None, 0.0).into(); + Arc::new(n) + }; + let keys = vec![rect(0.0, Color::BLACK), rect(100.0, Color::WHITE)]; + let mid = |spec: &BlendSpec| { + let out = blend_expand(&keys, spec); + let n = &out[1]; + (n.geometric_bounds().unwrap().x0, n.appearance.fill_paint()) + }; + // Position easing runs along a spine: Align to Path gives the blend one. + let base = BlendSpec { spacing: BlendSpacing::Steps(1), orientation: BlendOrientation::AlignToPath, ..Default::default() }; + let (x0, p0) = mid(&base); + // Colour easing alone leaves the position and moves the colour. + let colour = BlendSpec { color_shift: 50.0, ..base.clone() }; + let (x1, p1) = mid(&colour); + assert!((x0 - x1).abs() < 1e-9 && p0 != p1); + // Position easing alone leaves the colour and moves the position. + let pos = BlendSpec { ease: crate::live::BlendEase { kind: crate::live::BlendEaseType::In, ramp: 1.0 }, ..base.clone() }; + let (x2, p2) = mid(&pos); + assert!((x0 - x2).abs() > 1e-3 && p0 == p2); + assert_eq!(mid(&pos).0, x2, "deterministic"); + } + #[test] fn affine_lerp_turns_the_short_way() { let x = Affine::rotate(170f64.to_radians()); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 0817b1cd6..63aad8521 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -99,7 +99,7 @@ pub use graph::{ }; pub use hit::{Hit, HitKind}; pub use links::{LinkInfo, PlacementOptions}; -pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint}; +pub use live::{BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint}; pub use live_paint::{GapOptions, GapSize}; pub use metadata::{CopyrightStatus, DocMetadata}; pub use node::Knockout; @@ -935,6 +935,136 @@ type Places = BTreeMap, Vec<(usize, usize)>>; pub const FORMAT_VERSION: u32 = 1; impl Document { + /// Is everything but the layers the same in `other`, as the next version of the document after + /// an edit of its art? The id counter and the id index are left out, and art outside the layers + /// (symbols, patterns, graph designs) compares by identity, which an edit keeps for what it + /// doesn't change: quick for every frame of an animation. + pub fn same_outside_layers(&self, other: &Self) -> bool { + let same_art = |a: &[Arc], b: &[Arc]| a.len() == b.len() && a.iter().zip(b).all(|(a, b)| Arc::ptr_eq(a, b)); + let same_pattern = |a: &PatternDef, b: &PatternDef| { + same_art(&a.art, &b.art) && PatternDef { art: Vec::new(), ..a.clone() } == PatternDef { art: Vec::new(), ..b.clone() } + }; + let Self { + version, + title, + template, + units, + ruler_origin, + rulers, + artboard_ruler_origins, + color_mode, + artboards, + layers: _, + swatches, + swatch_groups, + graphic_styles, + char_styles, + para_styles, + text_threads, + symbols, + graph_designs, + guides, + views, + saved_selections, + variables, + grid, + raster_effects_ppi, + images, + patterns, + pattern_edit, + mask_edit, + next_id: _, + unknown, + page_isolate, + page_knockout, + spot_use_lab, + setup, + paste_remembers_layers, + metadata, + raster_effects, + last_view, + transparency_grid, + layers_open, + color_profiles, + export_settings, + extra, + slices, + slices_clip_to_artboard, + print_setup, + assets, + svg_scripts, + puppet, + new_art, + dimensions, + index: _, + } = self; + *version == other.version + && *title == other.title + && *template == other.template + && *units == other.units + && *ruler_origin == other.ruler_origin + && *rulers == other.rulers + && *artboard_ruler_origins == other.artboard_ruler_origins + && *color_mode == other.color_mode + && *artboards == other.artboards + && *swatches == other.swatches + && *swatch_groups == other.swatch_groups + && *graphic_styles == other.graphic_styles + && *char_styles == other.char_styles + && *para_styles == other.para_styles + && *text_threads == other.text_threads + && symbols.len() == other.symbols.len() + && symbols.iter().zip(&other.symbols).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art)) + && graph_designs.len() == other.graph_designs.len() + && graph_designs.iter().zip(&other.graph_designs).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art)) + && *guides == other.guides + && *views == other.views + && *saved_selections == other.saved_selections + && *variables == other.variables + && *grid == other.grid + && *raster_effects_ppi == other.raster_effects_ppi + && *images == other.images + && patterns.len() == other.patterns.len() + && patterns.iter().zip(&other.patterns).all(|(a, b)| same_pattern(a, b)) + && match (pattern_edit, &other.pattern_edit) { + (Some(a), Some(b)) => { + a.pattern == b.pattern + && a.layer == b.layer + && match (&a.original, &b.original) { + (Some(a), Some(b)) => same_pattern(a, b), + (a, b) => a.is_none() && b.is_none(), + } + } + (a, b) => a.is_none() && b.is_none(), + } + && *mask_edit == other.mask_edit + && *unknown == other.unknown + && *page_isolate == other.page_isolate + && *page_knockout == other.page_knockout + && *spot_use_lab == other.spot_use_lab + && *setup == other.setup + && *paste_remembers_layers == other.paste_remembers_layers + && *metadata == other.metadata + && *raster_effects == other.raster_effects + && *last_view == other.last_view + && *transparency_grid == other.transparency_grid + && *layers_open == other.layers_open + && *color_profiles == other.color_profiles + && *export_settings == other.export_settings + && *extra == other.extra + && *slices == other.slices + && *slices_clip_to_artboard == other.slices_clip_to_artboard + && *print_setup == other.print_setup + && *assets == other.assets + && *svg_scripts == other.svg_scripts + && match (puppet, &other.puppet) { + (Some(a), Some(b)) => Arc::ptr_eq(a, b), + (a, b) => a.is_none() && b.is_none(), + } + && *new_art == other.new_art + && *dimensions == other.dimensions + } + /// Document Raster Effects Settings › Resolution in ppi, 1 to 2400 (72 when the stored value /// isn't a number). pub fn raster_ppi(&self) -> f64 { diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 9175c88c5..7fac073dc 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -60,12 +60,75 @@ pub enum BlendOrientation { AlignToPath, } +/// Distribution of blend steps, as offered by the Blend panel's Type menu. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BlendEaseType { + #[default] + In, + Out, + InOut, + OutIn, +} + +impl BlendEaseType { + pub fn id(self) -> &'static str { + match self { + Self::In => "easeIn", + Self::Out => "easeOut", + Self::InOut => "easeInOut", + Self::OutIn => "easeOutIn", + } + } +} + +/// Blend panel Type and Ramp. Ramp is a fraction from 0 (even) to 1. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct BlendEase { + #[serde(default)] + pub kind: BlendEaseType, + #[serde(default, deserialize_with = "blend_ramp")] + pub ramp: f64, +} + +impl BlendEase { + pub fn is_linear(&self) -> bool { + self.ramp <= 0.0 + } + + /// Only the default Type and Ramp can be omitted without losing a chosen Type at Ramp 0. + pub fn is_default(&self) -> bool { + self.kind == BlendEaseType::In && self.ramp == 0.0 + } + + /// A monotone polynomial distribution, blended with even spacing by Ramp. Public help + /// describes the four distributions, not their numeric curves; these curves are original. + pub fn apply(&self, u: f64) -> f64 { + let u = if u.is_finite() { u.clamp(0.0, 1.0) } else { 0.0 }; + let r = if self.ramp.is_finite() { self.ramp.clamp(0.0, 1.0) } else { 0.0 }; + let eased = match self.kind { + BlendEaseType::In => u * u, + BlendEaseType::Out => u * (2.0 - u), + BlendEaseType::InOut => u * u * (3.0 - 2.0 * u), + BlendEaseType::OutIn if u < 0.5 => 2.0 * u * (1.0 - u), + BlendEaseType::OutIn => 0.5 + 2.0 * (u - 0.5) * (u - 0.5), + }; + u + r * (eased - u) + } +} + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct BlendSpec { #[serde(default)] pub spacing: BlendSpacing, #[serde(default)] pub orientation: BlendOrientation, + /// Acceleration of the steps' shapes and positions along the spine between two keys. + #[serde(default, skip_serializing_if = "BlendEase::is_default")] + pub ease: BlendEase, + /// Independent color acceleration. -100..100, with 0 preserving the even color ramp. + #[serde(default, rename = "colorShift", skip_serializing_if = "is_zero", deserialize_with = "blend_color_shift")] + pub color_shift: f64, /// Replaced spine. `None` = the straight lines between the key objects' centres. #[serde(default, skip_serializing_if = "Option::is_none")] pub spine: Option, @@ -1757,10 +1820,58 @@ pub(crate) fn max_outset(nodes: &[Arc], symbols: Option<&crate::SymbolBoxe o } +/// Reading a native file never fails over one bad blend number: out-of-range values clamp, +/// and anything that is not a finite number (NaN, null, text) becomes the default. The +/// commands still refuse such input (`ease_param`). +fn blend_number<'de, D: serde::Deserializer<'de>>(d: D, min: f64, max: f64) -> Result { + let v = serde_json::Value::deserialize(d)?; + Ok(v.as_f64().filter(|n| n.is_finite()).map_or(0.0, |n| n.clamp(min, max))) +} + +fn blend_ramp<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + blend_number(d, 0.0, 1.0) +} + +fn blend_color_shift<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + blend_number(d, -100.0, 100.0) +} + +/// Whether an optional stored color shift has its unchanged default. +fn is_zero(v: &f64) -> bool { + *v == 0.0 +} + +impl BlendSpec { + /// Shift the color transition independently of shape placement. The exponential curve is + /// original; the documented behavior specifies the direction, not numeric sample values. + pub fn color_position(&self, u: f64) -> f64 { + let u = if u.is_finite() { u.clamp(0.0, 1.0) } else { 0.0 }; + let shift = if self.color_shift.is_finite() { self.color_shift.clamp(-100.0, 100.0) } else { 0.0 }; + if shift == 0.0 { u } else { u.powf(2.0f64.powf(shift / 50.0)) } + } +} + #[cfg(test)] mod tests { use super::*; + /// A bad Ramp or Shift in a file clamps (non-finite values take the default) instead of + /// failing the whole document. + #[test] + fn blend_easing_numbers_clamp_when_read() { + use serde::de::value::{Error, F64Deserializer}; + let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"kind":"out","ramp":7.5},"colorShift":-900}"#).unwrap(); + assert_eq!((spec.ease.ramp, spec.color_shift), (1.0, -100.0)); + let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"ramp":-3},"colorShift":250}"#).unwrap(); + assert_eq!((spec.ease.ramp, spec.color_shift), (0.0, 100.0)); + let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"ramp":null},"colorShift":"NaN"}"#).unwrap(); + assert_eq!((spec.ease.ramp, spec.color_shift), (0.0, 0.0)); + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert_eq!(blend_ramp(F64Deserializer::::new(bad)).unwrap(), 0.0); + assert_eq!(blend_color_shift(F64Deserializer::::new(bad)).unwrap(), 0.0); + } + } + /// #1578: Arc bends in the box's real proportions. In half-width units (y scaled by the /// aspect), the top and bottom edges are concentric circles about the bend's centre, at their /// real distance from the centre line; an inner edge that would pass the centre stops there. diff --git a/crates/doc/src/metadata.rs b/crates/doc/src/metadata.rs index 708cb82e8..1a84ef219 100644 --- a/crates/doc/src/metadata.rs +++ b/crates/doc/src/metadata.rs @@ -3,6 +3,7 @@ //! document title is [`crate::Document::title`]. Every field is defaulted, so documents saved //! before File Info existed load unchanged. +use chrono::{Datelike, Timelike}; use serde::{Deserialize, Serialize}; /// File Info → Copyright Status. @@ -118,23 +119,26 @@ pub fn now_unix() -> Option { None } -/// Unix seconds (UTC) → `[year, month, day, hour, minute, second]` (civil-from-days, proleptic -/// Gregorian; years clamped to 0–9999). +/// Unix seconds (UTC) → `[year, month, day, hour, minute, second]` (proleptic Gregorian; years +/// clamped to 0–9999). pub fn civil(t: i64) -> [i64; 6] { - // Clamp first so every step stays in range for any input. + // Clamp the input first so every input maps to a representable date (0000-01-01 .. 9999-12-31), + // exactly as the old code did. The clamped instant is always inside chrono's range, so the + // conversion below never falls back; the trailing `clamp(0, 9999)` is the old year clamp. let t = t.clamp(-62_167_219_200, 253_402_300_799); - let days = t.div_euclid(86_400); - let secs = t.rem_euclid(86_400); - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let day = doy - (153 * mp + 2) / 5 + 1; - let month = if mp < 10 { mp + 3 } else { mp - 9 }; - let year = yoe + era * 400 + i64::from(month <= 2); - [year.clamp(0, 9999), month, day, secs / 3600, secs / 60 % 60, secs % 60] + let (days, sod) = (t.div_euclid(86_400), t.rem_euclid(86_400)); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0)); + match (date, time) { + (Some(d), Some(tm)) => { + let year = i128::from(d.year()) + i128::from(cycles) * 400; + let year = i64::try_from(year).unwrap_or(if year < 0 { i64::MIN } else { i64::MAX }); + [year.clamp(0, 9999), i64::from(d.month()), i64::from(d.day()), i64::from(tm.hour()), i64::from(tm.minute()), i64::from(tm.second())] + } + _ => [1970, 1, 1, 0, 0, 0], // unreachable: the clamped `t` is always a valid date + } } /// Unix seconds → `2026-10-05T14:03:00Z`. @@ -151,8 +155,15 @@ mod tests { fn dates_and_statuses() { assert_eq!(iso8601(0), "1970-01-01T00:00:00Z"); assert_eq!(iso8601(1_791_208_980), "2026-10-05T14:03:00Z"); + // The input is clamped to 0000-01-01..9999-12-31, then the year is clamped to 0..=9999, + // exactly as the old code did: inputs far past chrono's range land on the clamp bounds, + // never on the epoch. assert_eq!(civil(i64::MIN)[0], 0); assert_eq!(civil(i64::MAX)[0], 9999); + assert_eq!(iso8601(i64::MIN), "0000-01-01T00:00:00Z"); + assert_eq!(iso8601(i64::MAX), "9999-12-31T23:59:59Z"); + assert_eq!(iso8601(10_000_000_000_000), "9999-12-31T23:59:59Z"); + assert_eq!(iso8601(-10_000_000_000_000), "0000-01-01T00:00:00Z"); assert_eq!(CopyrightStatus::parse("Public Domain"), Some(CopyrightStatus::PublicDomain)); assert_eq!(CopyrightStatus::parse("publicdomain"), Some(CopyrightStatus::PublicDomain)); assert_eq!(CopyrightStatus::parse("maybe"), None); diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index ad2d76b19..eef469061 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -1301,10 +1301,8 @@ impl Node { self.projected(Some(b)).map(|b| b.inflate(o, o)) } NodeKind::Text(t) => { - // The characters' strokes paint around the glyphs, scaled with the type. - let k = Scaling::factor(t.xf).unwrap_or(1.0); - let chars = t.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k; - let o = self.appearance.outset().max(chars); + // The characters' strokes paint around the glyphs. + let o = self.appearance.outset().max(t.char_stroke_outset()); self.geometric(symbols).map(|b| b.inflate(o, o)) } // An image is measured by its frame, without its own fills and strokes. diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 3b7df2f42..1dc202553 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -104,6 +104,12 @@ pub struct CharStyle { /// their line line up with it. #[serde(default, rename = "charAlign", skip_serializing_if = "crate::skip::is_default")] pub char_align: CharAlign, + /// Character Direction (Character panel menu, Middle Eastern options): these characters are + /// set left to right or right to left inside their paragraph, each stretch of them a Unicode + /// bidi embedding (UAX #9 LRE or RLE); None = Default, the direction the bidi algorithm gives + /// them. + #[serde(default, rename = "charDirection", skip_serializing_if = "Option::is_none")] + pub char_direction: Option, /// Proportional Metrics (Character panel menu, East Asian options): full-width glyphs take the /// font's own proportional widths, OpenType `palt` (#966); in vertical type upright glyphs take /// its proportional heights, `vpal`. @@ -297,6 +303,7 @@ impl Default for CharStyle { stroke_align: StrokeAlign::Center, position: CharPosition::Normal, char_align: CharAlign::RomanBaseline, + char_direction: None, proportional_metrics: false, tate_chu_yoko: false, small_caps: None, @@ -862,7 +869,8 @@ impl From for ParaStyle { } } -/// A paragraph's base direction ([`ParaStyle::direction`]). +/// A paragraph's base direction ([`ParaStyle::direction`]), or the direction characters are set +/// in ([`CharStyle::char_direction`]). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum ParaDirection { @@ -1702,6 +1710,12 @@ impl TextObject { TextKind::Point => Some(self.xf.transform_rect_bbox(self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()))), } } + /// How far the characters' strokes paint past their glyphs, in document space: they scale + /// with the type. + pub fn char_stroke_outset(&self) -> f64 { + let k = crate::node::Scaling::factor(self.xf).unwrap_or(1.0); + self.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k + } pub fn transform(&mut self, a: Affine) { self.xf = a * self.xf; } @@ -1906,6 +1920,22 @@ impl crate::Document { mod tests { use super::*; + /// Character Direction is saved only when set, and styles written before it existed read as + /// Default. + #[test] + fn character_direction_is_saved_only_when_set() { + let st = CharStyle::default(); + assert_eq!(st.char_direction, None); + assert!(serde_json::to_value(&st).unwrap().get("charDirection").is_none()); + for (d, key) in [(ParaDirection::LeftToRight, "leftToRight"), (ParaDirection::RightToLeft, "rightToLeft")] { + let set = CharStyle { char_direction: Some(d), ..CharStyle::default() }; + assert_eq!(serde_json::to_value(&set).unwrap()["charDirection"], key); + assert_eq!(serde_json::from_value::(serde_json::to_value(&set).unwrap()).unwrap(), set); + } + let old = serde_json::to_value(&st).unwrap(); + assert_eq!(serde_json::from_value::(old).unwrap().char_direction, None); + } + /// Ruby saves with the type and a file saved before it (no `ruby` key) reads with none. #[test] fn ruby_is_saved_and_missing_ruby_reads_as_none() { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index e9d2905f1..dd583e7b8 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -11,6 +11,7 @@ use super::clipboard::SwatchChoices; use super::*; use crate::{Clipboard, EngineError, inspect}; +/// Register editing commands, including document-to-document artwork drops and their parameter contracts. pub fn specs() -> Vec { vec![ cmd!( @@ -627,6 +628,7 @@ enum PasteMode { } impl PasteMode { + /// Return the user-visible action label for this insertion mode. fn label(self) -> &'static str { match self { PasteMode::Front => "Paste in Front", @@ -668,6 +670,7 @@ fn onto_artboard(doc: &Document, p: &Value, clip: &Clipboard) -> Affine { } } +/// Insert clipboard resources atomically in the active editable context using the requested placement mode. fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, choices: &SwatchChoices) -> Result { let off = s.prefs.paste_offset; let st = s.doc()?; diff --git a/crates/engine/src/cmd/fileio/tests_dxf.rs b/crates/engine/src/cmd/fileio/tests_dxf.rs index f31b8d721..6bea6dad7 100644 --- a/crates/engine/src/cmd/fileio/tests_dxf.rs +++ b/crates/engine/src/cmd/fileio/tests_dxf.rs @@ -125,6 +125,24 @@ fn placed_images_come_back_as_linked_files() { let _ = std::fs::remove_dir_all(dir); } +/// Two placed images whose pixel data can't be decoded are left out of the drawing and get no +/// linked file, and `warnings` holds the note raster exports give, once, without naming either +/// image's key. +#[test] +fn images_that_cant_be_decoded_give_the_raster_note_once() { + const NOTE: &str = "an image couldn't be decoded and is missing from the picture"; + let mut s = session(200.0, 100.0, 1); + crate::tests_menucmds::place_undecodable_png(&mut s); + crate::tests_menucmds::place_undecodable_png_sized(&mut s, 32, 16); + let keys: Vec = s.doc().unwrap().doc.images.keys().cloned().collect(); + assert_eq!(keys.len(), 2); + let r = s.execute("document.export", &json!({"format": "dxf"})).unwrap(); + let w: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + assert_eq!(w.iter().filter(|w| **w == NOTE).count(), 1, "{w:?}"); + assert!(!w.iter().any(|w| keys.iter().any(|k| w.contains(k.as_str()))), "{w:?}"); + assert!(entities(&text(&r)).is_empty() && r.get("linked").is_none(), "{r}"); +} + #[test] fn bad_dxf_options_are_refused() { let mut s = two_boards(); diff --git a/crates/engine/src/cmd/fileio/tests_eps.rs b/crates/engine/src/cmd/fileio/tests_eps.rs index 4ec90b60f..681d99852 100644 --- a/crates/engine/src/cmd/fileio/tests_eps.rs +++ b/crates/engine/src/cmd/fileio/tests_eps.rs @@ -156,6 +156,23 @@ fn placed_images_are_written_and_previews_are_black_and_white_on_request() { assert!(l3.contains("/FlateDecode filter >> image")); } +/// Two placed images whose pixel data can't be decoded are left out of the EPS, and `warnings` +/// holds the note raster exports give, once, without naming either image's key. +#[test] +fn images_that_cant_be_decoded_give_the_raster_note_once() { + const NOTE: &str = "an image couldn't be decoded and is missing from the picture"; + let mut s = session(200.0, 100.0, 1); + crate::tests_menucmds::place_undecodable_png(&mut s); + crate::tests_menucmds::place_undecodable_png_sized(&mut s, 32, 16); + let keys: Vec = s.doc().unwrap().doc.images.keys().cloned().collect(); + assert_eq!(keys.len(), 2); + let r = s.execute("document.exportEps", &json!({"previewFormat": "none"})).unwrap(); + let w = warnings(&r); + assert_eq!(w.iter().filter(|w| *w == NOTE).count(), 1, "{w:?}"); + assert!(!w.iter().any(|w| keys.iter().any(|k| w.contains(k.as_str()))), "{w:?}"); + assert!(!postscript(&b64(&r)).contains("filter >> image"), "no image is written"); +} + #[test] fn bad_eps_options_are_refused_and_formats_list_eps() { let mut s = session(100.0, 100.0, 1); diff --git a/crates/engine/src/cmd/fileio/tests_metafile.rs b/crates/engine/src/cmd/fileio/tests_metafile.rs index 9d21907aa..80c15ace3 100644 --- a/crates/engine/src/cmd/fileio/tests_metafile.rs +++ b/crates/engine/src/cmd/fileio/tests_metafile.rs @@ -95,6 +95,24 @@ fn raster_effects_are_written_as_images() { assert!(kinds.contains(&114), "the shadow is an image with transparency"); } +/// Two placed images whose pixel data can't be decoded are left out of an EMF or WMF, and +/// `warnings` holds the note raster exports give, once, without naming either image's key. +#[test] +fn images_that_cant_be_decoded_give_the_raster_note_once() { + const NOTE: &str = "an image couldn't be decoded and is missing from the picture"; + let mut s = session(200.0, 100.0, 1); + crate::tests_menucmds::place_undecodable_png(&mut s); + crate::tests_menucmds::place_undecodable_png_sized(&mut s, 32, 16); + let keys: Vec = s.doc().unwrap().doc.images.keys().cloned().collect(); + assert_eq!(keys.len(), 2); + for format in ["emf", "wmf"] { + let r = s.execute("document.export", &json!({"format": format})).unwrap(); + let w: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + assert_eq!(w.iter().filter(|w| **w == NOTE).count(), 1, "{format}: {w:?}"); + assert!(!w.iter().any(|w| keys.iter().any(|k| w.contains(k.as_str()))), "{format}: {w:?}"); + } +} + #[test] fn metafiles_open_and_place() { let mut s = two_boards(); diff --git a/crates/engine/src/cmd/fileio/tests_pdf.rs b/crates/engine/src/cmd/fileio/tests_pdf.rs index 7438c6fd3..6f4730729 100644 --- a/crates/engine/src/cmd/fileio/tests_pdf.rs +++ b/crates/engine/src/cmd/fileio/tests_pdf.rs @@ -131,6 +131,32 @@ fn options_not_applied_yet_and_document_features_warn() { assert!(warnings(&s.execute("document.export", &json!({"format": "png"})).unwrap()).is_empty()); } +/// A placed 16-bit PNG is written at 8 bits a sample in PDF 1.3 and 1.4 files (PDF/X-3 and the +/// pages of a .ai file too), and keeps 16 bits from PDF 1.5. +#[test] +fn a_16_bit_png_is_written_at_8_bits_before_pdf_1_5() { + let mut s = session(1); + let deep = image::ImageBuffer::from_fn(8, 8, |x, y| image::Rgb([x as u16 * 8000, y as u16 * 8000, 40000])); + let mut png = vec![]; + image::DynamicImage::ImageRgb16(deep).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + s.execute("file.place", &json!({"name": "deep.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [50, 40]})).unwrap(); + let readable = json!({"compressText": false}); + for (cmd, p, depth) in [ + ("document.exportPdf", json!({"compatibility": "1.3", "compression": readable}), 8), + ("document.exportPdf", json!({"compatibility": "1.4", "compression": readable}), 8), + ("document.exportPdf", json!({"standard": "pdfX3", "compression": readable}), 8), + ("file.saveCopy", json!({"format": "ai", "compatibility": "1.4", "compress": false}), 8), + ("document.exportPdf", json!({"compatibility": "1.5", "compression": readable}), 16), + ("document.exportPdf", json!({"compression": readable}), 16), + ] { + let v = s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")); + let text = String::from_utf8_lossy(&b64(&v)).into_owned(); + let (eight, sixteen) = (text.contains("/BitsPerComponent 8"), text.contains("/BitsPerComponent 16")); + assert_eq!((eight, sixteen), (depth == 8, depth == 16), "{cmd} {p}"); + assert!(warnings(&v).iter().all(|w| !w.contains("decoded")), "{cmd} {p}: {}", v["warnings"]); + } +} + /// A placed image whose pixel data can't be decoded is left out of every PDF the engine writes, /// and the rest of the page is written. Each reply that carries the PDF's warnings has the note /// raster exports give, once. diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs index 0f6debd88..9d417c7d9 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -10,8 +10,8 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::live::{ - self, BlendDefaults, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, EnvelopeMap, EnvelopeOptions, GradientMesh, MeshAppearance, - PreserveShape, Spine, + self, BlendDefaults, BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, EnvelopeMap, EnvelopeOptions, + GradientMesh, MeshAppearance, PreserveShape, Spine, }; use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection}; use vectorcraft_geom::{Affine, Anchor, PathData, Point, Vec2}; @@ -29,7 +29,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Blend"], Some("Cmd+Alt+B"), - "{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name and transparency) instead of nesting → {id}", + "{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100; 0: even)} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name and transparency) instead of nesting → {id}", has_doc, blend_make ), @@ -47,7 +47,7 @@ pub fn specs() -> Vec { "Blend Options…", ["Object", "Blend"], None, - "{spacing?: smooth|steps|distance, value?: n, steps?: n, distance?: pt, orientation?: page|path, smooth?: bool} set the options of the selected blends; with no blend selected, the options new blends start with (a tool setting, not an undo step) → {defaults?: true}", + "{spacing?: smooth|steps|distance, value?: n, steps?: n, distance?: pt, orientation?: page|path, smooth?: bool, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct} set the options of the selected blends; easingType chooses the distribution, ramp sets its strength (0..100; 0 = even), colorShift (-100..100; 0 = even) shifts colours independently of shape/position easing (omitted ones keep their value; out of range is an error); with no blend selected, the options new blends start with (spacing and orientation only; a tool setting, not an undo step) → {defaults?: true}", always, blend_options ), @@ -83,7 +83,7 @@ pub fn specs() -> Vec { "Blend Info", [], None, - "{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, keys?: [ids], starts?: [anchor|null], spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}", + "{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct, keys?: [ids], starts?: [anchor|null], spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}", always, blend_info ), @@ -451,6 +451,32 @@ fn orientation_param(p: &Value, current: BlendOrientation) -> BlendOrientation { } } +/// Blend panel Type and Ramp, validated before any document mutation. +fn ease_param(p: &Value, cmd: &str, current: BlendEase) -> Result { + let kind = match p.get("easingType").filter(|v| !v.is_null()) { + None => current.kind, + Some(v) => match v.as_str() { + Some("easeIn") => BlendEaseType::In, + Some("easeOut") => BlendEaseType::Out, + Some("easeInOut") => BlendEaseType::InOut, + Some("easeOutIn") => BlendEaseType::OutIn, + _ => return Err(bad(cmd, "easingType must be easeIn, easeOut, easeInOut or easeOutIn")), + }, + }; + Ok(BlendEase { kind, ramp: percent_param(p, cmd, "ramp", current.ramp * 100.0, 0.0)? / 100.0 }) +} + +/// Bounded percentage input; missing or null preserves the current value. +fn percent_param(p: &Value, cmd: &str, key: &str, current: f64, min: f64) -> Result { + match p.get(key).filter(|v| !v.is_null()) { + None => Ok(current), + Some(v) => v + .as_f64() + .filter(|n| n.is_finite() && (min..=100.0).contains(n)) + .ok_or_else(|| bad(cmd, format!("{key} must be a percentage {min}..100"))), + } +} + /// `starts`: per object of `ids` (else of `roots`), the anchor its blend starts from. fn starts_param(p: &Value, roots: &[NodeId]) -> Result> { const C: &str = "object.blend.make"; @@ -533,6 +559,12 @@ fn blend_make(s: &mut Session, p: &Value) -> Result { }; let spacing = spacing_param(p, C, cur.spacing)?; let orientation = orientation_param(p, cur.orientation); + let (ease0, color0) = match base.as_ref().map(|n| &n.kind) { + Some(NodeKind::Blend { spec, .. }) => (spec.ease, spec.color_shift), + _ => Default::default(), + }; + let ease = ease_param(p, C, ease0)?; + let color_shift = percent_param(p, C, "colorShift", color0, -100.0)?; let id = s.edit("Make Blend", |d, sel| { wrap(d, sel, &roots, |id, nodes| { let (children, starts) = merge_keys(nodes, &starts); @@ -543,7 +575,7 @@ fn blend_make(s: &mut Session, p: &Value) -> Result { n.knockout = vectorcraft_doc::Knockout::On; n }); - let mut spec = BlendSpec { spacing, orientation, starts, ..Default::default() }; + let mut spec = BlendSpec { spacing, orientation, ease, color_shift, starts, ..Default::default() }; if let NodeKind::Blend { children: old, spec: old_spec } = &n.kind { extend_spine(&mut spec, old, old_spec, &children); } @@ -772,7 +804,10 @@ fn selected_spec(s: &Session) -> Option<(NodeId, BlendSpec, Vec)> { fn blend_options(s: &mut Session, p: &Value) -> Result { const C: &str = "object.blend.options"; let Some((_, cur, _)) = selected_spec(s) else { - // Nothing to set them on: the options new blends start with. + if ["easingType", "ramp", "colorShift"].iter().any(|key| p.get(*key).is_some_and(|v| !v.is_null())) { + return Err(bad(C, "select a blend to set easing or color shift")); + } + // Nothing to set them on: the options new blends start with (easing is per blend). let d = s.prefs.blend_options.unwrap_or_default(); let spacing = spacing_param(p, C, d.spacing)?; s.prefs.blend_options = Some(BlendDefaults { spacing, orientation: orientation_param(p, d.orientation) }); @@ -780,9 +815,13 @@ fn blend_options(s: &mut Session, p: &Value) -> Result { }; let spacing = spacing_param(p, C, cur.spacing)?; let orientation = orientation_param(p, cur.orientation); + let ease = ease_param(p, C, cur.ease)?; + let color_shift = percent_param(p, C, "colorShift", cur.color_shift, -100.0)?; edit_blends(s, "Blend Options", |_, spec| { spec.spacing = spacing; spec.orientation = orientation; + spec.ease = ease; + spec.color_shift = color_shift; }) } @@ -804,6 +843,9 @@ fn blend_info(s: &mut Session, _: &Value) -> Result { let mut v = blend_options_json(spec.spacing, spec.orientation); v["target"] = json!("blend"); v["id"] = json!(id.0); + v["easingType"] = json!(spec.ease.kind.id()); + v["ramp"] = json!(spec.ease.ramp * 100.0); + v["colorShift"] = json!(spec.color_shift); v["starts"] = json!((0..keys.len()).map(|i| spec.start(i)).collect::>()); v["keys"] = json!(keys.iter().map(|k| k.0).collect::>()); if let Some(NodeKind::Blend { children, .. }) = s.doc()?.doc.node(id).map(|n| &n.kind) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 4817d38a0..4b5ccc8e3 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -252,8 +252,8 @@ pub fn specs() -> Vec { "Align", ["Window", "Align"], None, - "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection; a key guide, select.key {guide}, stays put and the objects line up on its line: a vertical guide sets where left, center and right go and vertical alignment leaves them, a horizontal one the other way round; aligning to the key, the other selected ruler guides move too: a vertical guide to the key's left edge, center or right edge, a horizontal one to its top, center or bottom), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} With anchor points selected (Direct Selection), the points align instead: to their own bounds, or to the artboard (#1180)", - has_selection, + "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection; a key guide, select.key {guide}, stays put and the objects line up on its line: a vertical guide sets where left, center and right go and vertical alignment leaves them, a horizontal one the other way round; aligning to the key, the other selected ruler guides move too: a vertical guide to the key's left edge, center or right edge, a horizontal one to its top, center or bottom; aligning to the artboard moves selected guides to its edges too (#3463), and with only guides selected aligning to the selection moves them to their collective bounds), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} With anchor points selected (Direct Selection), the points align instead: to their own bounds, or to the artboard (#1180)", + has_selection_or_guides, align ), cmd!( @@ -1361,11 +1361,22 @@ fn align_reference(s: &Session, p: &Value, ids: &[NodeId], preview: bool, own: O Ok((r, true, true)) } +/// Bounds of the selected ruler guides (each guide is a zero-thickness line), for Align to +/// Selection when no objects are selected. +fn selected_guides_bounds(st: &crate::DocState) -> Option { + st.selection.guides.iter().filter_map(|&i| st.doc.guides.get(i)).fold(None, |acc: Option, g| { + let r = if g.vertical { Rect::new(g.pos, 0.0, g.pos, 0.0) } else { Rect::new(0.0, g.pos, 0.0, g.pos) }; + Some(acc.map(|a| a.union(r)).unwrap_or(r)) + }) +} + fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Result { let st = s.doc()?; match align_to(s, p) { Some("artboard") => { - let b = effects::preview::bounds_of(&st.doc, ids, preview).unwrap_or_default(); + let b = effects::preview::bounds_of(&st.doc, ids, preview) + .or_else(|| ids.is_empty().then(|| selected_guides_bounds(st)).flatten()) + .unwrap_or_default(); // Align to Artboard uses artboard `artboard` (the app passes the active one). Without // it, or with an index the document doesn't have, it uses the artboard under the center // of the selection, or the first one when no artboard is there. @@ -1377,7 +1388,13 @@ fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Resu let k = st.selection.key.ok_or_else(|| EngineError::Other("no key object".into()))?; effects::preview::bounds_of(&st.doc, &[k], preview).ok_or(EngineError::NoNode(k)) } - _ => effects::preview::bounds_of(&st.doc, ids, preview).ok_or_else(|| EngineError::Other("nothing to align".into())), + _ => { + if ids.is_empty() { + selected_guides_bounds(st).ok_or_else(|| EngineError::Other("nothing to align".into())) + } else { + effects::preview::bounds_of(&st.doc, ids, preview).ok_or_else(|| EngineError::Other("nothing to align".into())) + } + } } } @@ -1443,15 +1460,22 @@ fn align(s: &mut Session, p: &Value) -> Result { apply_moves(s, "Align", p, &ids, &moves, &guides) } -/// The selected ruler guides that Align to the key object (or key guide) moves, and where: a -/// vertical guide goes to the left edge, centre or right edge of `r` that `h` names, a horizontal -/// one to the top, centre or bottom that `v` names. The key guide stays put, and so do guides -/// when aligning to the selection or the artboard. +/// The selected ruler guides that Align moves, and where: a vertical guide goes to the left edge, +/// centre or right edge of `r` that `h` names, a horizontal one to the top, centre or bottom that +/// `v` names. Align to the key object (or key guide) moves them too, and the key guide stays put. +/// Align to the artboard moves them (#3463). Align to the selection moves them when only guides are +/// selected; with objects in the selection they stay put. fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str>) -> Result> { - if align_to(s, p) != Some("key") { + let st = s.doc()?; + let to = align_to(s, p); + let move_guides = match to { + Some("key" | "artboard") => true, + Some("selection") => st.selection.is_empty(), + _ => false, + }; + if !move_guides { return Ok(vec![]); } - let st = s.doc()?; let at = |vertical: bool| match (vertical, h, v) { (true, Some("left"), _) => Some(r.x0), (true, Some("center"), _) => Some(r.center().x), @@ -1461,11 +1485,12 @@ fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str> (false, _, Some("bottom")) => Some(r.y1), _ => None, }; + let skip_key = to == Some("key"); Ok(st .selection .guides .iter() - .filter(|&&i| Some(i) != st.selection.key_guide) + .filter(|&&i| !skip_key || Some(i) != st.selection.key_guide) .filter_map(|&i| Some((i, at(st.doc.guides.get(i)?.vertical)?))) .collect()) } diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 0db52da4b..d4cccc212 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), 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), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), 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), 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\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", has_doc, set_format ), @@ -327,6 +327,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "direction", "leadingModel", "charAlign", + "charDirection", "proportionalMetrics", "tateChuYoko", "noBreak", @@ -350,6 +351,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { }; let (position, small_caps) = super::docsetup::script_params(p, &s.doc()?.doc.setup, C)?; let char_align = super::textedit::char_align_param(p, C)?; + let char_direction = super::textedit::char_direction_param(p, C)?; let digits = super::textedit::digits_param(p, C)?; let font_axes = super::textedit::font_axes_param(p, C)?; let mojikumi = match p.get("mojikumi") { @@ -460,6 +462,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = char_align { st.char_align = v; } + if let Some(v) = char_direction { + st.char_direction = v; + } if let Some(v) = digits { st.digits = v; } diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index c51c05846..98d31cfb7 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "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\", 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), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), 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}", + "{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\", 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), 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\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), 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}", has_doc, set_range_style ), @@ -256,6 +256,7 @@ pub(crate) struct CharChange { position: Option, small_caps: Option>, char_align: Option, + char_direction: Option>, proportional_metrics: Option, tate_chu_yoko: Option, no_break: Option, @@ -263,6 +264,19 @@ pub(crate) struct CharChange { font_axes: Option, } +/// `charDirection: "default"|"leftToRight"|"rightToLeft"` → the Character Direction to set (`None` +/// inside: Default). +pub(crate) fn char_direction_param(p: &Value, cmd: &str) -> Result>> { + use vectorcraft_doc::ParaDirection; + let Some(v) = p.get("charDirection").filter(|v| !v.is_null()) else { return Ok(None) }; + Ok(Some(match v.as_str() { + Some("default") => None, + Some("leftToRight") => Some(ParaDirection::LeftToRight), + Some("rightToLeft") => Some(ParaDirection::RightToLeft), + _ => return Err(bad(cmd, "`charDirection` must be \"default\", \"leftToRight\" or \"rightToLeft\"")), + })) +} + /// `digits: "default"|"arabic"|"hindi"|"farsi"` → the numeral system the digits are drawn in. pub(crate) fn digits_param(p: &Value, cmd: &str) -> Result> { use vectorcraft_doc::Digits; @@ -416,6 +430,7 @@ impl CharChange { position, small_caps, char_align: char_align_param(p, cmd)?, + char_direction: char_direction_param(p, cmd)?, proportional_metrics: flag("proportionalMetrics"), tate_chu_yoko: flag("tateChuYoko"), no_break: flag("noBreak"), @@ -459,6 +474,7 @@ impl CharChange { && self.position.is_none() && self.small_caps.is_none() && self.char_align.is_none() + && self.char_direction.is_none() && self.proportional_metrics.is_none() && self.tate_chu_yoko.is_none() && self.no_break.is_none() @@ -554,6 +570,9 @@ impl CharChange { if let Some(v) = self.char_align { st.char_align = v; } + if let Some(v) = self.char_direction { + st.char_direction = v; + } if let Some(v) = self.proportional_metrics { st.proportional_metrics = v; } diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 77984967c..cf3585f59 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -155,6 +155,7 @@ pub fn char_attrs(v: &CharStyle) -> Map { ("position", json!(v.position)), ("small_caps", json!(v.small_caps)), ("charAlign", json!(v.char_align)), + ("charDirection", json!(v.char_direction)), ("proportionalMetrics", json!(v.proportional_metrics)), ("tateChuYoko", json!(v.tate_chu_yoko)), ("noBreak", json!(v.no_break)), diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 89ec47958..67fcccd12 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -1599,6 +1599,47 @@ mod area_tests { } } + /// Character Direction (Character panel menu, Middle Eastern options): a character attribute + /// that `text.setFormat` and `text.setRangeStyle` set in one undo step each, saved only when + /// set and kept by a native save. + #[test] + fn character_direction_is_a_character_attribute_saved_only_when_set() { + use vectorcraft_doc::ParaDirection; + 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": "שלום 21 10 2026"})).unwrap()["id"].as_u64().unwrap(); + let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!("text"), + }; + let set = |s: &Session| runs(s).iter().map(|r| (r.text.clone(), r.style.char_direction)).collect::>(); + assert_eq!(runs(&s)[0].style.char_direction, None); + assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("charDirection").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + for (key, want) in [("rightToLeft", Some(ParaDirection::RightToLeft)), ("leftToRight", Some(ParaDirection::LeftToRight)), ("default", None)] { + s.execute("text.setFormat", &json!({"charDirection": key})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.char_direction == want)); + let saved = serde_json::to_value(&runs(&s)[0].style).unwrap(); + assert_eq!(saved.get("charDirection").and_then(|v| v.as_str()), want.is_some().then_some(key)); + } + let err = s.execute("text.setFormat", &json!({"charDirection": "up"})).unwrap_err().to_string(); + assert!(err.contains("`charDirection` must be"), "{err}"); + // Characters selected with the Type tool: only the range takes it (שלום and the space are 9 bytes). + s.execute("text.setRangeStyle", &json!({"id": id, "start": 9, "end": 19, "charDirection": "leftToRight"})).unwrap(); + assert_eq!(set(&s), [("שלום ".to_string(), None), ("21 10 2026".to_string(), Some(ParaDirection::LeftToRight))]); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.char_direction.is_none())); + s.execute("edit.redo", &json!({})).unwrap(); + // Saved and reopened as it was. + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; + let reopened: Vec<_> = t.runs.iter().map(|r| (r.text.clone(), r.style.char_direction)).collect(); + assert_eq!(reopened, set(&s)); + // A character style captured from the characters carries it, Default included. + assert_eq!(super::super::textstyles::char_attrs(&runs(&s)[1].style)["charDirection"], "leftToRight"); + assert_eq!(super::super::textstyles::char_attrs(&runs(&s)[0].style).get("charDirection"), Some(&serde_json::Value::Null)); + } + /// Proportional Metrics (#966): a character attribute, saved only when on. #[test] fn proportional_metrics_is_a_character_attribute_saved_only_when_on() { diff --git a/crates/engine/src/tests_blendopts.rs b/crates/engine/src/tests_blendopts.rs index ab70333d2..4682ba1ea 100644 --- a/crates/engine/src/tests_blendopts.rs +++ b/crates/engine/src/tests_blendopts.rs @@ -153,3 +153,79 @@ fn start_points_round_trip_and_old_blends_load() { assert!(old.starts.is_empty() && old.start(0).is_none()); assert!(serde_json::to_value(&old).unwrap().get("starts").is_none()); } + +#[test] +fn easing_is_set_validated_saved_and_old_blends_are_even() { + let mut s = session(); + assert!(s.execute("object.blend.options", &json!({"ramp": 50})).is_err()); + let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0)); + sel(&mut s, &[a, b]); + let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3, "easingType": "easeIn", "ramp": 40})).unwrap()); + assert_eq!(spec(&s, g).1.ease.ramp, 0.4); + sel(&mut s, &[g]); + for kind in ["easeIn", "easeOut", "easeInOut", "easeOutIn"] { + s.execute("object.blend.options", &json!({"easingType": kind, "colorShift": -50})).unwrap(); + let info = s.execute("object.blend.info", &json!({})).unwrap(); + assert_eq!((&info["easingType"], &info["ramp"], &info["colorShift"]), (&json!(kind), &json!(40.0), &json!(-50.0))); + } + let before = spec(&s, g).1; + let depth = s.doc().unwrap().history.undo.len(); + for bad in [json!({"ramp": 101}), json!({"ramp": -1}), json!({"colorShift": "x"}), json!({"colorShift": 1e308}), json!({"easingType": "unknown"})] + { + assert!(s.execute("object.blend.options", &bad).is_err(), "{bad}"); + assert_eq!(spec(&s, g).1, before); + assert_eq!(s.doc().unwrap().history.undo.len(), depth); + } + let d = &s.doc().unwrap().doc; + let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap(); + assert_eq!(&back.layers, &d.layers); + s.execute("object.blend.options", &json!({"ramp": 0})).unwrap(); + let zero = spec(&s, g).1; + let back: BlendSpec = serde_json::from_value(serde_json::to_value(&zero).unwrap()).unwrap(); + assert_eq!(back.ease, zero.ease, "a chosen Type survives at Ramp 0"); + let old: BlendSpec = serde_json::from_value(json!({"spacing": {"steps": 4}})).unwrap(); + assert!(old.ease.is_linear() && old.color_shift == 0.0); + let v = serde_json::to_value(&old).unwrap(); + assert!(v.get("ease").is_none() && v.get("colorShift").is_none()); +} + +#[test] +fn a_document_with_bad_blend_easing_numbers_opens_clamped_while_commands_refuse() { + let mut s = session(); + let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0)); + sel(&mut s, &[a, b]); + let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3, "easingType": "easeOut", "ramp": 40, "colorShift": -50})).unwrap()); + let text = String::from_utf8(vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let blend_of = |doc: &vectorcraft_doc::Document| match doc.node(g).unwrap().kind.clone() { + NodeKind::Blend { spec, .. } => *spec, + _ => panic!("not a blend"), + }; + for (ramp, shift, want) in [("12.5", "-9000.0", (1.0, -100.0)), ("-3.0", "250.0", (0.0, 100.0)), ("null", "\"NaN\"", (0.0, 0.0))] { + let t = text.replacen("\"ramp\":0.4", &format!("\"ramp\":{ramp}"), 1).replacen("\"colorShift\":-50.0", &format!("\"colorShift\":{shift}"), 1); + assert!(t != text && t.contains("\"ramp\":") && t.contains(shift), "the saved blend carries ramp and colorShift"); + let doc = vectorcraft_format::load(t.as_bytes()).expect("one bad blend number must not fail the document"); + let spec = blend_of(&doc); + assert_eq!((spec.ease.ramp, spec.color_shift), want, "{ramp} / {shift}"); + } + // The commands still refuse the same values and leave the blend alone. + sel(&mut s, &[g]); + let before = spec(&s, g).1; + for bad in [json!({"ramp": 1250}), json!({"ramp": -3}), json!({"colorShift": 250}), json!({"colorShift": "NaN"}), json!({"colorShift": 1e308})] { + assert!(s.execute("object.blend.options", &bad).is_err(), "{bad}"); + assert_eq!(spec(&s, g).1, before); + } +} + +#[test] +fn native_blend_easing_values_clamp_and_non_finite_become_default() { + for (bad, ramp, shift) in [ + (json!({"ease": {"ramp": 1e308}}), 1.0, 0.0), + (json!({"ease": {"ramp": -0.1}}), 0.0, 0.0), + (json!({"colorShift": 101}), 0.0, 100.0), + (json!({"colorShift": -101}), 0.0, -100.0), + (json!({"ease": {"ramp": null}, "colorShift": "NaN"}), 0.0, 0.0), + ] { + let spec: BlendSpec = serde_json::from_value(bad.clone()).unwrap_or_else(|e| panic!("{bad}: {e}")); + assert_eq!((spec.ease.ramp, spec.color_shift), (ramp, shift), "{bad}"); + } +} diff --git a/crates/engine/src/tests_clipboard.rs b/crates/engine/src/tests_clipboard.rs index 9429a7ae9..b32b1c44f 100644 --- a/crates/engine/src/tests_clipboard.rs +++ b/crates/engine/src/tests_clipboard.rs @@ -83,16 +83,19 @@ fn renamed(v: &Value) -> Vec<(String, String, String)> { // ---------- M4.22: resources ---------- +/// Verify ordinary paste retains image, symbol and pattern resources and their rendered appearance. #[test] fn an_image_symbol_and_pattern_rect_pasted_into_a_new_document_render_the_same() { resource_transfer(false); } +/// Verify a translated artwork drop preserves editable resources, pattern phase, opacity and clipboard contents. #[test] fn dropped_artwork_preserves_editable_images_symbols_patterns_and_clipboard() { resource_transfer(true); } +/// Exercise paste or tab-drop resource transfer and compare source and destination rendering. fn resource_transfer(drag: bool) { let mut s = session(); // Pattern "Dots" painting a rectangle. @@ -675,6 +678,7 @@ fn paste_remembers_layers_survives_save_and_old_files_load_without_it() { assert!(!vectorcraft_format::load(v.to_string().as_bytes()).unwrap().paste_remembers_layers); } +/// Verify document-scoped object IDs, grab-point placement and one destination Undo/Redo step. #[test] fn artwork_drop_uses_source_ids_hotspot_and_one_target_undo_step() { let mut s = session(); @@ -701,6 +705,7 @@ fn artwork_drop_uses_source_ids_hotspot_and_one_target_undo_step() { assert_eq!(s.doc().unwrap().selection.objects, ids); } +/// Verify malformed, oversized, stale and overflowing drop requests leave document and clipboard state intact. #[test] fn invalid_artwork_drop_is_atomic() { let mut s = session(); @@ -740,6 +745,7 @@ fn invalid_artwork_drop_is_atomic() { assert_eq!(s.doc().unwrap().doc, target.doc); } +/// Verify refusing a locked insertion context preserves the target document and its undo history. #[test] fn artwork_drop_into_a_locked_destination_is_atomic() { let mut s = session(); @@ -755,6 +761,7 @@ fn artwork_drop_into_a_locked_destination_is_atomic() { assert_eq!(s.doc().unwrap().history.undo.len(), target.history.undo.len()); } +/// Verify tab drops use the editable current layer despite Paste Remembers Layers and source-layer name collisions. #[test] fn artwork_drop_uses_the_current_layer_even_when_paste_remembers_hidden_or_locked_source_layers() { for hidden in [true, false] { diff --git a/crates/engine/src/tests_keyguide.rs b/crates/engine/src/tests_keyguide.rs index 2c3dcfcdf..9e45ae0f5 100644 --- a/crates/engine/src/tests_keyguide.rs +++ b/crates/engine/src/tests_keyguide.rs @@ -153,9 +153,9 @@ fn selected_guides_align_to_a_clicked_key_object() { assert_eq!(guides(&s), [140.0, 110.0], "horizontal alignment moves only the vertical guide"); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(guides(&s), [120.0, 110.0], "one undo step"); - // Aligning to the artboard leaves the guides alone. + // Aligning to the artboard moves the vertical guide to the artboard's left edge. s.execute("object.align", &json!({"horizontal": "left", "to": "artboard"})).unwrap(); - assert_eq!(guides(&s), [120.0, 110.0]); + assert_eq!(guides(&s), [0.0, 110.0]); // Deselecting the guides lets the lone key go. s.execute("guide.select", &json!({"indexes": [0, 1], "toggle": true})).unwrap(); assert_eq!(s.doc().unwrap().selection.objects, vec![a]); @@ -310,6 +310,18 @@ fn positions(s: &Session) -> Vec { s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect() } +#[test] +fn guides_selected_alone_align_to_artboard() { + let mut s = guides_alone(&[100.0, 400.0], &[50.0, 300.0]); + s.execute("object.align", &json!({"horizontal": "left", "to": "artboard", "artboard": 0})).unwrap(); + assert_eq!(positions(&s), [0.0, 0.0, 50.0, 300.0]); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("object.align", &json!({"horizontal": "right", "to": "artboard", "artboard": 0})).unwrap(); + assert_eq!(&positions(&s)[..2], [800.0, 800.0]); + s.execute("object.align", &json!({"vertical": "top", "to": "artboard", "artboard": 0})).unwrap(); + assert_eq!(positions(&s), [800.0, 800.0, 0.0, 0.0]); +} + #[test] fn distribute_spaces_guides_selected_without_objects() { let mut s = guides_alone(&[100.0, 150.0, 500.0], &[50.0, 60.0, 400.0]); diff --git a/crates/engine/src/tests_legacy_ai.rs b/crates/engine/src/tests_legacy_ai.rs index f05ec58d7..fb7a23019 100644 --- a/crates/engine/src/tests_legacy_ai.rs +++ b/crates/engine/src/tests_legacy_ai.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; -use super::tests_menucmds::place_undecodable_png; +use super::tests_menucmds::{place_undecodable_png, place_undecodable_png_sized}; use super::*; use crate::tests_links::Folder; @@ -362,6 +362,29 @@ fn a_flattened_image_that_cant_be_decoded_is_noted_once() { assert_eq!(undecoded_notes(&r), 1, "{r}"); } +/// Images that can't be decoded and are written as they are get the same note as one that is +/// flattened. All of them give one note, and the note names no image's key. +#[test] +fn images_written_or_flattened_that_cant_be_decoded_are_noted_once() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + place_undecodable_png(&mut s); + place_undecodable_png_sized(&mut s, 48, 24); + let keys: Vec = s.doc().unwrap().doc.images.keys().cloned().collect(); + assert_eq!(keys.len(), 2); + let decode_notes = |r: &Value| warnings(r).iter().filter(|w| w.contains("decoded")).count(); + let named = |r: &Value| warnings(r).iter().any(|w| keys.iter().any(|k| w.contains(k.as_str()))); + let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap(); + assert_eq!((undecoded_notes(&r), decode_notes(&r), named(&r)), (1, 1, false), "{r}"); + // A third image, at 50% opacity, is flattened. The engine's note and the writer's note come out + // as one warning. + let cut = place_undecodable_png_sized(&mut s, 32, 16); + s.execute("transparency.set", &json!({"ids": [cut.0], "opacity": 50})).unwrap(); + let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap(); + assert!(warnings(&r).iter().any(|w| w.contains("flattened with the art under them")), "{r}"); + assert_eq!((undecoded_notes(&r), decode_notes(&r), named(&r)), (1, 1, false), "{r}"); +} + #[test] fn an_image_that_cant_be_decoded_with_an_expanded_effect_is_noted() { let mut s = Session::new(); diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs index bb3ed1dac..a6eb3f8a9 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests_menucmds.rs @@ -41,8 +41,14 @@ fn sel(s: &mut Session, ids: &[NodeId]) { /// A placed 64 × 32 px PNG whose header reads (so it places at its size) but whose pixel data is /// cut short: the renderer can't decode it and draws nothing. pub(crate) fn place_undecodable_png(s: &mut Session) -> NodeId { + place_undecodable_png_sized(s, 64, 32) +} + +/// Places a PNG like [`place_undecodable_png`] does, at `w` × `h` px. PNGs of different sizes are +/// different images, each stored under its own key. +pub(crate) fn place_undecodable_png_sized(s: &mut Session, w: u32, h: u32) -> NodeId { let mut png = Vec::new(); - image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255])) + image::RgbaImage::from_pixel(w, h, image::Rgba([200, 30, 30, 255])) .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) .unwrap(); let idat = png.windows(4).position(|w| w == b"IDAT").unwrap(); diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 368c2eeb7..4d8a1453e 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -472,6 +472,8 @@ fn structured_junk() { ("object.blend.make", json!({"ids": [1, 2, 3], "starts": [u64::MAX, -1, "x", null, 1e308]})), ("object.blend.make", json!({"ids": [2, 2], "starts": {"a": 1}, "steps": 1e308})), ("object.blend.options", json!({"spacing": "distance", "value": -1e308, "orientation": 5})), + ("object.blend.options", json!({"ramp": 1e308, "colorShift": -1e308, "easingType": [1]})), + ("object.blend.make", json!({"ids": [1, 2], "ramp": f64::NAN, "colorShift": [1]})), ("object.blend.spine.moveAnchor", json!({"id": 2, "anchor": u64::MAX, "x": 1e308, "y": -1e308, "handle": "sideways"})), ("object.blend.spine.addAnchor", json!({"id": u64::MAX, "x": 0, "y": 0})), ("object.blend.spine.reshapeSegment", json!({"id": 2, "segment": u64::MAX, "t": f64::NAN, "dx": 1e308, "dy": -1e308})), diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 91cf653cd..ebe9a943e 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -98,7 +98,10 @@ const STROKES: &str = "strokes a plain line can't draw (inside or outside the path, variable width, gradients, brushes, arrowheads) are written as filled outlines"; const IMAGE_ALPHA: &str = "transparent image pixels are written over white"; const LIVE: &str = "symbols, blends, envelopes, meshes, repeats, placed documents and intertwines are written as their art"; -const UNREADABLE_IMAGE: &str = "an image that can't be decoded or is too large was left out"; +/// The warning for an image left out because it can't be decoded: the note raster exports give +/// (`vectorcraft-engine`'s `rasterfx::undecoded_warning`). +const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture"; +const LARGE_IMAGE: &str = "an image that is empty or too large for legacy .ai was left out"; const ELLIPTICAL: &str = "elliptical radial gradients are written circular"; /// What to write in the header, and how to write what the format can't hold. @@ -1302,13 +1305,18 @@ impl<'a> Writer<'a> { if !invertible(m) { return; } - let Some(img) = self.doc.images.get(&im.key).and_then(|b| image::load_from_memory(&b.bytes).ok()) else { - self.warn(UNREADABLE_IMAGE); + let blob = self.doc.images.get(&im.key); + if blob.is_some_and(|b| b.bytes.is_empty()) { + self.warn(LARGE_IMAGE); + return; + } + let Some(img) = blob.and_then(|b| image::load_from_memory(&b.bytes).ok()) else { + self.warn(UNDECODED); return; }; let (w, h) = (img.width(), img.height()); if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_IMAGE_PIXELS { - self.warn(UNREADABLE_IMAGE); + self.warn(LARGE_IMAGE); return; } // The object places the image's own pixel grid (its width × height) wherever the file's is. diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index d11cae6f0..6d8be8474 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -33,6 +33,10 @@ const TRANSPARENCY: &str = const RASTER_FX: &str = "raster effects (shadows, glows, blurs, feathering) are left out: EPS has no transparency, flatten them first"; const IMAGE_ALPHA_L2: &str = "transparent image pixels are written white at PostScript Level 2 (Level 3 masks them out)"; const IMAGE_PARTLY: &str = "partly transparent image pixels are written over white"; +/// The warning for an image left out because it can't be decoded: the note raster exports give +/// (`vectorcraft-engine`'s `rasterfx::undecoded_warning`). +const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture"; +const LARGE_IMAGE: &str = "an image that is too large for EPS was left out"; const SPOT_GRADIENTS: &str = "gradients of spot colours are written in process colours"; const MISSING_PATTERN: &str = "missing patterns are written as mid-grey"; @@ -859,11 +863,11 @@ impl<'a> Scene<'a> { /// Image `key` as RGBA pixels; `None` (with a warning) when it can't be written. fn decode_image(&mut self, key: &str) -> Option { let Some(img) = self.doc.images.get(key).and_then(|blob| image::load_from_memory(&blob.bytes).ok()) else { - self.warn(&format!("image '{key}' could not be decoded and was left out")); + self.warn(UNDECODED); return None; }; if u64::from(img.width()) * u64::from(img.height()) > MAX_IMAGE_PIXELS { - self.warn(&format!("image '{key}' is too large for EPS and was left out")); + self.warn(LARGE_IMAGE); return None; } Some(img.to_rgba8()) diff --git a/crates/eps/src/tests_legacy_ai.rs b/crates/eps/src/tests_legacy_ai.rs index 731e2537c..048daf834 100644 --- a/crates/eps/src/tests_legacy_ai.rs +++ b/crates/eps/src/tests_legacy_ai.rs @@ -382,6 +382,28 @@ fn images_are_written_in_the_form_the_specification_shows() { assert!(rgb.iter().zip([247.0, 148.0, 29.0]).all(|(c, want)| (c * 255.0 - want).abs() < 0.1), "{rgb:?}"); } +/// An image whose data is empty is left out with the note for empty or too large images, and an +/// image whose data can't be decoded is left out with the decode note. +#[test] +fn empty_images_get_the_size_note_and_undecodable_ones_the_decode_note() { + let notes = |bytes: Vec| { + let mut doc = Document::new(100.0, 100.0); + doc.layers.clear(); + let layer = doc.add_layer(Some("Images")); + let blob = ImageBlob::new("image/png", bytes); + let key = blob.content_key(); + doc.images.insert(key.clone(), blob); + let im = ImageObject { key, width: 4, height: 2, xf: Affine::IDENTITY, link: None, placement: Default::default() }; + let node = Node::new(doc.alloc_id(), NodeKind::Image(Box::new(im))); + doc.insert(Some(layer), usize::MAX, node).unwrap(); + let out = export_legacy_ai(&doc, &LegacyAiOptions::default()).unwrap(); + assert!(rasters(&String::from_utf8(out.bytes).unwrap()).is_empty()); + out.warnings + }; + assert_eq!(notes(vec![]), [LARGE_IMAGE]); + assert_eq!(notes(b"not an image".to_vec()), [UNDECODED]); +} + #[test] fn a_gradient_fill_and_its_stroke_are_one_object() { let mut doc = Document::new(100.0, 100.0); diff --git a/crates/metafile/src/scene.rs b/crates/metafile/src/scene.rs index 6f9ed1059..06256e686 100644 --- a/crates/metafile/src/scene.rs +++ b/crates/metafile/src/scene.rs @@ -33,6 +33,9 @@ const CLIPPING: &str = "clipping masks are not applied: the art they clip is wri const FLAT_GRADIENTS: &str = "gradients are written as one colour, the average of their stops"; const FLAT_PATTERNS: &str = "pattern fills are written as one colour"; const TOO_MANY_TILES: &str = "pattern fills past 250,000 tiles in all are written as one colour"; +/// The warning for an image left out because it can't be decoded: the note raster exports give +/// (`vectorcraft-engine`'s `rasterfx::undecoded_warning`). +const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture"; /// A stroke as a pen draws it. #[derive(Clone, Debug, PartialEq)] @@ -449,7 +452,7 @@ impl<'a> Scene<'a> { .filter(|i| i.width > 0 && i.height > 0) .map(Arc::new); if img.is_none() { - self.warn(&format!("image '{key}' could not be decoded and was left out")); + self.warn(UNDECODED); } self.images.insert(key.to_string(), img.clone()); img diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index a9c110aee..6dca09aa3 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -24,6 +24,7 @@ image = { workspace = true } jpeg-encoder = { workspace = true } craft-pdf = { workspace = true } log = { workspace = true } +chrono = { workspace = true } smallvec = "1" weezl = "0.1" serde = { workspace = true } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 0f8a561a3..3997d5ec2 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -569,6 +569,9 @@ pub(crate) struct Exporter<'a> { lab_spots: Vec<(String, vectorcraft_color::cms::Lab)>, /// Images may ask viewers to smooth them (not in PDF/A). interpolate: bool, + /// Images may have 16 bits a sample (PDF 1.5 and later); in earlier files they are written at + /// 8 bits ([`embed`]). + sixteen_bit: bool, /// How images are resampled and compressed. compression: &'a crate::CompressionSettings, /// Layers whose Print option is off are written too. @@ -629,6 +632,8 @@ impl<'a> Exporter<'a> { knockout: doc.page_knockout, lab_spots: vec![], interpolate: set.standard != Standard::PdfA2b, + // PDF 1.3 and 1.4 files are written with the PDF 1.4 settings ([`Writer::new`]). + sixteen_bit: !matches!(set.compatibility, Compatibility::Pdf13 | Compatibility::Pdf14), compression: &set.compression, non_printing: false, intent: vectorcraft_color::cms::active().settings().intent, @@ -1791,7 +1796,7 @@ impl Exporter<'_> { if let Some(w) = r.warning { self.warn(w); } - r.image.or_else(|| embed(blob, interpolate)) + r.image.or_else(|| embed(blob, interpolate, self.sixteen_bit)) }); if img.is_none() { self.warn(UNDECODED); @@ -1851,11 +1856,41 @@ fn placed_size(im: &vectorcraft_doc::ImageObject) -> (f64, f64) { (im.width as f64 * a.hypot(b), im.height as f64 * c.hypot(d)) } +/// Does PNG `bytes` have 16 bits a sample: the bit depth of its IHDR chunk, which follows the +/// signature? +fn png_16_bit(bytes: &[u8]) -> bool { + bytes.get(12..16) == Some(b"IHDR".as_slice()) && bytes.get(24) == Some(&16) +} + +/// PNG `bytes` at 8 bits a sample: decoded, and encoded again as gray or RGB, with alpha when the +/// source has it, and with the source's ICC profile. `None` when it can't be decoded. +fn png_8_bit(bytes: &[u8]) -> Option> { + use image::{ColorType, DynamicImage, ImageDecoder, ImageEncoder}; + let mut decoder = image::codecs::png::PngDecoder::new(std::io::Cursor::new(bytes)).ok()?; + let icc = decoder.icc_profile().ok().flatten(); + let img = DynamicImage::from_decoder(decoder).ok()?; + let img = match img.color() { + ColorType::L8 | ColorType::L16 => DynamicImage::ImageLuma8(img.into_luma8()), + ColorType::La8 | ColorType::La16 => DynamicImage::ImageLumaA8(img.into_luma_alpha8()), + ColorType::Rgb8 | ColorType::Rgb16 => DynamicImage::ImageRgb8(img.into_rgb8()), + _ => DynamicImage::ImageRgba8(img.into_rgba8()), + }; + let mut png = vec![]; + let mut encoder = image::codecs::png::PngEncoder::new(&mut png); + if let Some(icc) = icc { + encoder.set_icc_profile(icc).ok()?; + } + img.write_with_encoder(encoder).ok()?; + Some(png) +} + /// Image `blob` as it is: PNG, JPEG, GIF and WebP through the PDF writer (JPEG data unchanged), -/// other formats decoded. -fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool) -> Option { +/// other formats decoded. Without `sixteen_bit` (a file before PDF 1.5), a PNG of 16 bits a +/// sample is written at 8 bits ([`png_8_bit`]). +fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixteen_bit: bool) -> Option { let bytes = blob.bytes.as_ref().clone(); let direct = match blob.mime.as_str() { + "image/png" if !sixteen_bit && png_16_bit(&bytes) => png_8_bit(&bytes).and_then(|png| Image::from_png(png.into(), interpolate).ok()), "image/png" => Image::from_png(bytes.clone().into(), interpolate).ok(), "image/jpeg" | "image/jpg" => Image::from_jpeg(bytes.clone().into(), interpolate).ok(), "image/gif" => Image::from_gif(bytes.clone().into(), interpolate).ok(), @@ -1893,4 +1928,22 @@ mod tests { s.finish(); page.finish(); } + + #[test] + fn a_16_bit_png_at_8_bits_keeps_its_color_type_and_icc_profile() { + use image::{ImageDecoder, ImageEncoder}; + let icc = vectorcraft_color::cms::icc_bytes(vectorcraft_color::cms::GRAY).unwrap().to_vec(); + let gray = image::DynamicImage::ImageLuma16(image::ImageBuffer::from_fn(4, 2, |x, y| image::Luma([x as u16 * 20000 + y as u16]))); + let mut png = vec![]; + let mut encoder = image::codecs::png::PngEncoder::new(&mut png); + encoder.set_icc_profile(icc.clone()).unwrap(); + gray.write_with_encoder(encoder).unwrap(); + assert!(png_16_bit(&png)); + let out = png_8_bit(&png).unwrap(); + assert!(!png_16_bit(&out)); + let mut decoder = image::codecs::png::PngDecoder::new(std::io::Cursor::new(out.as_slice())).unwrap(); + assert_eq!(decoder.icc_profile().unwrap(), Some(icc)); + assert_eq!(decoder.color_type(), image::ColorType::L8); + assert_eq!(image::DynamicImage::from_decoder(decoder).unwrap().to_luma8(), gray.to_luma8()); + } } diff --git a/crates/pdf/src/file_info.rs b/crates/pdf/src/file_info.rs index c8e7e9488..983a7dcea 100644 --- a/crates/pdf/src/file_info.rs +++ b/crates/pdf/src/file_info.rs @@ -234,14 +234,8 @@ fn pdf_date(d: DateTime) -> Option { /// Whether `day` is a day of `month` (1 to 12) in `year` (proleptic Gregorian). fn in_month(year: i64, month: i64, day: i64) -> bool { - let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); - let last = match month { - 2 if leap => 29, - 2 => 28, - 4 | 6 | 9 | 11 => 30, - _ => 31, - }; - (1..=last).contains(&day) + let ymd = i32::try_from(year).ok().zip(u32::try_from(month).ok()).zip(u32::try_from(day).ok()); + ymd.is_some_and(|((y, m), d)| chrono::NaiveDate::from_ymd_opt(y, m, d).is_some()) } /// An XMP date (ISO 16684-1: `YYYY`, `YYYY-MM`, `YYYY-MM-DD`, then optionally `Thh:mm`, `:ss` with @@ -282,13 +276,16 @@ fn xmp_date(s: &str) -> Option { } /// `[year, month, day, hour, minute, second]` at a UTC offset of `offset` minutes → Unix seconds -/// (days-from-civil, proleptic Gregorian: the inverse of [`vectorcraft_doc::metadata::civil`]). +/// (the inverse of [`vectorcraft_doc::metadata::civil`]). The date has already passed [`in_month`], +/// so it is a real day; an impossible one reads as the epoch rather than panicking. fn unix([year, month, day, hour, minute, second]: [i64; 6], offset: i64) -> i64 { - let y = if month <= 2 { year - 1 } else { year }; - let era = y.div_euclid(400); - let yoe = y - era * 400; - let doy = (153 * ((month + 9) % 12) + 2) / 5 + day - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - let days = era * 146_097 + doe - 719_468; + let days = i32::try_from(year) + .ok() + .zip(u32::try_from(month).ok()) + .zip(u32::try_from(day).ok()) + .and_then(|((y, m), d)| chrono::NaiveDate::from_ymd_opt(y, m, d)) + .zip(chrono::NaiveDate::from_ymd_opt(1970, 1, 1)) + .map(|(date, epoch)| (date - epoch).num_days()) + .unwrap_or(0); days * 86_400 + hour * 3600 + minute * 60 + second - offset * 60 } diff --git a/crates/pdf/src/tests_compression.rs b/crates/pdf/src/tests_compression.rs index 2954f91c3..dd25a98eb 100644 --- a/crates/pdf/src/tests_compression.rs +++ b/crates/pdf/src/tests_compression.rs @@ -1,6 +1,7 @@ //! Image compression and downsampling: images above the threshold resolution are resampled to the //! target, compressed with JPEG or ZIP as their kind (colour, greyscale, monochrome) asks, and the -//! codecs the writer lacks are written as ZIP with a warning. +//! codecs the writer lacks are written as ZIP with a warning. In files before PDF 1.5, 16-bit +//! images are written at 8 bits. use std::io::Cursor; @@ -81,6 +82,59 @@ fn jpeg_zip_and_automatic_compression() { assert!(!text.contains("/DCTDecode") && text.contains("/SMask")); } +#[test] +fn sixteen_bit_images_are_written_at_8_bits_before_pdf_1_5() { + let mut clear = colour().to_rgba8(); + clear.put_pixel(0, 0, image::Rgba([0, 0, 0, 0])); + let clear = DynamicImage::from(clear); + // 16-bit PNGs of RGB, gray and RGBA pixels, and the 8-bit pixels they hold. + let images = [ + ("RGB", DynamicImage::ImageRgb16(colour().to_rgb16()), colour()), + ("gray", DynamicImage::ImageLuma16(colour().grayscale().to_luma16()), colour().grayscale()), + ("RGBA", DynamicImage::ImageRgba16(clear.to_rgba16()), clear), + ]; + for (kind, deep, pixels) in images { + let d = doc(encoded(deep, ImageFormat::Png), "image/png"); + // PDF/X-1a and PDF/X-3 files are PDF 1.3. A PDF 1.3 file has no soft masks: the app + // flattens an image with transparency before it is written. + let mut cases = vec![(json!({"compatibility": "1.4"}), 8), (json!({"compatibility": "1.5"}), 16), (json!({"compatibility": "1.7"}), 16)]; + if kind != "RGBA" { + cases.extend([(json!({"compatibility": "1.3"}), 8), (json!({"standard": "pdfX3", "compatibility": "1.3"}), 8)]); + } + if kind == "gray" { + cases.push((json!({"standard": "pdfX1a", "compatibility": "1.3"}), 8)); + } + for (case, depth) in cases { + let mut settings: PdfSettings = serde_json::from_value(case.clone()).unwrap(); + settings.compression.compress_text = false; + let r = export_with_report(&d, &PdfOptions { settings, created: Some(0), ..Default::default() }) + .unwrap_or_else(|e| panic!("{kind}, {case}: {e}")); + let text = String::from_utf8_lossy(&r.bytes); + let other = if depth == 8 { 16 } else { 8 }; + assert!( + text.contains(&format!("/BitsPerComponent {depth}")) && !text.contains(&format!("/BitsPerComponent {other}")), + "{kind}, {case}: {depth} bits" + ); + assert!(!r.warnings.iter().any(|w| w.contains("decoded")), "{kind}, {case}: {:?}", r.warnings); + if depth == 8 { + // The image reads back with the 8-bit pixels (within 1 for the PDF/X-3 file's RGB + // image, which is tagged with the sRGB profile). + let back = import(&r.bytes).unwrap(); + let got = image::load_from_memory(&back.images.values().next().unwrap().bytes).unwrap().to_rgba8(); + let close = got.dimensions() == (600, 600) && got.as_raw().iter().zip(pixels.to_rgba8().as_raw()).all(|(a, b)| a.abs_diff(*b) <= 1); + assert!(back.images.len() == 1 && close, "{kind}, {case}: the pixels"); + } + } + } + // A 16-bit PNG whose pixel data can't be decoded is left out with the note. + let mut cut = encoded(DynamicImage::ImageRgb16(colour().to_rgb16()), ImageFormat::Png); + let at = cut.windows(4).position(|w| w == b"IDAT").unwrap(); + cut.truncate(at + 10); + let settings = serde_json::from_value(json!({"compatibility": "1.4"})).unwrap(); + let r = export_with_report(&doc(cut, "image/png"), &PdfOptions { settings, ..Default::default() }).unwrap(); + assert_eq!(r.warnings, ["an image couldn't be decoded and is missing from the picture"]); +} + #[test] fn codecs_the_writer_lacks_are_zip_with_a_warning() { let png = doc(encoded(colour(), ImageFormat::Png), "image/png"); diff --git a/crates/render/src/changed.rs b/crates/render/src/changed.rs new file mode 100644 index 000000000..8af4c07d9 --- /dev/null +++ b/crates/render/src/changed.rs @@ -0,0 +1,120 @@ +//! What draws differently after an edit, so a view can draw again only there. + +use std::sync::Arc; + +use vectorcraft_doc::{Document, Node, NodeKind, SymbolBoxes}; +use vectorcraft_geom::{Affine, Rect}; + +/// What may draw differently in a document than in its version before an edit ([`changed`]). +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Changed { + Nothing, + /// This area of the document, outside of which every pixel draws as before. + Area(Rect), + Everything, +} + +/// What may draw differently in `new` than in `old`, the version before an edit: the bounds of +/// everything the objects that changed paint, before and after the edit ([`crate::painted_bounds`]), +/// or everything when more than the art in the layers changed, or while a pattern is edited (its +/// art also draws in the copies round it). Objects that an edit doesn't touch +/// keep their identity, so this costs what changed, not the size of the document. +pub fn changed(old: &Document, new: &Document) -> Changed { + if new.pattern_edit.is_some() || !old.same_outside_layers(new) { + return Changed::Everything; + } + let mut diff = Diff { old: old.symbol_boxes(), new: new.symbol_boxes(), area: None }; + diff.children(&old.layers, &new.layers); + diff.area.map_or(Changed::Nothing, Changed::Area) +} + +/// The pixels of a `w`×`h` render through `view` that drawing `area` again covers, as x0, y0, x1, +/// y1: its box in the view, grown by a pixel for the anti-aliased edges. None when it covers none. +pub fn view_pixels(area: Rect, view: Affine, w: u32, h: u32) -> Option<[u32; 4]> { + let r = view.transform_rect_bbox(area).inflate(1.0, 1.0); + if ![r.x0, r.y0, r.x1, r.y1].iter().all(|c| c.is_finite()) { + return None; + } + let (x0, y0) = (r.x0.floor().max(0.0), r.y0.floor().max(0.0)); + let (x1, y1) = (r.x1.ceil().min(f64::from(w)), r.y1.ceil().min(f64::from(h))); + (x1 > x0 && y1 > y0).then_some([x0 as u32, y0 as u32, x1 as u32, y1 as u32]) +} + +/// [`changed`] in progress: the symbols' boxes of both versions, and the area so far. +struct Diff<'a> { + old: SymbolBoxes<'a>, + new: SymbolBoxes<'a>, + area: Option, +} + +impl Diff<'_> { + /// Members in the same order compare one by one. Members added, removed or restacked count + /// before and after, except those that kept their place unchanged. + fn children(&mut self, old: &[Arc], new: &[Arc]) { + if old.len() == new.len() && old.iter().zip(new).all(|(a, b)| a.id == b.id) { + for (a, b) in old.iter().zip(new) { + self.node(a, b); + } + return; + } + for i in 0..old.len().max(new.len()) { + match (old.get(i), new.get(i)) { + (Some(a), Some(b)) if Arc::ptr_eq(a, b) => {} + (a, b) => { + self.add_old(a.map(|a| &**a)); + self.add_new(b.map(|b| &**b)); + } + } + } + } + + /// A group or layer that only its members changed in, without a clipping mask or effects of + /// its own that draw from all of them, changed where they did. Any other object that changed + /// counts whole, before and after. + fn node(&mut self, old: &Arc, new: &Arc) { + if Arc::ptr_eq(old, new) { + return; + } + if let (Some(a), Some(b)) = (members(old), members(new)) + && same_but_members(old, new) + { + self.children(a, b); + } else { + self.add_old(Some(old)); + self.add_new(Some(new)); + } + } + + fn add_old(&mut self, n: Option<&Node>) { + self.add(n.and_then(|n| crate::brush_fx::cull_bounds(&self.old, n))); + } + + fn add_new(&mut self, n: Option<&Node>) { + self.add(n.and_then(|n| crate::brush_fx::cull_bounds(&self.new, n))); + } + + fn add(&mut self, r: Option) { + self.area = vectorcraft_geom::union_opt(self.area, r); + } +} + +/// The members of a group or layer whose members draw each on their own: no clipping mask, and no +/// effects of its own. +fn members(n: &Node) -> Option<&[Arc]> { + match &n.kind { + NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !crate::fx::has_object_fx(n) => Some(children), + _ => None, + } +} + +/// Are `a` and `b` the same but for their members? +fn same_but_members(a: &Node, b: &Node) -> bool { + let strip = |n: &Node| { + let mut n = n.clone(); + if let Some(children) = n.children_mut() { + children.clear(); + } + n + }; + strip(a) == strip(b) +} diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index f078d235c..98a1d6baa 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -478,12 +478,25 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio } _ => { let b = n.visual_bounds_with(symbols)?; + let b = match &n.kind { + NodeKind::Text(t) => vectorcraft_geom::union_opt(Some(b), glyphs_box(t, n.appearance.outset())).unwrap_or(b), + _ => b, + }; let a = live_content_reach(symbols, n); Some(b.inflate(a, a)) } } } +/// The box of type `t`'s glyphs as laid out (its layout bounds, else laid out now), grown by its +/// characters' strokes and by `outset`: on a path they stand above it and run past its ends, in an +/// area they can hang past its frame, where its own box ([`Node::visual_bounds_with`]) doesn't. +fn glyphs_box(t: &vectorcraft_doc::TextObject, outset: f64) -> Option { + let b = t.cached_bounds.unwrap_or_else(|| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).bounds); + let o = outset.max(t.char_stroke_outset()); + (b.width() > 0.0 || b.height() > 0.0).then(|| t.xf.transform_rect_bbox(b).inflate(o, o)) +} + /// How far the content of live object `n` (a blend, envelope or repeat) can paint past its visual /// bounds: for each object in it, how far the effects in a symbol instance's art paint past that /// art ([`art_effects_reach`]) and how far brush art can paint ([`crate::brush_fx::pad`]), on the @@ -643,7 +656,7 @@ pub(crate) struct PixelEntry { /// For one cropped to the view, or made at a lower resolution, the view and the rect it was /// made for: it draws only there, so dragging other art over the object keeps it (a wide blur, /// or any blur zoomed in, reaches past the pixel budget). - view: Option, + view: Option, made: Made, /// What the content read when the raster was made, with the channels the effects ran on and, /// under De-Interlace, the raster resolution. @@ -672,13 +685,29 @@ fn linear_key(view: Affine) -> [u64; 4] { [c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()] } -/// A view's six coefficients (its pan included) and the device-pixel rect a frame draws in, as a -/// cache key: a raster cropped to that rect, or made at a lower resolution than the view's, draws -/// only in that view of that rect ([`PixelEntry::view`], [`ShadowEntry::view`]). -type ViewKey = ([u64; 6], [u64; 4]); +/// The pan of the view a raster was cropped for, or made at a lower resolution than the view's, and +/// the device-pixel rect of the frame it was made in ([`PixelEntry::view`], [`ShadowEntry::view`]). +#[derive(Clone, Copy, Debug)] +struct ViewCrop { + pan: Vec2, + screen: Rect, +} -fn view_key(view: Affine, screen: Rect) -> ViewKey { - (view.as_coeffs().map(f64::to_bits), [screen.x0, screen.y0, screen.x1, screen.y1].map(f64::to_bits)) +impl ViewCrop { + fn of(view: Affine, screen: Rect) -> Self { + Self { pan: view.translation(), screen } + } + + /// Does the raster draw in a frame through `view` (of the same zoom and rotation) onto + /// `screen`? When the view is panned by whole pixels and the frame's rect, panned back, lies + /// within the one the raster was made in: the same pixels in the same places, as when a frame + /// is drawn again only in a part of it. + fn serves(&self, view: Affine, screen: Rect) -> bool { + let d = view.translation() - self.pan; + let whole = Vec2::new(d.x.round(), d.y.round()); + let s = screen - whole; + (d - whole).hypot() < 1e-6 && s.x0 >= self.screen.x0 && s.y0 >= self.screen.y0 && s.x1 <= self.screen.x1 && s.y1 <= self.screen.y1 + } } /// Most pixels a raster of [`Renderer::pixel_content`] or [`Renderer::with_filters`] holds while @@ -768,7 +797,7 @@ pub(crate) struct ShadowEntry { /// `None` for a raster of the whole silhouette's reach: it draws after a pan too. For one /// cropped to the view, or made over such a raster, the view and the rect it was made for: it /// draws only there ([`PixelEntry::view`]). - view: Option, + view: Option, made: Made, /// What the silhouette read when the raster was made. inputs: Inputs, @@ -1084,9 +1113,9 @@ impl Renderer { // What the silhouette reads: as the raster kept for `n` found it, else found now. let reads = self.shadows.get(&key).filter(|e| e.node.is(n)).map_or_else(|| Reads::of(n), |e| e.inputs.reads); let inputs = self.raster_inputs(f, n, reads); - let in_view = view_key(f.view, screen); + let in_view = ViewCrop::of(f.view, screen); let exact = |e: &ShadowEntry| { - e.linear == linear && e.node.is(n) && e.inputs == inputs && (interacting || !e.moved) && e.view.is_none_or(|v| v == in_view) + e.linear == linear && e.node.is(n) && e.inputs == inputs && (interacting || !e.moved) && e.view.is_none_or(|v| v.serves(f.view, screen)) }; if interacting && !self.shadows.get(&key).is_some_and(exact) { self.move_shadow(f, n, key, fx, reach + Vec2::new(dx, dy), owner, &inputs, paint); @@ -1784,7 +1813,7 @@ impl Renderer { // The cached raster: of the whole content at full size at this zoom, else of this view. let linear = linear_key(f.view); let whole = fits && k >= 1.0; - let in_view = (!whole).then(|| view_key(f.view, screen)); + let in_view = (!whole).then(|| ViewCrop::of(f.view, screen)); let key = content.cache.map(|slot| (content.node as *const Node as usize, slot, f.ink)); let stamp = self.stamp; let channels = match f.ink { @@ -1803,8 +1832,12 @@ impl Renderer { self.raster_inputs(f, content.node, reads).with((std::mem::discriminant(&channels), interlaced.then_some(ppi.to_bits()))) }); let interacting = f.opts.interacting; + let in_place = |e: &PixelEntry| match e.view { + None => whole, + Some(v) => !whole && v.serves(f.view, screen), + }; let exact = |e: &PixelEntry| { - e.linear == linear && e.view == in_view && e.node.is(content.node) && Some(&e.inputs) == inputs.as_ref() && (interacting || !e.moved) + e.linear == linear && in_place(e) && e.node.is(content.node) && Some(&e.inputs) == inputs.as_ref() && (interacting || !e.moved) }; if interacting && whole diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f71957999..fa38b9372 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -7,6 +7,7 @@ #![forbid(unsafe_code)] mod brush_fx; +mod changed; pub mod encode; mod freeform; mod fx; @@ -35,6 +36,7 @@ use ink::Ink; use lru::FrameCache; pub use brush_fx::instance_art; +pub use changed::{Changed, changed, view_pixels}; pub use effects::stroke::width_outline; pub use images::{IMAGE_CACHE_MAX, image_cache_bytes, set_image_cache_budget}; pub use live::expand_live; @@ -101,7 +103,7 @@ pub fn raster_size(region: Rect, scale: f64) -> Result<(u32, u32), String> { } /// Rendering options. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct RenderOptions { /// Outline (wireframe) view: 1 px black paths, no paint. pub outline: bool, @@ -444,8 +446,6 @@ pub struct Renderer { text_fonts: u64, /// Clip regions per clipping path (see [`Self::clip_of`]). clips: FrameCache, - /// Context reused when rendering single-threaded (`threads == 0`). - ctx_st: Option, /// Worker threads for the multithreaded rasterizer (0 = single-threaded). pub threads: u16, geom: PtrMap, @@ -476,7 +476,10 @@ pub struct Renderer { stamp: u64, /// Opacity folded into paint alpha for the leaf being drawn (avoids a compositing layer). alpha: f32, - ctx: Option, + /// The contexts kept for the next frames, with the threads they draw on, the last used last: a + /// frame of the size of one reuses it instead of making a context and its threads. Two are + /// kept, so a view's frames and the parts of them drawn again ([`changed`]) each keep theirs. + contexts: Vec<(u16, RenderContext)>, resources: Resources, /// Statistics of the last frame. pub stats: FrameStats, @@ -613,7 +616,6 @@ impl Renderer { texts: FrameCache::new(lru::TEXT_BUDGET, 1024), text_fonts: 0, clips: FrameCache::new(lru::CLIP_BUDGET, 256), - ctx_st: None, threads: default_threads(), geom: PtrMap::default(), symbols: vec![], @@ -625,7 +627,7 @@ impl Renderer { fx_geom: PtrMap::default(), stamp: 0, alpha: 1.0, - ctx: None, + contexts: Vec::new(), resources: Resources::new(), stats: FrameStats::default(), brushes: Default::default(), @@ -761,13 +763,13 @@ impl Renderer { /// Draw frame `f` (`w`×`h` pixels): the background, the artboards and the page's art. fn draw_frame(&mut self, f: &Frame, w: u16, h: u16) -> Vec { let (threads, opts) = (self.threads, f.opts); - let slot = if threads == 0 { self.ctx_st.take() } else { self.ctx.take() }; - let mut ctx = match slot { - Some(mut c) if c.width() == w && c.height() == h => { + let kept = self.contexts.iter().position(|(t, c)| *t == threads && c.width() == w && c.height() == h); + let mut ctx = match kept.map(|i| self.contexts.remove(i)) { + Some((_, mut c)) => { c.reset(); c } - _ => RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: threads, ..Default::default() }), + None => RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: threads, ..Default::default() }), }; // `reset` keeps the threshold of the previous render: set it every time. ctx.set_aliasing_threshold(opts.anti_alias.threshold()); @@ -800,10 +802,9 @@ impl Renderer { self.draw_page(&mut ctx, f); } let pm = self.finish(&mut ctx, self.raster); - if threads == 0 { - self.ctx_st = Some(ctx); - } else { - self.ctx = Some(ctx); + self.contexts.push((threads, ctx)); + if self.contexts.len() > 2 { + self.contexts.remove(0); } pm.data_as_u8_slice().to_vec() } @@ -2560,6 +2561,8 @@ mod tests_alternate; #[cfg(test)] mod tests_blend; #[cfg(test)] +mod tests_changed; +#[cfg(test)] mod tests_charstroke; #[cfg(test)] mod tests_clip; diff --git a/crates/render/src/tests_changed.rs b/crates/render/src/tests_changed.rs new file mode 100644 index 000000000..b5e78138f --- /dev/null +++ b/crates/render/src/tests_changed.rs @@ -0,0 +1,215 @@ +//! Drawing again only what an edit changed gives the pixels of drawing it all again. + +use super::*; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::Appearance; +use vectorcraft_geom::{Point, shapes}; + +const W: u32 = 160; +const H: u32 = 120; + +/// A view that doesn't line the art up with the pixels, so the edges are anti-aliased. +fn view() -> Affine { + Affine::translate((3.3, 2.7)) * Affine::scale(1.37) +} + +fn rect(d: &mut Document, r: Rect, fill: Color) -> Node { + let id = d.alloc_id(); + Node::path(id, shapes::rectangle(r), Appearance::basic(Paint::solid(fill), Paint::solid(Color::BLACK), 2.0)) +} + +fn shadow() -> vectorcraft_doc::Effect { + vectorcraft_doc::Effect { + id: "stylize.dropShadow".into(), + params: serde_json::json!({"x": 6.0, "y": 6.0, "blur": 4.0, "opacity": 75.0}), + visible: true, + } +} + +/// A layer with a rectangle, a group at half opacity holding two, a clip group, and one with a +/// drop shadow; the ids of the loose rectangle, a member of each group, and the shadowed one. +fn document() -> (Document, [NodeId; 5]) { + let mut d = Document::new(110.0, 80.0); + let l = d.layers[0].id; + let loose = rect(&mut d, Rect::new(5.0, 5.0, 25.0, 20.0), Color::rgb(1.0, 0.0, 0.0)); + let loose = d.insert(Some(l), 0, loose).unwrap(); + let (a, b) = ( + rect(&mut d, Rect::new(30.0, 5.0, 50.0, 25.0), Color::rgb(0.0, 0.6, 0.0)), + rect(&mut d, Rect::new(40.0, 15.0, 60.0, 35.0), Color::rgb(0.0, 0.0, 1.0)), + ); + let mut group = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(a), Arc::new(b)], clip: false }); + group.opacity = 0.5; + let member = group.children().unwrap()[1].id; + d.insert(Some(l), 1, group).unwrap(); + let (mask, art) = + (rect(&mut d, Rect::new(5.0, 40.0, 30.0, 60.0), Color::WHITE), rect(&mut d, Rect::new(0.0, 45.0, 40.0, 55.0), Color::rgb(0.8, 0.4, 0.0))); + let clipped = art.id; + let clip = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(mask), Arc::new(art)], clip: true }); + d.insert(Some(l), 2, clip).unwrap(); + let mut shadowed = rect(&mut d, Rect::new(60.0, 45.0, 80.0, 60.0), Color::rgb(0.9, 0.9, 0.2)); + shadowed.appearance.effects.push(shadow()); + let shadowed = d.insert(Some(l), 3, shadowed).unwrap(); + let other = rect(&mut d, Rect::new(85.0, 10.0, 105.0, 30.0), Color::rgb(0.5, 0.0, 0.5)); + let other = d.insert(Some(l), 4, other).unwrap(); + (d, [loose, member, clipped, shadowed, other]) +} + +/// Draws `new` again only where it changed from `old`, over `old` drawn whole, and checks that +/// this is `new` drawn whole; returns what changed. +fn redraw(old: &Document, new: &Document) -> Changed { + let mut r = Renderer::new(); + let opts = RenderOptions::default(); + let before = r.render(old, W, H, view(), &opts); + let after = r.render(new, W, H, view(), &opts); + let change = changed(old, new); + let mut patched = Rendered { width: W, height: H, pixels: before.pixels.clone() }; + match change { + Changed::Everything => patched.pixels = after.pixels.clone(), + Changed::Nothing => {} + Changed::Area(area) => { + if let Some([x0, y0, x1, y1]) = view_pixels(area, view(), W, H) { + let part = r.render(new, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view(), &opts); + for y in 0..y1 - y0 { + let (from, to) = ((y * (x1 - x0) * 4) as usize, (((y0 + y) * W + x0) * 4) as usize); + let n = ((x1 - x0) * 4) as usize; + patched.pixels[to..to + n].copy_from_slice(&part.pixels[from..from + n]); + } + } + } + } + assert!(patched.pixels == after.pixels, "{change:?}: drawn again only there, it differs from drawing it all"); + change +} + +fn area(c: Changed) -> Rect { + match c { + Changed::Area(a) => a, + other => panic!("an area: {other:?}"), + } +} + +#[test] +fn moving_an_object_changes_where_it_was_and_is() { + let (old, [loose, ..]) = document(); + let mut new = old.clone(); + new.node_mut(loose).unwrap().transform(Affine::translate((7.0, 3.0)), false); + let a = area(redraw(&old, &new)); + assert!(a.contains(Point::new(6.0, 6.0)) && a.contains(Point::new(31.0, 22.0)), "before and after: {a:?}"); + assert!(a.x1 < 35.0, "not the objects beside it: {a:?}"); +} + +#[test] +fn recolouring_a_member_of_a_translucent_group_changes_only_the_member() { + let (old, [_, member, ..]) = document(); + let mut new = old.clone(); + new.node_mut(member).unwrap().appearance.fill_mut().unwrap().paint = Paint::solid(Color::rgb(1.0, 0.5, 0.0)); + let a = area(redraw(&old, &new)); + assert!(a.x0 > 35.0, "not the group's other member: {a:?}"); +} + +#[test] +fn moving_clipped_art_changes_its_whole_clip_group() { + let (old, [_, _, clipped, ..]) = document(); + let mut new = old.clone(); + new.node_mut(clipped).unwrap().transform(Affine::translate((0.0, 4.0)), false); + redraw(&old, &new); +} + +#[test] +fn moving_a_shadowed_object_changes_where_its_shadow_falls() { + let (old, [.., shadowed, _]) = document(); + let mut new = old.clone(); + new.node_mut(shadowed).unwrap().transform(Affine::translate((-5.0, 5.0)), false); + let a = area(redraw(&old, &new)); + assert!(a.x1 > 86.0 && a.y1 > 70.0, "the shadow, before and after: {a:?}"); +} + +#[test] +fn adding_removing_and_restacking_change_those_objects() { + let (old, [loose, _, _, _, other]) = document(); + let mut added = old.clone(); + let n = rect(&mut added, Rect::new(70.0, 5.0, 90.0, 15.0), Color::BLACK); + let l = added.layers[0].id; + added.insert(Some(l), 5, n).unwrap(); + redraw(&old, &added); + let mut removed = old.clone(); + removed.remove(loose).unwrap(); + redraw(&old, &removed); + let mut restacked = old.clone(); + let n = restacked.remove(other).unwrap(); + restacked.insert(Some(l), 0, (*n).clone()).unwrap(); + redraw(&old, &restacked); +} + +/// A blur too wide for a raster of all of it is cropped to the frame. A part of the frame drawn +/// again, during a drag too, draws it from that raster: the same pixels, and no preview at the +/// drag's lower resolution. +#[test] +fn a_part_of_a_frame_draws_a_cropped_blur_from_the_frames_raster() { + let (mut d, _) = document(); + let mut back = rect(&mut d, Rect::new(0.0, 0.0, 110.0, 80.0), Color::rgb(0.1, 0.3, 0.9)); + back.appearance.effects.push(vectorcraft_doc::Effect { id: "blur.gaussian".into(), params: serde_json::json!({"radius": 500.0}), visible: true }); + let l = d.layers[0].id; + d.insert(Some(l), 0, back).unwrap(); + let view = Affine::scale(2.0) * Affine::translate((-20.0, -15.0)); + let mut r = Renderer::new(); + let whole = r.render(&d, W, H, view, &RenderOptions::default()); + let [x0, y0, x1, y1] = [40, 30, 100, 80]; + let dragging = RenderOptions { interacting: true, dragging: true, ..Default::default() }; + let part = r.render(&d, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &dragging); + assert_eq!(r.stats.previewed, 0, "drawn from the frame's raster"); + for y in 0..y1 - y0 { + for x in 0..x1 - x0 { + assert_eq!(part.pixel(x, y), whole.pixel(x0 + x, y0 + y), "at {x}, {y}"); + } + } +} + +/// Type on a path whose path a script reshapes: its letters stand above the path and run past +/// where it ends, and they change where they were and where they are, outside the path's box. +#[test] +fn reshaping_the_path_of_type_on_a_path_changes_where_its_letters_were_and_are() { + let mut old = Document::new(110.0, 80.0); + let l = old.layers[0].id; + let on = |y: f64| { + let mut t = vectorcraft_doc::text::TextObject::point( + Point::ZERO, + "Text along a path", + vectorcraft_doc::text::CharStyle { size: 14.0, ..Default::default() }, + ); + let line = kurbo::Line::new((10.0, 60.0), (90.0, y)).to_path(0.1); + t.kind = vectorcraft_doc::TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&line), start: 0.0, end: None }; + t.xf = Affine::IDENTITY; + // The engine lays out type that changed ([`vectorcraft_doc::TextObject::cached_bounds`]). + t.cached_bounds = Some(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).bounds); + t + }; + let text = old.insert(Some(l), 0, Node::new(NodeId(0), NodeKind::Text(Box::new(on(55.0))))).unwrap(); + let mut new = old.clone(); + if let Some(NodeKind::Text(t)) = new.node_mut(text).map(|n| &mut n.kind) { + **t = on(25.0); + } + redraw(&old, &new); +} + +#[test] +fn an_unchanged_document_changes_nothing_and_a_new_symbol_everything() { + let (old, _) = document(); + assert_eq!(redraw(&old, &old.clone()), Changed::Nothing); + let mut new = old.clone(); + let art = Arc::new(rect(&mut new, Rect::new(0.0, 0.0, 5.0, 5.0), Color::BLACK)); + new.symbols.push(vectorcraft_doc::Symbol { name: "Dot".into(), art }); + assert_eq!(redraw(&old, &new), Changed::Everything); +} + +/// While a pattern is edited its art also draws in the copies round it: an edit of it changes +/// everything. +#[test] +fn an_edit_while_a_pattern_is_edited_changes_everything() { + let (mut old, [loose, ..]) = document(); + let l = old.layers[0].id; + old.pattern_edit = Some(vectorcraft_doc::PatternEdit { pattern: "Tile".into(), layer: l, original: None }); + let mut new = old.clone(); + new.node_mut(loose).unwrap().transform(Affine::translate((7.0, 3.0)), false); + assert_eq!(changed(&old, &new), Changed::Everything); +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index f66f7c024..3bfa57b9e 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -1885,9 +1885,11 @@ impl Writer<'_> { fn text(&mut self, n: &Node, t: &TextObject) { // Live SVG text is written in logical order from left-aligned pieces: bidirectional text // (and right-to-left paragraphs) keep their look as outlines until it is written with - // `direction`/`unicode-bidi`. + // `direction`/`unicode-bidi`. So do runs set Right to Left (Character Direction), whose + // `direction="rtl"` would anchor their pieces at the right end. let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); - if lay.glyphs.iter().any(|g| g.rtl) || lay.lines.iter().any(|l| l.rtl) { + let rtl_runs = t.runs.iter().any(|r| r.style.char_direction == Some(vectorcraft_doc::ParaDirection::RightToLeft)); + if lay.glyphs.iter().any(|g| g.rtl) || lay.lines.iter().any(|l| l.rtl) || rtl_runs { self.warn("bidirectional text is outlined to preserve shaping and visual order"); return self.text_outlines(n, t); } @@ -2035,7 +2037,8 @@ impl Writer<'_> { if st.rotation != 0.0 { attrs.push_str(&format!(" rotate=\"{}\"", self.num(-st.rotation))); } - let diff = run_diff(&base.props, self.char_props(st, base.space)); + let mut diff = run_diff(&base.props, self.char_props(st, base.space)); + diff.extend(direction_props(st)); attrs.push_str(&self.attrs(&diff)); let text = xml_text(&seg.text); if attrs.is_empty() && fewer { @@ -2165,7 +2168,8 @@ impl Writer<'_> { let offset = fmt_num(start * 100.0, 3); let mut s = format!(""); for run in &t.runs { - let diff = run_diff(&base, self.char_props(&run.style, space)); + let mut diff = run_diff(&base, self.char_props(&run.style, space)); + diff.extend(direction_props(&run.style)); let a = self.attrs(&diff); s.push_str(&format!("{}", xml_text(&run.text.replace('\n', " ")))); } @@ -2198,6 +2202,16 @@ fn hangs_on_path(t: &TextObject, lay: &vectorcraft_text::TextLayout) -> bool { lay.glyphs.len() != flush.glyphs.len() || lay.glyphs.iter().zip(&flush.glyphs).any(|(a, b)| a.origin != b.origin) } +/// The `direction` and `unicode-bidi` of a `` of characters in style `st`: an embedding in +/// its Character Direction, and nothing without one. Each `` of characters with a direction +/// carries them, and the `` does not. Type with a run set Right to Left is written as +/// outlines ([`Writer::text`]), so live text writes `direction="ltr"` only. +fn direction_props(st: &CharStyle) -> Props { + let Some(d) = st.char_direction else { return Props::new() }; + let direction = if d == vectorcraft_doc::ParaDirection::RightToLeft { "rtl" } else { "ltr" }; + vec![("direction", direction.into()), ("unicode-bidi", "embed".into())] +} + /// What the `` of a run whose properties are `run` sets over its ``'s `base`: the /// properties that differ, and the initial value of each one `base` sets and the run doesn't. fn run_diff(base: &Props, run: Props) -> Props { diff --git a/crates/svg/tests/text_layout.rs b/crates/svg/tests/text_layout.rs index d86403749..c186347bc 100644 --- a/crates/svg/tests/text_layout.rs +++ b/crates/svg/tests/text_layout.rs @@ -125,6 +125,43 @@ fn proportional_metrics_write_palt() { assert!(tags[2].contains("dlig") && tags[2].contains("palt"), "{}", tags[2]); } +/// Character Direction: live text writes each run set Left to Right as a bidi embedding +/// (`direction` and `unicode-bidi`). Type with a run set Right to Left is written as outlines. +#[test] +fn character_direction_is_written_on_its_runs() { + use vectorcraft_doc::ParaDirection; + let ltr = CharStyle { char_direction: Some(ParaDirection::LeftToRight), ..style(12.0) }; + let d = area_doc(vec![run("Due ", style(12.0)), run("21 10 2026", ltr.clone()), run(" ok", style(12.0))], 280.0, Justify::Left); + let svg = export(&d, &ExportOptions::default()); + let tags = tspans(&svg); + assert_eq!(tags.len(), 3, "{svg}"); + assert!(!tags[0].contains("direction") && !tags[2].contains("direction"), "{svg}"); + assert!(tags[1].contains(" direction=\"ltr\" unicode-bidi=\"embed\""), "{}", tags[1]); + // Type on a path: on the run's `` in the `` too. + let mut on_path = TextObject::point(Point::ZERO, "", style(12.0)); + on_path.runs = vec![run("Due ", style(12.0)), run("21 10 2026", ltr)]; + let mut line = BezPath::new(); + line.move_to((20.0, 100.0)); + line.line_to((280.0, 100.0)); + on_path.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + let mut d = Document::new(300.0, 200.0); + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(on_path))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let svg = export(&d, &ExportOptions::default()); + let tags = tspans(&svg); + assert!(svg.contains(" { find(b).or_else(|| b.checked_sub(1).and_then(find)).or_else(|| self.runs.first().map(|(_, s)| *s)).unwrap_or(&self.default) } + /// The stretches of paragraph `r` whose characters have a Character Direction, as byte ranges + /// from `r.start` with their direction, in order; neighboring runs of one direction make one + /// stretch. + fn char_directions(&self, r: &Range) -> Vec<(Range, ParaDirection)> { + let mut out: Vec<(Range, ParaDirection)> = Vec::new(); + for (rr, st) in &self.runs { + let (a, b) = (rr.start.max(r.start), rr.end.min(r.end)); + let Some(d) = st.char_direction.filter(|_| a < b) else { continue }; + let (a, b) = (a - r.start, b - r.start); + match out.last_mut() { + Some((prev, pd)) if *pd == d && prev.end == a => prev.end = b, + _ => out.push((a..b, d)), + } + } + out + } + /// Shape paragraph `r` (its bidi resolution `bidi`). fn shape_para(&self, r: Range, bidi: Option<&BidiInfo<'_>>) -> Vec { let mut v = Vec::with_capacity(r.len()); @@ -1908,7 +1925,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio // its first line) follow its own. pen.model = para.leading_model; let text = cx.text; - let bidi = para_bidi(text.get(pr.clone()).unwrap_or_default(), para.direction); + let bidi = para_bidi_with(text.get(pr.clone()).unwrap_or_default(), para.direction, &cx.char_directions(pr)); let rtl = is_rtl(bidi.as_ref()); let mut sg = cx.shape_para(pr.clone(), bidi.as_ref()); // Thai, Lao, Khmer and Myanmar break lines between words, not at spaces. @@ -2310,7 +2327,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, path: &Path let mut shaped = Vec::new(); for pr in paras { let text = cx.text; - let bidi = para_bidi(text.get(pr.clone()).unwrap_or_default(), para.direction); + let bidi = para_bidi_with(text.get(pr.clone()).unwrap_or_default(), para.direction, &cx.char_directions(pr)); rtl.get_or_insert(is_rtl(bidi.as_ref()) && !cx.vertical); let glyphs = cx.shape_para(pr.clone(), bidi.as_ref()); shaped.push((pr.clone(), bidi.filter(|b| b.has_rtl() && !cx.vertical), glyphs)); @@ -2554,6 +2571,52 @@ pub(crate) fn para_bidi(text: &str, direction: Option) -> Option< Some(BidiInfo::new(text, level)) } +/// [`para_bidi`] for a paragraph whose characters in `set` (byte ranges of `text`, in order and not +/// overlapping, from [`Ctx::char_directions`]) have a Character Direction: each range is resolved +/// as a Unicode bidi embedding, as if an LRE or RLE stood before it and a PDF after it (UAX #9 X2, +/// X3, X7). The levels, classes and paragraphs come back for the bytes of `text`. The paragraph's own +/// direction is unchanged: the first strong character is found inside embeddings too (P2). +pub(crate) fn para_bidi_with<'a>(text: &'a str, direction: Option, set: &[(Range, ParaDirection)]) -> Option> { + let plain = para_bidi(text, direction); + if set.is_empty() || (plain.is_none() && set.iter().all(|(_, d)| *d == ParaDirection::LeftToRight)) { + // A left-to-right embedding in left-to-right text takes an even level: its order is the same. + return plain; + } + // The text with the embedding controls, and where each byte of `text` is in it. + let mut marked = String::with_capacity(text.len() + set.len() * 6); + let mut at_byte: Vec = Vec::with_capacity(text.len()); + let mut copy = |marked: &mut String, from: usize, to: usize| { + if let Some(s) = text.get(from..to) { + at_byte.extend(marked.len()..marked.len() + s.len()); + marked.push_str(s); + } + }; + let mut done = 0; + for (r, d) in set { + let (a, b) = (r.start.max(done).min(text.len()), r.end.min(text.len())); + if a >= b { + continue; + } + copy(&mut marked, done, a); + marked.push(if *d == ParaDirection::RightToLeft { '\u{202B}' } else { '\u{202A}' }); + copy(&mut marked, a, b); + marked.push('\u{202C}'); + done = b; + } + copy(&mut marked, done, text.len()); + let level = direction.map(|d| if d == ParaDirection::RightToLeft { Level::rtl() } else { Level::ltr() }); + let info = BidiInfo::new(&marked, level); + let back = |i: usize| at_byte.partition_point(|&m| m < i); + let paragraphs = + info.paragraphs.iter().map(|p| unicode_bidi::ParagraphInfo { range: back(p.range.start)..back(p.range.end), level: p.level }).collect(); + Some(BidiInfo { + text, + original_classes: at_byte.iter().filter_map(|&i| info.original_classes.get(i).copied()).collect(), + levels: at_byte.iter().filter_map(|&i| info.levels.get(i).copied()).collect(), + paragraphs, + }) +} + /// Does the paragraph run right to left ([`para_bidi`])? pub(crate) fn is_rtl(bidi: Option<&BidiInfo<'_>>) -> bool { bidi.and_then(|b| b.paragraphs.first()).is_some_and(|p| p.level.is_rtl()) diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 868592356..1fa96c87a 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -433,9 +433,10 @@ fn source_run_at(runs: &[(Range, &CharStyle)], span: &Range, byte: /// Do text runs in styles `a` and `b` shape alike, so that they are shaped as one piece of text? /// They do when they differ only in paint and stroke, underline and strikethrough, overprint, the -/// character style's name, leading, baseline shift, rotation, Character Alignment, the kerning -/// (each glyph takes its run's, see [`shape_segment`]) and the tracking, as long as the tracking -/// keeps or drops standard ligatures alike ([`crate::features::ligatures_suppressed_by`]). +/// character style's name, leading, baseline shift, rotation, Character Alignment, Character +/// Direction (text is shaped apart where its bidi level changes), the kerning (each glyph takes its +/// run's, see [`shape_segment`]) and the tracking, as long as the tracking keeps or drops standard +/// ligatures alike ([`crate::features::ligatures_suppressed_by`]). fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { // Every field is named, so that a new attribute has to be sorted into one group or the other. let CharStyle { @@ -469,6 +470,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { position, small_caps, char_align: _, + char_direction: _, proportional_metrics, tate_chu_yoko, digits, diff --git a/crates/text/src/tests_bidi.rs b/crates/text/src/tests_bidi.rs index c213f09cf..291b6fa8f 100644 --- a/crates/text/src/tests_bidi.rs +++ b/crates/text/src/tests_bidi.rs @@ -1,8 +1,9 @@ //! Direction, source-cluster and editing regressions; no RTL font assets required. use super::*; use kurbo::{Affine, BezPath, Point, Rect, Shape}; +use std::ops::Range; use std::sync::Arc; -use vectorcraft_doc::{CharStyle, TextKind, TextRun}; +use vectorcraft_doc::{CharStyle, ParaDirection, TextKind, TextRun}; fn text_layout(s: &str) -> TextLayout { layout(FontDb::global(), &TextObject::point(Point::ZERO, s, CharStyle::default())) @@ -88,6 +89,40 @@ fn mixed_text_preserves_latin_and_number_order() { assert_eq!(s, "שלום Rust 123", "layout must not rewrite source text"); } +/// The characters of `s` in the order a layout of it draws them, left to right, with the bytes +/// `set` given Character Direction `d`. +fn drawn_with_direction(s: &str, set: Range, d: Option) -> String { + let mut t = TextObject::point(Point::ZERO, s, CharStyle::default()); + edit::style_range(&mut t.runs, set.start, set.end, |st| st.char_direction = d); + let l = layout(FontDb::global(), &t); + let mut glyphs: Vec<_> = l.glyphs.iter().collect(); + glyphs.sort_by(|a, b| a.origin.x.total_cmp(&b.origin.x)); + glyphs.iter().filter_map(|g| s.get(g.byte..)?.chars().next()).collect() +} + +/// Character Direction Left to Right keeps a date written with spaces in its own order inside a +/// right-to-left paragraph, where the bidi algorithm alone sets its numbers right to left. +#[test] +fn left_to_right_characters_keep_a_date_in_order_in_right_to_left_text() { + let s = "שלום 21 10 2026"; + let date = s.find('2').unwrap_or_default()..s.len(); + assert_eq!(drawn_with_direction(s, date.clone(), None), "2026 10 21 םולש"); + assert_eq!(drawn_with_direction(s, date, Some(ParaDirection::LeftToRight)), "21 10 2026 םולש"); +} + +/// Character Direction Right to Left sets a Hebrew word and the exclamation mark after it right to +/// left inside a left-to-right paragraph, where the bidi algorithm alone leaves the mark after the +/// word in the paragraph's direction. +#[test] +fn right_to_left_characters_set_a_word_and_its_mark_right_to_left_in_left_to_right_text() { + let s = "abc שלום! def"; + let word = s.find('ש').unwrap_or_default()..s.find('!').map_or(0, |i| i + 1); + assert_eq!(drawn_with_direction(s, word.clone(), None), "abc םולש! def"); + assert_eq!(drawn_with_direction(s, word.clone(), Some(ParaDirection::RightToLeft)), "abc !םולש def"); + // Left to Right on the same characters of a left-to-right paragraph draws them as without it. + assert_eq!(drawn_with_direction(s, word, Some(ParaDirection::LeftToRight)), "abc םולש! def"); +} + #[test] fn rtl_hit_testing_arrows_and_selection_follow_visual_positions() { let s = "שלום"; diff --git a/crates/text/src/tests_ruby.rs b/crates/text/src/tests_ruby.rs index 2435fa434..fa49bf13a 100644 --- a/crates/text/src/tests_ruby.rs +++ b/crates/text/src/tests_ruby.rs @@ -142,12 +142,18 @@ fn vertical_ruby_is_upright_to_the_right_of_the_column() { let ruby = ruby_ink(&lay); assert!(ruby.x0 > base.x0, "ruby right of the column: {ruby:?} vs {base:?}"); assert!(ruby.x0 >= base.x1 - 1.0, "ruby outside the column: {ruby:?} vs {base:?}"); - // Upright kana are taller than wide (they would be lying on their side if not). + // Upright: each reading glyph's ink has the width and height of its outline at 10 pt (half the + // base size); a glyph on its side has them swapped. + let mut not_square = false; for g in &lay.ruby { - if let Some(b) = ink(g) { - assert!(b.width() <= b.height() + 1.0, "upright ruby glyph {b:?}"); - } + let Some(r) = ink(g) else { continue }; + let face = db().face_by_id(g.font_id).expect("the ruby glyph's face"); + let o = db().outline(&face, g.gid).bounding_box(); + let (w, h) = (o.width() * 10.0 / face.units_per_em(), o.height() * 10.0 / face.units_per_em()); + assert!((r.width() - w).abs() < 0.01 && (r.height() - h).abs() < 0.01, "upright ruby glyph {r:?}, its outline {w} x {h}"); + not_square |= (w - h).abs() > 0.5; } + assert!(not_square, "one reading glyph's outline is not square"); // Down the column: the reading's glyphs have distinct y positions. let ys: Vec = lay.ruby.iter().filter_map(|g| ink(g).map(|b| b.center().y)).collect(); assert!(ys.windows(2).any(|w| w[0] != w[1]), "the reading runs down the column: {ys:?}"); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 44ac37711..8c0555ea9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -101,6 +101,7 @@ enum Drag { Present, } +/// Clear canvas interaction state before a tab transfer takes ownership of the artwork drag. pub(crate) fn end_document_drag(ctx: &egui::Context) { ctx.data_mut(|d| { d.remove::(drag_id()); @@ -387,6 +388,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { { upload(app, ui.ctx(), &done.img, done.key.pixel.is_some()); app.canvas.key = Some(done.key); + app.canvas.shown = Some((done.doc, done.opts)); app.perf.render_ms = done.ms; app.canvas.last_ms = done.ms; app.canvas.moved = done.moved > 0; @@ -461,13 +463,30 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let settling = app.canvas.moved && !key.interacting; let staying = key.interacting && app.canvas.from_worker; let heavy = (app.canvas.last_ms > 8.0 || settling || staying) && app.canvas.texture.is_some() && same_doc; - match (&mut app.canvas.worker, heavy) { - (Some(worker), true) => worker.submit(crate::render_worker::Job { key: key.clone(), doc: doc.clone(), w, h, view, opts }), + let changed = changed_pixels(app, &key, &doc, &opts, view, w, h); + match (&mut app.canvas.worker, heavy, changed) { + // Only the document changed since the frame shown, in a part of it: that part is + // rendered again, here, into the texture. `last_ms` stays the whole frame's. + (_, _, Some(region)) => { + let t0 = now_ms(); + if let (Some([x0, y0, x1, y1]), Some(tex)) = (region, &mut app.canvas.texture) { + let part = app.canvas.renderer.render(&doc, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &opts); + let color = color_image(&part); + let options = if pixel.is_some() { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR }; + tex.set_partial([x0 as usize, y0 as usize], color, options); + app.canvas.moved |= app.canvas.renderer.stats.moved > 0; + } + app.canvas.key = Some(key.clone()); + app.canvas.shown = Some((doc.clone(), opts.clone())); + app.perf.render_ms = now_ms() - t0; + } + (Some(worker), true, None) => worker.submit(crate::render_worker::Job { key: key.clone(), doc: doc.clone(), w, h, view, opts }), _ => { let t0 = now_ms(); let img = app.canvas.renderer.render(&doc, w, h, view, &opts); upload(app, ui.ctx(), &img, pixel.is_some()); app.canvas.key = Some(key.clone()); + app.canvas.shown = Some((doc.clone(), opts.clone())); app.perf.render_ms = now_ms() - t0; app.canvas.last_ms = app.perf.render_ms; app.canvas.moved = app.canvas.renderer.stats.moved > 0; @@ -803,6 +822,7 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon { } } +/// Dispatch canvas pointer, keyboard and drop events through the active document view. fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) { let line = ui.ctx().options(|o| o.input_options.line_scroll_speed); let wheel_zooms = app.session.prefs.zoom_with_mouse_wheel; @@ -3137,6 +3157,52 @@ pub(crate) fn color_image(img: &vectorcraft_render::Rendered) -> egui::ColorImag egui::ColorImage::new(size, pixels) } +/// The pixels of the `w`×`h` frame through `view` to render again for `doc`, when only the document +/// changed since the frame shown (its key `key` but for the revision, and its render options +/// `opts`), which this thread rendered, and no frame is on its way from the worker: those it +/// changed in (`Some(None)`: none), grown to whole eighths of the frame, as long as they are at +/// most half of it. None: render the whole frame. Frames the worker renders stay +/// whole: its renderer keeps the rasters of the art's effects, this thread's doesn't. +fn changed_pixels( + app: &VectorcraftApp, + key: &CacheKey, + doc: &vectorcraft_doc::Document, + opts: &vectorcraft_render::RenderOptions, + view: Affine, + w: u32, + h: u32, +) -> Option> { + let (shown, _) = app.canvas.shown.as_ref().filter(|(_, o)| o == opts)?; + // A viewed opacity mask shows its coverage wherever its art reaches, past its object's bounds. + if opts.mask_view.is_some() { + return None; + } + let same_view = app.canvas.key.as_ref().is_some_and(|k| *k == CacheKey { revision: k.revision, ..key.clone() }); + if !same_view || app.canvas.texture.is_none() || app.canvas.from_worker || app.canvas.worker.as_ref().is_some_and(|w| w.pending.is_some()) { + return None; + } + match vectorcraft_render::changed(shown, doc) { + vectorcraft_render::Changed::Nothing => Some(None), + vectorcraft_render::Changed::Area(area) => match vectorcraft_render::view_pixels(area, view, w, h) { + None => Some(None), + Some([x0, y0, x1, y1]) => { + let ((x0, x1), (y0, y1)) = (in_steps(x0, x1, w), in_steps(y0, y1, h)); + (u64::from(x1 - x0) * u64::from(y1 - y0) * 2 <= u64::from(w) * u64::from(h)).then_some(Some([x0, y0, x1, y1])) + } + }, + vectorcraft_render::Changed::Everything => None, + } +} + +/// `a..b` of `0..n`, grown to whole eighths of `n` and moved back into `0..n`: the parts of a +/// frame rendered again come in few sizes, which keep the contexts the renderer keeps. +fn in_steps(a: u32, b: u32, n: u32) -> (u32, u32) { + let step = n.div_ceil(8).max(1); + let len = (b - a).max(1).div_ceil(step).saturating_mul(step).min(n); + let a = a.min(n - len); + (a, a + len) +} + /// Upload the art's raster; Pixel Preview's document pixels keep hard edges when magnified. fn upload(app: &mut VectorcraftApp, ctx: &egui::Context, img: &vectorcraft_render::Rendered, pixel: bool) { let color = color_image(img); @@ -4701,6 +4767,69 @@ mod tests { (v, out.textures_delta) } + /// An edit of one object renders the canvas again only where it changed, into its texture; a + /// change of the view renders all of it. + #[test] + fn an_edit_renders_only_its_part_of_the_canvas() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width":400,"height":300})).unwrap(); + app.ui.task_bar = false; + app.ui.view.rulers = false; + app.canvas.worker_started = true; // every frame on this thread, however long it takes + let id = app.run("shape.rectangle", json!({"x":50,"y":50,"width":40,"height":30})).unwrap()["id"].as_u64().unwrap(); + app.run("shape.rectangle", json!({"x":250,"y":200,"width":40,"height":30})).unwrap(); + app.run("select.none", json!({})).unwrap(); + let ctx = egui::Context::default(); + // Where the canvas texture changed in a frame, and how much of it. + let canvas = |app: &VectorcraftApp, mut delta: egui::TexturesDelta| { + let tex = app.canvas.texture.as_ref().map(|t| t.id()); + let set = delta.set.iter().find(|(t, _)| tex == Some(**t)).and_then(|(_, d)| d.last()).map(|d| (d.pos, d.image.size())); + delta.clear(); + set + }; + let (_, delta) = frame_output(&mut app, &ctx); + assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "the first frame renders all of it"); + app.run("paint.setFill", json!({"color": "#ff0000", "ids": [id]})).unwrap(); + let (_, delta) = frame_output(&mut app, &ctx); + let (pos, size) = canvas(&app, delta).expect("the edit shows"); + let whole = app.canvas.key.as_ref().map(|k| (k.w as usize, k.h as usize)).unwrap(); + assert!(pos.is_some() && size[0] * size[1] * 10 < whole.0 * whole.1, "a small part of it: {pos:?} {size:?} of {whole:?}"); + // An edit with other render options than the frame shown's renders all of it. + app.session.prefs.pattern_tile_edge_color = "#ff00ff".into(); + app.run("paint.setFill", json!({"color": "#00ff00", "ids": [id]})).unwrap(); + let (_, delta) = frame_output(&mut app, &ctx); + assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "other options render all of it"); + app.run("view.setZoom", json!({"zoom": 2})).unwrap(); + let (_, delta) = frame_output(&mut app, &ctx); + assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "a new view renders all of it"); + } + + /// While an opacity mask is viewed, an edit renders all of the canvas: the mask view shows the + /// mask's coverage wherever its art reaches, past its object's bounds. + #[test] + fn an_edit_while_a_mask_is_viewed_renders_all_of_the_canvas() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width":400,"height":300})).unwrap(); + app.ui.task_bar = false; + app.ui.view.rulers = false; + app.canvas.worker_started = true; // every frame on this thread, however long it takes + let rect = + |app: &mut VectorcraftApp, x: f64| app.run("shape.rectangle", json!({"x": x, "y": 50, "width": 60, "height": 40})).unwrap()["id"].clone(); + let (obj, mask) = (rect(&mut app, 50.0), rect(&mut app, 70.0)); + app.run("select.set", json!({"ids": [obj, mask]})).unwrap(); + app.run("transparency.makeOpacityMask", json!({})).unwrap(); + app.run("transparency.viewOpacityMask", json!({})).unwrap(); + let ctx = egui::Context::default(); + let (_, mut delta) = frame_output(&mut app, &ctx); + delta.clear(); + app.run("paint.setFill", json!({"color": "#ff0000", "ids": [obj]})).unwrap(); + let (_, mut delta) = frame_output(&mut app, &ctx); + let tex = app.canvas.texture.as_ref().map(|t| t.id()); + let pos = delta.set.iter().find(|(t, _)| tex == Some(**t)).and_then(|(_, d)| d.last()).map(|d| d.pos); + delta.clear(); + assert_eq!(pos, Some(None), "all of it"); + } + /// #1278: a segment click marks the edge, not its endpoint anchors or neighboring handles. #[test] fn a_segment_pick_has_hollow_anchors_and_only_its_own_handles() { @@ -5308,7 +5437,7 @@ mod tests { // Deliver the actual replacement and verify that it replaces the cached key. let img = app.canvas.renderer.render(&job.doc, job.w, job.h, job.view, &job.opts); let expected = job.key.clone(); - completed.send(crate::render_worker::Done { key: job.key, img, ms: 1.0, moved: 0 }).unwrap(); + completed.send(crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: 1.0, moved: 0 }).unwrap(); frame(&mut app, size, new_ppp); assert_eq!(app.canvas.key.as_ref(), Some(&expected), "the worker's frame arrived"); } diff --git a/crates/ui-egui/src/document_drag.rs b/crates/ui-egui/src/document_drag.rs index e135f38d5..2ca57ce48 100644 --- a/crates/ui-egui/src/document_drag.rs +++ b/crates/ui-egui/src/document_drag.rs @@ -29,16 +29,20 @@ struct Hover { since: f64, } +/// Key for the source document and original canvas grab point. fn origin_id() -> Id { Id::new("document-drag-origin") } +/// Key for the tab identity and dwell start time of the current hover. fn hover_id() -> Id { Id::new("document-drag-hover") } +/// Key for the pointer position latched after tab activation to prevent cascading switches. fn activated_at_id() -> Id { Id::new("document-drag-activated-at") } +/// Record the Selection drag source and grab point, clearing any previous tab-hover timer. pub(crate) fn begin(app: &VectorcraftApp, ctx: &Context, grab: Point) { ctx.data_mut(|d| { d.remove::(origin_id()); @@ -155,12 +159,14 @@ pub(crate) fn hover_tab(app: &mut VectorcraftApp, resp: &Response, index: usize, true } +/// Reset the dwell timer when no eligible inactive tab is under the artwork. pub(crate) fn finish_tabs(ctx: &Context, hovering: bool) { if !hovering { ctx.data_mut(|d| d.remove::(hover_id())); } } +/// Release artwork into the active canvas; returning to the source resumes Move or Alt-copy. pub(crate) fn drop_on_canvas(app: &mut VectorcraftApp, resp: &Response, xf: &canvas::Xf) { let Some(payload) = egui::DragAndDrop::payload::(&resp.ctx) else { return; diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 0272b39e4..acee8e9fc 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2342,6 +2342,7 @@ Proof Setup Настройка на пробен печат Properties Свойства Proportional Metrics Пропорционални метрики + Character Direction Посока на знаците Digits Цифри Arabic Арабски Hindi Хинди @@ -3900,7 +3901,7 @@ axis Both И двете @msg give the path `id` and the width point `index` дайте пътя `id` и точката на ширина `index` @msg guides are locked водачите са заключени @msg handle must be in or out handle трябва да е in или out -@msg image '{key}' has no pixels and was left out изображението '{key}' няма пиксели и беше пропуснато +@msg an image has no pixels and was left out изображение без пиксели беше пропуснато @msg image data `{_1}` is missing данните за изображение `{_1}` липсват @msg image would be too large; lower the resolution изображението ще бъде твърде голямо; намалете разделителната способност @msg index must be a number or first, previous, next or last индексът трябва да е число или first, previous, next или last @@ -5334,6 +5335,12 @@ theme Appearance Външен вид Water Paper Мокра хартия Ruby… Руби… Ruby Руби + Ease In Плавно начало + Ease Out Плавен край + Ease In Out Плавно начало и край + Ease Out In Плавен край и начало + Ramp: Рампа: + Shift: Изместване: Fresco… Фреска… Fresco Фреска diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index fc24cfc97..62235737d 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1394,6 +1394,7 @@ lighting Light Světlo Leading Proklad No Break Nezalamovat Proportional Metrics Proporcionální metrika + Character Direction Směr znaků Digits Číslice Arabic Arabské Hindi Hindské @@ -4291,7 +4292,7 @@ imageTrace Outline Obrys @msg give the path `id` and the width point `index` zadejte `id` cesty a `index` bodu šířky @msg guides are locked vodítka jsou zamčená @msg handle must be in or out handle musí být in nebo out -@msg image '{key}' has no pixels and was left out obrázek '{key}' nemá žádné obrazové body a byl vynechán +@msg an image has no pixels and was left out obrázek bez obrazových bodů byl vynechán @msg image data `{_1}` is missing chybí obrazová data `{_1}` @msg image would be too large; lower the resolution obraz by byl příliš velký; snižte rozlišení @msg index must be a number or first, previous, next or last index musí být číslo nebo first, previous, next či last @@ -5377,6 +5378,12 @@ mark-style Japanese Japonské Water Paper Mokrý papír Ruby… Ruby… Ruby Ruby + Ease In Pozvolný začátek + Ease Out Pozvolný konec + Ease In Out Pozvolný začátek a konec + Ease Out In Pozvolný konec a začátek + Ramp: Síla: + Shift: Posun: Fresco… Freska… Fresco Freska diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 3954cdf87..491db411c 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1312,6 +1312,7 @@ lighting Light Lys Leading Linjeafstand No Break Ingen ombrydning Proportional Metrics Proportionale metrikker + Character Direction Tegnretning Digits Cifre Arabic Arabisk Hindi Hindi @@ -4013,7 +4014,7 @@ imageTrace Outline Kontur @msg give horizontal or vertical angiv vandret eller lodret @msg give the path `id` and the width point `index` angiv `id` for kurven og `index` for breddepunktet @msg guides are locked hjælpelinjerne er låst -@msg image '{key}' has no pixels and was left out billedet '{key}' har ingen pixels og blev udeladt +@msg an image has no pixels and was left out et billede uden pixels blev udeladt @msg image data `{_1}` is missing billeddata `{_1}` mangler @msg image would be too large; lower the resolution billedet ville blive for stort; sænk opløsningen @msg index must be a number or first, previous, next or last indeks skal være et tal eller first, previous, next eller last @@ -5350,6 +5351,12 @@ swatch Bright Violet Kraftig violet Water Paper Vådt papir Ruby… Ruby… Ruby Ruby + Ease In Langsom start + Ease Out Langsom slutning + Ease In Out Langsom start og slutning + Ease Out In Langsom slutning og start + Ramp: Styrke: + Shift: Forskydning: Fresco… Fresko… Fresco Fresko diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0fac7d9fa..136d671e0 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2360,6 +2360,7 @@ mark-style Japanese Japanisch Proof Setup Proof einrichten Properties Eigenschaften Proportional Metrics Proportionale Metrik + Character Direction Zeichenrichtung Digits Ziffern Arabic Arabisch Hindi Hindi @@ -3912,7 +3913,7 @@ theme Appearance Erscheinungsbild @msg give the path `id` and the width point `index` geben Sie die `id` des Pfads und den `index` des Breitenpunkts an @msg guides are locked die Hilfslinien sind gesperrt @msg handle must be in or out handle muss in oder out sein -@msg image '{key}' has no pixels and was left out das Bild '{key}' hat keine Pixel und wurde ausgelassen +@msg an image has no pixels and was left out ein Bild ohne Pixel wurde ausgelassen @msg image data `{_1}` is missing die Bilddaten `{_1}` fehlen @msg image would be too large; lower the resolution das Bild wäre zu groß; verringern Sie die Auflösung @msg index must be a number or first, previous, next or last index muss eine Zahl oder first, previous, next oder last sein @@ -4958,6 +4959,12 @@ effect Soft Weich Water Paper Feuchtes Papier Ruby… Ruby… Ruby Ruby + Ease In Langsam starten + Ease Out Langsam enden + Ease In Out Langsam starten und enden + Ease Out In Langsam enden und starten + Ramp: Stärke: + Shift: Verschiebung: Fresco… Fresko… Fresco Fresko diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index a3c20159b..80b551e81 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1738,6 +1738,7 @@ Proof Colors Χρώματα δοκιμίου Proof Setup Ρύθμιση δοκιμίου Properties Ιδιότητες + Character Direction Κατεύθυνση χαρακτήρων Digits Ψηφία Punctuation Στίξη Puppet Warp Παραμόρφωση με σημεία ελέγχου @@ -3969,7 +3970,7 @@ theme Appearance Εμφάνιση @msg give the path `id` and the width point `index` δώστε το `id` διαδρομής και το `index` σημείου πλάτους @msg guides are locked οι οδηγοί είναι κλειδωμένοι @msg handle must be in or out το handle πρέπει να είναι in ή out -@msg image '{key}' has no pixels and was left out η εικόνα '{key}' δεν περιέχει εικονοστοιχεία και παραλείφθηκε +@msg an image has no pixels and was left out παραλείφθηκε εικόνα χωρίς εικονοστοιχεία @msg image data `{_1}` is missing λείπουν τα δεδομένα εικόνας `{_1}` @msg image would be too large; lower the resolution η εικόνα θα ήταν υπερβολικά μεγάλη· μειώστε την ανάλυση @msg index must be a number or first, previous, next or last ο δείκτης πρέπει να είναι αριθμός ή first, previous, next ή last @@ -5329,6 +5330,12 @@ effect Points Σημεία Water Paper Βρεγμένο χαρτί Ruby… Ruby… Ruby Ruby + Ease In Ομαλή αρχή + Ease Out Ομαλό τέλος + Ease In Out Ομαλή αρχή και τέλος + Ease Out In Ομαλό τέλος και αρχή + Ramp: Ένταση: + Shift: Μετατόπιση: Fresco… Νωπογραφία… Fresco Νωπογραφία diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 368d0f4d9..d062ffaf6 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2394,6 +2394,7 @@ Proof Setup Ajuste de prueba Properties Propiedades Proportional Metrics Métrica proporcional + Character Direction Dirección de caracteres Digits Dígitos Arabic Árabes Hindi Hindi @@ -3988,7 +3989,7 @@ axis Both Ambos @msg give the path `id` and the width point `index` indica el `id` del trazado y el `index` del punto de anchura @msg guides are locked las guías están bloqueadas @msg handle must be in or out handle debe ser in u out -@msg image '{key}' has no pixels and was left out la imagen '{key}' no tiene píxeles y se omitió +@msg an image has no pixels and was left out se omitió una imagen sin píxeles @msg image data `{_1}` is missing faltan los datos de imagen `{_1}` @msg image would be too large; lower the resolution la imagen sería demasiado grande; baja la resolución @msg index must be a number or first, previous, next or last index debe ser un número o first, previous, next o last @@ -5319,6 +5320,12 @@ mark-style Japanese Japonesas Water Paper Papel mojado Ruby… Ruby… Ruby Ruby + Ease In Entrada suave + Ease Out Salida suave + Ease In Out Entrada y salida suaves + Ease Out In Salida y entrada suaves + Ramp: Intensidad: + Shift: Desplazamiento: Fresco… Fresco… Fresco Fresco diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index c115a5c14..c1398c4ed 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1369,6 +1369,7 @@ lighting Light Valo Leading Riviväli No Break Ei katkoa Proportional Metrics Suhteelliset metriikat + Character Direction Merkkien suunta Digits Numerot Arabic Arabia Hindi Hindi @@ -4206,7 +4207,7 @@ imageTrace Outline Ääriviiva @msg give the path `id` and the width point `index` anna polun `id` ja leveyspisteen `index` @msg guides are locked apuviivat on lukittu @msg handle must be in or out handle-arvon on oltava in tai out -@msg image '{key}' has no pixels and was left out kuvassa '{key}' ei ole pikseleitä, joten se jätettiin pois +@msg an image has no pixels and was left out kuva, jossa ei ole pikseleitä, jätettiin pois @msg image data `{_1}` is missing kuvan data `{_1}` puuttuu @msg image would be too large; lower the resolution kuvasta tulisi liian suuri; pienennä tarkkuutta @msg index must be a number or first, previous, next or last indeksin on oltava luku tai first, previous, next tai last @@ -5408,6 +5409,12 @@ swatch Bright Violet Kirkas violetti Water Paper Märkä paperi Ruby… Ruby… Ruby Ruby + Ease In Hidas alku + Ease Out Hidas loppu + Ease In Out Hidas alku ja loppu + Ease Out In Hidas loppu ja alku + Ramp: Voimakkuus: + Shift: Siirtymä: Fresco… Fresko… Fresco Fresko diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b7cb1d456..15411ea15 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2391,6 +2391,7 @@ Proof Setup Format d’épreuve Properties Propriétés Proportional Metrics Métrique proportionnelle + Character Direction Direction des caractères Digits Chiffres Arabic Arabes Hindi Hindi @@ -3985,7 +3986,7 @@ axis Both Les deux @msg give the path `id` and the width point `index` indiquez l’`id` du tracé et l’`index` du point de largeur @msg guides are locked les repères sont verrouillés @msg handle must be in or out handle doit valoir in ou out -@msg image '{key}' has no pixels and was left out l’image « {key} » n’a aucun pixel et a été ignorée +@msg an image has no pixels and was left out une image sans aucun pixel a été ignorée @msg image data `{_1}` is missing les données d’image `{_1}` sont manquantes @msg image would be too large; lower the resolution l’image serait trop grande ; réduisez la résolution @msg index must be a number or first, previous, next or last index doit être un nombre ou first, previous, next ou last @@ -5376,6 +5377,12 @@ theme Appearance Apparence Water Paper Papier humide Ruby… Ruby… Ruby Ruby + Ease In Accélération progressive + Ease Out Décélération progressive + Ease In Out Accélération et décélération + Ease Out In Décélération et accélération + Ramp: Intensité : + Shift: Décalage : Fresco… Fresque… Fresco Fresque diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c5f75fd4c..4a4187172 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -982,6 +982,7 @@ Diffuse Glow Szórt ragyogás Diffuse Glow… Szórt ragyogás… Diffusion Diffúziós + Character Direction Karakterirány Digits Számjegyek Dim Copies to: Másolatok halványítása: Dim Images to: Képek halványítása: @@ -4604,7 +4605,7 @@ imageTrace Outline Körvonal @msg column docks a set as a dock column, with no anchor or icon a column egy készletet dokkoszlopként dokkol, anchor és icon nélkül @msg guides are locked a segédvonalak zárolva vannak @msg handle must be in or out handle: in vagy out kell -@msg image '{key}' has no pixels and was left out a kép kimaradt, mert nincsenek pixelei: '{key}' +@msg an image has no pixels and was left out kimaradt egy kép, mert nincsenek pixelei @msg image data `{_1}` is missing hiányoznak a képadatok: `{_1}` @msg image is empty a kép üres @msg Image Trace: the traced image is missing Képvektorizálás: a vektorizált kép hiányzik @@ -5363,6 +5364,12 @@ imageTrace Outline Körvonal Water Paper Nedves papír Ruby… Ruby… Ruby Ruby + Ease In Lassú kezdés + Ease Out Lassú befejezés + Ease In Out Lassú kezdés és befejezés + Ease Out In Lassú befejezés és kezdés + Ramp: Erősség: + Shift: Eltolás: Fresco… Freskó… Fresco Freskó diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index cf5b69d46..93f9acf67 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1312,6 +1312,7 @@ lighting Light Cahaya Leading Leading No Break Tanpa pemenggalan Proportional Metrics Metrik proporsional + Character Direction Arah karakter Digits Digit Arabic Arab Hindi Hindi @@ -4013,7 +4014,7 @@ imageTrace Outline Kontur @msg give horizontal or vertical berikan horizontal atau vertikal @msg give the path `id` and the width point `index` berikan jalur `id` dan titik lebar `index` @msg guides are locked panduan terkunci -@msg image '{key}' has no pixels and was left out gambar '{key}' tidak memiliki piksel dan dilewati +@msg an image has no pixels and was left out gambar tanpa piksel dilewati @msg image data `{_1}` is missing data gambar `{_1}` tidak ada @msg image would be too large; lower the resolution gambar akan menjadi terlalu besar; turunkan resolusinya @msg index must be a number or first, previous, next or last index harus berupa angka atau first, previous, next, atau last @@ -5350,6 +5351,12 @@ swatch Bright Violet Violet cerah Water Paper Kertas Basah Ruby… Ruby… Ruby Ruby + Ease In Awal Lambat + Ease Out Akhir Lambat + Ease In Out Awal dan Akhir Lambat + Ease Out In Akhir dan Awal Lambat + Ramp: Kekuatan: + Shift: Pergeseran: Fresco… Fresko… Fresco Fresko diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index be2df2614..bca168232 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2425,6 +2425,7 @@ mark-style Japanese Giapponese Proof Setup Impostazione prova Properties Proprietà Proportional Metrics Metrica proporzionale + Character Direction Direzione caratteri Digits Cifre Arabic Arabe Hindi Hindi @@ -4028,7 +4029,7 @@ axis Both Entrambi @msg give the path `id` and the width point `index` specifica l'`id` del tracciato e l'`index` del punto di larghezza @msg guides are locked le guide sono bloccate @msg handle must be in or out handle deve essere in o out -@msg image '{key}' has no pixels and was left out l'immagine '{key}' non ha pixel ed è stata omessa +@msg an image has no pixels and was left out un'immagine senza pixel è stata omessa @msg image data `{_1}` is missing mancano i dati immagine `{_1}` @msg image would be too large; lower the resolution l'immagine sarebbe troppo grande; riduci la risoluzione @msg index must be a number or first, previous, next or last index deve essere un numero oppure first, previous, next o last @@ -4992,6 +4993,12 @@ lighting Light Luce Water Paper Carta bagnata Ruby… Ruby… Ruby Ruby + Ease In Inizio graduale + Ease Out Fine graduale + Ease In Out Inizio e fine graduali + Ease Out In Fine e inizio graduali + Ramp: Intensità: + Shift: Spostamento: Fresco… Affresco… Fresco Affresco diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index cc5f1cd38..e9cb4482d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -39,6 +39,7 @@ ICF Top/Right 平均字面の上 / 右 ICF Bottom/Left 平均字面の下 / 左 Proportional Metrics プロポーショナルメトリクス + Character Direction 文字の方向 Digits 数字 Arabic アラビア数字 Hindi ヒンディー数字 @@ -3878,7 +3879,7 @@ swatch Bright Violet ビビッドバイオレット @msg the scale is not a usable number 縮尺が有効な数値ではありません @msg the drawing is too large to open 図面が大きすぎて開けません @msg scale must be a number from {_1} to {_2}, not {_3} 縮尺は {_1} から {_2} までの数値にしてください ({_3} は指定できません) -@msg image '{key}' has no pixels and was left out 画像「{key}」はピクセルがないため省略しました +@msg an image has no pixels and was left out ピクセルがない画像を省略しました @msg Embedded {n} image(s) 画像を {n} 枚埋め込みました @msg object {0} cannot have children オブジェクト {0} には子オブジェクトを含められません @msg range `{s}` is empty 範囲 `{s}` が空です @@ -5039,6 +5040,12 @@ lighting Light ライト Yonroku-ban 四六判 Ruby… ルビ… Ruby ルビ + Ease In イーズイン + Ease Out イーズアウト + Ease In Out イーズインアウト + Ease Out In イーズアウトイン + Ramp: 強さ: + Shift: シフト: Fresco… フレスコ… Fresco フレスコ diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 615689b69..003fdf8b9 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1227,6 +1227,7 @@ lighting Light სინათლე Leading სტრიქონთა დაშორება No Break გადატანის გარეშე Proportional Metrics პროპორციული მეტრიკა + Character Direction სიმბოლოების მიმართულება Digits ციფრები Arabic არაბული Hindi ჰინდი @@ -3773,7 +3774,7 @@ effect Regular ჩვეულებრივი @msg give horizontal or vertical მიუთითეთ horizontal ან vertical @msg give the path `id` and the width point `index` მიუთითეთ ტრაექტორიის `id` და სიგანის წერტილის `index` @msg guides are locked დამხმარე ხაზები ჩაკეტილია -@msg image '{key}' has no pixels and was left out გამოსახულებას '{key}' პიქსელები არ აქვს და გამოტოვდა +@msg an image has no pixels and was left out გამოსახულება, რომელსაც პიქსელები არ აქვს, გამოტოვდა @msg image data `{_1}` is missing გამოსახულების მონაცემები `{_1}` აკლია @msg image would be too large; lower the resolution გამოსახულება ზედმეტად დიდი იქნებოდა; შეამცირეთ გარჩევადობა @msg index must be a number or first, previous, next or last index უნდა იყოს რიცხვი ან first, previous, next ან last @@ -5287,6 +5288,12 @@ imageTrace Outline კონტური selects several, or draws one inside an artboard) რამდენიმეს მონიშნავს ან ახალს სამუშაო დაფის შიგნით ხატავს) Ruby… რუბი… Ruby რუბი + Ease In ნელი დასაწყისი + Ease Out ნელი დასასრული + Ease In Out ნელი დასაწყისი და დასასრული + Ease Out In ნელი დასასრული და დასაწყისი + Ramp: სიძლიერე: + Shift: წანაცვლება: Fresco… ფრესკა… Fresco ფრესკა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 12136ab2a..0b2ea414a 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -35,6 +35,7 @@ ICF Top/Right ICF 위/오른쪽 ICF Bottom/Left ICF 아래/왼쪽 Proportional Metrics 비례 메트릭 + Character Direction 문자 방향 Digits 숫자 Arabic 아라비아어 Hindi 힌디어 @@ -3810,7 +3811,7 @@ theme Appearance 외관 @msg the scale is not a usable number 배율이 사용할 수 없는 숫자입니다 @msg the drawing is too large to open 도면이 너무 커서 열 수 없습니다 @msg scale must be a number from {_1} to {_2}, not {_3} 배율은 {_1}에서 {_2} 사이의 숫자여야 합니다: {_3} -@msg image '{key}' has no pixels and was left out 이미지 '{key}'에 픽셀이 없어 제외했습니다 +@msg an image has no pixels and was left out 픽셀이 없는 이미지를 제외했습니다 @msg Embedded {n} image(s) 이미지 {n}개를 포함했습니다 @msg object {0} cannot have children 개체 {0}에는 하위 항목을 둘 수 없습니다 @msg range `{s}` is empty 범위 `{s}`이(가) 비어 있습니다 @@ -4955,6 +4956,12 @@ lighting Light 광원 Water Paper 물 종이 Ruby… 루비… Ruby 루비 + Ease In 서서히 시작 + Ease Out 서서히 종료 + Ease In Out 서서히 시작 및 종료 + Ease Out In 서서히 종료 및 시작 + Ramp: 강도: + Shift: 이동: Fresco… 프레스코… Fresco 프레스코 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 2b899fdf8..154cc8687 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1326,6 +1326,7 @@ lighting Light Licht Leading Regelafstand No Break Niet afbreken Proportional Metrics Proportionele metingen + Character Direction Tekenrichting Digits Cijfers Arabic Arabisch Hindi Hindi @@ -4027,7 +4028,7 @@ imageTrace Outline Contour @msg give horizontal or vertical geef horizontaal of verticaal @msg give the path `id` and the width point `index` geef het `id` van het pad en de `index` van het breedtepunt @msg guides are locked de hulplijnen zijn vergrendeld -@msg image '{key}' has no pixels and was left out afbeelding '{key}' heeft geen pixels en is weggelaten +@msg an image has no pixels and was left out een afbeelding zonder pixels is weggelaten @msg image data `{_1}` is missing afbeeldingsgegevens `{_1}` ontbreken @msg image would be too large; lower the resolution de afbeelding zou te groot worden; verlaag de resolutie @msg index must be a number or first, previous, next or last index moet een getal zijn of first, previous, next of last @@ -5392,6 +5393,12 @@ swatch Bright Violet Felviolet Water Paper Nat papier Ruby… Ruby… Ruby Ruby + Ease In Langzaam beginnen + Ease Out Langzaam eindigen + Ease In Out Langzaam beginnen en eindigen + Ease Out In Langzaam eindigen en beginnen + Ramp: Sterkte: + Shift: Verschuiving: Fresco… Fresco… Fresco Fresco diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 7b27038e8..5ea6f0fb3 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1308,6 +1308,7 @@ lighting Light Światło Leading Interlinia No Break Bez podziału Proportional Metrics Metryka proporcjonalna + Character Direction Kierunek znaków Digits Cyfry Arabic Arabskie Hindi Hindi @@ -4113,7 +4114,7 @@ imageTrace Outline Kontur @msg give the path `id` and the width point `index` podaj `id` ścieżki i `index` punktu szerokości @msg guides are locked linie pomocnicze są zablokowane @msg handle must be in or out handle musi mieć wartość in lub out -@msg image '{key}' has no pixels and was left out obraz '{key}' nie ma pikseli i został pominięty +@msg an image has no pixels and was left out pominięto obraz bez pikseli @msg image data `{_1}` is missing brak danych obrazu `{_1}` @msg image would be too large; lower the resolution obraz byłby zbyt duży; zmniejsz rozdzielczość @msg index must be a number or first, previous, next or last indeks musi być liczbą lub first, previous, next albo last @@ -5229,6 +5230,12 @@ mark-style Japanese Japońskie Water Paper Mokry papier Ruby… Ruby… Ruby Ruby + Ease In Łagodny początek + Ease Out Łagodny koniec + Ease In Out Łagodny początek i koniec + Ease Out In Łagodny koniec i początek + Ramp: Siła: + Shift: Przesunięcie: Fresco… Fresk… Fresco Fresk diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ce7c122af..99d3298e7 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1976,6 +1976,7 @@ Proof Setup Configuração de Prova Properties Propriedades Proportional Metrics Métrica Proporcional + Character Direction Direção de Caracteres Digits Dígitos Arabic Arábicos Hindi Hindi @@ -3563,7 +3564,7 @@ axis Both Ambos @msg give the path `id` and the width point `index` informe o `id` do traçado e o `index` do ponto de largura @msg guides are locked as guias estão bloqueadas @msg handle must be in or out handle deve ser in ou out -@msg image '{key}' has no pixels and was left out a imagem '{key}' não tem pixels e foi omitida +@msg an image has no pixels and was left out uma imagem sem pixels foi omitida @msg image data `{_1}` is missing os dados da imagem `{_1}` estão ausentes @msg image would be too large; lower the resolution a imagem ficaria grande demais; reduza a resolução @msg index must be a number or first, previous, next or last index deve ser um número ou first, previous, next ou last @@ -5294,6 +5295,12 @@ mark-style Japanese Japonesas Water Paper Papel molhado Ruby… Ruby… Ruby Ruby + Ease In Entrada suave + Ease Out Saída suave + Ease In Out Entrada e saída suaves + Ease Out In Saída e entrada suaves + Ramp: Intensidade: + Shift: Deslocamento: Fresco… Afresco… Fresco Afresco diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8cdc6dff0..87b18a711 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2421,6 +2421,7 @@ mark-style Japanese Японский Proof Setup Настройка цветопробы Properties Свойства Proportional Metrics Пропорциональные метрики + Character Direction Направление символов Digits Цифры Arabic Арабские Hindi Хинди @@ -4024,7 +4025,7 @@ axis Both Оба @msg give the path `id` and the width point `index` укажите `id` контура и `index` точки ширины @msg guides are locked направляющие заблокированы @msg handle must be in or out handle должен быть in или out -@msg image '{key}' has no pixels and was left out изображение '{key}' не имеет пикселей и было пропущено +@msg an image has no pixels and was left out изображение без пикселей пропущено @msg image data `{_1}` is missing данные изображения `{_1}` отсутствуют @msg image would be too large; lower the resolution изображение было бы слишком большим; уменьшите разрешение @msg index must be a number or first, previous, next or last index должен быть числом или first, previous, next, last @@ -4988,6 +4989,12 @@ lighting Light Свет Water Paper Мокрая бумага Ruby… Руби… Ruby Руби + Ease In Плавное начало + Ease Out Плавное окончание + Ease In Out Плавное начало и окончание + Ease Out In Плавное окончание и начало + Ramp: Сила: + Shift: Сдвиг: Fresco… Фреска… Fresco Фреска diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index af4a2f084..c33f4270c 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2342,6 +2342,7 @@ Proof Setup Provtrycksinställningar Properties Egenskaper Proportional Metrics Proportionella mått + Character Direction Teckenriktning Digits Siffror Arabic Arabiska Hindi Hindi @@ -3905,7 +3906,7 @@ axis Both Båda @msg give the path `id` and the width point `index` ange banans `id` och breddpunktens `index` @msg guides are locked stödlinjerna är låsta @msg handle must be in or out handle måste vara in eller out -@msg image '{key}' has no pixels and was left out bilden '{key}' har inga pixlar och utelämnades +@msg an image has no pixels and was left out en bild utan pixlar utelämnades @msg image data `{_1}` is missing bilddata `{_1}` saknas @msg image would be too large; lower the resolution bilden skulle bli för stor; sänk upplösningen @msg index must be a number or first, previous, next or last index måste vara ett tal eller first, previous, next eller last @@ -5333,6 +5334,12 @@ theme Appearance Utseende Water Paper Vått papper Ruby… Ruby… Ruby Ruby + Ease In Långsam start + Ease Out Långsamt slut + Ease In Out Långsam start och slut + Ease Out In Långsamt slut och start + Ramp: Styrka: + Shift: Förskjutning: Fresco… Fresk… Fresco Fresk diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ee81445a6..0b980f2b7 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2417,6 +2417,7 @@ mark-style Japanese Японський Proof Setup Налаштування кольоропроби Properties Властивості Proportional Metrics Пропорційні метрики + Character Direction Напрямок символів Digits Цифри Arabic Арабські Hindi Гінді @@ -4012,7 +4013,7 @@ axis Both Обидві @msg give the path `id` and the width point `index` вкажіть `id` контуру та `index` точки ширини @msg guides are locked напрямні заблоковані @msg handle must be in or out handle має бути in або out -@msg image '{key}' has no pixels and was left out зображення '{key}' не має пікселів і було пропущено +@msg an image has no pixels and was left out зображення без пікселів пропущено @msg image data `{_1}` is missing дані зображення `{_1}` відсутні @msg image would be too large; lower the resolution зображення буде завеликим; зменште роздільну здатність @msg index must be a number or first, previous, next or last index має бути числом або first, previous, next, last @@ -4972,6 +4973,12 @@ lighting Light Світло Water Paper Мокрий папір Ruby… Рубі… Ruby Рубі + Ease In Плавний початок + Ease Out Плавне завершення + Ease In Out Плавний початок і завершення + Ease Out In Плавне завершення і початок + Ramp: Сила: + Shift: Зсув: Fresco… Фреска… Fresco Фреска diff --git a/crates/ui-egui/src/i18n/untranslated_messages.txt b/crates/ui-egui/src/i18n/untranslated_messages.txt index 05f865c83..7ee0845b7 100644 --- a/crates/ui-egui/src/i18n/untranslated_messages.txt +++ b/crates/ui-egui/src/i18n/untranslated_messages.txt @@ -55,6 +55,7 @@ Text options: {e} `background` must be a colour `burasagari` must be "none", "standard" or "forced" `charAlign` must be "romanBaseline", "emBoxTop", "emBoxCenter", "emBoxBottom", "icfTop" or "icfBottom" +`charDirection` must be "default", "leftToRight" or "rightToLeft" `color` needs a `rule` to make the theme from `colors` is empty `columnType` is vertical, uniform, repeating or sliding @@ -430,6 +431,7 @@ each of `items` is {{id, corners?}} with an object id, not {it} each row needs `from` each stop needs a position each subpath needs an anchors array +easingType must be easeIn, easeOut, easeInOut or easeOutIn editingData must be true or false editingData must be true, false or "always" edits overlap @@ -934,6 +936,7 @@ scope must be selection or all (got {other}) scribble is too long segment does not exist select a Shaper group +select a blend to set easing or color shift select a graph select a group or layer with visible, unlocked members select a layer @@ -1254,6 +1257,7 @@ zoom must be positive {key} must be 0–{MAX_ADD_AROUND} pt {key} must be Unix seconds (an integer) or null {key} must be [x,y] +{key} must be a percentage {min}..100 {key} must be a positive number, not {v} {key} must be an array of non-negative integer object ids {key} must be one of {_1} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 902a344c0..8e4bb35af 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -876,6 +876,7 @@ Differences from {preset}: Khác biệt so với {preset}: Diffuse Glow Ánh sáng khuếch tán Diffuse Glow… Ánh sáng khuếch tán… + Character Direction Hướng ký tự Digits Chữ số Dim Copies to: Làm mờ bản sao đến: Dim Images to: Làm mờ ảnh đến: @@ -4250,7 +4251,7 @@ theme Appearance Giao diện @msg only a dock group above or below others collapses, not one beside another chỉ thu gọn được nhóm dock nằm trên hoặc dưới các nhóm khác, không phải nhóm nằm cạnh @msg guides are locked đường căn bị khóa @msg handle must be in or out handle phải là in hoặc out -@msg image '{key}' has no pixels and was left out ảnh '{key}' không có pixel và bị bỏ qua +@msg an image has no pixels and was left out một ảnh không có pixel đã bị bỏ qua @msg image data `{_1}` is missing dữ liệu ảnh `{_1}` bị thiếu @msg image is empty ảnh trống @msg Image Trace: the traced image is missing Vector hóa ảnh: ảnh đã vector hóa bị thiếu @@ -4972,6 +4973,12 @@ theme Appearance Giao diện Water Paper Giấy ướt Ruby… Ruby… Ruby Ruby + Ease In Chậm lúc đầu + Ease Out Chậm lúc cuối + Ease In Out Chậm lúc đầu và cuối + Ease Out In Chậm lúc cuối và đầu + Ramp: Cường độ: + Shift: Dịch chuyển: Fresco… Bích họa… Fresco Bích họa diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d6d528cd4..df8088e72 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2298,6 +2298,7 @@ font-script Japanese 汉字 Proof Setup 校样设置 Properties 属性 Proportional Metrics 比例度量 + Character Direction 字符方向 Digits 数字 Arabic 阿拉伯数字 Hindi 印地数字 @@ -3623,7 +3624,7 @@ imageTrace Outline 轮廓 @msg the scale is not a usable number 比例不是可用的数字 @msg the drawing is too large to open 图纸过大,无法打开 @msg scale must be a number from {_1} to {_2}, not {_3} 比例必须是 {_1} 到 {_2} 之间的数字,而不是 {_3} -@msg image '{key}' has no pixels and was left out 图像 '{key}' 没有像素,已略去 +@msg an image has no pixels and was left out 已略去没有像素的图像 @msg Embedded {n} image(s) 已嵌入 {n} 张图像 @msg object {0} cannot have children 对象 {0} 不能包含子对象 @msg range `{s}` is empty 范围 `{s}` 为空 @@ -5216,6 +5217,12 @@ lighting Light 光源 Water Paper 水彩画纸 Ruby… 注音… Ruby 注音 + Ease In 缓入 + Ease Out 缓出 + Ease In Out 缓入缓出 + Ease Out In 缓出缓入 + Ramp: 强度: + Shift: 偏移: Fresco… 壁画… Fresco 壁画 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 184f8518a..b06c4df82 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1612,7 +1612,7 @@ file-filter Templates 範本 @msg the scale is not a usable number 比例不是可用的數字 @msg the drawing is too large to open 圖面過大,無法開啟 @msg scale must be a number from {_1} to {_2}, not {_3} 比例必須是 {_1} 到 {_2} 之間的數字,而非 {_3} -@msg image '{key}' has no pixels and was left out 影像 '{key}' 沒有像素,已略過 +@msg an image has no pixels and was left out 已略過沒有像素的影像 @msg Embedded {n} image(s) 已內嵌 {n} 個影像 @msg object {0} cannot have children 物件 {0} 不能有子物件 @msg range `{s}` is empty 範圍 `{s}` 是空的 @@ -3172,6 +3172,7 @@ font-script Japanese 漢字 Proof Setup 校樣設定 Properties 內容 Proportional Metrics 比例度量 + Character Direction 字元方向 Digits 數字 Arabic 阿拉伯數字 Hindi 印地數字 @@ -5011,6 +5012,12 @@ lighting Light 光源 Water Paper 水彩畫紙 Ruby… 注音… Ruby 注音 + Ease In 緩入 + Ease Out 緩出 + Ease In Out 緩入緩出 + Ease Out In 緩出緩入 + Ramp: 強度: + Shift: 偏移: Fresco… 壁畫… Fresco 壁畫 diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 4c02b48fa..972b214ad 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -410,16 +410,17 @@ fn with_save_extension(path: &str, f: &Format, mode: SaveMode) -> String { /// [`with_save_extension`] for a save panel that reports the file type picked in it (`picked`, /// that type's extension; the XDG portal's panel on Linux and the BSDs), which then takes `f`'s -/// place. A path that still ends in the extension of the name the panel suggested (`suggested`) -/// gets the picked type's extension instead. Without a picked type, or with one the panel did not +/// place. A path that still ends in the extension of the name the panel suggested (`suggested`), +/// in any letter case, gets the picked type's extension instead, unless that extension is one of +/// the picked type's; such a path stays as typed. Without a picked type, or with one the panel did not /// offer, [`with_save_extension`] with `f`. fn with_picked_type(path: &str, suggested: &str, picked: Option<&str>, f: &Format, mode: SaveMode) -> String { let formats = if mode == SaveMode::Template { TEMPLATE_FORMATS } else { SAVE_FORMATS }; let Some(g) = picked.and_then(fileio::format).filter(|g| formats.contains(&g.id)) else { return with_save_extension(path, f, mode); }; - let has = std::path::Path::new(path).extension(); - if has.is_some() && has == std::path::Path::new(suggested).extension() { + let has = fileio::extension(path); + if !has.is_empty() && has == fileio::extension(suggested) && !g.extensions.contains(&has.as_str()) { return std::path::Path::new(path).with_extension(g.extensions[0]).to_string_lossy().into_owned(); } with_save_extension(path, g, mode) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e6948fb71..7553e67d7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -324,6 +324,10 @@ pub struct CanvasCache { pub renderer: vectorcraft_render::Renderer, pub texture: Option, pub key: Option, + /// The document the frame shown was rendered from, and the options it was rendered with, with + /// [`Self::key`]: when only the document changes, the canvas renders again only where it + /// changed ([`vectorcraft_render::changed`]). + pub shown: Option<(std::sync::Arc, vectorcraft_render::RenderOptions)>, pub last_ms: f64, /// The frame shown drew raster effects from rasters made before their objects moved, or at a /// drag's lower resolution ([`vectorcraft_render::FrameStats::moved`]): the next frame outside @@ -568,6 +572,7 @@ impl VectorcraftApp { renderer: vectorcraft_render::Renderer::new(), texture: None, key: None, + shown: None, last_ms: 0.0, moved: false, from_worker: false, diff --git a/crates/ui-egui/src/panels/align.rs b/crates/ui-egui/src/panels/align.rs index 36cafab8f..1f79030d2 100644 --- a/crates/ui-egui/src/panels/align.rs +++ b/crates/ui-egui/src/panels/align.rs @@ -223,6 +223,8 @@ fn align_to(app: &VectorcraftApp) -> AlignTo { pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let n = selection_len(app); + let guides = selected_guides(app); + let can_align = n + guides >= 1; let to = align_to(app); // `spacing` is the Distribute Spacing value. The Distribute Objects and Distribute Spacing // buttons send it while there is a key object or a key guide (#2143). @@ -236,12 +238,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { for (i, (icon, tip, axis, v)) in ALIGN.iter().enumerate() { if i == 3 { ui.add_space(6.0); - if widgets::icon_button_enabled(ui, "dc-al-center", tl!("Horizontal & Vertical Align Center"), false, n >= 1, 32.0).clicked() { + if widgets::icon_button_enabled(ui, "dc-al-center", tl!("Horizontal & Vertical Align Center"), false, can_align, 32.0).clicked() { run_button(app, Button::AlignCenters).ok(); } ui.add_space(6.0); } - if widgets::icon_button_enabled(ui, icon, tl!(tip), false, n >= 1, 32.0).clicked() { + if widgets::icon_button_enabled(ui, icon, tl!(tip), false, can_align, 32.0).clicked() { run_button(app, Button::Align(axis, v)).ok(); } } @@ -883,6 +885,29 @@ mod tests { assert_eq!(app.session.active().unwrap().doc.guides[0].pos, 100.0, "the guide stays put"); } + /// Three vertical ruler guides selected without objects: Align Objects buttons are on, and + /// Horizontal Align Left puts every guide on the artboard's left edge (#3463). + #[test] + fn the_align_buttons_align_guides_selected_alone_to_artboard() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + for pos in [20, 50, 180] { + app.run("guide.add", json!({"vertical": true, "pos": pos})).unwrap(); + } + app.run("guide.select", json!({"indexes": [0, 1, 2]})).unwrap(); + app.ui.align_to = AlignTo::Artboard; + let ctx = egui::Context::default(); + frame_events(&ctx, &mut app, vec![], show); + let row = widget_rects(&ctx, egui::vec2(32.0, 32.0)); + let top = row.iter().map(|r| r.top()).fold(f32::MAX, f32::min); + let mut row: Vec = row.into_iter().filter(|r| r.top() == top).collect(); + row.sort_by(|a, b| a.left().total_cmp(&b.left())); + assert!(row[0].width() > 0.0, "Horizontal Align Left is enabled"); + click(&ctx, &mut app, row[0].center(), show); + let pos = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| g.pos).collect::>(); + assert_eq!(pos(&app), [0.0, 0.0, 0.0], "{}", app.ui.status); + } + /// Three vertical ruler guides selected without objects: the Distribute buttons are on, and /// Horizontal Distribute Center puts the middle guide halfway between the other two, or with /// Align to Artboard spaces them from its left edge to its right edge, as a public forum answer diff --git a/crates/ui-egui/src/panels/blend.rs b/crates/ui-egui/src/panels/blend.rs index 5b723519e..e258cfbc0 100644 --- a/crates/ui-egui/src/panels/blend.rs +++ b/crates/ui-egui/src/panels/blend.rs @@ -94,6 +94,36 @@ fn settings(app: &mut VectorcraftApp, ui: &mut Ui) { } } }); + let selected = info["target"] == "blend"; + // Position easing needs a spine to run along: a replaced one, or Align to Path. + let spined = info["spine"]["explicit"] == true || info["orientation"] == "path"; + ui.add_enabled_ui(selected && spined, |ui| { + let modes = ["easeIn", "easeOut", "easeInOut", "easeOutIn"]; + let labels = [tl!("Ease In"), tl!("Ease Out"), tl!("Ease In Out"), tl!("Ease Out In")]; + let mode = info["easingType"].as_str().unwrap_or(modes[0]); + let index = modes.iter().position(|m| *m == mode).unwrap_or(0); + widgets::label_row(ui, tl!("Type:"), LABEL_W, |ui| { + if let Some(i) = widgets::dropdown(ui, "blend-panel-easing", labels[index], &labels, ui.available_width().max(FIELD_W)) + && let Some(mode) = modes.get(i) + { + change = Some(json!({"easingType": mode})); + } + }); + widgets::label_row(ui, tl!("Ramp:"), LABEL_W, |ui| { + let mut ramp = info["ramp"].as_f64().unwrap_or(0.0); + if ui.add(egui::Slider::new(&mut ramp, 0.0..=100.0).suffix("%")).changed() { + change = Some(json!({"ramp": ramp})); + } + }); + }); + ui.add_enabled_ui(selected, |ui| { + widgets::label_row(ui, tl!("Shift:"), LABEL_W, |ui| { + let mut shift = info["colorShift"].as_f64().unwrap_or(0.0); + if ui.add(egui::Slider::new(&mut shift, -100.0..=100.0).suffix("%")).changed() { + change = Some(json!({"colorShift": shift})); + } + }); + }); if let Some(p) = change { app.run("object.blend.options", p).ok(); } diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 97f30f54c..a6959e7c9 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -712,6 +712,24 @@ fn style_toggle(ui: &mut Ui, g: Glyph, tip: &str, on: bool) -> bool { resp.clicked() } +/// The Character Direction submenu: Default, Left to Right and Right to Left set the characters the +/// panel acts on ([`char_cmd`]) as the bidi algorithm sets them, or left to right or right to left +/// inside their paragraph. The direction those characters share is checked, and none is checked +/// when they differ. +fn char_direction_items(app: &mut VectorcraftApp, ui: &mut Ui) { + use vectorcraft_doc::ParaDirection; + let direction = shared(app, |s| s.char_direction); + for (label, d, key) in [ + (tl!("Default"), None, "default"), + (tl!("Left to Right"), Some(ParaDirection::LeftToRight), "leftToRight"), + (tl!("Right to Left"), Some(ParaDirection::RightToLeft), "rightToLeft"), + ] { + if menu_item(ui, label, true, direction == Some(d)) { + format(app, json!({"charDirection": key})); + } + } +} + pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let st = text_style(app); let has = st.is_some(); @@ -795,6 +813,9 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } }); }); + ui.add_enabled_ui(has, |ui| { + ui.menu_button(format!(" {}", tl!("Character Direction")), |ui| char_direction_items(app, ui)); + }); ui.separator(); } // No Break: the selected characters stay on one line. @@ -847,8 +868,9 @@ fn fractional_widths_on(app: &VectorcraftApp) -> bool { /// Reset Panel: the characters the panel acts on ([`char_cmd`]) take tracking 0, Auto leading and /// kerning, no baseline shift, 100% scales, no rotation, no underline or strikethrough, All Caps /// and Small Caps off, the normal position (neither superscript nor subscript), Roman Baseline -/// character alignment, Proportional Metrics, Tate-chu-yoko and No Break off and the Default digits, whether -/// or not Show East Asian Options and Show Indic Options are on. +/// character alignment, Proportional Metrics, Tate-chu-yoko and No Break off, the Default character +/// direction and the Default digits, whether or not Show East Asian Options and Show Indic Options +/// are on. fn reset_panel(app: &mut VectorcraftApp) { let d = CharStyle::default(); style(app, json!({"tracking": 0, "leading": "auto"})); @@ -857,7 +879,8 @@ fn reset_panel(app: &mut VectorcraftApp) { json!({ "kerning": "auto", "baselineShift": 0, "hScale": 100, "vScale": 100, "rotation": 0, "underline": false, "strikethrough": false, "allCaps": false, "smallCaps": d.small_caps.is_some(), "position": d.position.id(), "charAlign": d.char_align, - "proportionalMetrics": d.proportional_metrics, "tateChuYoko": d.tate_chu_yoko, "noBreak": d.no_break, "digits": d.digits + "proportionalMetrics": d.proportional_metrics, "tateChuYoko": d.tate_chu_yoko, "noBreak": d.no_break, "charDirection": "default", + "digits": d.digits }), ); } @@ -1292,6 +1315,36 @@ mod tests { assert!(shown(&mut app)); } + /// Character Direction is in the panel menu with the Middle Eastern (Indic) options, beside + /// Digits. Its submenu checks the direction the characters the panel acts on share: the + /// selected type's, or the range the Type tool has selected. It checks no item when they differ. + #[test] + fn character_direction_checks_the_direction_of_the_selection() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "שלום 21 10 2026"})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let shown = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, menu).contains("Character Direction"); + assert!(!shown(&mut app)); + app.session.prefs.show_indic_options = true; + assert!(shown(&mut app)); + let checked = |app: &mut VectorcraftApp| { + let text = crate::tests_labels::painted_text(app, char_direction_items); + text.lines().filter_map(|l| l.strip_prefix("✓ ")).map(str::to_string).collect::>() + }; + assert_eq!(checked(&mut app), ["Default"]); + app.run("text.setRangeStyle", json!({"id": id, "start": 9, "end": 19, "charDirection": "leftToRight"})).unwrap(); + // The object's characters differ: nothing is checked. + assert!(checked(&mut app).is_empty()); + // The Type tool's selection: the date, then the Hebrew word. + app.select_tool("type"); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + app.session.set_tool_option("select", &json!({"start": 9, "end": 19})); + assert_eq!(checked(&mut app), ["Left to Right"]); + app.session.set_tool_option("select", &json!({"start": 0, "end": 8})); + assert_eq!(checked(&mut app), ["Default"]); + } + /// No Break is in the panel menu, and choosing it marks the selected characters. #[test] fn no_break_menu_item_toggles_the_selected_characters() { @@ -1321,8 +1374,8 @@ mod tests { /// Reset Panel sets every character attribute the panel shows, except the font family, style /// and size, back to its default (Small Caps, the position, Character Alignment, Proportional - /// Metrics, Tate-chu-yoko, No Break and Digits included), in the selected type and in the text - /// the Type tool has selected while it edits. + /// Metrics, Tate-chu-yoko, No Break, Character Direction and Digits included), in the selected + /// type and in the text the Type tool has selected while it edits. #[test] fn reset_panel_resets_every_attribute_but_the_font() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); @@ -1350,6 +1403,7 @@ mod tests { s.proportional_metrics != d.proportional_metrics, s.tate_chu_yoko != d.tate_chu_yoko, s.no_break != d.no_break, + s.char_direction != d.char_direction, s.digits != d.digits, ] }; @@ -1359,16 +1413,17 @@ mod tests { json!({ "id": id, "tracking": 50, "leading": 30, "kerning": 20, "baselineShift": 3, "hScale": 120, "vScale": 90, "rotation": 15, "underline": true, "strikethrough": true, "allCaps": true, "smallCaps": true, "position": "superscript", - "charAlign": "emBoxCenter", "proportionalMetrics": true, "tateChuYoko": true, "noBreak": true, "digits": "hindi" + "charAlign": "emBoxCenter", "proportionalMetrics": true, "tateChuYoko": true, "noBreak": true, + "charDirection": "rightToLeft", "digits": "hindi" }), ) .unwrap(); }; let plain = runs(&app)[0].1.clone(); - assert_eq!(differs(&plain, &CharStyle::default()), [false; 17]); + assert_eq!(differs(&plain, &CharStyle::default()), [false; 18]); custom(&mut app); let styled = runs(&app)[0].1.clone(); - assert_eq!(differs(&styled, &CharStyle::default()), [true; 17]); + assert_eq!(differs(&styled, &CharStyle::default()), [true; 18]); app.run("select.set", json!({"ids": [id]})).unwrap(); reset_panel(&mut app); assert_eq!(runs(&app), [("One 12".to_string(), plain.clone())]); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 36d3f24e8..84446d84f 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -579,7 +579,10 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) { match &**live { vectorcraft_doc::LiveShape::Rectangle { .. } => corner_radius_row(app, ui, &n, "radius"), vectorcraft_doc::LiveShape::Polygon { .. } => super::transform::polygon_rows(app, ui, &n, live, "props-polygon"), - vectorcraft_doc::LiveShape::Ellipse { pie, .. } => super::transform::pie_rows(app, ui, *pie, "props-pie"), + vectorcraft_doc::LiveShape::Ellipse { pie, .. } => { + widgets::subheader(ui, tl!("Ellipse Properties:")); + super::transform::pie_rows(app, ui, *pie, "props-pie"); + } _ => {} } } @@ -752,6 +755,22 @@ mod tests { assert_eq!(selected(&app), std::slice::from_ref(&mask), "Edit Clipping Path selects the clipping path"); } + /// #3451: the Properties panel Transform section shows ellipse pie angles and Invert Pie. + #[test] + fn transform_section_shows_ellipse_pie_properties() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.ellipse", json!({"x": 50, "y": 50, "width": 120, "height": 80})).unwrap(); + let ctx = egui::Context::default(); + let labels: Vec = panel_frame(&mut app, &ctx, vec![], transform_section).into_iter().map(|(t, _)| t).collect(); + for t in ["Ellipse Properties:", "Pie Start Angle:", "0°", "Pie End Angle:", "360°", "Invert Pie"] { + assert!(labels.iter().any(|s| s == t), "{t} in {labels:?}"); + } + app.run("object.setLiveShape", json!({"pieStart": 30, "pieEnd": 120})).unwrap(); + let labels: Vec = panel_frame(&mut app, &ctx, vec![], transform_section).into_iter().map(|(t, _)| t).collect(); + assert!(labels.iter().any(|s| s == "30°") && labels.iter().any(|s| s == "120°"), "{labels:?}"); + } + /// The Snap Options and Scale Strokes & Effects checkboxes keep a visible box at rest in every /// theme, as the Preferences ones do (#1080), and toggle when clicked. #[test] diff --git a/crates/ui-egui/src/panels/tests_blend.rs b/crates/ui-egui/src/panels/tests_blend.rs index d2b15f7d9..405fe0017 100644 --- a/crates/ui-egui/src/panels/tests_blend.rs +++ b/crates/ui-egui/src/panels/tests_blend.rs @@ -225,3 +225,37 @@ fn each_button_runs_its_blend_command() { let objects = top(&app); assert!(objects.len() >= 2 && objects.iter().all(|(_, blend)| !blend), "the keys are back: {objects:?}"); } + +#[test] +fn panel_easing_type_ramp_and_shift_edit_the_blend_and_undo() { + // Type and Ramp need a spine: Align to Path gives the blend one. + let mut app = app(); + let id = NodeId(app.run("object.blend.make", json!({"smooth": true, "orientation": "path"})).unwrap()["id"].as_u64().unwrap()); + let mut f = Frames::new(); + for label in ["Type:", "Ease In", "Ramp:", "Shift:"] { + f.text(&mut app, label); + } + f.click_text(&mut app, "Ease In"); + f.click_text(&mut app, "Ease Out In"); + assert_eq!(spec(&app, id).ease.kind, vectorcraft_doc::BlendEaseType::OutIn); + // Click each real slider: its command updates the engine, and Undo restores the old value. + for (index, key) in [(0, "ramp"), (1, "colorShift")] { + let out = f.run(&mut app, vec![]); + let nodes = &out.platform_output.accesskit_update.as_ref().unwrap().nodes; + let mut sliders: Vec<_> = nodes.iter().filter(|(_, n)| n.role() == egui::accesskit::Role::Slider).collect(); + // The tree is not in layout order: Ramp sits above Shift. + sliders.sort_by(|a, b| a.1.bounds().unwrap().y0.total_cmp(&b.1.bounds().unwrap().y0)); + let slider = sliders[index]; + let b = slider.1.bounds().unwrap(); + let at = pos2((b.x0 + 0.3 * (b.x1 - b.x0)) as f32, ((b.y0 + b.y1) * 0.5) as f32); + let before = app.session.execute("object.blend.info", &json!({})).unwrap()[key].clone(); + f.click(&mut app, at); + let after = app.session.execute("object.blend.info", &json!({})).unwrap()[key].clone(); + assert_ne!(after, before, "{key}"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.execute("object.blend.info", &json!({})).unwrap()[key], before); + } + app.run("ui.blendOptions", json!({})).unwrap(); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + assert!(!text.contains("Ease In:") && !text.contains("Ramp:") && !text.contains("Color Ease"), "{text}"); +} diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index 67a407f02..92cedee07 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -505,6 +505,24 @@ mod tests { assert!(live(&app).polygon_sides_equal()); } + /// #3451: a live ellipse shows pie start/end angles and Invert Pie in the Transform panel and + /// the Properties panel's Transform section; typed angles update the shape. + #[test] + fn an_ellipse_shows_its_pie_properties() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.ellipse", json!({"x": 50, "y": 50, "width": 120, "height": 80})).unwrap(); + for draw in [show as fn(&mut VectorcraftApp, &mut Ui), crate::panels::properties::transform_section] { + let shown = texts(&mut app, draw); + for t in ["Ellipse Properties:", "Pie Start Angle:", "0°", "Pie End Angle:", "360°", "Invert Pie"] { + assert!(shown.iter().any(|s| s == t), "{t} in {shown:?}"); + } + } + app.run("object.setLiveShape", json!({"pieStart": 30, "pieEnd": 120})).unwrap(); + let shown = texts(&mut app, show); + assert!(shown.iter().any(|s| s == "30°") && shown.iter().any(|s| s == "120°"), "{shown:?}"); + } + #[test] fn the_artboard_tool_shows_the_active_artboard() { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); diff --git a/crates/ui-egui/src/render_worker.rs b/crates/ui-egui/src/render_worker.rs index 4dcf879b8..704c9058a 100644 --- a/crates/ui-egui/src/render_worker.rs +++ b/crates/ui-egui/src/render_worker.rs @@ -22,6 +22,9 @@ pub struct Job { pub struct Done { pub key: CacheKey, + /// The document rendered, and the options it was rendered with. + pub doc: Arc, + pub opts: RenderOptions, pub img: Rendered, pub ms: f64, /// [`vectorcraft_render::FrameStats::moved`] of the frame. @@ -53,7 +56,8 @@ impl Worker { } let t0 = crate::now_ms(); let img = r.render(&job.doc, job.w, job.h, job.view, &job.opts); - if done_tx.send(Done { key: job.key, img, ms: crate::now_ms() - t0, moved: r.stats.moved }).is_err() { + if done_tx.send(Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: crate::now_ms() - t0, moved: r.stats.moved }).is_err() + { break; } ctx.request_repaint(); diff --git a/crates/ui-egui/src/tests_document_drag.rs b/crates/ui-egui/src/tests_document_drag.rs index bb698870b..d75365301 100644 --- a/crates/ui-egui/src/tests_document_drag.rs +++ b/crates/ui-egui/src/tests_document_drag.rs @@ -7,6 +7,7 @@ use vectorcraft_geom::Point; use crate::{VectorcraftApp, canvas, chrome}; +/// Run a real tab-strip/canvas frame and return title bounds for pointer-driven gestures. fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec) -> Vec { app.sync_views(); let focused = events @@ -46,10 +47,12 @@ fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec Event { Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() } } +/// Verify dwell activation, editable placement, source and clipboard preservation, and target Undo/Redo. #[test] fn dragging_artwork_over_a_tab_switches_then_copies_on_drop() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); @@ -100,10 +103,12 @@ fn dragging_artwork_over_a_tab_switches_then_copies_on_drop() { assert_eq!(app.session.active().unwrap().history.undo.len(), dest_undo + 1); } +/// Begin a Selection move in the first of three documents with no keyboard modifiers. fn drag_fixture() -> (VectorcraftApp, egui::Context, Pos2, Vec) { drag_fixture_modifiers(Default::default()) } +/// Create three document tabs and start dragging source artwork with the supplied modifiers. fn drag_fixture_modifiers(modifiers: egui::Modifiers) -> (VectorcraftApp, egui::Context, Pos2, Vec) { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.run("file.new", json!({"width":400,"height":300,"name":"Source"})).unwrap(); @@ -134,6 +139,7 @@ fn drag_fixture_modifiers(modifiers: egui::Modifiers) -> (VectorcraftApp, egui:: (app, ctx, grab, tabs) } +/// Verify that leaving a tab restarts its dwell timer and hovering its close button never activates it. #[test] fn tab_hover_delay_restarts_after_leaving_and_close_button_does_not_activate() { let (mut app, ctx, grab, tabs) = drag_fixture(); @@ -155,6 +161,7 @@ fn tab_hover_delay_restarts_after_leaving_and_close_button_does_not_activate() { assert_eq!(app.session.active_index(), Some(1)); } +/// Verify Escape and release outside the canvas preserve both documents and their undo histories. #[test] fn escape_or_release_over_tab_cancels_the_transfer_without_edits() { for escape in [false, true] { @@ -186,6 +193,7 @@ fn escape_or_release_over_tab_cancels_the_transfer_without_edits() { } } +/// Verify destination view transforms preserve the original artwork grab point during placement. #[test] fn tab_drop_preserves_hotspot_across_zoom_and_rotation() { let (mut app, ctx, _, tabs) = drag_fixture(); @@ -205,6 +213,7 @@ fn tab_drop_preserves_hotspot_across_zoom_and_rotation() { assert!((bounds.x0 - 150.0).abs() < 0.001 && (bounds.y0 - 150.0).abs() < 0.001, "{bounds:?}"); } +/// Verify returning to the source tab moves its original object instead of inserting a duplicate. #[test] fn hovering_back_to_source_resumes_move_instead_of_copying() { let (mut app, ctx, _, tabs) = drag_fixture(); @@ -226,6 +235,7 @@ fn hovering_back_to_source_resumes_move_instead_of_copying() { assert!((bounds.x0 - 150.0).abs() < 0.001 && (bounds.y0 - 150.0).abs() < 0.001, "{bounds:?}"); } +/// Verify Alt-drag transfers restored originals across tabs without retaining preview duplicates. #[test] fn alt_drag_can_continue_across_multiple_tabs_without_copying_the_source_preview() { let (mut app, ctx, _, tabs) = drag_fixture_modifiers(egui::Modifiers::ALT); @@ -246,6 +256,7 @@ fn alt_drag_can_continue_across_multiple_tabs_without_copying_the_source_preview assert!(app.session.documents()[1].history.undo.is_empty()); } +/// Verify closing the originating document cancels a transfer before its eventual mouse release. #[test] fn closing_the_source_during_a_transfer_cancels_it() { let (mut app, ctx, _, tabs) = drag_fixture(); @@ -261,6 +272,7 @@ fn closing_the_source_during_a_transfer_cancels_it() { assert!(app.session.active().unwrap().history.undo.is_empty()); } +/// Verify document changes, reused IDs, focus loss and touch cancellation cannot release stale artwork. #[test] fn source_edits_target_closure_switches_and_focus_loss_cancel_stale_transfers() { for change in ["source", "close target", "switch target", "focus", "touch cancel", "two fingers"] { @@ -312,6 +324,7 @@ fn source_edits_target_closure_switches_and_focus_loss_cancel_stale_transfers() } } +/// Verify tab-strip relayout under a stationary pointer cannot trigger another document switch. #[test] fn scrolling_tabs_under_a_stationary_drag_does_not_cascade_to_other_documents() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); diff --git a/crates/ui-egui/src/tests_picks.rs b/crates/ui-egui/src/tests_picks.rs index 58ba8d2e0..3aa8f21b3 100644 --- a/crates/ui-egui/src/tests_picks.rs +++ b/crates/ui-egui/src/tests_picks.rs @@ -229,7 +229,9 @@ fn variables_panel_data_sets_ask_off_the_ui_thread() { let (mut app, shown, _) = app(); app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); let dir = vectorcraft_testkit::temp_dir("picks-data-sets"); - let _ = std::fs::remove_dir_all(&dir); + if let Err(e) = std::fs::remove_dir_all(&dir) { + assert_eq!(e.kind(), std::io::ErrorKind::NotFound, "cannot empty {}: {e}", dir.display()); + } std::fs::create_dir_all(&dir).unwrap(); let (csv, out) = (dir.join("sets.csv"), dir.join("out.csv")); std::fs::write(&csv, "name,Name\nB,y\n").unwrap(); diff --git a/crates/ui-egui/src/tests_saveext.rs b/crates/ui-egui/src/tests_saveext.rs index 5272af5cc..01e9a1d9f 100644 --- a/crates/ui-egui/src/tests_saveext.rs +++ b/crates/ui-egui/src/tests_saveext.rs @@ -161,6 +161,25 @@ fn the_file_type_picked_in_the_save_dialog_decides_the_format() { } } +/// The suggested name's extension typed in another case still gets the picked type's +/// (`Untitled-1.VECTORCRAFT` with .ai picked is saved as `Untitled-1.ai`), and the picked type's +/// own extension typed in another case stays as typed (`a.AI` with .ai picked). +#[test] +fn the_suggested_names_extension_in_another_case_gets_the_picked_types() { + for (cmd, answer, expected) in [ + ("file.save", &["/art/Untitled-1.VECTORCRAFT", "ai"][..], "/art/Untitled-1.ai"), + ("file.save", &["/art/Untitled-1.VectorCraft", "svg"], "/art/Untitled-1.svg"), + ("file.saveAsTemplate", &["/art/Untitled-1.VCTemplate", "ait"], "/art/Untitled-1.ait"), + ] { + let (mut app, shown, written) = portal_app(); + assert_eq!(saved_from(&mut app, &shown, &written, cmd, answer), expected, "{cmd} {answer:?}"); + } + // A document saved as .ai suggests `a.ai`. + let (mut app, shown, written) = portal_app(); + app.run("file.saveAs", json!({"path": "/art/a.ai", "options": {}})).unwrap(); + assert_eq!(saved_from(&mut app, &shown, &written, "file.saveAs", &["/art/a.AI", "ai"]), "/art/a.AI"); +} + /// A name typed with the extension of a format the save does not write has it replaced, with or /// without a picked type: `card.ait` and `card.png` are saved as `card.vectorcraft`, not /// `card.ait.vectorcraft`. An extension that names no format stays part of the name. diff --git a/docs/development.md b/docs/development.md index b2d0edb22..1b12e7d70 100644 --- a/docs/development.md +++ b/docs/development.md @@ -439,6 +439,7 @@ Scores and hour estimates are unchanged. | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Tab-drag follow-up: reconcile upstream progress updates and document drag lifecycle, insertion behavior and regression contracts; behavior, scores and estimates unchanged. | | 2026-10-22 | minor | Artwork dragged with Selection switches visible document tabs after a 400 ms hover, then copies editable artwork on canvas release; source and clipboard stay intact; scores and hours unchanged | | 2026-10-21 | minor | M3: highlighted-text Eyedropper sampling preserves ranges and rejects unsupported source/target transparency atomically; regression-tested; scores and estimates unchanged. | | 2026-10-21 | minor | Fonts › Leaving out the bundled fonts: vectorcraft-ui-egui's and vectorcraft-mcp's `bundled-fonts` features, and the engine declared without default features. | diff --git a/docs/gaps.md b/docs/gaps.md index dacd8d34c..96316fad5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Artwork dragged with Selection switches visible document tabs after a 400 ms hover, then copies editable artwork on canvas release; source and clipboard stay intact; scores and hours unchanged; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; filter plug-ins read and set effect records and stroke joins, caps and miter limits, G17; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; G1: one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top; the selection tools take a clipping path on its outline, over the art it clips; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; transparency grid visibility saved in native documents, #1968; G6: MCP cancellation, progress notifications and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged; interactive Mirror Repeat axis; G12: Background Export progress dialog with Cancel, #2564; no entry closed, estimates unchanged; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; transparency grid visibility saved in native documents, #1968; G6: MCP cancellation, progress notifications and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Artwork dragged with Selection switches visible document tabs after a 400 ms hover, then copies editable artwork on canvas release; source and clipboard stay intact; scores and hours unchanged; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; filter plug-ins read and set effect records and stroke joins, caps and miter limits, G17; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; G1: one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top; the selection tools take a clipping path on its outline, over the art it clips; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; transparency grid visibility saved in native documents, #1968; G6: MCP cancellation, progress notifications and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged; interactive Mirror Repeat axis; G12: Background Export progress dialog with Cancel, #2564; no entry closed, estimates unchanged; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; transparency grid visibility saved in native documents, #1968; G6: MCP cancellation, progress notifications and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make @@ -264,6 +264,11 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Done (from users' reports):** Object › Create Gradient Mesh on an embedded image turns it into a mesh over its rectangle, with the image's transform kept; each point takes the average colour and opacity of the picture around it (#1676). A linked image is refused, as the public documentation has it. +- **Partial:** blend easing (#1124, 30.7): Window › Blend has Type (Ease In, Ease Out, Ease In Out, + Ease Out In), Ramp 0–100% and independent color Shift; easing is disabled without a spine. + The original polynomial distributions approximate the public description. On-canvas easing handles + and their synchronization remain missing. [Public source](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html). + - **Missing (asked for by users, from earlier versions):** radial gradients in Image Trace's Gradients option, which traces linear ones only (draft #1849). Inside the estimate below. - **Missing:** gradient dither and perceptual blending, gradient presets (30.0); tangent and perpendicular @@ -402,7 +407,9 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Tab-drag follow-up: reconcile upstream progress updates and document drag lifecycle, insertion behavior and regression contracts; behavior, scores and estimates unchanged. | | 2026-10-22 | minor | Artwork dragged with Selection switches visible document tabs after a 400 ms hover, then copies editable artwork on canvas release; source and clipboard stay intact; scores and hours unchanged | +| 2026-10-21 | minor | Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations. Scores and estimates unchanged. | | 2026-10-21 | minor | M3: highlighted-text Eyedropper sampling preserves ranges and rejects unsupported source/target transparency atomically; regression-tested; scores and estimates unchanged. | | 2026-10-21 | minor | Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog. Scores and estimates unchanged. | | 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged | @@ -422,6 +429,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-10-18 | minor | G11: Shape Builder Gap Detection (#2523): Shape Builder Tool Options with a Gap Length of Small, Medium, Large or Custom bridges small gaps between open paths for the regions they bound; the other Shape Builder options stay missing. The stale gap line on deleting an interior anchor of an open path is removed (the behaviour was already implemented). Estimates unchanged | | 2026-10-18 | minor | Mirror Repeat shows its axis when selected (#2877): drag the line or center handle to move it, drag an end handle to rotate it (Shift: 45°), Escape cancels and each drag is one undo step. Engine and headless canvas gesture regressions passed; native move, rotation and Escape screenshots reviewed. Formatting, Clippy, WASM and repository checks passed; the undo label reuses translated Repeat Options. (#2877) Scores and hours unchanged. | | 2026-10-18 | minor | Bounding-box handles take priority where a selected Mirror Repeat axis crosses them; a Top-handle regression confirms scaling of the default repeat. Center and end handles retain their move and rotation behavior. G1 scope and estimate unchanged. | +| 2026-10-19 | minor | G13: blend easing (#1124): Ease In / Ease Out and separate Color Ease In / Out in Blend Options, saved with the blend; on-canvas handles and easing in the Blend panel remain. Estimate unchanged | | 2026-10-17 | minor | Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged. Windows native canvas checks and screenshots reviewed in PR #2643. | | 2026-10-17 | minor | G12: Background Export progress dialog with Cancel and Continue in Background (#2564); no entry closed, estimates unchanged | | 2026-10-16 | minor | Native documents restore transparency grid visibility (#1968); save/open regression covers on/off, old files and SVG; scores and hours unchanged (estimated) | diff --git a/docs/mcp.md b/docs/mcp.md index 499c7f803..0e8c4bb15 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -615,6 +615,8 @@ Images follow the `compression` settings of their kind (`color`, `gray`, or `mon `abovePpi` as placed they are resampled (`downsample`: `average`, `subsample` or `bicubic`) to `ppi`, and compressed with `zip`, `jpeg` (at `quality`; images with transparency stay lossless) or `auto` (JPEGs stay JPEG, the others lossless). `none`, `jpeg2000`, CCITT and `runLength` are written as ZIP, with a warning when an image needs them. +A 16-bit PNG that is not resampled, recompressed or converted keeps its 16 bits a sample in PDF 1.5 and later files, +and is written at 8 bits in PDF 1.3 and 1.4 files. CMYK images stay CMYK (DeviceCMYK, or ICC-based with the CMYK profile when colours are tagged, as in PDF/X-3 and PDF/X-4): a CMYK JPEG neither resampled nor recompressed is written unchanged, and the others' ink amounts are resampled and compressed again (CMYK JPEG or ZIP). `output.conversion` converts them like CMYK colours: `destination` @@ -732,9 +734,9 @@ PostScript form, which keeps layers, groups, compound paths and clipping groups of `range` (default all; `{name}-{artboard}.ai` when several, listed in the result's `files`), with `transparentImages: "flatten"` (default) or `"omit"` for images with see-through pixels, opacity or a blending mode, `hiddenObjects: "layer"` (default: a hidden layer of each layer's hidden objects), `"omit"` or `"visible"`, and -`outlineText: true` to write type as outlines instead of text objects. When an image in art that is flattened or whose -raster effects are expanded can't be decoded, the result does not show it, and `warnings` carries "an image couldn't -be decoded and is missing from the picture" once. From the CLI: `vectorcraft-cli convert in.svg +`outlineText: true` to write type as outlines instead of text objects. An image that can't be decoded is left out of +the file and does not show in art that is flattened or whose raster effects are expanded; `warnings` carries "an image +couldn't be decoded and is missing from the picture" once for all such images. From the CLI: `vectorcraft-cli convert in.svg out.ai --ai-version 7 [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] [--outline-text] [--range R]`. `document.export`, `serialize`, `exportSelection` and `exportForScreens` refuse a `.ait` path or `format: "ait"` with an error that names `file.saveAsTemplate`, which writes it. The CLI's `run --export x.ait` and @@ -3205,7 +3207,9 @@ width profiles, arrowheads) as filled outlines; `"editability"` keeps type as te `alterPaths` writes every stroke as its filled outline, `outlineText` outlines type, `selectedOnly` writes only the selected objects in their layers (every export takes it), and `useArtboards: true` writes one drawing per chosen artboard holding the art over it. What DXF can't hold (gradients and patterns as one colour, blending, opacity masks, -raster effects, clipping) comes back in `warnings`. +raster effects, clipping) comes back in `warnings`. In R13 and later, an image that can't be decoded is left out with +no linked file, and `warnings` carries "an image couldn't be decoded and is missing from the picture" once for all such +images. DWG can't be written (no openly licensed writer exists): `document.formats` lists it under `unsupported` with that hint, and exporting to it answers with the hint to export DXF. PICT files are not supported either and say so when @@ -3378,7 +3382,9 @@ per artboard, `{stem}_{artboard}.eps`, each bounded by its artboard. `level: 3` shadings and masks transparent image pixels out; `level: 2` writes gradients as bands of colour (as does `compatibleGradients`) and transparent pixels white. Type is always glyph outlines, so no fonts are needed (`embedFonts` is accepted); spot colours are Separation colour spaces, and overprinting fills and strokes overprint unless -`overprints: "discard"`. RGB documents are written in CMYK unless `cmykPostScript: false`. +`overprints: "discard"`. RGB documents are written in CMYK unless `cmykPostScript: false`. When the file would hold an +image that can't be decoded, the image is left out, and `warnings` carries "an image couldn't be decoded and is missing +from the picture" once for all such images. PostScript has no transparency: it is flattened first with `flattenerPreset` (`high`, `medium` (default), `low` or a saved preset, see `flattener.presets.list`) and `flattener: {…}` options over it (those of @@ -3511,7 +3517,9 @@ outlines; gradients become images clipped to their shape, pattern fills their ti effects images rendered at the document's raster effects resolution. The header's frame is the artboard in 0.01 mm. WMF flattens curves into polygons in 16-bit units behind a placeable header (1440 units an inch, fewer for pictures over about 22 inches); it has no clipping, transparency or gradients, so clipped art is written whole, images over -white and gradients and patterns as one colour. What a format leaves out comes back in `warnings`. +white and gradients and patterns as one colour. What a format leaves out comes back in `warnings`. When the picture +would hold an image that can't be decoded, the image is left out, and `warnings` carries "an image couldn't be decoded +and is missing from the picture" once for all such images. `document.open` and `file.place` read `.emf` and `.wmf` (placeable or not) into one artboard, the picture's frame, and one layer: paths with their fills and strokes (pens become strokes with caps, joins and dashes), clipping groups, @@ -4144,6 +4152,25 @@ explicitly, `{}` for none. Each command is one undo step. {"name":"run_command","arguments":{"command":"text.setFormat","params":{"fontAxes":{"wght":650,"wdth":87.5}}}} ``` +## Character Direction + +`text.setFormat {charDirection: "default"|"leftToRight"|"rightToLeft"}` (the selected type, or `ids`) and +`text.setRangeStyle {id, start, end, charDirection}` (a range) set the direction of the characters inside their +paragraph, as the Character panel menu's Character Direction does with the Middle Eastern (Indic) options shown. The bidi +algorithm (UAX #9) resolves each stretch of characters with a direction as an embedding in that direction, as if an LRE +or RLE stood before it and a PDF after it: `leftToRight` keeps a date written with spaces, such as `21 10 2026`, in its +order inside a right-to-left paragraph, and `rightToLeft` sets a Hebrew or Arabic word and the punctuation after it right +to left inside a left-to-right paragraph. Characters keep their own strong direction inside an embedding: Latin letters +set Right to Left still read left to right. `default` leaves the characters to the bidi algorithm. The paragraph's own +direction is unchanged. SVG export writes type with a character drawn right to left, or with a run set Right to Left, +as outlines; live SVG text writes `direction="ltr"` and `unicode-bidi="embed"` on each run set Left to Right. Character +Direction is Default until set and is saved only when set, and character styles captured from type carry it. Each +command is one undo step. + +```json +{"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":12,"start":9,"end":19,"charDirection":"leftToRight"}}} +``` + ## Digits `text.setFormat {digits: "default"|"arabic"|"hindi"|"farsi"}` (the selected type, or `ids`) and `text.setRangeStyle {id, diff --git a/docs/roadmap.md b/docs/roadmap.md index 780a13c2f..5a89ddbb1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Artwork dragged with Selection switches visible document tabs after a 400 ms hover, then copies editable artwork on canvas release; source and clipboard stay intact; scores and hours unchanged; M14: panels dock into any dock group or the icon column by dragging (UI.6), workspaces keep their layout changes (UI.7), dock groups collapse to their tabs by a double-click (UI.8); estimates unchanged; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; M12 duplicate milestone rows consolidated without losing distinct details; scores and estimates unchanged; live-trace bounds note placed under Image Trace rather than Graphs; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; M3.57: CSS swatch libraries retain fractional gradient stop positions; scores and hours unchanged; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; DXF circular cubic paths export as native CIRCLE/ARC with R12 weighted strokes kept as POLYLINE and closing-chord safeguards (#1299); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Image Trace: Auto Color keeps rare but distinct colours; Image Trace: large images trace with a warning; anchor limit raised to 8,000,000; Image Trace: Raster::new returns an error for a buffer of the wrong length; M12 graph pruning and junction merging measured; M12 exact crop EDT and Guo–Hall thinning measured; M12 centre-lines baseline measured; scores and hours unchanged; Image Trace: a higher Paths never gives fewer segments on collapsed loops; Image Trace: the notch rule reads only its own piece; Image Trace: an edge's sub-pixel shift is the same from either side; Image Trace: a simpler contour walk with no precomputed direction table; transparency grid visibility saved in native documents, #1968; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: M12's Image Trace quick presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; M2: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; legacy `.ai` of version 7 saved, #868; estimates unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Artwork dragged with Selection switches visible document tabs after a 400 ms hover, then copies editable artwork on canvas release; source and clipboard stay intact; scores and hours unchanged; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximation; M14: panels dock into any dock group or the icon column by dragging (UI.6), workspaces keep their layout changes (UI.7), dock groups collapse to their tabs by a double-click (UI.8); estimates unchanged; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; M12 duplicate milestone rows consolidated without losing distinct details; scores and estimates unchanged; live-trace bounds note placed under Image Trace rather than Graphs; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; M3.57: CSS swatch libraries retain fractional gradient stop positions; scores and hours unchanged; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; DXF circular cubic paths export as native CIRCLE/ARC with R12 weighted strokes kept as POLYLINE and closing-chord safeguards (#1299); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Image Trace: Auto Color keeps rare but distinct colours; Image Trace: large images trace with a warning; anchor limit raised to 8,000,000; Image Trace: Raster::new returns an error for a buffer of the wrong length; M12 graph pruning and junction merging measured; M12 exact crop EDT and Guo–Hall thinning measured; M12 centre-lines baseline measured; scores and hours unchanged; Image Trace: a higher Paths never gives fewer segments on collapsed loops; Image Trace: the notch rule reads only its own piece; Image Trace: an edge's sub-pixel shift is the same from either side; Image Trace: a simpler contour walk with no precomputed direction table; transparency grid visibility saved in native documents, #1968; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: M12's Image Trace quick presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; M2: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; legacy `.ai` of version 7 saved, #868; estimates unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Forward-looking plan. Where VectorCraft stands is in [`ROADMAP.md`](../ROADMAP.md) and [target-app-parity.md](target-app-parity.md); the ranked work list is [gaps.md](gaps.md). Hours are Opus 5.5 @@ -103,7 +103,9 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Tab-drag follow-up: reconcile upstream progress updates and document drag lifecycle, insertion behavior and regression contracts; behavior, scores and estimates unchanged. | | 2026-10-22 | minor | Artwork dragged with Selection switches visible document tabs after a 400 ms hover, then copies editable artwork on canvas release; source and clipboard stay intact; scores and hours unchanged | +| 2026-10-21 | minor | Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximation. Scores and estimates unchanged. | | 2026-10-21 | minor | M14: workspaces keep the layout changes made in them across switches and restarts until Reset (UI.7); estimates unchanged | | 2026-10-21 | minor | M14: panels dock by dragging into any dock group (tabs or a top/bottom split) or the icon column; dock tabs close from their context menu (UI.6); estimates unchanged | | 2026-10-21 | minor | M14: a double-click on a dock tab collapses its group to its tabs and expands it again (UI.8); estimates unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d073c8f18..aa0d259f0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Artwork dragged with Selection switches visible document tabs after a 400 ms hover, then copies editable artwork on canvas release; source and clipboard stay intact; scores and hours unchanged; UI chrome: panels dock into any dock group or the icon column by dragging (UI.6), workspaces keep their layout changes (UI.7), dock groups collapse to their tabs by a double-click (UI.8); scores and hours unchanged; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls; filter plug-ins read and set effect records and stroke joins, caps and miter limits, G17; the Print dialog's Media list has the Japanese papers: JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 beside the ISO B4/B5, which keep their ISO 216 sizes, and はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; a paper is named by its JSON id or its dialog label, any case and spacing; imported PDF type that is letter-spaced keeps its word spaces and no others; Fractional Widths: Character panel menu, per type object, on by default; System Layout remains a panel stub; scores unchanged; G4: manual Tate-chu-yoko; G4: No Break; scores unchanged; the selection tools take a clipping path on its outline, G1; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; CSS swatch libraries retain fractional gradient stop positions; scores and hours unchanged; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; Libraries panel: the list scrolls, items and groups reorder by dragging, items drag into and out of groups (`library.moveItem` before/after, `library.moveGroup`); scores unchanged; PDF invisible text opens as type with no fill or stroke and is written back invisible, #2675; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; Image Trace: Auto Color keeps rare but distinct colours; Image Trace: large images trace with a warning; anchor limit raised to 8,000,000; Image Trace: Raster::new returns an error for a buffer of the wrong length; M12 maximal-disc graph pruning and junction merging measured; M12 crop EDT and Guo–Hall thinning measured; M12 centre-lines baseline measured; scores and hours unchanged; installed plug-ins stay installed after a restart, #2541; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; Image Trace: a higher Paths never gives fewer segments on collapsed loops; Image Trace: the notch rule reads only its own piece; Image Trace: an edge's sub-pixel shift is the same from either side; Image Trace Flat Logo traces line art on white paper instead of a blank result; score and hours unchanged; Image Trace: a simpler contour walk with no precomputed direction table; transparency grid visibility saved in native documents, #1968; MCP cancellation, progress and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth and drawn, with SVG/PDF outlines where ruby is present; scores and hours unchanged; previously:; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top, G1; the Print dialog's Media list has the Japanese papers: JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 beside the ISO B4/B5, which keep their ISO 216 sizes, and はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; a paper is named by its JSON id or its dialog label, any case and spacing; imported PDF type that is letter-spaced keeps its word spaces and no others; G4: System Layout; Fractional Widths: Character panel menu, per type object, on by default; System Layout remains a panel stub; scores unchanged; G4: No Break; scores unchanged; the selection tools take a clipping path on its outline, G1; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; CSS swatch libraries retain fractional gradient stop positions; scores and hours unchanged; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; Libraries panel: the list scrolls, items and groups reorder by dragging, items drag into and out of groups (`library.moveItem` before/after, `library.moveGroup`); scores unchanged; PDF invisible text opens as type with no fill or stroke and is written back invisible, #2675; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; Image Trace: Auto Color keeps rare but distinct colours; Image Trace: large images trace with a warning; anchor limit raised to 8,000,000; Image Trace: Raster::new returns an error for a buffer of the wrong length; M12 maximal-disc graph pruning and junction merging measured; M12 crop EDT and Guo–Hall thinning measured; M12 centre-lines baseline measured; scores and hours unchanged; installed plug-ins stay installed after a restart, #2541; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; Image Trace: a higher Paths never gives fewer segments on collapsed loops; Image Trace: the notch rule reads only its own piece; Image Trace: an edge's sub-pixel shift is the same from either side; Image Trace Flat Logo traces line art on white paper instead of a blank result; score and hours unchanged; Image Trace: a simpler contour walk with no precomputed direction table; transparency grid visibility saved in native documents, #1968; MCP cancellation, progress and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Artwork dragged with Selection switches visible document tabs after a 400 ms hover, then copies editable artwork on canvas release; source and clipboard stay intact; scores and hours unchanged; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximation; UI chrome: panels dock into any dock group or the icon column by dragging (UI.6), workspaces keep their layout changes (UI.7), dock groups collapse to their tabs by a double-click (UI.8); scores and hours unchanged; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls; filter plug-ins read and set effect records and stroke joins, caps and miter limits, G17; the Print dialog's Media list has the Japanese papers: JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 beside the ISO B4/B5, which keep their ISO 216 sizes, and はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; a paper is named by its JSON id or its dialog label, any case and spacing; imported PDF type that is letter-spaced keeps its word spaces and no others; Fractional Widths: Character panel menu, per type object, on by default; System Layout remains a panel stub; scores unchanged; G4: manual Tate-chu-yoko; G4: No Break; scores unchanged; the selection tools take a clipping path on its outline, G1; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; CSS swatch libraries retain fractional gradient stop positions; scores and hours unchanged; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; Libraries panel: the list scrolls, items and groups reorder by dragging, items drag into and out of groups (`library.moveItem` before/after, `library.moveGroup`); scores unchanged; PDF invisible text opens as type with no fill or stroke and is written back invisible, #2675; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; Image Trace: Auto Color keeps rare but distinct colours; Image Trace: large images trace with a warning; anchor limit raised to 8,000,000; Image Trace: Raster::new returns an error for a buffer of the wrong length; M12 maximal-disc graph pruning and junction merging measured; M12 crop EDT and Guo–Hall thinning measured; M12 centre-lines baseline measured; scores and hours unchanged; installed plug-ins stay installed after a restart, #2541; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; Image Trace: a higher Paths never gives fewer segments on collapsed loops; Image Trace: the notch rule reads only its own piece; Image Trace: an edge's sub-pixel shift is the same from either side; Image Trace Flat Logo traces line art on white paper instead of a blank result; score and hours unchanged; Image Trace: a simpler contour walk with no precomputed direction table; transparency grid visibility saved in native documents, #1968; MCP cancellation, progress and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth and drawn, with SVG/PDF outlines where ruby is present; scores and hours unchanged; previously:; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top, G1; the Print dialog's Media list has the Japanese papers: JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 beside the ISO B4/B5, which keep their ISO 216 sizes, and はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; a paper is named by its JSON id or its dialog label, any case and spacing; imported PDF type that is letter-spaced keeps its word spaces and no others; G4: System Layout; Fractional Widths: Character panel menu, per type object, on by default; System Layout remains a panel stub; scores unchanged; G4: No Break; scores unchanged; the selection tools take a clipping path on its outline, G1; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; CSS swatch libraries retain fractional gradient stop positions; scores and hours unchanged; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; Libraries panel: the list scrolls, items and groups reorder by dragging, items drag into and out of groups (`library.moveItem` before/after, `library.moveGroup`); scores unchanged; PDF invisible text opens as type with no fill or stroke and is written back invisible, #2675; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; Image Trace: Auto Color keeps rare but distinct colours; Image Trace: large images trace with a warning; anchor limit raised to 8,000,000; Image Trace: Raster::new returns an error for a buffer of the wrong length; M12 maximal-disc graph pruning and junction merging measured; M12 crop EDT and Guo–Hall thinning measured; M12 centre-lines baseline measured; scores and hours unchanged; installed plug-ins stay installed after a restart, #2541; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; Image Trace: a higher Paths never gives fewer segments on collapsed loops; Image Trace: the notch rule reads only its own piece; Image Trace: an edge's sub-pixel shift is the same from either side; Image Trace Flat Logo traces line art on white paper instead of a blank result; score and hours unchanged; Image Trace: a simpler contour walk with no precomputed direction table; transparency grid visibility saved in native documents, #1968; MCP cancellation, progress and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md), @@ -80,7 +80,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Type core | 9 | 80% | Auto-leading style resets are implemented; remaining: hyphenation and justification options, Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; scores and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Raster effects (Effect Gallery) | 4 | 49% | 3 Photoshop-style filters (Sketch › Conté Crayon among them), the Effect Gallery dialog, Load Texture | 8–14 | measured count 54/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch › Torn Edges added); texture redraw distances optimized (#1257), pixels verified; depth estimated | -| Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, dynamic symbols, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guide settings (stored, not drawn) are done | 5–9 | estimated; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested; scores and hours unchanged | +| Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, dynamic symbols, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guide settings (stored, not drawn) are done | 5–9 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested; scores and hours unchanged | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) | | Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, dynamic symbols, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration and Export Type (Graphic / Movie Clip) are done | 5–9 | estimated | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; large images trace with a warning; Auto Color keeps rare distinct colours) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | @@ -683,6 +683,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Optical Margin Alignment and Roman Hanging Punctuation hang right-to-left paragraphs of point and area type, and type on a path whose first paragraph runs right to left, too. Auto aligns a right-to-left line at its right, or at the end bracket of a path, so it hangs there. A right-to-left line is read from right to left: where it hangs at its left margin, `."` ending it hangs both glyphs. The Paragraph panel menu's Hyphenation… (`ui.hyphenation`, `text.setFormat {wordsLongerThan, afterFirst, beforeLast, hyphenLimit, hyphenationZone, hyphenateCapitalized}`) sets each paragraph's hyphenation limits: the shortest word that breaks, the fewest letters on each side of the hyphen, the most hyphenated lines in a row, a zone at the right edge where the Single-line Composer breaks before a word instead of hyphenating it, and whether capitalized words break. The defaults are words longer than 5 letters, 3 letters on each side, no limit or zone, and capitalized words hyphenated; paragraph styles and the native file keep the options. The Paragraph panel's Insert Kashidas (with the Indic options; `text.setFormat {kashida}`) lengthens each Arabic word of a justified horizontal area type line with a kashida, a tatweel between its last pair of joined letters, before the word spaces widen: Short, Medium and Long kashidas are at most a quarter, half and a whole em long, Stylistic ones are whole tatweels up to an em, and None (the default) leaves the line as before; paragraph styles and the native file keep the setting. + The Character panel menu's Character Direction (with the Indic options; `text.setFormat {charDirection}`, `text.setRangeStyle {charDirection}`) sets the selected characters left to right or right to left inside their paragraph, as a Unicode bidi embedding: a date set Left to Right keeps its order in right-to-left text, and a word set Right to Left keeps the punctuation after it on its left. Default (the default) leaves the characters to the bidi algorithm; character styles and the native file keep the setting. In vertical type, the Tabs panel names its alignment buttons Top-Justified Tab, Center-Justified Tab, Bottom-Justified Tab and Decimal-Justified Tab, in their tool tips and for screen readers, and labels the position field Y. In horizontal type the buttons keep the names Left-Justified Tab and Right-Justified Tab, and the field keeps the label X. The Tabs panel's ruler is numbered in the General unit with the canvas rulers' label steps and a tick every tenth of a step. With the panel menu's Snap to Unit on, or Shift held, a tab stop placed by a click on the ruler or dragged on it lands on the nearest tick (`text.tabs.set`). With Snap to Unit off and Shift not held, a tab stop placed by a click on the Tabs panel's ruler or dragged on it lands on the nearest whole point. A position typed in the X or Y field keeps its hundredths of a point. @@ -704,6 +705,10 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Gradient mesh from an image:** Object › Create Gradient Mesh (`object.mesh.create`) on an embedded image makes a mesh of 1 to 50 rows and columns over the picture's rectangle, placed with the image's transform; each point takes the alpha-weighted average colour and the opacity of the area around it, and Appearance and Highlight apply. A linked image is refused with a message to embed it (#1676). Scores and hours unchanged. - **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. Line art whose only solid area is white paper (strokes a pixel or two wide) takes its colours from the strokes' cores, with the lighter shades along a stroke's anti-aliased edge folded into its ink; a page whose ink (a channel at least 48 below white) covers under 1% of it traces to nothing, as blank paper does. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original. - **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own, and regions they bound merge without seams where the lines cross at rounded coordinates (#893); filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points is refused with advice rather than running out of memory, an image over 64 megapixels is traced scaled down to that size (aspect kept, paths at the image's own size) and one over 192 megapixels is refused, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). A single selected mirror repeat shows a prominent axis with a center move handle and two rotation handles. Drag the line or center to change spacing, or an end to change angle; Shift snaps rotation to 45°. The original artwork stays in place, the reflected copy previews live, Escape cancels and each drag takes one undo step through `object.repeat.options`. Controls follow zoom/view rotation and hide with edges or the bounding box. Engine and headless canvas regressions passed for gestures, history and visibility; native move, rotation and Escape screenshots were reviewed. [Public mirror-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-mirror-repeat-options.html). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). + Window › Blend exposes easing Type, Ramp and independent color Shift through `object.blend.options`. + The four polynomial distributions are original approximations of the public description, not measured + numeric parity. See [Blend panel overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html). + - Create Strokes uses exact squared Euclidean distances and Guo–Hall thinning in bounded per-blob crops. Maximal-disc pruning, split-crossing merges and degree-two joins simplify its skeleton graph. The whole-blob width rule and ink fitting remain; 46 code-drawn inputs measure ends and junctions in [`image-trace-quality.md`](image-trace-quality.md#centre-lines). Junction degrees measure 44/45 correct in each input variant; remaining spurs are recorded. This is a measured skeleton improvement; the Image Trace score, missing per-feature selection and width controls, and estimates remain unchanged. Object › Live Paint › Merge adds the other selected paths and Live Paint groups to the bottom selected Live Paint group ([Live Paint groups](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/live-paint-groups.html)), which keeps its id, the paint of its faces and edges, and its name, lock, visibility, transparency, appearance, opacity mask and graphic style link. Object › Live Paint › Make with a Live Paint group selected, and a Live Paint Bucket click on a selected path beside a selected Live Paint group, add the paths to that group the same way. Live Paint Gap Options (Object › Live Paint › Gap Options…, `livePaint.gapOptions`): with Gap Detection on, paint stops at the gaps that run from a loose end of a path to the nearest path, up to Small (3 pt), Medium (6 pt), Large (12 pt) or a custom size; Close Gaps With Paths adds an unpainted path across each gap; Preview applies the options live and draws the gaps in the Gap Preview Color. The options are saved, copied and undone with the group, and Merge keeps the bottom group's options when its Gap Detection is on, or else turns Gap Detection on with Small when another merged group has it on. When Gap Detection is off in every merged group, it stays off. @@ -778,7 +783,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A name typed in the desktop save panel without the file type's extension gets it, and when a file of that name exists, which the panel did not ask about, Save, Save As, Save a Copy, Save as Template, the exports and every other write through a save panel ask before replacing it (dialog `replaceFile`; Cancel writes nothing). A name returned by the desktop save panel with the extension of a format the save does not write has it replaced with the saved format's: Save As of a new document saves `card.ait` and `card.png` as `card.vectorcraft`, and Save as Template saves `card.pdf` as `card.vctemplate`. An extension that names no format stays part of the name (`notes.v2` is saved as `notes.v2.vectorcraft`). - macOS: the save sheet of Save As, Save a Copy, Save as Template and the first Save of a new document has a Format menu that lists each format the save can write, with the suggested file's format selected. Choosing a format changes the extension in the name field, and a name typed without an extension gets the chosen format's. A name typed with another listed extension is saved in that format. The menu is set up before the sheet opens; a save panel with one file type is shown by rfd as before (#866, `apps/vectorcraft/src/mac_save_format.rs`). - - Linux and the BSDs: the save dialog of Save As, Save a Copy, Save as Template and the first Save of a new document is asked of the desktop portal, starting on the suggested file's type, and the file type picked in it decides the format. A name typed without an extension, or with the suggested file's, gets the picked type's extension; a name typed with another save format's extension (for Save as Template, a template format's) is saved in that format, another format's extension is replaced with the picked type's, and any other extension gets the picked type's added. rfd shows the dialog when the portal can't (#2679, `apps/vectorcraft/src/portal_save.rs`). + - Linux and the BSDs: the save dialog of Save As, Save a Copy, Save as Template and the first Save of a new document is asked of the desktop portal, starting on the suggested file's type, and the file type picked in it decides the format. A name typed without an extension, or with the suggested file's in any letter case, gets the picked type's extension; a name typed with another save format's extension (for Save as Template, a template format's) is saved in that format, another format's extension is replaced with the picked type's, and any other extension gets the picked type's added. rfd shows the dialog when the portal can't (#2679, `apps/vectorcraft/src/portal_save.rs`). - Save as Template writes a PDF-compatible `.ait` as well as a `.vctemplate`, which stays the default: the save panel and the web build's Save as Template dialog list both, and New from Template opens the `.ait` as an untitled document with its layers and art (`file.saveAsTemplate {format: "ait"}`, or a path ending in `.ait`). Save as Template always writes a file named with its template type's extension: `file.saveAsTemplate` saves a path ending in another format's extension (`card.png`) as `card.vctemplate` (or `card.ait`), the desktop save panel does the same with a Save As format's extension typed in it (`card.pdf` is saved as `card.vctemplate`), and `file.saveAs` and `document.save` refuse `ait` with an error that names `file.saveAsTemplate`. A `.ait` is PDF-compatible only: `aiVersion: "7"` is an error for it, and the web build's dialog shows no Version row for it. Exports refuse a `.ait` with an error that names `file.saveAsTemplate`, and `vectorcraft-cli convert in.pdf out.ait` and `run --export out.ait` write the template as `file.saveAsTemplate` does. @@ -939,7 +944,9 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Tab-drag follow-up: reconcile upstream progress updates and document drag lifecycle, insertion behavior and regression contracts; behavior, scores and estimates unchanged. | | 2026-10-22 | minor | Artwork dragged with Selection switches visible document tabs after a 400 ms hover, then copies editable artwork on canvas release; source and clipboard stay intact; scores and hours unchanged | +| 2026-10-21 | minor | Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximation. Scores and estimates unchanged. | | 2026-10-21 | minor | UI chrome: workspaces keep the layout changes made in them when switching away and back, and across restarts; Reset returns to the saved layout (UI.7); score and hours unchanged (estimated) | | 2026-10-21 | minor | UI chrome: a panel group dragged onto a dock group's tabs joins it, onto its upper or lower half makes a new group above or below it, onto the icon column becomes icons (the tabbed group's panels too); dock tabs close from their context menu (UI.6); score and hours unchanged (estimated) | | 2026-10-21 | minor | UI chrome: a double-click on a dock tab collapses its group to its tabs and expands it again, as floating groups already did (UI.8); scores and hours unchanged (estimated) | @@ -973,6 +980,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-10-18 | minor | Shape Builder: Gap Detection (Small 3 pt, Medium 6 pt, Large 12 pt, Custom) in the new Shape Builder Tool Options bridges small gaps between open paths for the regions they bound (#2523); Path operations stays 80% (estimated) while #937 is open | | 2026-10-18 | minor | Mirror Repeat shows its axis when selected (#2877): drag the line or center handle to move it, drag an end handle to rotate it (Shift: 45°), Escape cancels and each drag is one undo step. Engine and headless canvas gesture regressions passed; native move, rotation and Escape screenshots reviewed. Formatting, Clippy, WASM and repository checks passed; the undo label reuses translated Repeat Options. (#2877) Scores and hours unchanged. | | 2026-10-18 | minor | Bounding-box handles take priority where a selected Mirror Repeat axis crosses them: the Top handle scales the repeat, while the center still moves it and the ends still rotate it. Headless canvas regression confirms vertical scaling without width change; scores and hours unchanged. | +| 2026-10-19 | minor | Blends: ease in/out and separate colour easing in Blend Options (#1124); on-canvas handles and easing in the Blend panel remain. Symbols, blends, envelopes score unchanged (estimated) | | 2026-10-17 | minor | Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged. Windows native canvas checks and screenshots reviewed in PR #2643. | | 2026-10-17 | minor | Evidence: Artboard Options has X and Y beside a reference point locator, counted from the ruler origin (#1876); scores unchanged | | 2026-10-17 | minor | Image Trace: Auto Color keeps rare but distinct colours drawn as regions of their own (stems, highlights, near-white on pale backgrounds), at most six, protected from mixed-colour merging and Noise: every key colour on 66 of 66 flat test logos (was 39); scans unchanged. Scores and hours unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index ae2d284b2..99240d200 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Artwork dragged with Selection switches visible document tabs after a 400 ms hover, then copies editable artwork on canvas release; source and clipboard stay intact; scores and hours unchanged; Panels dock into any dock group or the icon column by dragging (UI.6), workspaces keep their layout changes (UI.7), dock groups collapse to their tabs by a double-click (UI.8); scores unchanged; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top, G1; Distribute Objects and Distribute Spacing space from a key guide (#3165) with the Distribute Spacing value; Distribute spaces selected ruler guides with the objects, #1597 in part; the selection tools take a clipping path on its outline, over the art it clips; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; a ruler guide can be the key object Align aligns to, and selected guides align to a key object, #2143; Pencil: Alt key toggles to Smooth Tool, #2001; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; on-canvas Mirror Repeat axis, #2877; Background Export progress dialog with Cancel, #2564; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; scores unchanged; library panels: one per library, dockable and tabbable, with Persistent, #2603; the Selection tool threads text from the out port, #2572 in part; scores unchanged; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; scores unchanged; transparency grid visibility saved in native documents, #1968; Live Corners radius up to the neighbouring cuts, #2332; Image Trace quick presets: wording corrected to the shipped behaviour, the Image Trace button opens the controls; batch exporting, #1935; document window scroll bars, #2322; Anchor Point tool: a click on a handle end retracts that handle only, #2267; scores unchanged; Smart Guides: the dragged end of a straight segment keeps to its extension at any angle, #2120; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144; interactive Mirror Repeat axis; Background Export progress dialog with Cancel, #2564; scores unchanged; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; scores unchanged; library panels: one per library, dockable and tabbable, with Persistent, #2603; the Selection tool threads text from the out port, #2572 in part; scores unchanged; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; scores unchanged; transparency grid visibility saved in native documents, #1968; Live Corners radius up to the neighbouring cuts, #2332; Image Trace quick presets: wording corrected to the shipped behaviour, the Image Trace button opens the controls; batch exporting, #1935; document window scroll bars, #2322; Anchor Point tool: a click on a handle end retracts that handle only, #2267; scores unchanged; Smart Guides: the dragged end of a straight segment keeps to its extension at any angle, #2120; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Artwork dragged with Selection switches visible document tabs after a 400 ms hover, then copies editable artwork on canvas release; source and clipboard stay intact; scores and hours unchanged; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; Panels dock into any dock group or the icon column by dragging (UI.6), workspaces keep their layout changes (UI.7), dock groups collapse to their tabs by a double-click (UI.8); scores unchanged; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top, G1; Distribute Objects and Distribute Spacing space from a key guide (#3165) with the Distribute Spacing value; Distribute spaces selected ruler guides with the objects, #1597 in part; the selection tools take a clipping path on its outline, over the art it clips; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; a ruler guide can be the key object Align aligns to, and selected guides align to a key object, #2143; Pencil: Alt key toggles to Smooth Tool, #2001; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; on-canvas Mirror Repeat axis, #2877; Background Export progress dialog with Cancel, #2564; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; scores unchanged; library panels: one per library, dockable and tabbable, with Persistent, #2603; the Selection tool threads text from the out port, #2572 in part; scores unchanged; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; scores unchanged; transparency grid visibility saved in native documents, #1968; Live Corners radius up to the neighbouring cuts, #2332; Image Trace quick presets: wording corrected to the shipped behaviour, the Image Trace button opens the controls; batch exporting, #1935; document window scroll bars, #2322; Anchor Point tool: a click on a handle end retracts that handle only, #2267; scores unchanged; Smart Guides: the dragged end of a straight segment keeps to its extension at any angle, #2120; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144; interactive Mirror Repeat axis; Background Export progress dialog with Cancel, #2564; scores unchanged; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; scores unchanged; library panels: one per library, dockable and tabbable, with Persistent, #2603; the Selection tool threads text from the out port, #2572 in part; scores unchanged; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; scores unchanged; transparency grid visibility saved in native documents, #1968; Live Corners radius up to the neighbouring cuts, #2332; Image Trace quick presets: wording corrected to the shipped behaviour, the Image Trace button opens the controls; batch exporting, #1935; document window scroll bars, #2322; Anchor Point tool: a click on a handle end retracts that handle only, #2267; scores unchanged; Smart Guides: the dragged end of a straight segment keeps to its extension at any angle, #2120; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x) How VectorCraft's tools, handles, snapping and panels behave next to Illustrator's. A vector illustrator's trust is in the details: where a handle lands, what Shift and Alt do mid-drag, what a smart guide snaps to, @@ -248,7 +248,7 @@ empty), without opening the dialog. | Canvas context menu | done | Layers rows, panels and type have none (gap) | | SVG Interactivity panel: JavaScript for each object's events and the document's linked JavaScript files, written to SVG and read back as text | done | `svgInteractivity.*`, `ui.javascriptFilesDialog` | | Retype, Comments, Version History panels | missing | the last two are cloud features (out of scope) | -| Blend panel (30.7) | partial | Window › Blend: Make, Spacing, Orientation, Reverse Front to Back, Reverse Spine, Replace Spine, Expand and Release, running the Object › Blend commands; the easing and color controls are missing | +| Blend panel (30.7) | partial | Window › Blend provides Type (Ease In, Ease Out, Ease In Out, Ease Out In), Ramp 0–100% and independent color Shift, alongside the existing blend controls. Easing is disabled without a spine. Original polynomial distributions approximate the publicly documented behavior; on-canvas easing handles remain missing. [Public source](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html) | | SVG Interactivity, Retype, Comments, Version History panels | missing | the last two are cloud features (out of scope) | | Workspaces (save, reset, switch); each remembers the layout changes made in it across switches and restarts until Reset | done | `UiState::workspace_changes` | | Background Export: a progress dialog with the file name, the artboard count and a bar (busy for one artboard and for formats made in one go), Cancel (Esc) and Continue in Background; it opens after 0.4 s and never over another dialog. A cancelled export writes no file | done | #2564; on the shared `vectorcraft_doc::cancel` token (#2546), so a cancelled render keeps no half-drawn pattern tile or effect raster. The bar moves between artboards, not within one. Save As, Export for Screens and Export Selection don't show it yet | @@ -294,7 +294,9 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Tab-drag follow-up: reconcile upstream progress updates and document drag lifecycle, insertion behavior and regression contracts; behavior, scores and estimates unchanged. | | 2026-10-22 | minor | Artwork dragged with Selection switches visible document tabs after a 400 ms hover, then copies editable artwork on canvas release; source and clipboard stay intact; scores and hours unchanged | +| 2026-10-21 | minor | Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations. Scores and estimates unchanged. | | 2026-10-21 | minor | Workspaces keep the layout changes made in them when switching away and back, and across restarts; Reset returns to the saved layout; score unchanged (estimated) | | 2026-10-21 | minor | Panels dragged by the mouse join a dock group as tabs, split it top/bottom or become icons in the icon column; dock tabs close from their context menu; score unchanged (estimated) | | 2026-10-21 | minor | A double-click on a dock tab collapses its group to its tabs, as floating groups already did, and expands it again; another of its tabs expands it (`window.panel.collapse`); scores unchanged (estimated) | @@ -316,6 +318,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-10-19 | minor | Color Picker layout (#2008): compact controls, centered header, stacked actions and aligned inputs; screen-reader channel values, current shared button names and styling, screen sampler and model-by-model Tab order retained. Narrow windows scroll vertically; scores and hours unchanged. | | 2026-10-18 | minor | Mirror Repeat shows its axis when selected (#2877): drag the line or center handle to move it, drag an end handle to rotate it (Shift: 45°), Escape cancels and each drag is one undo step. Engine and headless canvas gesture regressions passed; native move, rotation and Escape screenshots reviewed. Formatting, Clippy, WASM and repository checks passed; the undo label reuses translated Repeat Options. (#2877) Scores and hours unchanged. | | 2026-10-18 | minor | Bounding-box handles take priority where a selected Mirror Repeat axis crosses them: the Top handle scales the repeat, while the center still moves it and the ends still rotate it. A headless canvas regression confirms height changes while width stays fixed; scores and hours unchanged. | +| 2026-10-19 | minor | Blend Options: Ease In / Ease Out and Color Ease In / Out sliders with live preview (#1124); the Blend panel row stays partial (no easing in the panel, no on-canvas handles) | | 2026-10-17 | minor | Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged. Windows native canvas checks and screenshots reviewed in PR #2643. | | 2026-10-17 | minor | Artboard Options has X and Y beside a reference point locator, counted from the ruler origin as the Transform panel's are; a typed X or Y moves the artboard's reference point there and a new Width or Height keeps it in place (#1876); scores unchanged (estimated) | | 2026-10-17 | minor | Background Export shows a progress dialog with Cancel and Continue in Background (#2564), built on the shared cancel token of #2546; scores and hours unchanged (estimated) | diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 65dd1e03a..56c5705a9 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -117,7 +117,7 @@ if has appimage; then if [ -z "$TOOL" ]; then TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage" if [ ! -x "$TOOL" ]; then - curl -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" + curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" chmod +x "$TOOL" fi fi