From e71b4783f26c817235cf4e7f9eb31ac1be2c2089 Mon Sep 17 00:00:00 2001 From: z3r0pix3l Date: Sun, 8 Nov 2026 21:13:54 +0300 Subject: [PATCH 01/16] PDF import: Arabic ligatures keep their letters in order (#6072) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A PDF names a ligature's characters in logical order (lam then alef for the lam-alef glyph), while a horizontal line of type is gathered glyph by glyph in visual order and then put back in logical order character by character. The ligature's characters were turned round with the line: لا came in as ال, بلال as بالل and الله as هللا. Each glyph's characters now go into the line in visual order, so putting the line back restores them. --- crates/pdf/src/import/builder.rs | 16 +++++++++++++++ crates/pdf/src/tests/live_text.rs | 34 +++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/crates/pdf/src/import/builder.rs b/crates/pdf/src/import/builder.rs index 86a4a89a3..b74498926 100644 --- a/crates/pdf/src/import/builder.rs +++ b/crates/pdf/src/import/builder.rs @@ -842,6 +842,9 @@ impl<'p> Builder<'p> { let vx = self.fonts.get(&key).and_then(|f| f.vertical.as_ref()).map_or(500.0, |v| v.centre(glyph)); m * kurbo::Point::new(vx, 880.0) }); + // A horizontal line is gathered in visual order and put back in logical order as a whole: + // a ligature's characters (lam-alef, lam-lam-heh) go in visual order too (#6072). + let text = if top.is_none() { visual_glyph_text(text) } else { text }; let place = |line: &mut TextLine| match top { Some(top) => line.push_upright(&look, at, opacity, Upright { top }, &text), None => line.push(&look, at, opacity, advance, &text), @@ -871,3 +874,16 @@ impl<'p> Builder<'p> { true } } + +/// The characters of one glyph in the order they are drawn: a PDF names a ligature's characters +/// in logical order (lam then alef for لا), while a horizontal line of type is gathered glyph by +/// glyph in visual order and only then put back in logical order. Right-to-left characters are +/// turned round here so that putting the line back restores them. +fn visual_glyph_text(text: String) -> String { + if text.chars().nth(1).is_none() { + return text; + } + let Some((order, _)) = vectorcraft_text::logical_order(&text) else { return text }; + let chars: Vec = text.chars().collect(); + order.iter().filter_map(|&i| chars.get(i)).collect() +} diff --git a/crates/pdf/src/tests/live_text.rs b/crates/pdf/src/tests/live_text.rs index 4cb4417bc..2cc9f938c 100644 --- a/crates/pdf/src/tests/live_text.rs +++ b/crates/pdf/src/tests/live_text.rs @@ -477,3 +477,37 @@ fn hebrew_comes_back_in_logical_order() { assert_eq!(shown(&t[0]), shown(&original), "{text}"); } } + +/// The text objects a PDF of `text` set in `face` imports as. +fn imported_texts(face: &vectorcraft_text::FontFace, text: &str) -> Vec { + let mut pdf = krilla::Document::new(); + let mut page = pdf.start_page_with(PageSettings::from_wh(300.0, 200.0).unwrap()); + let mut s = page.surface(); + let font = Font::new(face.file_data().to_vec().into(), face.face_index()).unwrap(); + s.draw_text(KPoint::from_xy(20.0, 50.0), font, 24.0, text, false, TextDirection::Auto); + s.finish(); + page.finish(); + let r = import_with_report(&pdf.finish().unwrap(), &ImportOptions::default()).unwrap(); + texts(&r.document).iter().map(|t| t.plain_text()).collect() +} + +#[test] +fn arabic_ligatures_keep_their_letters_in_order() { + // A lam-alef ligature is one glyph whose text is lam then alef: it must not be turned round + // with the line it sits in (#6072). + let db = vectorcraft_text::FontDb::global(); + let preferred = ["DejaVu Sans", "Arial", "Times New Roman", "Noto Sans Arabic", "Noto Naskh Arabic"].map(String::from); + // An installed Arabic font whose glyphs come back as live type. + let Some(face) = preferred + .iter() + .chain(&db.families()) + .filter_map(|f| db.face(f, "Regular")) + .filter(|f| f.covers('\u{0644}') && f.covers('\u{0627}')) + .find(|f| !imported_texts(f, "بب").is_empty()) + else { + return; // No such font on this host. + }; + for text in ["لا", "بلال", "السلام عليكم", "لا إله إلا الله"] { + assert_eq!(imported_texts(&face, text), [text], "in {}", face.family); + } +} From e0336d861a63a2059c5d23bc11d4b4ae605090ef Mon Sep 17 00:00:00 2001 From: z3r0pix3l Date: Sun, 8 Nov 2026 22:52:47 +0300 Subject: [PATCH 02/16] Test: use a font that draws lam-alef as one glyph (#6072) --- crates/pdf/src/tests/live_text.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/pdf/src/tests/live_text.rs b/crates/pdf/src/tests/live_text.rs index 2cc9f938c..bddeb758d 100644 --- a/crates/pdf/src/tests/live_text.rs +++ b/crates/pdf/src/tests/live_text.rs @@ -497,12 +497,18 @@ fn arabic_ligatures_keep_their_letters_in_order() { // with the line it sits in (#6072). let db = vectorcraft_text::FontDb::global(); let preferred = ["DejaVu Sans", "Arial", "Times New Roman", "Noto Sans Arabic", "Noto Naskh Arabic"].map(String::from); - // An installed Arabic font whose glyphs come back as live type. + // Whether `face` draws lam-alef as one glyph, the ligature the fix is about. + let ligates = |face: &vectorcraft_text::FontFace| { + let style = vectorcraft_doc::CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), ..Default::default() }; + let t = TextObject::point(kurbo::Point::ORIGIN, "لا", style); + vectorcraft_text::layout(db, &t).glyphs.len() == 1 + }; + // An installed Arabic font with a lam-alef ligature whose glyphs come back as live type. let Some(face) = preferred .iter() .chain(&db.families()) .filter_map(|f| db.face(f, "Regular")) - .filter(|f| f.covers('\u{0644}') && f.covers('\u{0627}')) + .filter(|f| f.covers('\u{0644}') && f.covers('\u{0627}') && ligates(f)) .find(|f| !imported_texts(f, "بب").is_empty()) else { return; // No such font on this host. From ac674accfd8b4500ab73d06ab8af7f15f975666d Mon Sep 17 00:00:00 2001 From: Patrick Nadeau Date: Sat, 10 Oct 2026 18:19:12 -0400 Subject: [PATCH 03/16] M4: fix PDF gradient placement in transparency groups Move form transforms into page space so Quartz keeps gradients aligned with their artwork. Add macOS rendering regressions and round-trip coverage for transparency, masks, layers and overprint. Update the fix for the shared craft-pdf writer and the split exporter modules. Preserve upstream mask, coordinate-limit and layer-box fixes, moving the layer's paper bounds into the form's new space too. Verified all three Quartz regressions fail on upstream 65adb7b21 and all five gradient-form tests pass with the fix. The new translated-layer regression also fails without moving the layer's paper bounds. Before the final rebase onto 782f213e4: 463 PDF tests passed (one existing ignored test), workspace fmt, PDF Clippy, wasm, pdf-deps, layers, assets and brands passed. The final rebase reconciles documentation only; the PDF crate is unchanged from that tested revision. After the final rebase, workspace fmt and Clippy pass. Full cargo xtask ci timed out while compiling workspace tests, so the full test suite and remaining CI stages did not complete. The earlier macOS Clippy blocker has been fixed upstream. --- Cargo.lock | 2 + ROADMAP.md | 4 + crates/pdf/Cargo.toml | 6 + crates/pdf/src/export.rs | 6 +- crates/pdf/src/export/exporter.rs | 14 +- crates/pdf/src/export/nodes.rs | 17 ++- crates/pdf/src/export/paint.rs | 2 +- crates/pdf/src/export/writer.rs | 4 +- crates/pdf/src/forms.rs | 12 +- crates/pdf/src/lib.rs | 1 + crates/pdf/src/surface.rs | 140 +++++++++++++++++++ crates/pdf/tests/gradient_forms.rs | 215 +++++++++++++++++++++++++++++ docs/file-format-parity.md | 9 ++ docs/gaps.md | 7 + docs/roadmap.md | 2 + docs/target-app-parity.md | 4 +- 16 files changed, 426 insertions(+), 19 deletions(-) create mode 100644 crates/pdf/src/surface.rs create mode 100644 crates/pdf/tests/gradient_forms.rs diff --git a/Cargo.lock b/Cargo.lock index 93ae3be0f..b7ef1c02a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6643,6 +6643,8 @@ dependencies = [ "kurbo 0.13.1", "log", "md-5", + "objc2-core-foundation", + "objc2-core-graphics", "roxmltree 0.21.2", "serde", "serde_json", diff --git a/ROADMAP.md b/ROADMAP.md index 1bedf0b95..c685b9206 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # VectorCraft Roadmap +> **Gradient-form update:** 2026-11-08 · minor · PDF export fix rebased onto current main and checked with macOS Quartz; headline scores and estimates unchanged. + - Contour add-on (beyond Illustrator): drag a selected shape's edge outward or inward for a live, editable offset copy, with a floating bar for distance and Sharp, Round or Square corners; Bake turns it into paths. Scores and estimates unchanged. - Compound Shapes add-on (beyond Illustrator): a menu for Make, Release and Expand Compound Shape and a setting, off by default, that makes the Pathfinder panel's Shape Mode buttons create compound shapes on a click. Scores and estimates unchanged. @@ -137,6 +139,8 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap ## Progress log - 2026-11-05: Window › Toolbars › Keep Tools Beside the Canvas, off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge, and the canvas's left edge then also docks a floating Tools panel (`window.toolsBesideCanvas`, #5395). Workspaces keep the setting; workspaces saved without it keep Tools at the window's edge. An optional VectorCraft layout choice; headline numbers unchanged. +- 2026-11-08: PDF form-gradient placement reverified on current main: three Quartz regressions fail without the fix and pass with it. The fix follows the exporter module split and keeps upstream layer-box repairs in the form's new space, with a translated-layer regression. Headline numbers unchanged. +- 2026-10-26: PDF form-gradient placement checked with macOS Quartz on the shared writer: linear and radial gradients follow transparency groups, masks, PDF layers and overprinting art. Headline numbers unchanged. - 2026-11-05: Release Type on a Path joins the Type Tools add-on (Object › Plug-ins › Type Tools): each selected type on a path splits into point type with the same text and attributes, on one straight line starting where its first character sat, and the plain path it was set on, with its exact shape and place in the stacking order and no paint; or keeps only the path or only the text, optionally leaving the original. A dialog with Preview, one undo step (`addon.type.releasePath`, #5532). A VectorCraft addition: headline numbers unchanged. - 2026-11-04: New Document: the units chosen in Preset Details now belong to the chosen document, as the pane's options are listed for the selected preset; the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units, so switching between inches and millimetres no longer restyles every recent template. Scores and estimates unchanged. - 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged. diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 66ff8b6e3..a5a3b8386 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -52,3 +52,9 @@ vectorcraft-text = { workspace = true, features = ["test-fonts"] } serde_json = { workspace = true } # `draw_text`, to write test files with live text as other writers do. krilla = { workspace = true, features = ["simple-text"] } + +# An independent raster oracle for gradient placement in PDF forms. These are test-only macOS +# bindings; the exporter stays portable Rust and does not depend on an OS PDF renderer. +[target.'cfg(target_os = "macos")'.dev-dependencies] +objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFData"] } +objc2-core-graphics = { version = "0.3", default-features = false, features = ["std", "CGBitmapContext", "CGColorSpace", "CGContext", "CGDataProvider", "CGImage", "CGPDFDocument", "CGPDFPage"] } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 47184d520..c159c1f19 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -9,12 +9,11 @@ use craft_pdf::write::{self, Metadata}; use krilla::color::separation::{Color as SepColor, SeparationColorant, SeparationSpace}; use krilla::color::{cmyk, luma, rgb}; use krilla::configure::{Archival, PdfVersion}; -use krilla::geom::{Path, PathBuilder, Size, Transform}; +use krilla::geom::{Path, PathBuilder, Size}; use krilla::image::Image; use krilla::num::NormalizedF32; use krilla::page::PageSettings; use krilla::paint::{Fill, LinearGradient, RadialGradient, SpreadMethod, Stop, Stroke, StrokeDash}; -use krilla::surface::Surface; use krilla::text::{Glyph, GlyphId, KrillaGlyph}; use kurbo::{PathEl, Shape, Vec2}; use vectorcraft_color::{BlendMode, Color, GradientKind, Paint}; @@ -28,6 +27,7 @@ use crate::forms::Mark; use crate::lab_spot::{find, rfind}; use crate::marks::PageBoxes; use crate::output::ColorOut; +use crate::surface::Surface; use crate::{Compatibility, ExportReport, PdfError, PdfOptions, Standard}; mod entry; @@ -131,7 +131,7 @@ mod tests { fn writable_bounds_values_the_transform_and_values_through_it() { let mut pdf = krilla::Document::new(); let mut page = pdf.start_page_with(PageSettings::new(Size::from_wh(100.0, 100.0).unwrap())); - let mut s = page.surface(); + let mut s = Surface::page(page.surface(), 100.0); let r = Rect::new(0.0, 0.0, 10.0, 10.0); assert!(writable(&s, Affine::IDENTITY, r)); assert!(!writable(&s, Affine::IDENTITY, Rect::new(-2_147_483_649.0, 0.0, 10.0, 10.0))); diff --git a/crates/pdf/src/export/exporter.rs b/crates/pdf/src/export/exporter.rs index eb312021e..1901ab9ea 100644 --- a/crates/pdf/src/export/exporter.rs +++ b/crates/pdf/src/export/exporter.rs @@ -1,9 +1,11 @@ use super::entry::RgbInCmyk; use super::{ Affine, Arc, BezPath, BlendMode, Color, Compatibility, Document, Exporter, Fill, FillRule, HashMap, HashSet, Node, NormalizedF32, Path, - PathBuilder, PathEl, Rect, Shape, Standard, Surface, Transform, cmyk, krilla, luma, rgb, + PathBuilder, PathEl, Rect, Shape, Standard, Surface, cmyk, krilla, luma, rgb, }; +pub(super) use crate::surface::xf; + /// How deep inline graphics nest before they are left out. pub(super) const MAX_INLINE_DEPTH: u32 = 4; @@ -92,11 +94,6 @@ pub(super) const MAX_GRADIENT_PX: f64 = 4096.0; /// with overflow checks it panics on -2,147,483,648. pub(super) const MAX_COORD: f64 = 1e9; -pub(super) fn xf(a: Affine) -> Transform { - let c = a.as_coeffs(); - Transform::from_row(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32, c[4] as f32, c[5] as f32) -} - /// The current transform of surface `s` in the content stream it draws into, without that stream's /// root transform (a page's y flip). pub(super) fn ctm(s: &Surface) -> Affine { @@ -199,9 +196,12 @@ pub(super) fn cover(s: &mut Surface, page: Rect, level: u8, opacity: f32) { /// group of that opacity that, unlike the writer's opacity groups, is not isolated. /// The cover is white, so a reader that takes every soft mask as a luminosity mask shows the same. pub(super) fn constant_mask(s: &mut Surface, page: Rect, alpha: f32) -> krilla::mask::Mask { + let transform = s.group_transform(); let mut sb = s.stream_builder(); - let mut ms = sb.surface(); + let mut ms = Surface::stream(sb.surface()); + ms.push_transform(&transform); cover(&mut ms, page, 255, alpha); + ms.pop(); ms.finish(); krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Alpha) } diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index dbf4b0d87..2d9a9cc17 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -22,8 +22,10 @@ impl Exporter<'_> { /// Opacity mask → luminosity soft mask. Outside the art the backdrop is black (clip) or white; /// invert is a white Difference rect on top (luminance is linear, so luma(1 − c) = 1 − luma(c)). fn soft_mask(&mut self, s: &mut Surface, m: &vectorcraft_doc::OpacityMask, page: Rect) -> Option { + let transform = s.group_transform(); let mut sb = s.stream_builder(); - let mut ms = sb.surface(); + let mut ms = Surface::stream(sb.surface()); + ms.push_transform(&transform); // An opaque backdrop when it matters: white outside the art (no clip), or black for the // inverting Difference pass below to turn white. if !m.clip || m.invert { @@ -39,6 +41,8 @@ impl Exporter<'_> { self.image_left_out = image_left_out.map(|outer| outer || incomplete); (self.knockout, self.overprint) = (knockout, overprint); if incomplete { + ms.pop(); + ms.finish(); return None; } if m.invert { @@ -46,6 +50,7 @@ impl Exporter<'_> { cover(&mut ms, page, 255, 1.0); ms.pop(); } + ms.pop(); ms.finish(); Some(krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity)) } @@ -96,21 +101,27 @@ impl Exporter<'_> { s.opacity = 1.0; s }; + let transform = s.group_transform(); let mut sb = s.stream_builder(); - let mut ms = sb.surface(); + let mut ms = Surface::stream(sb.surface()); + ms.push_transform(&transform); cover(&mut ms, page, 255, 1.0); let alpha = { + let transform = ms.group_transform(); let mut ab = ms.stream_builder(); - let mut als = ab.surface(); + let mut als = Surface::stream(ab.surface()); + als.push_transform(&transform); let overprint = std::mem::take(&mut self.overprint); self.measured_node(&mut als, &shape, page, false, Some(measured)); self.overprint = overprint; + als.pop(); als.finish(); krilla::mask::Mask::new(ab.finish(), krilla::mask::MaskType::Alpha) }; ms.push_mask(alpha); cover(&mut ms, page, 0, 1.0); ms.pop(); + ms.pop(); ms.finish(); krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity) } diff --git a/crates/pdf/src/export/paint.rs b/crates/pdf/src/export/paint.rs index 17a3a10bf..bb0a660f0 100644 --- a/crates/pdf/src/export/paint.rs +++ b/crates/pdf/src/export/paint.rs @@ -351,7 +351,7 @@ impl Exporter<'_> { let cell = Rect::new(0.0, 0.0, pw, ph); let tiles = def.instances_in(Affine::IDENTITY, cell); let mut sb = s.stream_builder(); - let mut cs = sb.surface(); + let mut cs = Surface::stream(sb.surface()); let reaches_out = tiles.iter().any(|t| t.visual_bounds().is_some_and(|b| cell.intersect(b) != b)); let clip = reaches_out.then(|| to_path(&cs, &cell.to_path(0.1))).flatten(); if let Some(c) = &clip { diff --git a/crates/pdf/src/export/writer.rs b/crates/pdf/src/export/writer.rs index d7e652f2e..027025ad2 100644 --- a/crates/pdf/src/export/writer.rs +++ b/crates/pdf/src/export/writer.rs @@ -2,7 +2,7 @@ use super::entry::RgbInCmyk; use super::exporter::{constant_mask, cover, to_path, writable, xf}; use super::{ Affine, Arc, Archival, ColorOut, Compatibility, Document, Exporter, HashSet, Image, Metadata, Node, PageSettings, PdfError, PdfVersion, Rect, - Shape, Size, Standard, Vec2, find, krilla, rfind, write, + Shape, Size, Standard, Surface, Vec2, find, krilla, rfind, write, }; /// A PDF being written, shared by export and print: the file's configuration and metadata, then @@ -125,7 +125,7 @@ impl Writer { let size = Size::from_wh(w.max(least) as f32, h.max(least) as f32).ok_or_else(|| PdfError::BadSetting(format!("page size {w} × {h}")))?; let at = |b: Rect| krilla::geom::Rect::from_ltrb(b.x0 as f32, b.y0 as f32, b.x1 as f32, b.y1 as f32); let mut page = self.pdf.document_mut().start_page_with(PageSettings::new(size).with_trim_box(at(sheet.trim)).with_bleed_box(at(sheet.bleed))); - let mut s = page.surface(); + let mut s = Surface::page(page.surface(), size.height()); let paper = Rect::new(0.0, 0.0, w, h); if sheet.negative { cover(&mut s, paper, 255, 1.0); diff --git a/crates/pdf/src/forms.rs b/crates/pdf/src/forms.rs index 4a1d06147..b771e88b7 100644 --- a/crates/pdf/src/forms.rs +++ b/crates/pdf/src/forms.rs @@ -13,7 +13,6 @@ use std::io::Read; use craft_pdf::write::backend as krilla; use krilla::geom::PathBuilder; use krilla::graphic::Graphic; -use krilla::surface::Surface; use std::sync::Arc; use vectorcraft_doc::{Document, Node, NodeKind}; @@ -21,6 +20,7 @@ use vectorcraft_doc::{Document, Node, NodeKind}; use crate::encrypt::{Lexer, Obj, StreamSpans, Tok, int, stream_spans}; use crate::output::{deflate, text_string}; use crate::patch::{Patch, Xref}; +use crate::surface::{Surface, affine}; use crate::{PdfError, PdfSettings, Standard}; /// What a marked form is. @@ -87,6 +87,9 @@ pub(crate) fn form(s: &mut Surface, mark: Mark, draw: impl FnOnce(&mut Surface)) /// form's drawing space), rounded outwards to whole units: [`finish`] gives the form a box that /// covers the paper and the art. pub(crate) fn layer_form(s: &mut Surface, i: usize, paper: vectorcraft_geom::Rect, draw: impl FnOnce(&mut Surface)) { + // The art and its box move into page space together; keeping the old local paper here + // would clip layers on translated artboards or give the importer a spurious clipping group. + let paper = affine(s.group_transform()).transform_rect_bbox(paper); let corner = |x: f64, y: f64| (x as f32, y as f32); marked(s, Mark::Layer(i), [corner(paper.x0.floor(), paper.y0.floor()), corner(paper.x1.ceil(), paper.y1.ceil())], draw); } @@ -94,8 +97,9 @@ pub(crate) fn layer_form(s: &mut Surface, i: usize, paper: vectorcraft_geom::Rec /// Draw `draw` as a form of its own, marked `mark`: an empty clip from the mark's first point /// through `rest`. fn marked(s: &mut Surface, mark: Mark, rest: [(f32, f32); 2], draw: impl FnOnce(&mut Surface)) { + let transform = s.group_transform(); let mut sb = s.stream_builder(); - let mut fs = sb.surface(); + let mut fs = Surface::stream(sb.surface()); let (x, y) = mark.at(); let mut pb = PathBuilder::new(); pb.move_to(x, y); @@ -108,10 +112,14 @@ fn marked(s: &mut Surface, mark: Mark, rest: [(f32, f32); 2], draw: impl FnOnce( fs.push_clip_path(&p, &krilla::paint::FillRule::NonZero); fs.pop(); } + fs.push_transform(&transform); draw(&mut fs); + fs.pop(); fs.finish(); let stream = sb.finish(); + s.push_transform(&transform.invert().unwrap_or_default()); s.draw_graphic(Graphic::new(stream, false)); + s.pop(); } // ---------- finishing the written file ---------- diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index ca7e44ae7..5c9701e7e 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -70,6 +70,7 @@ mod presets; mod print; mod settings; mod streams; +mod surface; mod syntax; pub use editing::{EDITING_FILE, Editing, LEGACY_EDITING_FILE, editing, editing_with}; diff --git a/crates/pdf/src/surface.rs b/crates/pdf/src/surface.rs new file mode 100644 index 000000000..4229781cd --- /dev/null +++ b/crates/pdf/src/surface.rs @@ -0,0 +1,140 @@ +//! Keep form XObjects in page space. Quartz resolves shading patterns inside a form against the +//! page's initial space, whereas other readers also apply the transform at the form's invocation. +//! Moving that transform inside the form makes both interpretations agree (including gradients +//! in opacity groups, masks, layers and overprinting art), without rasterizing the art. +//! +//! krilla's page surface has an implicit y flip which `ctm()` does not include. Remember that +//! root separately; a form's surface has an identity root. A group still counts as one push/pop +//! for the drawing code, even though moving its transform takes three krilla pushes. + +use std::ops::{Deref, DerefMut}; + +use craft_pdf::write::backend as krilla; +use krilla::blend::BlendMode; +use krilla::geom::{Path, Transform}; +use krilla::mask::Mask; +use krilla::num::NormalizedF32; +use krilla::paint::FillRule; +use vectorcraft_geom::Affine; + +pub(crate) fn xf(a: Affine) -> Transform { + let c = a.as_coeffs(); + Transform::from_row(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32, c[4] as f32, c[5] as f32) +} + +pub(crate) fn affine(t: Transform) -> Affine { + Affine::new([t.sx(), t.ky(), t.kx(), t.sy(), t.tx(), t.ty()].map(f64::from)) +} + +struct Push { + count: usize, + root: Affine, +} + +/// A drawing surface whose form groups keep gradients and masks in the same page space. +pub(crate) struct Surface<'a> { + inner: krilla::surface::Surface<'a>, + root: Affine, + pushes: Vec, +} + +impl<'a> Surface<'a> { + /// Wrap a page while recording the writer's implicit Y flip outside its reported CTM. + pub fn page(inner: krilla::surface::Surface<'a>, height: f32) -> Self { + Self { inner, root: Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, height.into()]), pushes: vec![] } + } + + /// Wrap a form or pattern stream, which starts without a page's implicit Y flip. + pub fn stream(inner: krilla::surface::Surface<'a>) -> Self { + Self { inner, root: Affine::IDENTITY, pushes: vec![] } + } + + /// Current drawing space → page space, for both a form's art and its mask. A singular + /// enclosing transform draws no area: keep that form local rather than trying to invert it. + pub fn group_transform(&self) -> Transform { + let t = xf(self.root * affine(self.inner.ctm())); + if t.invert().is_some() { t } else { Transform::identity() } + } + + /// Move the enclosing transform inside a form and record its three pushes as one group. + fn group(&mut self, push: impl FnOnce(&mut krilla::surface::Surface)) { + let t = self.group_transform(); + self.inner.push_transform(&t.invert().unwrap_or_default()); + push(&mut self.inner); + self.inner.push_transform(&t); + self.pushes.push(Push { count: 3, root: self.root }); + self.root = Affine::IDENTITY; + } + + /// Apply opacity in page space when it creates a form; full opacity is a plain push. + pub fn push_opacity(&mut self, opacity: NormalizedF32) { + if opacity == NormalizedF32::ONE { + // krilla does not create a form for full opacity. + self.inner.push_opacity(opacity); + self.plain_push(); + } else { + self.group(|s| s.push_opacity(opacity)); + } + } + + pub fn push_isolated(&mut self) { + self.group(|s| s.push_isolated()); + } + + /// The mask's stream is already in [`Self::group_transform`]'s space, like the group's art. + pub fn push_mask(&mut self, mask: Mask) { + self.group(|s| s.push_mask(mask)); + } + + fn plain_push(&mut self) { + self.pushes.push(Push { count: 1, root: self.root }); + } + + pub fn push_transform(&mut self, transform: &Transform) { + self.inner.push_transform(transform); + self.plain_push(); + } + + pub fn push_clip_path(&mut self, path: &Path, rule: &FillRule) { + self.inner.push_clip_path(path, rule); + self.plain_push(); + } + + pub fn push_blend_mode(&mut self, mode: BlendMode) { + self.inner.push_blend_mode(mode); + self.plain_push(); + } + + /// Restore the enclosing coordinate space, undoing every writer push for the last group. + /// An unmatched pop is logged rather than forwarded to the writer's stack. + pub fn pop(&mut self) { + let Some(push) = self.pushes.pop() else { + log::error!("PDF surface pop without a matching push"); + return; + }; + for _ in 0..push.count { + self.inner.pop(); + } + self.root = push.root; + } + + pub fn finish(self) { + self.inner.finish(); + } +} + +// Drawing operations use krilla directly; every push/pop above keeps our coordinate state in +// step. Stream builders are wrapped with `Surface::stream` when their surfaces are created. +impl<'a> Deref for Surface<'a> { + type Target = krilla::surface::Surface<'a>; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for Surface<'_> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} diff --git a/crates/pdf/tests/gradient_forms.rs b/crates/pdf/tests/gradient_forms.rs new file mode 100644 index 000000000..9f06c9b84 --- /dev/null +++ b/crates/pdf/tests/gradient_forms.rs @@ -0,0 +1,215 @@ +//! Gradients in forms must follow the art, including on pages with a nonzero origin. Quartz +//! exposed a failure the import/render round trip did not: it resolved a form's shading pattern +//! in page space, leaving off-centre gradients painted entirely in their last stop's colour. +//! These fixtures are original, generated geometry; no user artwork or external files are used. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::Arc; + +use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; +use vectorcraft_doc::{Appearance, ColorMode, Document, Knockout, Node, OpacityMask}; +use vectorcraft_geom::{Affine, Point, Rect, shapes}; +use vectorcraft_pdf::{PdfOptions, export, import}; +use vectorcraft_testkit::raster::{assert_similar, render_artboard}; + +#[derive(Clone, Copy, Debug)] +enum Form { + Page, + Opacity, + Isolated, + Nested, + PassThrough, + Mask, + Knockout, + Layer, + Overprint, +} + +const FORMS: [Form; 9] = + [Form::Page, Form::Opacity, Form::Isolated, Form::Nested, Form::PassThrough, Form::Mask, Form::Knockout, Form::Layer, Form::Overprint]; + +fn rect(d: &mut Document, bounds: Rect, paint: Paint) -> Node { + Node::path(d.alloc_id(), shapes::rectangle(bounds), Appearance::basic(paint, Paint::None, 0.0)) +} + +fn group(d: &mut Document, n: Node) -> Node { + Node::group(d.alloc_id(), vec![Arc::new(n)]) +} + +/// Generate off-centre gradient art in each form type, moving the art and artboard together. +fn fixture(form: Form, kind: GradientKind, mode: ColorMode, origin: Point) -> (Document, PdfOptions) { + let mut d = Document::new_with_mode(120.0, 180.0, mode); + let color = |k| if mode == ColorMode::Cmyk { Color::cmyk(0.0, 0.0, 0.0, k) } else { Color::gray(k) }; + let mut gradient = GradientPaint::new(Gradient { + kind, + stops: vec![GradientStop::new(0.0, color(1.0)), GradientStop::new(0.6, color(0.4)), GradientStop::new(1.0, color(0.0))], + ..Default::default() + }); + let (start, end) = match kind { + GradientKind::Radial => (Point::new(32.0, 35.0), Point::new(60.0, 35.0)), + _ => (Point::new(10.0, 10.0), Point::new(70.0, 70.0)), + }; + gradient.geom = Some(GradientGeom { start, end, aspect: 0.8, focal: None }); + let mut n = rect(&mut d, Rect::new(10.0, 10.0, 70.0, 70.0), Paint::Gradient(Box::new(gradient))); + let mut opts = PdfOptions::default(); + match form { + Form::Page => d.page_isolate = true, + Form::Opacity => n.opacity = 0.6, + Form::Isolated => { + n = group(&mut d, n); + n.isolate = true; + } + Form::Nested => { + n.opacity = 0.75; + n = group(&mut d, n); + n.opacity = 0.8; + } + Form::PassThrough => { + n.blend = BlendMode::Multiply; + n = group(&mut d, n); + n.opacity = 0.6; + } + Form::Mask => { + let mask = rect(&mut d, Rect::new(10.0, 10.0, 44.0, 70.0), Paint::solid(Color::WHITE)); + n.extras_mut().mask = Some(Box::new(OpacityMask::new(mask, true))); + } + Form::Knockout => { + let top = rect(&mut d, Rect::new(50.0, 50.0, 80.0, 80.0), Paint::solid(Color::WHITE)); + n = Node::group(d.alloc_id(), vec![Arc::new(n), Arc::new(top)]); + n.knockout = Knockout::On; + } + Form::Layer => opts.settings.create_layers = true, + Form::Overprint => n.appearance.fill_mut().unwrap().overprint = true, + } + n.transform(Affine::translate(origin.to_vec2()), false); + d.artboards[0].rect = d.artboards[0].rect + origin.to_vec2(); + let layer = d.default_layer().unwrap(); + d.insert(Some(layer), 0, n).unwrap(); + (d, opts) +} + +/// Round trips preserve gradient placement for the form types the importer supports. +#[test] +fn gradients_in_forms_keep_their_position_when_read_back() { + // The importer has a pre-existing mismatch on the knockout's synthesized nested masks; + // Quartz below checks its actual PDF output, along with the other forms. + for form in FORMS.into_iter().filter(|f| !matches!(f, Form::Knockout)) { + for kind in [GradientKind::Linear, GradientKind::Radial] { + for origin in [Point::ORIGIN, Point::new(125.0, -80.0)] { + let (d, opts) = fixture(form, kind, ColorMode::Rgb, origin); + let back = import(&export(&d, &opts).unwrap()).unwrap(); + assert_similar(&render_artboard(&back), &render_artboard(&d), 5.0, 0.01); + } + } + } +} + +/// Layer form bounds stay page-sized after translation, without extra imported clipping groups. +#[test] +fn translated_layer_forms_cover_the_paper_in_their_new_space() { + // The layer-box repair added upstream must move with the gradient fix's form transform. + // A local-space paper box would otherwise introduce a clipping group on import. + for origin in [Point::ORIGIN, Point::new(125.0, -80.0), Point::new(-250.0, 450.0)] { + let (d, mut opts) = fixture(Form::Layer, GradientKind::Radial, ColorMode::Rgb, origin); + opts.settings.compression.compress_text = false; + let bytes = export(&d, &opts).unwrap(); + let text = String::from_utf8_lossy(&bytes); + let boxes: Vec> = text + .split("/Subtype/Form") + .skip(1) + .map(|form| { + form.split("/BBox[").nth(1).unwrap().split(']').next().unwrap().split_ascii_whitespace().map(|v| v.parse().unwrap()).collect() + }) + .collect(); + assert_eq!(boxes, [vec![0.0, 0.0, 120.0, 180.0]], "{origin:?}: the layer covers exactly the page"); + let back = import(&bytes).unwrap(); + let children = back.layers[0].children().unwrap(); + assert_eq!(children.len(), 1); + assert!(children[0].path_data().is_some(), "{origin:?}: no clipping group wraps the layer's art"); + } +} + +// Test the renderer that exhibited the fault, not just our own importer (which accepted the +// old files). OS bindings are confined to this integration-test module. +#[cfg(target_os = "macos")] +#[allow(unsafe_code)] +mod quartz { + use objc2_core_foundation::CFData; + use objc2_core_graphics::{CGBitmapContextCreate, CGColorSpace, CGContext, CGDataProvider, CGImageAlphaInfo, CGPDFDocument}; + use vectorcraft_testkit::raster::Image; + + use super::*; + + /// Render the first PDF page through Quartz as an oracle independent of our importer. + fn render(bytes: &[u8]) -> Image { + let data = CFData::from_bytes(bytes); + let provider = CGDataProvider::with_cf_data(Some(&data)).unwrap(); + let doc = CGPDFDocument::with_provider(Some(&provider)).unwrap(); + let page = CGPDFDocument::page(Some(&doc), 1).unwrap(); + let space = CGColorSpace::new_device_rgb().unwrap(); + let (width, height) = (120, 180); + let mut rgba = vec![255u8; width * height * 4]; + // SAFETY: the buffer holds every RGBA row, stays allocated and is not accessed until the + // context is dropped. All inputs are the small, generated fixtures above. + let context = unsafe { + CGBitmapContextCreate(rgba.as_mut_ptr().cast(), width, height, 8, width * 4, Some(&space), CGImageAlphaInfo::PremultipliedLast.0) + } + .unwrap(); + CGContext::draw_pdf_page(Some(&context), Some(&page)); + drop(context); + Image { width: width as u32, height: height as u32, rgba } + } + + /// Quartz places RGB and CMYK radial centres, edges and masks with their translated art. + #[test] + fn quartz_keeps_off_centre_radials_in_transparency_and_layer_forms() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + for form in FORMS { + for origin in [Point::ORIGIN, Point::new(125.0, -80.0)] { + let (d, opts) = fixture(form, GradientKind::Radial, mode, origin); + let img = render(&export(&d, &opts).unwrap()); + let centre = img.over_white(32, 35)[0]; + let edge = img.over_white(32, 55)[0]; + assert!(centre < 150 && edge.saturating_sub(centre) > 40, "{mode:?} {form:?} {origin:?}: centre {centre}, edge {edge}"); + assert_eq!(img.over_white(90, 100), [255; 3], "art stays in its bounds"); + if matches!(form, Form::Mask) { + assert_eq!(img.over_white(46, 35), [255; 3], "the mask moves with the gradient"); + } + } + } + } + } + + /// Quartz retains the linear ramp's direction inside forms on a translated artboard. + #[test] + fn quartz_keeps_linear_gradient_directions_in_forms() { + for form in FORMS.into_iter().filter(|f| !matches!(f, Form::Mask | Form::Knockout)) { + let (d, opts) = fixture(form, GradientKind::Linear, ColorMode::Rgb, Point::new(-30.0, 45.0)); + let img = render(&export(&d, &opts).unwrap()); + let (start, end) = (img.over_white(20, 20)[0], img.over_white(60, 60)[0]); + assert!(end.saturating_sub(start) > 60, "{form:?}: start {start}, end {end}"); + } + } + + /// A gradient's stop-opacity mask remains aligned with its colour through nested forms. + #[test] + fn quartz_keeps_gradient_stop_opacity_aligned_inside_nested_forms() { + let (mut d, opts) = fixture(Form::Nested, GradientKind::Radial, ColorMode::Rgb, Point::ORIGIN); + let mut leaves = vec![]; + d.walk(|n| { + if n.path_data().is_some() { + leaves.push(n.id); + } + }); + for id in leaves { + let fill = d.node_mut(id).unwrap().appearance.fill_mut().unwrap(); + let Paint::Gradient(g) = &mut fill.paint else { panic!("gradient fixture") }; + for stop in &mut g.gradient.stops { + stop.opacity = 0.6 * (1.0 - stop.offset); + } + } + let img = render(&export(&d, &opts).unwrap()); + let (centre, edge) = (img.over_white(32, 35)[0], img.over_white(32, 55)[0]); + assert!(centre > 145 && centre < 205 && edge > 240, "centre {centre}, edge {edge}"); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 9b86156bf..136722fab 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -308,6 +308,15 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | Affinity `.af`, `.afdesign`, `.afpub` | no | yes (beyond Illustrator) | no (#665) | 51 pinned files, [affinity-validation.md](affinity-validation.md) | | Clipboard: PDF, SVG, PNG, EMF, text | yes | yes | yes | copied files paste as art; cells copied from a spreadsheet (tab-separated text) paste as a table of point type over gridlines, at most 1000 rows, 256 columns and 20,000 cells (#5296) | +## PDF form gradients + +PDF export's linear and radial gradients stay aligned inside transparency groups, opacity masks, +PDF layers and overprinting art. The original synthetic tests in +`crates/pdf/tests/gradient_forms.rs` check the output with macOS Quartz and an import/render +round trip. The three Quartz checks fail on main `65adb7b21` and pass with the form-space fix; +translated layer bounds also have a regression for the current writer's layer-box repair. +Stop opacity on translated artboards remains outside this regression coverage. + ## PDF editable imports - [x] Place from bytes or unlinked paths, and PDF clipboard paste, keep the selected page's live text. Synthetic two-page regressions in `crates/engine/src/cmd/fileio/tests_pdffidelity.rs` cover both sources, page selection and explicit outlines. The single-page importer honors the same text choice as Open; existing PDF fallback rules are unchanged. diff --git a/docs/gaps.md b/docs/gaps.md index 467946e93..7fcf7586e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -5,6 +5,13 @@ ## Feature submission evidence **Feature evidence last updated:** 2026-11-02. + +**PDF form gradients — 2026-11-08:** original synthetic Quartz checks reproduce the placement, direction and nested +stop-opacity failures on main `65adb7b21` and pass with the rebased fix. The import/render round trip +also passes; a translated-layer regression covers the interaction with the upstream layer-box +repair (`crates/pdf/tests/gradient_forms.rs`). Stop opacity on translated artboards remains outside +this coverage. No ranked gap or estimate is closed by this narrow export fix. + | 2026-11-06 | minor | Opening a file that takes longer than 0.4 s shows a progress window: the file name, a moving bar (reading reports no steps), how many more files wait, Cancel (Esc; the file doesn't open) and Continue in Background (`file.cancelOpen`, `file.openInBackground`); it never opens over another dialog or progress window (part of #5930); scores and estimates unchanged | Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. diff --git a/docs/roadmap.md b/docs/roadmap.md index a5aaadc66..7bb2321e7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -195,6 +195,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-11-08 | minor | M7: spell check engine, `text.spelling.check` and `text.spelling.suggest` on craft-proof (#4936); the dialog, Auto Spell Check and the custom dictionary remain; scores and estimates unchanged. | | 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. | | 2026-11-09 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. | +| 2026-11-08 | minor | M4: PDF form-gradient fix follows the exporter module split and preserves page-sized layer bounds on translated artboards; Quartz and round-trip checks pass. Estimates unchanged. | | 2026-11-07 | minor | M2: with the Pen or Curvature tool, Edit › Copy and Cut take the anchors and segments a Cmd/Ctrl-lent Direct Selection picked, not the whole path (#6123); estimates unchanged | | 2026-11-07 | minor | Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged. | | 2026-11-06 | minor | M2: a shape just drawn moves by its centre point (Show Center) and turns from the rotate zone just outside a bounding-box corner with its tool still active, as the Selection tool's gestures do (#5477); estimates unchanged. | @@ -210,6 +211,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-11-03 | minor | M7: retain composition trimming in rotated character placement and rotation metadata; estimates unchanged. | | 2026-10-29 | minor | M6: Pathfinder: live compound shapes (#4522): the panel menu's Make, Release and Expand Compound Shape, Alt-clicking a Shape Mode, a selected member's Shape Mode and the Expand button act; scores unchanged | | 2026-10-26 | minor | M4/G9: EPS Save and Save As: an opened EPS saves back to its own path as EPS, keeping the chosen artboard's page frame and transparent margins and the complete editable VectorCraft document; Save's TIFF preview is capped at 1024 px (Export keeps 2048); partly read, preview-only or damaged-editing-data sources still need confirmation before they are overwritten; it does not write Illustrator's own editing data (#868), and G9's real-file import fidelity is unchanged; scores and estimates unchanged | +| 2026-10-26 | minor | M4: PDF form-gradient placement checked with Quartz on the shared writer; estimates unchanged. | | 2026-10-25 | minor | M10/G1: a selected Grid Repeat shows handles: the one below the grid adds or removes rows, the one right of it columns, the one past its bottom-right corner both, and the sliders between the first two columns and rows set the horizontal and vertical spacing; each drag is one undo step and Escape cancels it (#2691); estimates unchanged | | 2026-10-24 | minor | Docs catch-up: Current focus's three G11 lines merged into one (#864 and #885 remain; #937, #983/#1037 and #834 fixed) and its two stale G2 lines (filters left, the Effect Gallery to build) removed; G2 stays the gallery's thumbnails and in-dialog preview, 1–2 h. Estimates unchanged. | | 2026-10-24 | minor | M10/G5: brush libraries (Window › Brush Libraries, the Brushes panel's Brush Libraries Menu, four generated libraries, Save Brushes, Other Library…); estimates unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cdffff919..af5c9f2f3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -226,7 +226,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | 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 | | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set; Artboard Options' Show Center Mark and Show Cross Hairs draw on the canvas only (exports and print leave them out), regression-tested; Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (per artboard, native format) are done ([video display options](#evidence-what-ships-today-by-area)), score unchanged | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`; the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box (`tests_native::aligned_type_lies_as_far_from_its_path_as_the_file_lays_it_out`), and the fit under Gravity does not reproduce the file's layout), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); PDF form-gradient placement and direction independently checked with macOS Quartz (`crates/pdf/tests/gradient_forms.rs`); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`; the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box (`tests_native::aligned_type_lies_as_far_from_its_path_as_the_file_lays_it_out`), and the fit under Gravity does not reproduce the file's layout), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, textures, map art and shading in 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged) | @@ -1909,6 +1909,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-11-07 | minor | File formats and Type core: `.ai`/EPS type from the text document lays its lines out closer to the file's page: auto leading per line at the paragraph's share, the paragraph's hyphenation options, possibly more of the last lines the file sets in an area frame of its own, and a line a soft return ends justified (#6137); evidence added; scores unchanged. | | 2026-11-06 | minor | `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; evidence updated; scores unchanged. | | 2026-11-08 | minor | Panel numeric wheel turns do not seed canvas zoom glides; corrected the disabled-field scroll description (#5949). Scores and estimates unchanged. | +| 2026-11-08 | minor | File formats: rebased PDF form-gradient fix, current-main Quartz evidence and translated-layer bounds regression; score 75% and hours unchanged (estimated). | | 2026-11-07 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. | | 2026-11-08 | minor | Consolidated the four current Guides feature-area rows using existing global/artboard ruler mode, video ruler and status-bar evidence; remaining gaps, score and estimates unchanged. The earlier detailed missing-items table remains historical. | | 2026-11-07 | minor | Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged. | @@ -1998,6 +1999,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-29 | minor | Object › Path › Smooth… with its slider and Auto-Smooth (#4115) added to the evidence; score and hours unchanged. | | 2026-10-26 | minor | File formats: EPS Save and Save As: an opened EPS saves back to its own path as EPS, keeping the chosen artboard's page frame and transparent margins and the complete editable VectorCraft document; Save's TIFF preview is capped at 1024 px (Export keeps 2048); partly read, preview-only or damaged-editing-data sources still need confirmation before they are overwritten; evidence added ([file-format-parity.md](file-format-parity.md#eps-save-and-source-protection)); writing Illustrator's editing data (#868) and the `.ai` structure issues stay in the missing items; score and hours unchanged (estimated) | | 2026-10-26 | minor | View & navigation: New View… / Edit Views… taken off the missing items (all 25 saved views in the View menu, an Edit Views… list dialog, views checked on open); evidence added; score and hours unchanged. | +| 2026-10-26 | minor | File formats: rebased PDF form-gradient fix and independent Quartz evidence; score 75% and hours unchanged (estimated). | | 2026-10-25 | minor | Type core: a double-click on a threaded port breaks the thread there (`text.thread.break`); evidence added; scores unchanged. | | 2026-10-25 | minor | Repeat: a selected Grid Repeat shows handles: the one below the grid adds or removes rows, the one right of it columns, the one past its bottom-right corner both, and the sliders between the first two columns and rows set the horizontal and vertical spacing; each drag is one undo step and Escape cancels it (#2691); evidence added; score and hours unchanged (estimated). | | 2026-10-24 | minor | Path operations: the Shape Builder's edge-source grid holds a bounded number of cell references (coincident segments no longer quadratic in memory) and files a segment once per cell; Path operations 80%, Shape Builder 70% unchanged (estimated).; area row and evidence list updated; the budget counts an item's distinct cells, so a degenerate grid's pieces don't send it to the scanned list. | From 6063e662bc5c4e1219f76f5a65623aaef9adf22c Mon Sep 17 00:00:00 2001 From: Patrick Nadeau Date: Sun, 8 Nov 2026 18:27:00 -0500 Subject: [PATCH 04/16] M4: correct PDF gradient progress dates after review --- ROADMAP.md | 4 ++-- docs/file-format-parity.md | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c685b9206..9250e67b6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -138,9 +138,8 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap ## Progress log -- 2026-11-05: Window › Toolbars › Keep Tools Beside the Canvas, off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge, and the canvas's left edge then also docks a floating Tools panel (`window.toolsBesideCanvas`, #5395). Workspaces keep the setting; workspaces saved without it keep Tools at the window's edge. An optional VectorCraft layout choice; headline numbers unchanged. - 2026-11-08: PDF form-gradient placement reverified on current main: three Quartz regressions fail without the fix and pass with it. The fix follows the exporter module split and keeps upstream layer-box repairs in the form's new space, with a translated-layer regression. Headline numbers unchanged. -- 2026-10-26: PDF form-gradient placement checked with macOS Quartz on the shared writer: linear and radial gradients follow transparency groups, masks, PDF layers and overprinting art. Headline numbers unchanged. +- 2026-11-05: Window › Toolbars › Keep Tools Beside the Canvas, off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge, and the canvas's left edge then also docks a floating Tools panel (`window.toolsBesideCanvas`, #5395). Workspaces keep the setting; workspaces saved without it keep Tools at the window's edge. An optional VectorCraft layout choice; headline numbers unchanged. - 2026-11-05: Release Type on a Path joins the Type Tools add-on (Object › Plug-ins › Type Tools): each selected type on a path splits into point type with the same text and attributes, on one straight line starting where its first character sat, and the plain path it was set on, with its exact shape and place in the stacking order and no paint; or keeps only the path or only the text, optionally leaving the original. A dialog with Preview, one undo step (`addon.type.releasePath`, #5532). A VectorCraft addition: headline numbers unchanged. - 2026-11-04: New Document: the units chosen in Preset Details now belong to the chosen document, as the pane's options are listed for the selected preset; the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units, so switching between inches and millimetres no longer restyles every recent template. Scores and estimates unchanged. - 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged. @@ -157,6 +156,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap - 2026-10-31: Export for Screens works as the reference app's dialog: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File (the chosen artboards as one PDF's pages, `pdfs: "single"`) or Multiple Files, iOS and Android rows, format presets in a ☰ menu, and a count of the files the export writes (#4895). About 55 community PRs and fixes landed the same day: Find Font, Replace Font, Outline Stroke and Expand leave hidden and locked art alone (with a warning); DXF export writes LINE and ELLIPSE entities and outlines only the strokes a CAD line can't draw; Blob Brush Angle and Roundness; Shift-constrained Perspective Selection; Grid Repeat Options Preview; a gradient mesh from a file budgeted at about a million quads; width-profile strokes keep their shape zoomed out; `.ai`/EPS import fidelity (area type, envelopes, transparency in dictionaries, character styles); PDF import finds hyphenated and CMap-suffixed font names; Windows renders follow the process's core affinity; Enter or Escape in a floating panel's field no longer reaches the canvas (#4803); Properties shows Blend Options for any selected blend (#4817); Artwork Cleanup finds doubled type (#4835) and joins stacked lines into one object (Join Point Text, #4826). Headline numbers unchanged. - 2026-10-31: Arrange Tools, a built-in add-on (Object › Plug-ins › Arrange Tools): Arrange in Grid lays the selected objects out in rows and columns, a row or a column with gaps, from the selection's top-left or centred on the artboard; Swap Objects swaps two objects' places (and, with Swap Objects and Sizes, their sizes); Replace with Top Object puts a copy of the topmost selected object in place of each of the others, kept, stretched or fitted in proportion; Set Size of Each gives every selected object an exact width and/or height about a reference point; Zoom to Selection fits the view to the selection. Each is one undo step with a previewed dialog where it has options. A VectorCraft addition: headline numbers unchanged. - 2026-10-30: Gradient panel presets: a Presets menu applies one of the built-in gradient libraries' presets (grouped by theme) or a saved one to the gradient behind the active proxy, keeping its placement, as one undo step; the panel menu saves the current gradient as a preset, kept with the preferences, and deletes saved ones (`gradient.presets.list/apply/save/delete`). +- 2026-10-26: PDF form-gradient placement checked with macOS Quartz on the shared writer: linear and radial gradients follow transparency groups, masks, PDF layers and overprinting art. Headline numbers unchanged. - 2026-10-23: Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add a thin overprinting strip of the lighter colour inside the darker one where two solid colours meet, so misregistered plates leave no gap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html)), with Thickness, Height/Width, Tint Reduction, Traps with Process Color and Reverse Traps. Illustrator effects 49/50 (SVG Filters remains); Live vector effects hours 6–10 → 4–8; headline numbers unchanged. - 2026-10-23: OpenType SVG colour fonts draw as vector art: each glyph's SVG document is read by the SVG importer, in the text's colours where it asks for them, and draws, exports and outlines as COLR glyphs do; all colour font formats are now drawn. - 2026-10-23: COLR colour fonts (v0 and v1, as in Noto Color Emoji's vector build, Segoe UI Emoji, Nabla) draw as vector art: their layers' colours, gradients, clips and blend modes on the canvas and in SVG, EPS, legacy `.ai` and PDF, and as editable paths with Create Outlines; glyphs using sweep gradients or compositing modes are drawn as images of the glyph. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 136722fab..ae797aaee 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -398,6 +398,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | 2026-10-29 | minor | Affinity import: attached Recolor Adjustment (RcRA) layers import as color overlays with HSL lightness (#4420); scores unchanged. | | 2026-10-30 | minor | EMF export: a pattern fill whose tiles stay apart (grid and brick tilings, art inside its tile) is written as one shape for each part of the tile, clipped once to what it fills, instead of every tile in a clip of its own; the picture is the same. A rotated two-colour stripe fill goes from 473 shapes and 246 clips to 2 shapes and 1 clip (122 KB to 61 KB; unrotated 99 KB to 33 KB). Tiles that overlap, hex tilings and tiles with strokes, images or nested clips are written as before (#3911). Tests in `crates/metafile/src/tests.rs` and `scene::merge_tests`; scores and hours unchanged | | 2026-10-26 | minor | EPS Save and Save As: an opened EPS saves back to its own path as EPS, keeping the chosen artboard's page frame and transparent margins and the complete editable VectorCraft document; Save's TIFF preview is capped at 1024 px (Export keeps 2048); partly read, preview-only or damaged-editing-data sources still need confirmation before they are overwritten; Save As leaves out the export-only crop and selection options. Synthetic regressions in `tests::save` (engine and UI) and `dialogs::save_options::tests`, listed under [EPS Save and source protection](#eps-save-and-source-protection); it does not write Illustrator's own editing data (#868), and G9's real-file import fidelity is unchanged; scores and hours unchanged | +| 2026-10-26 | minor | PDF form-gradient placement checked with Quartz on the shared writer; scores and estimates unchanged. | | 2026-10-25 | minor | `.ai` import: the editing data is read up to 512 MiB on 64-bit builds (the limit was 256 MiB), so a file whose embedded rasters unpack to 300 MiB or more keeps its layers, groups and text instead of opening as its PDF part (part of #3510); 32-bit and web builds keep 256 MiB. Own-fixture test `import::ai::tests::editing_data_past_256_mib_is_read`; scores and hours unchanged | | 2026-10-25 | minor | `.ai` import: the `--` placeholder of a radial gradient's highlight (`-- -- angle length Bh`, a gradient without one) and the `XC` operator after a linked image's file name no longer send the whole file to its PDF part, so files with them keep their layers, groups and live text (#4176). A `--` anywhere else is still an operator the reader doesn't know. Own-fixture tests in `import::ai::tests`; scores and hours unchanged | | 2026-10-24 | minor | EPS, EMF/WMF and legacy `.ai`: type with ruby (ふりがな) is written with the reading's glyphs where the canvas draws them (they were dropped); legacy `.ai` writes such type as outlines, since a text object can't hold ruby. Tests `eps::tests::ruby_is_written_with_its_type`, `tests_legacy_ai::type_with_ruby_is_outlines_with_the_reading`, `metafile::tests::ruby_is_written_with_its_type`; scores and hours unchanged | From ad7cf2898c4b051b9bf884b5ee86f37304e8160d Mon Sep 17 00:00:00 2001 From: Patrick Nadeau Date: Sun, 8 Nov 2026 19:13:06 -0500 Subject: [PATCH 05/16] M4: keep gradient evidence with October history --- docs/gaps.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/gaps.md b/docs/gaps.md index 7fcf7586e..d2b40b253 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -878,6 +878,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | 2026-10-29 | minor | G8: a blend makes at most 100,000 steps in all, the steps of blends nested in its keys and steps included; past it each pair of keys keeps the same share of its steps (#3579). No entry closed, estimates unchanged. | | 2026-10-26 | minor | G9: EPS save-back: an opened EPS saves back to its own path as EPS, keeping the chosen artboard's page frame and transparent margins and the complete editable VectorCraft document; Save's TIFF preview is capped at 1024 px (Export keeps 2048); partly read, preview-only or damaged-editing-data sources still need confirmation before they are overwritten. This narrows G9's save-back workflow only; its import-fidelity items (layer and group structure, guides, open speed, live objects) and estimate are unchanged, and writing Illustrator's own editing data stays out of scope (#868) | | 2026-10-26 | minor | G7: View › New View… / Edit Views… ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html)): the View menu lists every one of a document's 25 saved views (it listed 10), Edit Views… lists them to rename or delete in one undo step (`view.saved.edit {edits}`), and a file's views are checked when it opens; New View/Edit Views leave G7's missing list; estimate unchanged | +| 2026-10-26 | minor | PDF form-gradient placement checked with Quartz on the shared writer; broader gaps unchanged. | | 2026-10-25 | minor | G4: a double-click on a threaded port with the Selection tool breaks the thread there (`text.thread.break {id, port}`): the story flows into the frames before the break, the frames after it are left empty and stay threaded with each other when two or more; a double-click on a port with no thread does nothing; estimate unchanged | | 2026-10-31 | minor | G1: Grid Repeat Options has a Preview checkbox (#2651); estimate unchanged. | | 2026-10-25 | minor | G1: a selected Grid Repeat shows handles: the one below the grid adds or removes rows, the one right of it columns, the one past its bottom-right corner both, and the sliders between the first two columns and rows set the horizontal and vertical spacing; each drag is one undo step and Escape cancels it (#2691); bounding-box handles keep priority where they meet; Radial Repeat handles remain; estimates unchanged. | From 3befda6e12c86ccd72693665753b55523f45b310 Mon Sep 17 00:00:00 2001 From: Patrick Nadeau Date: Mon, 9 Nov 2026 08:35:33 -0500 Subject: [PATCH 06/16] M4: limit gradient documentation to PDF behavior --- ROADMAP.md | 4 ---- docs/file-format-parity.md | 1 - docs/gaps.md | 8 -------- docs/roadmap.md | 2 -- docs/target-app-parity.md | 4 +--- 5 files changed, 1 insertion(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9250e67b6..1bedf0b95 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,7 +1,5 @@ # VectorCraft Roadmap -> **Gradient-form update:** 2026-11-08 · minor · PDF export fix rebased onto current main and checked with macOS Quartz; headline scores and estimates unchanged. - - Contour add-on (beyond Illustrator): drag a selected shape's edge outward or inward for a live, editable offset copy, with a floating bar for distance and Sharp, Round or Square corners; Bake turns it into paths. Scores and estimates unchanged. - Compound Shapes add-on (beyond Illustrator): a menu for Make, Release and Expand Compound Shape and a setting, off by default, that makes the Pathfinder panel's Shape Mode buttons create compound shapes on a click. Scores and estimates unchanged. @@ -138,7 +136,6 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap ## Progress log -- 2026-11-08: PDF form-gradient placement reverified on current main: three Quartz regressions fail without the fix and pass with it. The fix follows the exporter module split and keeps upstream layer-box repairs in the form's new space, with a translated-layer regression. Headline numbers unchanged. - 2026-11-05: Window › Toolbars › Keep Tools Beside the Canvas, off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge, and the canvas's left edge then also docks a floating Tools panel (`window.toolsBesideCanvas`, #5395). Workspaces keep the setting; workspaces saved without it keep Tools at the window's edge. An optional VectorCraft layout choice; headline numbers unchanged. - 2026-11-05: Release Type on a Path joins the Type Tools add-on (Object › Plug-ins › Type Tools): each selected type on a path splits into point type with the same text and attributes, on one straight line starting where its first character sat, and the plain path it was set on, with its exact shape and place in the stacking order and no paint; or keeps only the path or only the text, optionally leaving the original. A dialog with Preview, one undo step (`addon.type.releasePath`, #5532). A VectorCraft addition: headline numbers unchanged. - 2026-11-04: New Document: the units chosen in Preset Details now belong to the chosen document, as the pane's options are listed for the selected preset; the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units, so switching between inches and millimetres no longer restyles every recent template. Scores and estimates unchanged. @@ -156,7 +153,6 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap - 2026-10-31: Export for Screens works as the reference app's dialog: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File (the chosen artboards as one PDF's pages, `pdfs: "single"`) or Multiple Files, iOS and Android rows, format presets in a ☰ menu, and a count of the files the export writes (#4895). About 55 community PRs and fixes landed the same day: Find Font, Replace Font, Outline Stroke and Expand leave hidden and locked art alone (with a warning); DXF export writes LINE and ELLIPSE entities and outlines only the strokes a CAD line can't draw; Blob Brush Angle and Roundness; Shift-constrained Perspective Selection; Grid Repeat Options Preview; a gradient mesh from a file budgeted at about a million quads; width-profile strokes keep their shape zoomed out; `.ai`/EPS import fidelity (area type, envelopes, transparency in dictionaries, character styles); PDF import finds hyphenated and CMap-suffixed font names; Windows renders follow the process's core affinity; Enter or Escape in a floating panel's field no longer reaches the canvas (#4803); Properties shows Blend Options for any selected blend (#4817); Artwork Cleanup finds doubled type (#4835) and joins stacked lines into one object (Join Point Text, #4826). Headline numbers unchanged. - 2026-10-31: Arrange Tools, a built-in add-on (Object › Plug-ins › Arrange Tools): Arrange in Grid lays the selected objects out in rows and columns, a row or a column with gaps, from the selection's top-left or centred on the artboard; Swap Objects swaps two objects' places (and, with Swap Objects and Sizes, their sizes); Replace with Top Object puts a copy of the topmost selected object in place of each of the others, kept, stretched or fitted in proportion; Set Size of Each gives every selected object an exact width and/or height about a reference point; Zoom to Selection fits the view to the selection. Each is one undo step with a previewed dialog where it has options. A VectorCraft addition: headline numbers unchanged. - 2026-10-30: Gradient panel presets: a Presets menu applies one of the built-in gradient libraries' presets (grouped by theme) or a saved one to the gradient behind the active proxy, keeping its placement, as one undo step; the panel menu saves the current gradient as a preset, kept with the preferences, and deletes saved ones (`gradient.presets.list/apply/save/delete`). -- 2026-10-26: PDF form-gradient placement checked with macOS Quartz on the shared writer: linear and radial gradients follow transparency groups, masks, PDF layers and overprinting art. Headline numbers unchanged. - 2026-10-23: Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add a thin overprinting strip of the lighter colour inside the darker one where two solid colours meet, so misregistered plates leave no gap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html)), with Thickness, Height/Width, Tint Reduction, Traps with Process Color and Reverse Traps. Illustrator effects 49/50 (SVG Filters remains); Live vector effects hours 6–10 → 4–8; headline numbers unchanged. - 2026-10-23: OpenType SVG colour fonts draw as vector art: each glyph's SVG document is read by the SVG importer, in the text's colours where it asks for them, and draws, exports and outlines as COLR glyphs do; all colour font formats are now drawn. - 2026-10-23: COLR colour fonts (v0 and v1, as in Noto Color Emoji's vector build, Segoe UI Emoji, Nabla) draw as vector art: their layers' colours, gradients, clips and blend modes on the canvas and in SVG, EPS, legacy `.ai` and PDF, and as editable paths with Create Outlines; glyphs using sweep gradients or compositing modes are drawn as images of the glyph. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ae797aaee..136722fab 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -398,7 +398,6 @@ Stop opacity on translated artboards remains outside this regression coverage. | 2026-10-29 | minor | Affinity import: attached Recolor Adjustment (RcRA) layers import as color overlays with HSL lightness (#4420); scores unchanged. | | 2026-10-30 | minor | EMF export: a pattern fill whose tiles stay apart (grid and brick tilings, art inside its tile) is written as one shape for each part of the tile, clipped once to what it fills, instead of every tile in a clip of its own; the picture is the same. A rotated two-colour stripe fill goes from 473 shapes and 246 clips to 2 shapes and 1 clip (122 KB to 61 KB; unrotated 99 KB to 33 KB). Tiles that overlap, hex tilings and tiles with strokes, images or nested clips are written as before (#3911). Tests in `crates/metafile/src/tests.rs` and `scene::merge_tests`; scores and hours unchanged | | 2026-10-26 | minor | EPS Save and Save As: an opened EPS saves back to its own path as EPS, keeping the chosen artboard's page frame and transparent margins and the complete editable VectorCraft document; Save's TIFF preview is capped at 1024 px (Export keeps 2048); partly read, preview-only or damaged-editing-data sources still need confirmation before they are overwritten; Save As leaves out the export-only crop and selection options. Synthetic regressions in `tests::save` (engine and UI) and `dialogs::save_options::tests`, listed under [EPS Save and source protection](#eps-save-and-source-protection); it does not write Illustrator's own editing data (#868), and G9's real-file import fidelity is unchanged; scores and hours unchanged | -| 2026-10-26 | minor | PDF form-gradient placement checked with Quartz on the shared writer; scores and estimates unchanged. | | 2026-10-25 | minor | `.ai` import: the editing data is read up to 512 MiB on 64-bit builds (the limit was 256 MiB), so a file whose embedded rasters unpack to 300 MiB or more keeps its layers, groups and text instead of opening as its PDF part (part of #3510); 32-bit and web builds keep 256 MiB. Own-fixture test `import::ai::tests::editing_data_past_256_mib_is_read`; scores and hours unchanged | | 2026-10-25 | minor | `.ai` import: the `--` placeholder of a radial gradient's highlight (`-- -- angle length Bh`, a gradient without one) and the `XC` operator after a linked image's file name no longer send the whole file to its PDF part, so files with them keep their layers, groups and live text (#4176). A `--` anywhere else is still an operator the reader doesn't know. Own-fixture tests in `import::ai::tests`; scores and hours unchanged | | 2026-10-24 | minor | EPS, EMF/WMF and legacy `.ai`: type with ruby (ふりがな) is written with the reading's glyphs where the canvas draws them (they were dropped); legacy `.ai` writes such type as outlines, since a text object can't hold ruby. Tests `eps::tests::ruby_is_written_with_its_type`, `tests_legacy_ai::type_with_ruby_is_outlines_with_the_reading`, `metafile::tests::ruby_is_written_with_its_type`; scores and hours unchanged | diff --git a/docs/gaps.md b/docs/gaps.md index d2b40b253..467946e93 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -5,13 +5,6 @@ ## Feature submission evidence **Feature evidence last updated:** 2026-11-02. - -**PDF form gradients — 2026-11-08:** original synthetic Quartz checks reproduce the placement, direction and nested -stop-opacity failures on main `65adb7b21` and pass with the rebased fix. The import/render round trip -also passes; a translated-layer regression covers the interaction with the upstream layer-box -repair (`crates/pdf/tests/gradient_forms.rs`). Stop opacity on translated artboards remains outside -this coverage. No ranked gap or estimate is closed by this narrow export fix. - | 2026-11-06 | minor | Opening a file that takes longer than 0.4 s shows a progress window: the file name, a moving bar (reading reports no steps), how many more files wait, Cancel (Esc; the file doesn't open) and Continue in Background (`file.cancelOpen`, `file.openInBackground`); it never opens over another dialog or progress window (part of #5930); scores and estimates unchanged | Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. @@ -878,7 +871,6 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | 2026-10-29 | minor | G8: a blend makes at most 100,000 steps in all, the steps of blends nested in its keys and steps included; past it each pair of keys keeps the same share of its steps (#3579). No entry closed, estimates unchanged. | | 2026-10-26 | minor | G9: EPS save-back: an opened EPS saves back to its own path as EPS, keeping the chosen artboard's page frame and transparent margins and the complete editable VectorCraft document; Save's TIFF preview is capped at 1024 px (Export keeps 2048); partly read, preview-only or damaged-editing-data sources still need confirmation before they are overwritten. This narrows G9's save-back workflow only; its import-fidelity items (layer and group structure, guides, open speed, live objects) and estimate are unchanged, and writing Illustrator's own editing data stays out of scope (#868) | | 2026-10-26 | minor | G7: View › New View… / Edit Views… ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html)): the View menu lists every one of a document's 25 saved views (it listed 10), Edit Views… lists them to rename or delete in one undo step (`view.saved.edit {edits}`), and a file's views are checked when it opens; New View/Edit Views leave G7's missing list; estimate unchanged | -| 2026-10-26 | minor | PDF form-gradient placement checked with Quartz on the shared writer; broader gaps unchanged. | | 2026-10-25 | minor | G4: a double-click on a threaded port with the Selection tool breaks the thread there (`text.thread.break {id, port}`): the story flows into the frames before the break, the frames after it are left empty and stay threaded with each other when two or more; a double-click on a port with no thread does nothing; estimate unchanged | | 2026-10-31 | minor | G1: Grid Repeat Options has a Preview checkbox (#2651); estimate unchanged. | | 2026-10-25 | minor | G1: a selected Grid Repeat shows handles: the one below the grid adds or removes rows, the one right of it columns, the one past its bottom-right corner both, and the sliders between the first two columns and rows set the horizontal and vertical spacing; each drag is one undo step and Escape cancels it (#2691); bounding-box handles keep priority where they meet; Radial Repeat handles remain; estimates unchanged. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 7bb2321e7..a5aaadc66 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -195,7 +195,6 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-11-08 | minor | M7: spell check engine, `text.spelling.check` and `text.spelling.suggest` on craft-proof (#4936); the dialog, Auto Spell Check and the custom dictionary remain; scores and estimates unchanged. | | 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. | | 2026-11-09 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. | -| 2026-11-08 | minor | M4: PDF form-gradient fix follows the exporter module split and preserves page-sized layer bounds on translated artboards; Quartz and round-trip checks pass. Estimates unchanged. | | 2026-11-07 | minor | M2: with the Pen or Curvature tool, Edit › Copy and Cut take the anchors and segments a Cmd/Ctrl-lent Direct Selection picked, not the whole path (#6123); estimates unchanged | | 2026-11-07 | minor | Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged. | | 2026-11-06 | minor | M2: a shape just drawn moves by its centre point (Show Center) and turns from the rotate zone just outside a bounding-box corner with its tool still active, as the Selection tool's gestures do (#5477); estimates unchanged. | @@ -211,7 +210,6 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-11-03 | minor | M7: retain composition trimming in rotated character placement and rotation metadata; estimates unchanged. | | 2026-10-29 | minor | M6: Pathfinder: live compound shapes (#4522): the panel menu's Make, Release and Expand Compound Shape, Alt-clicking a Shape Mode, a selected member's Shape Mode and the Expand button act; scores unchanged | | 2026-10-26 | minor | M4/G9: EPS Save and Save As: an opened EPS saves back to its own path as EPS, keeping the chosen artboard's page frame and transparent margins and the complete editable VectorCraft document; Save's TIFF preview is capped at 1024 px (Export keeps 2048); partly read, preview-only or damaged-editing-data sources still need confirmation before they are overwritten; it does not write Illustrator's own editing data (#868), and G9's real-file import fidelity is unchanged; scores and estimates unchanged | -| 2026-10-26 | minor | M4: PDF form-gradient placement checked with Quartz on the shared writer; estimates unchanged. | | 2026-10-25 | minor | M10/G1: a selected Grid Repeat shows handles: the one below the grid adds or removes rows, the one right of it columns, the one past its bottom-right corner both, and the sliders between the first two columns and rows set the horizontal and vertical spacing; each drag is one undo step and Escape cancels it (#2691); estimates unchanged | | 2026-10-24 | minor | Docs catch-up: Current focus's three G11 lines merged into one (#864 and #885 remain; #937, #983/#1037 and #834 fixed) and its two stale G2 lines (filters left, the Effect Gallery to build) removed; G2 stays the gallery's thumbnails and in-dialog preview, 1–2 h. Estimates unchanged. | | 2026-10-24 | minor | M10/G5: brush libraries (Window › Brush Libraries, the Brushes panel's Brush Libraries Menu, four generated libraries, Save Brushes, Other Library…); estimates unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index af5c9f2f3..cdffff919 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -226,7 +226,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | 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 | | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set; Artboard Options' Show Center Mark and Show Cross Hairs draw on the canvas only (exports and print leave them out), regression-tested; Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (per artboard, native format) are done ([video display options](#evidence-what-ships-today-by-area)), score unchanged | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); PDF form-gradient placement and direction independently checked with macOS Quartz (`crates/pdf/tests/gradient_forms.rs`); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`; the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box (`tests_native::aligned_type_lies_as_far_from_its_path_as_the_file_lays_it_out`), and the fit under Gravity does not reproduce the file's layout), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`; the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box (`tests_native::aligned_type_lies_as_far_from_its_path_as_the_file_lays_it_out`), and the fit under Gravity does not reproduce the file's layout), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, textures, map art and shading in 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged) | @@ -1909,7 +1909,6 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-11-07 | minor | File formats and Type core: `.ai`/EPS type from the text document lays its lines out closer to the file's page: auto leading per line at the paragraph's share, the paragraph's hyphenation options, possibly more of the last lines the file sets in an area frame of its own, and a line a soft return ends justified (#6137); evidence added; scores unchanged. | | 2026-11-06 | minor | `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; evidence updated; scores unchanged. | | 2026-11-08 | minor | Panel numeric wheel turns do not seed canvas zoom glides; corrected the disabled-field scroll description (#5949). Scores and estimates unchanged. | -| 2026-11-08 | minor | File formats: rebased PDF form-gradient fix, current-main Quartz evidence and translated-layer bounds regression; score 75% and hours unchanged (estimated). | | 2026-11-07 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. | | 2026-11-08 | minor | Consolidated the four current Guides feature-area rows using existing global/artboard ruler mode, video ruler and status-bar evidence; remaining gaps, score and estimates unchanged. The earlier detailed missing-items table remains historical. | | 2026-11-07 | minor | Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged. | @@ -1999,7 +1998,6 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-29 | minor | Object › Path › Smooth… with its slider and Auto-Smooth (#4115) added to the evidence; score and hours unchanged. | | 2026-10-26 | minor | File formats: EPS Save and Save As: an opened EPS saves back to its own path as EPS, keeping the chosen artboard's page frame and transparent margins and the complete editable VectorCraft document; Save's TIFF preview is capped at 1024 px (Export keeps 2048); partly read, preview-only or damaged-editing-data sources still need confirmation before they are overwritten; evidence added ([file-format-parity.md](file-format-parity.md#eps-save-and-source-protection)); writing Illustrator's editing data (#868) and the `.ai` structure issues stay in the missing items; score and hours unchanged (estimated) | | 2026-10-26 | minor | View & navigation: New View… / Edit Views… taken off the missing items (all 25 saved views in the View menu, an Edit Views… list dialog, views checked on open); evidence added; score and hours unchanged. | -| 2026-10-26 | minor | File formats: rebased PDF form-gradient fix and independent Quartz evidence; score 75% and hours unchanged (estimated). | | 2026-10-25 | minor | Type core: a double-click on a threaded port breaks the thread there (`text.thread.break`); evidence added; scores unchanged. | | 2026-10-25 | minor | Repeat: a selected Grid Repeat shows handles: the one below the grid adds or removes rows, the one right of it columns, the one past its bottom-right corner both, and the sliders between the first two columns and rows set the horizontal and vertical spacing; each drag is one undo step and Escape cancels it (#2691); evidence added; score and hours unchanged (estimated). | | 2026-10-24 | minor | Path operations: the Shape Builder's edge-source grid holds a bounded number of cell references (coincident segments no longer quadratic in memory) and files a segment once per cell; Path operations 80%, Shape Builder 70% unchanged (estimated).; area row and evidence list updated; the budget counts an item's distinct cells, so a degenerate grid's pieces don't send it to the scanned list. | From 99a6e4032319d5c59a686c94319ff914f0f974dc Mon Sep 17 00:00:00 2001 From: Christian Lappin <36496308+christianlappin@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:50:22 -0500 Subject: [PATCH 07/16] test(layers): synchronize thumbnail queue assertion (#6318) --- crates/ui-egui/src/panels/layers/thumbs.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/ui-egui/src/panels/layers/thumbs.rs b/crates/ui-egui/src/panels/layers/thumbs.rs index 4fcdc03e4..2a6d010b9 100644 --- a/crates/ui-egui/src/panels/layers/thumbs.rs +++ b/crates/ui-egui/src/panels/layers/thumbs.rs @@ -957,10 +957,15 @@ mod tests { assert!(thumbs.texture(&ctx, &src_b, n, 24).is_some(), "the shown document's thumbnails are shown"); } // Shown again, document 1's rows are asked for again and come back. + // Hold the worker so texture() cannot collect a completed row before the count. + let release = hold(&thumbs); for n in &a_rows { assert!(thumbs.texture(&ctx, &src_a, n, 24).is_none()); } assert_eq!(thumbs.pending.keys().filter(|k| k.0 == 1).count(), 3); + release.send(()).expect("the held job is waiting"); + let held = thumbs.worker.as_ref().and_then(ThumbWorker::wait).expect("the held job finishes"); + assert_eq!(held.key.0, (0, NodeId(0))); for _ in &a_rows { receive_checked(&ctx, &mut thumbs, 1, &a, &a_rows); } From 78a72a523bda31467e20bede28da81341798740a Mon Sep 17 00:00:00 2001 From: Christian Lappin <36496308+christianlappin@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:04:03 -0500 Subject: [PATCH 08/16] test(text): preserve duplicate glyph occurrence order (#6323) --- crates/text/src/tests/harakat.rs | 44 +++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/crates/text/src/tests/harakat.rs b/crates/text/src/tests/harakat.rs index c097fc48b..e590f103c 100644 --- a/crates/text/src/tests/harakat.rs +++ b/crates/text/src/tests/harakat.rs @@ -512,6 +512,43 @@ fn assert_authored_ownership(t: &TextObject, text: &str, l: &TextLayout, what: & } } +/// Repeated glyphs in one cluster can belong to differently painted runs. Match each authored +/// occurrence in order, without needing an installed font, and still reject a changed owner. +#[test] +fn duplicate_authored_glyphs_keep_occurrence_order() { + let mut text = TextObject::point(Point::ZERO, "\u{0651}", style(FAMILY)); + text.runs.push(vectorcraft_doc::TextRun::new("\u{0651}", CharStyle { fill: red(), ..style(FAMILY) })); + let glyph = |run| PositionedGlyph { + outline: Default::default(), + run, + byte: 0, + origin: Point::ZERO, + advance: 0.0, + len: 4, + angle: 0.0, + line: 0, + font_id: 0, + gid: 7, + xf: Affine::IDENTITY, + rtl: true, + scale: 1.0, + image: None, + colour: None, + bend: None, + unturned: Affine::IDENTITY, + pivot: Point::ZERO, + carets: Vec::new(), + width: 0.0, + width_start: 0.0, + }; + let before = TextLayout { glyphs: vec![glyph(0), glyph(1)], ..TextLayout::default() }; + assert_eq!(assert_kashida_ownership(&text, &before, &before, &red(), "duplicate glyphs", false), 0); + + let mut wrong = before.clone(); + wrong.glyphs[0].run = 1; + assert!(std::panic::catch_unwind(|| assert_kashida_ownership(&text, &before, &wrong, &red(), "changed owner", false)).is_err()); +} + /// Compare authored glyph run indices before/after Insert Kashidas (matched by byte and gid; /// layout order can differ between point and justified area), and assert every inserted tatweel /// uses the base letter's paint at the cluster start—not `mark_fill`. When `require_mark_last`, @@ -525,16 +562,17 @@ fn assert_kashida_ownership( what: &str, require_mark_last: bool, ) -> usize { - let mut before_runs: std::collections::HashMap<(usize, u32), Vec> = std::collections::HashMap::new(); + let mut before_runs: std::collections::HashMap<(usize, u32), std::collections::VecDeque> = std::collections::HashMap::new(); for g in before.glyphs.iter().filter(|g| g.len > 0) { - before_runs.entry((g.byte, g.gid)).or_default().push(g.run); + before_runs.entry((g.byte, g.gid)).or_default().push_back(g.run); } let mut after_n = 0; for g in with_kashida.glyphs.iter().filter(|g| g.len > 0) { after_n += 1; let runs = before_runs.get_mut(&(g.byte, g.gid)).unwrap_or_else(|| panic!("{what}: authored glyph {} at {} missing before kashida", g.gid, g.byte)); - let want_run = runs.pop().unwrap_or_else(|| panic!("{what}: no remaining before-run for {} at {}", g.gid, g.byte)); + // Duplicate glyphs at the same cluster byte retain their occurrence order. + let want_run = runs.pop_front().unwrap_or_else(|| panic!("{what}: no remaining before-run for {} at {}", g.gid, g.byte)); assert_eq!(g.run, want_run, "{what}: authored run at {} gid {}", g.byte, g.gid); let want_fill = coloured.runs.get(want_run).map(|r| &r.style.fill).unwrap_or_else(|| panic!("{what}: missing run {want_run}")); assert_eq!(&run_of(coloured, g).style.fill, want_fill, "{what}: authored fill at {}", g.byte); From 1c8503f3e211e40bad47f15b01a1f913dc5d2ff6 Mon Sep 17 00:00:00 2001 From: Christian Lappin <36496308+christianlappin@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:50:36 -0500 Subject: [PATCH 09/16] fix(mcp): route Rotate View gestures to the canvas (#6322) --- crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/canvas/input.rs | 8 +-- crates/ui-egui/src/canvas/view.rs | 10 ++++ crates/ui-egui/src/control.rs | 65 ++++++++++++++++++++++- crates/ui-egui/src/tests/rotateview.rs | 73 ++++++++++++++++++++++++++ docs/mcp.md | 14 ++++- 6 files changed, 162 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8ab3cc068..c769f32b1 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -77,6 +77,7 @@ use tool_overlays::{ print_tiling_overlay, rotate_view_dial, shaper_widget_overlay, slice_overlay, thread_overlays, }; pub(crate) use view::animated_zoom; +pub(crate) use view::drag_rotation; pub use view::fit; use view::scroll_bars; #[cfg(test)] diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 5fbc2b903..43e5df0bc 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -337,14 +337,8 @@ fn drag_held(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2) } } Drag::RotateView { start_angle, start_rot, snaps } => { - let c = rect.center(); - let a = (p.y - c.y).atan2(p.x - c.x) as f64; - let mut deg = start_rot + (a - start_angle).to_degrees(); - if snaps && m.shift { - deg = (deg / 15.0).round() * 15.0; - } if let Some(vm) = app.view_mut() { - vm.rotation = vectorcraft_geom::normalize_deg(deg); + vm.rotation = super::view::drag_rotation(rect.center(), p, start_angle, start_rot, snaps && m.shift); } } Drag::ZoomBox { start } => { diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs index 39bc80545..f4a470a4b 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -1,6 +1,16 @@ use super::drag::{drag_id, drop_press}; use super::{CornerRadius, Drag, Point, Pos2, Rect, SCROLL_BAR, Sense, Stroke, Tokens, Ui, VectorcraftApp, View, Xf, pos2, vec2}; +/// The mouse and control-channel Rotate View gestures use the same angle and Shift snapping. +pub(crate) fn drag_rotation(center: Pos2, point: Pos2, start_angle: f64, start_rotation: f64, snap: bool) -> f64 { + let angle = (point.y - center.y).atan2(point.x - center.x) as f64; + let mut degrees = start_rotation + (angle - start_angle).to_degrees(); + if snap { + degrees = (degrees / 15.0).round() * 15.0; + } + vectorcraft_geom::normalize_deg(degrees) +} + /// Fit the view (View → Fit Artboard / Fit All / Actual Size, Arrange Tools › Zoom to Selection). pub fn fit(app: &mut VectorcraftApp, how: &str) { let Some(rect) = app.canvas_rect else { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 6af51d95a..bb2918d13 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -608,6 +608,63 @@ pub(crate) fn save_screenshot_with_limit( #[cfg(test)] mod tests; +/// The window and coordinate mapping where a control-channel Rotate View drag began. +#[derive(Clone, Copy)] +struct RotateViewPress { + window: (u64, u32), + tool_resets: u64, + xf: Xf, + start_angle: f64, + start_rotation: f64, +} + +/// Rotate View belongs to the canvas, not the engine's placeholder tool. Keep its press mapping +/// across requests so document-coordinate drags do not move again as the view turns underneath. +fn rotate_view_pointer( + app: &mut VectorcraftApp, + ctx: &egui::Context, + kind: PointerKind, + position: vectorcraft_geom::Point, + screen: bool, + shift: bool, +) -> Result<(), String> { + let id = egui::Id::unique("control.rotateView"); + match kind { + PointerKind::Up => { + ctx.data_mut(|d| d.remove::(id)); + return Ok(()); + } + PointerKind::Move | PointerKind::DoubleClick => return Ok(()), + _ => {} + } + let window = app.session.active().zip(crate::doc_windows::current(app)).map(|(d, w)| (d.uid, w.1)).ok_or("no document")?; + let tool_resets = app.session.tool_resets(); + let mut press = if kind == PointerKind::Down { + let rect = app.canvas_rect.ok_or("no canvas yet")?; + let view = app.view().ok_or("no document")?; + RotateViewPress { window, tool_resets, xf: Xf::new(rect, view), start_angle: 0.0, start_rotation: view.rotation } + } else { + ctx.data(|d| d.get_temp::(id)).ok_or("Rotate View needs a down event before a drag")? + }; + // A gesture cannot turn a different document/view or resume after another tool was chosen. + if (press.window, press.tool_resets) != (window, tool_resets) { + ctx.data_mut(|d| d.remove::(id)); + return Err("the Rotate View gesture's window or tool changed".into()); + } + let point = if screen { egui::pos2(position.x as f32, position.y as f32) } else { press.xf.to_screen(position) }; + let center = press.xf.rect.center(); + if !point.is_finite() || !center.is_finite() { + return Err("Rotate View needs finite canvas coordinates".into()); + } + if kind == PointerKind::Down { + press.start_angle = (point.y - center.y).atan2(point.x - center.x) as f64; + ctx.data_mut(|d| d.insert_temp(id, press)); + } else { + app.view_mut().ok_or("no document")?.rotation = crate::canvas::drag_rotation(center, point, press.start_angle, press.start_rotation, shift); + } + Ok(()) +} + /// `ui.pointer`: pointer events through the tools. fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; @@ -634,6 +691,13 @@ fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRe let x = e.get("x").and_then(Value::as_f64).unwrap_or(0.0); let y = e.get("y").and_then(Value::as_f64).unwrap_or(0.0); let screen = e.get("space").and_then(Value::as_str) == Some("screen"); + let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods); + if app.session.tool_id() == "rotateView" { + if let Err(e) = rotate_view_pointer(app, ctx, kind, vectorcraft_geom::Point::new(x, y), screen, mods.shift) { + return err(e); + } + continue; + } let pos = if screen { match xf { Some(xf) => xf.to_doc(egui::pos2(x as f32, y as f32)), @@ -642,7 +706,6 @@ fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRe } else { vectorcraft_geom::Point::new(x, y) }; - let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods); // A tilt given in screen axes leans the same way on the page as on the screen. let pen = PointerEvent::json_pen(e).map(|q| match xf { Some(xf) if screen => crate::canvas::pose_on_page(q, &xf), diff --git a/crates/ui-egui/src/tests/rotateview.rs b/crates/ui-egui/src/tests/rotateview.rs index 8f477894a..a709a767e 100644 --- a/crates/ui-egui/src/tests/rotateview.rs +++ b/crates/ui-egui/src/tests/rotateview.rs @@ -55,6 +55,79 @@ fn view_rotate_turns_the_view_to_any_angle() { assert_eq!(empty.run("view.rotate", json!({"angle": 30})).unwrap_err(), "no document"); } +/// MCP's `pointer_gesture` reaches `ui.pointer`: view tools must turn the canvas there too, +/// rather than silently sending the gesture to the engine's no-op tool. +#[test] +fn control_rotate_view_gesture_turns_the_canvas_and_reset_restores_it() { + let mut app = with_document(); + let ctx = egui::Context::default(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))); + let view = app.view_mut().unwrap(); + view.center = vectorcraft_geom::Point::new(200.0, 150.0); + view.zoom = 1.0; + let revision = app.session.active().unwrap().revision; + let send = super::synthetic::control; + send(&mut app, &ctx, "ui.tool.select", json!({"tool": "rotateView"})); + send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "down", "x": 300, "y": 150}]})); + send( + &mut app, + &ctx, + "ui.pointer", + json!({"events": [ + {"kind": "drag", "x": 200, "y": 250}, + {"kind": "up", "x": 200, "y": 250} + ]}), + ); + let view = app.view().unwrap(); + assert!((view.rotation - 90.0).abs() < 1e-3, "{}", view.rotation); + assert_eq!(view.center, vectorcraft_geom::Point::new(200.0, 150.0)); + assert_eq!(view.zoom, 1.0); + assert_eq!(app.session.active().unwrap().revision, revision, "navigation does not edit the document"); + send(&mut app, &ctx, "engine.execute", json!({"command": "view.rotateReset", "params": {}})); + assert_eq!(app.view().unwrap().rotation, 0.0); +} + +#[test] +fn control_rotate_view_keeps_press_coordinates_and_shift_snapping_across_requests() { + let mut app = with_document(); + let ctx = egui::Context::default(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))); + let view = app.view_mut().unwrap(); + view.center = vectorcraft_geom::Point::new(200.0, 150.0); + view.zoom = 1.0; + view.rotation = 30.0; + let send = super::synthetic::control; + send(&mut app, &ctx, "ui.tool.select", json!({"tool": "rotateView"})); + send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "down", "x": 300, "y": 150}]})); + let angle = 20_f64.to_radians(); + let (x, y) = (200.0 + 100.0 * angle.cos(), 150.0 + 100.0 * angle.sin()); + for _ in 0..2 { + send(&mut app, &ctx, "ui.pointer", json!({"mods": {"shift": true}, "events": [{"kind": "drag", "x": x, "y": y}]})); + assert_eq!(app.view().unwrap().rotation, 45.0, "the same document point does not turn again as the view changes"); + } + send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "up", "x": x, "y": y}]})); + assert_eq!(app.view().unwrap().rotation, 45.0); +} + +#[test] +fn control_rotate_view_rejects_nonfinite_positions_and_a_changed_document() { + let mut app = with_document(); + let ctx = egui::Context::default(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))); + let send = super::synthetic::control; + send(&mut app, &ctx, "ui.tool.select", json!({"tool": "rotateView"})); + let (req, _) = crate::control::ControlRequest::new("ui.pointer", json!({"events": [{"kind": "down", "x": 1e300, "y": 0}]})); + let crate::control::Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("not done") }; + assert_eq!(reply["ok"], false); + assert_eq!(app.view().unwrap().rotation, 0.0); + send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "down", "x": 300, "y": 150}]})); + app.run("file.new", json!({})).unwrap(); + let (req, _) = crate::control::ControlRequest::new("ui.pointer", json!({"events": [{"kind": "drag", "x": 200, "y": 250}]})); + let crate::control::Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("not done") }; + assert_eq!(reply["ok"], false); + assert_eq!(app.view().unwrap().rotation, 0.0); +} + #[test] fn the_view_menu_lists_the_status_bar_angles_and_checks_the_current_one() { let mut app = with_document(); diff --git a/docs/mcp.md b/docs/mcp.md index fb9b3cfc5..d8a5f3759 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4009,7 +4009,19 @@ every document they read except a native file and the native document that an SV `view.rotate {angle}` (desktop app) turns the view to `angle` degrees about the center of the canvas; a positive angle turns the art clockwise on screen. View › Rotate View and the status bar's angle menu run it. It returns `{angle}`, the -angle it set, normalized to (-180, 180], and `view.rotateReset` turns the view back to 0. +angle it set, normalized to (-180, 180], and `view.rotateReset` turns the view back to 0, returning `null` on success. + +Select the Rotate View Tool with `select_tool` and the tool id `rotateView`. Its `pointer_gesture` +drags turn the canvas about its center; Shift snaps to 15°. Document coordinates for a rotation +gesture use the view at the initial down event, including when the gesture spans several calls. +Reset Rotate View is the command `view.rotateReset`: + +```json +{"name":"select_tool","arguments":{"tool":"rotateView"}} +{"name":"pointer_gesture","arguments":{"events":[{"kind":"down","x":300,"y":150},{"kind":"drag","x":200,"y":250},{"kind":"up","x":200,"y":250}]}} +{"name":"command_run","arguments":{"id":"view.rotateReset","params":{}}} +``` + `view.rotateToSelection` (desktop app, View › Rotate View to Selection) turns the view about the center of the canvas to the angle the selected objects share (`selectionRotation` in `document.inspect`, 0 when their angles differ), so their bounding box is square to the window. It returns `{angle}`, and with nothing selected it returns "nothing selected". From 7d73882d3a1729d236360aa9caf9a50abc006379 Mon Sep 17 00:00:00 2001 From: Julius van der Vaart Date: Mon, 9 Nov 2026 20:47:16 +0100 Subject: [PATCH 10/16] Stroke: Line Arrow arrowhead (#5421) Add Line Arrow, a 41st generated arrowhead: two arms as thick as the line (at 100 % scale) meeting at right angles in a sharp tip, cut square at their ends. It is saved by the new name `LineArrow`, appended so existing names stay stable, and listed third in the arrowhead menus after Arrow (open). Its arms spread four weights either side of the line, twice as wide as the other heads, so Arrowhead now states its own reach and half width. Visual bounds and clicks on the canvas (head_reach, head_contains) use them instead of one fixed 4.6 x 2 box; every other head keeps the sizes it had. Tests: the outline test checks each head against its own reach and half width; a new test checks the line arrow's tip, corners, open middle and arm thickness; outset_covers_arrowheads checks that the wider head grows the outset; the saved-name list includes LineArrow. --- crates/doc/src/appearance.rs | 1 - crates/doc/src/appearance/layers.rs | 9 ++---- crates/doc/src/appearance/stroke_style.rs | 27 +++++++++++++++- crates/doc/src/appearance/tests.rs | 13 ++++++-- crates/doc/src/reach.rs | 12 +++---- crates/effects/src/stroke/arrow.rs | 18 ++++++++--- crates/effects/src/stroke/tests_arrows.rs | 39 ++++++++++++++++++++--- crates/engine/src/cmd/stroke.rs | 2 +- docs/mcp.md | 7 ++-- docs/target-app-parity.md | 5 +-- 10 files changed, 101 insertions(+), 32 deletions(-) diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 3ac145fd2..2e19ad44a 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -8,7 +8,6 @@ mod layers; mod profile; mod stroke_style; -pub(crate) use layers::HEAD_REACH; pub use layers::{Effect, FillLayer, StrokeGradientMode, StrokeLayer, stroke_paint_bounds}; pub use profile::{AngleProfile, PressureProfile, ProfilePreset, SavedProfile, WidthProfile}; pub use stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign}; diff --git a/crates/doc/src/appearance/layers.rs b/crates/doc/src/appearance/layers.rs index 880f3490d..923275999 100644 --- a/crates/doc/src/appearance/layers.rs +++ b/crates/doc/src/appearance/layers.rs @@ -139,10 +139,6 @@ impl StrokeGradientMode { } } -/// How far any arrowhead reaches from its tip, in units of its weight (the open arrow's arms are -/// cut square a little behind its 4 × 4 box). -pub(crate) const HEAD_REACH: f64 = 4.6; - fn ten() -> f64 { 10.0 } @@ -188,7 +184,8 @@ impl StrokeLayer { } } /// Weight of the start (`end == false`) or end arrowhead: stroke weight × its scale, at least - /// a quarter point. A head of weight `hw` fits a box about `4·hw` long and wide. + /// a quarter point. A head of weight `hw` fits a box about `4·hw` long and wide (see + /// [`Arrowhead::reach`] and [`Arrowhead::half_width`]). pub fn arrow_weight(&self, end: bool) -> f64 { let pct = if end { self.arrow_scale.1 } else { self.arrow_scale.0 }; (self.width * pct / 100.0).max(0.25) @@ -197,7 +194,7 @@ impl StrokeLayer { /// without one): the head's diagonal, or with [`ArrowAlign::Extend`] its length, plus the cap. pub fn head_reach(&self, end: bool) -> f64 { let head = if end { self.end_arrow } else { self.start_arrow }; - head.map_or(0.0, |_| HEAD_REACH * self.arrow_weight(end) + self.width / 2.0) + head.map_or(0.0, |k| k.reach() * self.arrow_weight(end) + self.width / 2.0) } /// How far the arrowheads can reach from the path's end points (0 without heads). pub fn arrow_reach(&self) -> f64 { diff --git a/crates/doc/src/appearance/stroke_style.rs b/crates/doc/src/appearance/stroke_style.rs index acd4d8189..1bc88d2d0 100644 --- a/crates/doc/src/appearance/stroke_style.rs +++ b/crates/doc/src/appearance/stroke_style.rs @@ -49,6 +49,10 @@ impl Dash { } } +/// How far the arrowheads drawn in the 4 × 4 box reach from their tip, in units of their weight +/// (the open arrow's arms are cut square a little behind the box). +const HEAD_REACH: f64 = 4.6; + /// An arrowhead shape. Saved by variant name: new shapes are appended, never renamed or reordered. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Arrowhead { @@ -94,13 +98,17 @@ pub enum Arrowhead { Kite, TriangleBar, Oval, + /// Two arms of the line's own weight meeting at right angles in a sharp tip, cut square at + /// their ends. Twice as wide as the other heads. + LineArrow, } impl Arrowhead { /// Every arrowhead, in menu order (arrows, then outlined shapes, then bars and marks). - pub const ALL: [Arrowhead; 40] = [ + pub const ALL: [Arrowhead; 41] = [ Arrowhead::Arrow, Arrowhead::ArrowOpen, + Arrowhead::LineArrow, Arrowhead::Barbed, Arrowhead::Concave, Arrowhead::DoubleArrow, @@ -141,6 +149,22 @@ impl Arrowhead { Arrowhead::Fork, ]; + /// How far any part of the head can be from its tip, in units of its weight. + pub fn reach(self) -> f64 { + match self { + // Its farthest corners are 4 + ½√2 back and 4 − ½√2 out. + Arrowhead::LineArrow => 5.75, + _ => HEAD_REACH, + } + } + /// How far the head spreads either side of its line, in units of its weight. + pub fn half_width(self) -> f64 { + match self { + Arrowhead::LineArrow => 4.0, + _ => 2.0, + } + } + /// The name the arrowhead menus show. pub fn label(self) -> &'static str { match self { @@ -184,6 +208,7 @@ impl Arrowhead { Arrowhead::Kite => "Kite", Arrowhead::TriangleBar => "Triangle to Bar", Arrowhead::Oval => "Oval", + Arrowhead::LineArrow => "Line Arrow", } } } diff --git a/crates/doc/src/appearance/tests.rs b/crates/doc/src/appearance/tests.rs index c297b3475..0a1b3e1a5 100644 --- a/crates/doc/src/appearance/tests.rs +++ b/crates/doc/src/appearance/tests.rs @@ -219,7 +219,7 @@ fn profile_presets_round_trip_their_ids() { #[test] fn arrowhead_names_are_stable_and_every_one_is_listed_once() { // Saved files store these names: they may only ever be appended to. - const SAVED: [&str; 40] = [ + const SAVED: [&str; 41] = [ "Triangle", "TriangleOpen", "Circle", @@ -260,6 +260,7 @@ fn arrowhead_names_are_stable_and_every_one_is_listed_once() { "Kite", "TriangleBar", "Oval", + "LineArrow", ]; for name in SAVED { let a: Arrowhead = serde_json::from_value(serde_json::json!(name)).unwrap(); @@ -293,9 +294,15 @@ fn outset_covers_arrowheads() { st.arrow_scale = (100.0, 200.0); assert_eq!(st.arrow_weight(false), 4.0); assert_eq!(st.arrow_weight(true), 8.0); - assert_eq!((st.head_reach(false), st.head_reach(true)), (0.0, HEAD_REACH * 8.0 + 2.0)); + let reach = Arrowhead::Triangle.reach(); + assert_eq!((st.head_reach(false), st.head_reach(true)), (0.0, reach * 8.0 + 2.0)); // A 32 pt head whose tip sits up to its length (plus the cap) past the end point. - assert_eq!(a.outset(), HEAD_REACH * 8.0 + 2.0); + assert_eq!(a.outset(), reach * 8.0 + 2.0); + // The line arrow's arms spread twice as wide, so it reaches further from its tip. + a.stroke_mut().unwrap().end_arrow = Some(Arrowhead::LineArrow); + assert!(Arrowhead::LineArrow.reach() > reach); + assert_eq!(a.outset(), Arrowhead::LineArrow.reach() * 8.0 + 2.0); + a.stroke_mut().unwrap().end_arrow = Some(Arrowhead::Triangle); a.stroke_mut().unwrap().width = 0.01; assert_eq!(a.stroke().unwrap().arrow_weight(true), 0.25, "heads keep a minimum size"); } diff --git a/crates/doc/src/reach.rs b/crates/doc/src/reach.rs index 0c4ef497f..58ebaafc6 100644 --- a/crates/doc/src/reach.rs +++ b/crates/doc/src/reach.rs @@ -7,7 +7,6 @@ use vectorcraft_geom::hit::fill_contains; use vectorcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest}; use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathSeg, Point, Rect, Shape, Vec2}; -use crate::appearance::HEAD_REACH; use crate::{Appearance, ArrowAlign, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile}; /// Arc-length accuracy for placing a point along a profile (document points). @@ -283,18 +282,19 @@ impl StrokeLayer { } /// Is `p` within `tol` of where the start (`end == false`) or end arrowhead at `at` (the path - /// leaving along `out`) can paint? Heads are at most about 4.6 weights long and 4 wide. With + /// leaving along `out`) can paint? A head reaches [`crate::Arrowhead::reach`] weights from its tip and + /// [`crate::Arrowhead::half_width`] either side of its line (4.6 and 2 for most). With /// [`ArrowAlign::Extend`] a head points along `out` with its tip up to its length (or the cap) /// past the end; with [`ArrowAlign::Tip`] its tip is on the end and it lies back along the /// chord the path bends through under it, so anywhere within its length of the end. fn head_contains(&self, at: Point, out: Vec2, end: bool, p: Point, tol: f64) -> bool { - let hw = self.arrow_weight(end); - let v = p - at; + let Some(kind) = (if end { self.end_arrow } else { self.start_arrow }) else { return false }; + let (hw, v) = (self.arrow_weight(end), p - at); match self.arrow_align { - ArrowAlign::Tip => v.hypot() <= HEAD_REACH * hw + tol, + ArrowAlign::Tip => v.hypot() <= kind.reach() * hw + tol, ArrowAlign::Extend => { let along = v.dot(out); - v.cross(out).abs() <= 2.0 * hw + tol && along <= 4.0 * hw + self.width / 2.0 + tol && along >= -HEAD_REACH * hw - tol + v.cross(out).abs() <= kind.half_width() * hw + tol && along <= 4.0 * hw + self.width / 2.0 + tol && along >= -kind.reach() * hw - tol } } } diff --git a/crates/effects/src/stroke/arrow.rs b/crates/effects/src/stroke/arrow.rs index 1a5c90095..ac64cb30d 100644 --- a/crates/effects/src/stroke/arrow.rs +++ b/crates/effects/src/stroke/arrow.rs @@ -1,5 +1,5 @@ //! Arrowheads. A head of weight `hw` (stroke weight × scale) fits a box `4·hw` long and `4·hw` -//! wide. Hollow kinds are rings (`hw/2` walls) or, for the open arrow, a chevron with `hw` arms. +//! wide; the line arrow's arms reach twice as far out ([`Arrowhead::half_width`]). Hollow kinds are rings (`hw/2` walls) or, for the open arrow, a chevron with `hw` arms. //! The stroke ends under the head at the head's *inset* from the tip: halfway into solid heads, //! in the back wall of hollow ones (with the cap kept out of the hole) and at the chevron's inner //! corner. With [`ArrowAlign::Tip`] the tip sits on the end point and the stroke is shortened by @@ -8,7 +8,7 @@ use std::borrow::Cow; -use std::f64::consts::{FRAC_PI_2, PI}; +use std::f64::consts::{FRAC_1_SQRT_2, FRAC_PI_2, PI}; use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen, PathSeg, Point, Shape, Vec2}; use vectorcraft_doc::{ArrowAlign, Arrowhead, StrokeLayer}; @@ -261,7 +261,7 @@ fn local_stop(kind: Arrowhead) -> Stop { Arrowhead::Fork => Stop::At(2.7), Arrowhead::HalfArrowLeft | Arrowhead::HalfArrowRight | Arrowhead::Feather => Stop::At(1.3), Arrowhead::Swallowtail => Stop::At(1.4), - Arrowhead::Chevron => Stop::At(1.0), + Arrowhead::Chevron | Arrowhead::LineArrow => Stop::At(1.0), Arrowhead::Slash => Stop::At(1.2), Arrowhead::DoubleSlash => Stop::At(1.05), Arrowhead::DoubleArrow => Stop::At(1.7), @@ -273,8 +273,9 @@ fn local_stop(kind: Arrowhead) -> Stop { } /// The outline of a head added after the first ten, in local units: x from the tip (0) back -/// along the line (to at most 4), y across it (−2 to 2, positive to the right of the direction -/// the head points). Every shape is generated here. +/// along the line (to at most 4; the line arrow's square-cut arms 4.71), y across it (−2 to 2, +/// the line arrow −4 to 4; positive to the right of the direction the head points). Every shape +/// is generated here. fn local(kind: Arrowhead) -> BezPath { let mut p = BezPath::new(); let poly = |p: &mut BezPath, pts: &[(f64, f64)]| polygon(p, &pts.iter().map(|&q| Point::from(q)).collect::>()); @@ -363,6 +364,13 @@ fn local(kind: Arrowhead) -> BezPath { poly(&mut p, &[(0.0, 2.0), (0.6, 2.0), (0.6, 0.25), (4.0, 1.8), (4.0, -1.8), (0.6, -0.25), (0.6, -2.0), (0.0, -2.0)]) } Arrowhead::Oval => p.extend(kurbo::Ellipse::new((2.0, 0.0), (2.0, 1.2), 0.0).path_elements(LOCAL_TOL)), + Arrowhead::LineArrow => { + // Arms one unit thick (the line's weight at 100 %) at right angles to each other, from + // a sharp tip to their outer corners at (4, ±4), cut square across: a unit across an + // arm at 45° is ½√2 along each axis, and the arms' inner edges meet √2 behind the tip. + let t = FRAC_1_SQRT_2; + poly(&mut p, &[(0.0, 0.0), (4.0, 4.0), (4.0 + t, 4.0 - t), (2.0 * t, 0.0), (4.0 + t, t - 4.0), (4.0, -4.0)]); + } // The first ten are drawn by `shape`. Arrowhead::Triangle | Arrowhead::TriangleOpen diff --git a/crates/effects/src/stroke/tests_arrows.rs b/crates/effects/src/stroke/tests_arrows.rs index 39d6d9d57..432804899 100644 --- a/crates/effects/src/stroke/tests_arrows.rs +++ b/crates/effects/src/stroke/tests_arrows.rs @@ -1,6 +1,6 @@ -//! Every arrowhead's outline: drawn, inside its box (4 × 4 weights; the open arrow's square-cut -//! arms reach a little further back), without self-intersections, and covering the end of the -//! stroke under it. +//! Every arrowhead's outline: drawn, inside its reach and half width (4 × 4 weights for most; the +//! open arrow's square-cut arms reach a little further back, the line arrow's twice as far out), +//! without self-intersections, and covering the end of the stroke under it. use kurbo::{BezPath, PathEl, Point, Shape}; use vectorcraft_color::{Color, Paint}; @@ -53,7 +53,13 @@ fn every_outline_is_drawn_inside_its_box_without_self_intersections() { let h = head(kind); assert!(h.outline.area().abs() > 4.0, "{kind:?} is drawn"); let b = h.outline.bounding_box(); - assert!(b.x0 >= 91.0 - 1e-6 && b.x1 <= 100.0 + 1e-6 && b.y0 >= -4.0 - 1e-6 && b.y1 <= 4.0 + 1e-6, "{kind:?} leaves its box: {b:?}"); + let (reach, side) = (2.0 * kind.reach(), 2.0 * kind.half_width()); + assert!(b.x1 <= 100.0 + 1e-6 && b.y0 >= -side - 1e-6 && b.y1 <= side + 1e-6, "{kind:?} leaves its box: {b:?}"); + if kind.half_width() == 2.0 { + assert!(b.x0 >= 91.0 - 1e-6, "{kind:?} is longer than the 4 × 4 box: {b:?}"); + } + let far = contours(&h.outline).iter().flatten().map(|p| p.distance(h.tip)).fold(0.0, f64::max); + assert!(far <= reach + 1e-6, "{kind:?} reaches {far} from its tip, past {reach}"); let edges: Vec<(Point, Point)> = contours(&h.outline).iter().flat_map(|c| (0..c.len()).map(|i| (c[i], c[(i + 1) % c.len()]))).collect(); assert!(edges.len() >= 3, "{kind:?}"); for (i, a) in edges.iter().enumerate() { @@ -74,6 +80,31 @@ fn the_stroke_ends_inside_every_head() { } } +#[test] +fn the_line_arrow_is_a_right_angle_of_two_arms_as_thick_as_the_line() { + // Weight 2 at 100 %: a local unit (x back from the tip, y across) is 2 pt. + let h = head(Arrowhead::LineArrow); + let at = |x: f64, y: f64| Point::new(100.0 - 2.0 * x, 2.0 * y); + // A sharp tip on the end point; outer corners 4 units back and out, square-cut ends. + let pts: Vec = contours(&h.outline).concat(); + for (corner, name) in [(at(0.0, 0.0), "tip"), (at(4.0, 4.0), "right corner"), (at(4.0, -4.0), "left corner")] { + assert!(pts.iter().any(|p| p.distance(corner) < 1e-9), "{name} {corner:?} in {pts:?}"); + } + // Open between the arms, solid at the tip. + assert_eq!(h.outline.winding(at(2.5, 0.0)), 0); + assert_ne!(h.outline.winding(at(0.7, 0.0)), 0); + // Halfway along each arm, 45° off the line, it is one unit (the line's weight) across. + let s = std::f64::consts::FRAC_1_SQRT_2; + for side in [1.0, -1.0] { + let (mx, my) = (2.0 + 0.5 * s, side * (2.0 - 0.5 * s)); + let (nx, ny) = (-side * s, s); + for (d, inside) in [(0.45, true), (-0.45, true), (0.55, false), (-0.55, false)] { + let w = h.outline.winding(at(mx + nx * d, my + ny * d)); + assert_eq!(w != 0, inside, "arm {side} at {d} across"); + } + } +} + #[test] fn half_arrows_barb_on_their_own_side() { // Pointing along +x on a y-down page, left is −y. diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 1fb3373c7..e97659bfc 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -20,7 +20,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}", + "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}", has_doc, stroke_set ), diff --git a/docs/mcp.md b/docs/mcp.md index 8f605eee0..574a0e811 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3177,11 +3177,12 @@ and `stroke.widthProfile.reset` removes every saved one. `stroke.set {profile}` ## Arrowheads -`stroke.set {startArrow, endArrow}` takes any of 40 generated heads by name (the params doc lists them; `null` for -none): arrows (`Arrow`, `Barbed`, `Concave`, `DoubleArrow`, `HalfArrowLeft`/`Right`, `Chevron`, `Feather`, +`stroke.set {startArrow, endArrow}` takes any of 41 generated heads by name (the params doc lists them; `null` for +none): arrows (`Arrow`, `LineArrow`, `Barbed`, `Concave`, `DoubleArrow`, `HalfArrowLeft`/`Right`, `Chevron`, `Feather`, `Swallowtail`…), filled and open shapes (`Triangle`, `Circle`, `Oval`, `Target`, `Tag`, `Diamond`, `Hexagon`, `Star`, the `…Open` rings…) and marks (`Bar`, `DoubleBar`, `DotOnBar`, `Slash`, `DoubleSlash`, `Bracket`, `Fork`, `Cross`, -`Plus`). Each head fits a box about four times its weight (stroke weight × `arrowScale`) long and wide. +`Plus`). Each head fits a box about four times its weight (stroke weight × `arrowScale`) long and wide; `LineArrow`, two arms +of the line's weight at right angles, spreads twice as wide. ## Stroke bounds and clicks diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2effc1142..e5e7cb97d 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-11-09 · **Change:** minor (Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (Line Arrow arrowhead (#5421); scores and estimates unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) ## Feature submission evidence @@ -41,6 +41,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-09 | minor | Stroke panel: Line Arrow, a 41st generated arrowhead: two arms as thick as the line at right angles, a sharp tip and square-cut ends, twice as wide as the other heads; bounds and clicks follow each head's own reach (`stroke.set` `LineArrow`, #5421); scores and estimates unchanged | | 2026-11-07 | minor | File formats and Type core (#6147): `.ai`/EPS type keeps its paragraphs' tab stops (positions and alignments; leaders aren't read; at most 1,024 a paragraph and 2^20 a story), and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops; centred and justified lines are aligned as a whole as before; evidence added. Scores and estimates unchanged. | | 2026-11-07 | minor | File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them, and a line a soft return ends is justified; evidence added. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. | | 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | @@ -1231,7 +1232,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Cmd/Ctrl-click on the Gradient panel's Gradient Fill box or on the Tools panel's Gradient button resets the gradient of the active Fill or Stroke to the linear White, Black gradient, as one undo step ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). With a gradient stop or a freeform gradient point selected, Alt-clicking a color swatch in the Swatches panel recolors it instead of the inactive Fill or Stroke. - **Freeform gradient colors in their own model:** Object ▸ Expand and Object ▸ Create Gradient Mesh (`object.mesh.create`) mix a freeform gradient's points in the color model they share: points of C50 M20 Y0 K10 give mesh points of C50 M20 Y0 K10, and RGB points keep their values in a working RGB space other than sRGB. The canvas, the gradient chips and the SVG, PDF, EPS and EMF writers mix the points in that model too. PDF and EPS write a gradient whose points are all CMYK, or all Gray in a CMYK file, as a CMYK image, which PDF's Output settings convert as they convert any CMYK image. Points in different models mix in display RGB (on the canvas in a CMYK document that shows transparency, as the inks of each point), and a CMYK document stores the result of Expand as CMYK through the color settings. Points colored along CMYK stops (`paint.editGradient {stops}`, or a CMYK gradient switched to freeform) are CMYK, and so are a point `paint.freeform.splitLine` adds between CMYK points and a point `paint.freeform.addPoint` adds without `color` to CMYK points. Public reference: [Gradients](https://web.archive.org/web/20250714042311/https://helpx.adobe.com/illustrator/using/gradients.html) and [Meshes](https://web.archive.org/web/20250812201019/https://helpx.adobe.com/illustrator/using/meshes.html), which do not state how colors in different models mix; the rule is VectorCraft's own. - - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. + - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty-one more arrowheads (among them Line Arrow: two arms of the line's own weight at right angles, cut square, #5421), and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. - **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random, Pressure, Tilt, Bearing or Rotation with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values, Pressure from the first at the lightest pressure to the second at the heaviest, Tilt, Bearing or Rotation; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across, Overlap) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Art Brush Options › Overlap places art on each run between sharp corners so folds do not overlap (`ArtBrush.overlap`; `art_brush_overlap_splits_at_corners`). Still to come: Stylus Wheel, pen pressure on macOS where the windowing layer reports none, and tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web. Under Tints and Tints and Shades, art in black ink alone goes by its ink amount, and a process or gray stroke color's tints and shades stay in that color's model; these are VectorCraft's own rules. Type in a scatter brush's art takes the brush's colorization as its paths do: Tints, Tints and Shades and Hue Shift recolor the characters' solid fills and strokes in each copy the brush draws, and Brush Options' Key Color list, whose first color is Hue Shift's default key, counts the characters' fills and their strokes wider than 0. The Brushes page says that a scatter, art or pattern brush takes its colors from the current stroke color and the brush's colorization method () and does not mention type there; recoloring the characters' paints like the art's fills and strokes is VectorCraft's own rule. The Symbol Stainer mixes the characters' strokes of type toward the stain as it mixes their fills. Evidence: `tests::scatter_colorization_recolors_type`, `tests::art_colors_list_the_most_used_first`, `tests::stain_mixes_type_strokes_too` (vectorcraft-brush). From 0153a5d33e9f0ebe20e0565206f309450663ea2d Mon Sep 17 00:00:00 2001 From: Julius van der Vaart Date: Mon, 9 Nov 2026 21:17:41 +0100 Subject: [PATCH 11/16] Stroke: document where the stroke stops under a local arrowhead (#5421) --- crates/effects/src/stroke/arrow.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/effects/src/stroke/arrow.rs b/crates/effects/src/stroke/arrow.rs index ac64cb30d..603f46e28 100644 --- a/crates/effects/src/stroke/arrow.rs +++ b/crates/effects/src/stroke/arrow.rs @@ -252,6 +252,7 @@ const HEX: f64 = 2.0 * 1.732_050_807_568_877_2; /// Flattening tolerance of the curved local heads, in units. const LOCAL_TOL: f64 = 1e-3; +/// Where the stroke ends under a head drawn in local units ([`local`]), in those units. fn local_stop(kind: Arrowhead) -> Stop { match kind { Arrowhead::Target | Arrowhead::TagOpen | Arrowhead::DiamondOpen => Stop::Wall(4.0), From ef25dffe013d0a5c4f7dd4b71e5425ef6b70eb01 Mon Sep 17 00:00:00 2001 From: Christian Lappin <36496308+christianlappin@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:31:35 -0500 Subject: [PATCH 12/16] fix(mcp): validate and cancel Rotate View gestures (#6322) --- crates/mcp/src/tests.rs | 54 ++++++++++++ crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/commands.rs | 4 + crates/ui-egui/src/control.rs | 109 ++++++++++++++++++------- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/tests/rotateview.rs | 87 +++++++++++++++++++- docs/mcp.md | 2 + 7 files changed, 228 insertions(+), 31 deletions(-) diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index e986deda0..b12f44680 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -1463,6 +1463,60 @@ fn pointer_gesture_forwards_backquote_on_each_event() { assert_eq!(rect["bounds"]["x"], 100.0, "{doc}"); } +/// The documented Rotate View calls reach the desktop control methods, preserving document +/// coordinates and modifiers, and a successful null reset remains a successful MCP response. +#[test] +fn rotate_view_calls_forward_to_desktop_control() { + use std::cell::RefCell; + use std::rc::Rc; + + struct Recording(Rc>>); + impl Backend for Recording { + fn call(&mut self, method: &str, params: Value) -> Result { + self.0.borrow_mut().push((method.into(), params)); + Ok(Value::Null) + } + fn has_ui(&self) -> bool { + true + } + fn describe(&self) -> String { + "desktop control contract".into() + } + } + + let seen = Rc::new(RefCell::new(Vec::new())); + let mut s = Server::new(Box::new(Recording(seen.clone()))); + let selected = call(&mut s, 1, "select_tool", json!({"tool": "rotateView"})); + let turned = call( + &mut s, + 2, + "pointer_gesture", + json!({"events": [ + {"kind": "down", "x": 300, "y": 150}, {"kind": "drag", "x": 200, "y": 250}, {"kind": "up", "x": 200, "y": 250} + ], "mods": {"shift": true}}), + ); + let reset = call(&mut s, 3, "command_run", json!({"id": "view.rotateReset", "params": {}})); + for reply in [&selected, &turned, &reset] { + assert_eq!(reply["isError"], false, "{reply}"); + } + assert_eq!(text_of(&reset), "null"); + assert_eq!( + *seen.borrow(), + vec![ + ("ui.tool.select".into(), json!({"tool": "rotateView"})), + ( + "ui.pointer".into(), + json!({"events": [ + {"kind": "down", "x": 300, "y": 150, "space": "doc"}, + {"kind": "drag", "x": 200, "y": 250, "space": "doc"}, + {"kind": "up", "x": 200, "y": 250, "space": "doc"} + ], "mods": {"shift": true}}) + ), + ("engine.execute".into(), json!({"command": "view.rotateReset", "params": {}})) + ] + ); +} + /// A `backquote` that is not a boolean is an error, and none of the gesture's events runs. #[test] fn pointer_gesture_rejects_a_backquote_that_is_not_a_boolean() { diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index d02c59728..32e020523 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -123,6 +123,7 @@ impl VectorcraftApp { updates_rt: Default::default(), frame_rate: Default::default(), canvas_rect: None, + control_rotate_press: None, hover_doc: None, titlebar_from_env: false, custom_titlebar: false, diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index d5ca8a196..9e698b1bf 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -404,6 +404,10 @@ impl VectorcraftApp { if let Err(e) = switched { self.ui.status = e.to_string(); } + if before != self.session.tool_id() { + // A control gesture cannot resume after another tool has owned the pointer. + self.control_rotate_press = None; + } // A newly chosen Artboard tool starts on the active artboard (the view's). if before != "artboard" && self.session.tool_id() == "artboard" diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index bb2918d13..4b2b6f867 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -610,9 +610,9 @@ mod tests; /// The window and coordinate mapping where a control-channel Rotate View drag began. #[derive(Clone, Copy)] -struct RotateViewPress { +pub(crate) struct RotateViewPress { window: (u64, u32), - tool_resets: u64, + document_resets: u64, xf: Xf, start_angle: f64, start_rotation: f64, @@ -622,53 +622,102 @@ struct RotateViewPress { /// across requests so document-coordinate drags do not move again as the view turns underneath. fn rotate_view_pointer( app: &mut VectorcraftApp, - ctx: &egui::Context, kind: PointerKind, position: vectorcraft_geom::Point, screen: bool, shift: bool, ) -> Result<(), String> { - let id = egui::Id::unique("control.rotateView"); - match kind { - PointerKind::Up => { - ctx.data_mut(|d| d.remove::(id)); - return Ok(()); - } - PointerKind::Move | PointerKind::DoubleClick => return Ok(()), - _ => {} - } let window = app.session.active().zip(crate::doc_windows::current(app)).map(|(d, w)| (d.uid, w.1)).ok_or("no document")?; - let tool_resets = app.session.tool_resets(); - let mut press = if kind == PointerKind::Down { - let rect = app.canvas_rect.ok_or("no canvas yet")?; - let view = app.view().ok_or("no document")?; - RotateViewPress { window, tool_resets, xf: Xf::new(rect, view), start_angle: 0.0, start_rotation: view.rotation } - } else { - ctx.data(|d| d.get_temp::(id)).ok_or("Rotate View needs a down event before a drag")? + let document_resets = app.session.tool_resets(); + let stored = if kind == PointerKind::Down { None } else { app.control_rotate_press }; + if kind == PointerKind::Drag && stored.is_none() { + return Err("Rotate View needs a down event before a drag".into()); + } + let mut press = match stored { + Some(press) => press, + None => { + let rect = app.canvas_rect.ok_or("no canvas yet")?; + let view = app.view().ok_or("no document")?; + RotateViewPress { window, document_resets, xf: Xf::new(rect, view), start_angle: 0.0, start_rotation: view.rotation } + } }; - // A gesture cannot turn a different document/view or resume after another tool was chosen. - if (press.window, press.tool_resets) != (window, tool_resets) { - ctx.data_mut(|d| d.remove::(id)); - return Err("the Rotate View gesture's window or tool changed".into()); + // A gesture cannot turn a different document/view or resume after its document was reset. + if (press.window, press.document_resets) != (window, document_resets) { + return Err("the Rotate View gesture's window or document changed".into()); } let point = if screen { egui::pos2(position.x as f32, position.y as f32) } else { press.xf.to_screen(position) }; let center = press.xf.rect.center(); if !point.is_finite() || !center.is_finite() { return Err("Rotate View needs finite canvas coordinates".into()); } - if kind == PointerKind::Down { - press.start_angle = (point.y - center.y).atan2(point.x - center.x) as f64; - ctx.data_mut(|d| d.insert_temp(id, press)); - } else { - app.view_mut().ok_or("no document")?.rotation = crate::canvas::drag_rotation(center, point, press.start_angle, press.start_rotation, shift); + match kind { + PointerKind::Down => { + press.start_angle = (point.y - center.y).atan2(point.x - center.x) as f64; + app.control_rotate_press = Some(press); + } + PointerKind::Drag => { + app.view_mut().ok_or("no document")?.rotation = + crate::canvas::drag_rotation(center, point, press.start_angle, press.start_rotation, shift); + } + PointerKind::Up => { + app.control_rotate_press = None; + } + PointerKind::Move | PointerKind::DoubleClick => {} + } + Ok(()) +} + +/// A Rotate View packet accepts only the documented boolean modifier fields. +fn validate_rotate_view_mods(value: Option<&Value>) -> Result<(), String> { + let Some(value) = value else { return Ok(()) }; + let Some(fields) = value.as_object() else { return Err("Rotate View `mods` must be an object of booleans".into()) }; + if fields.iter().any(|(key, value)| !matches!(key.as_str(), "shift" | "alt" | "cmd" | "ctrl" | "space") || !value.is_boolean()) { + return Err("Rotate View `mods` accepts only boolean shift, alt, cmd, ctrl and space".into()); + } + Ok(()) +} + +/// Reject malformed coordinates and modifiers before any event can change the angle or press. +fn validate_rotate_view_events(params: &Value, events: &[Value]) -> Result<(), String> { + validate_rotate_view_mods(params.get("mods"))?; + for event in events { + for axis in ["x", "y"] { + if event.get(axis).and_then(Value::as_f64).filter(|value| value.is_finite()).is_none() { + return Err(format!("Rotate View `{axis}` must be a finite number")); + } + } + validate_rotate_view_mods(event.get("mods"))?; + if event.get("space").is_some_and(|space| !matches!(space.as_str(), Some("doc" | "screen"))) { + return Err("Rotate View `space` must be doc or screen".into()); + } } Ok(()) } /// `ui.pointer`: pointer events through the tools. fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { + let rotating = app.session.tool_id() == "rotateView"; + let before = if rotating { app.view().map(|view| view.rotation) } else { None }; + let outcome = pointer_events_inner(app, ctx, req); + if rotating && matches!(&outcome, Outcome::Done(reply) if reply.get("ok").and_then(Value::as_bool) == Some(false)) { + // A failed packet must neither leave a partial turn nor allow a later drag to resume it. + app.control_rotate_press = None; + if let (Some(before), Some(view)) = (before, app.view_mut()) { + view.rotation = before; + } + ctx.request_repaint(); + } + outcome +} + +/// Apply a pointer packet after Rotate View validation; other tools retain their existing routing. +fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let Some(events) = p.get("events").and_then(Value::as_array) else { return err("missing `events`") }; + let rotating = app.session.tool_id() == "rotateView"; + if rotating && let Err(error) = validate_rotate_view_events(p, events) { + return err(error); + } let base_mods: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default(); let view = app.view_info(); let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v)); @@ -692,8 +741,8 @@ fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRe let y = e.get("y").and_then(Value::as_f64).unwrap_or(0.0); let screen = e.get("space").and_then(Value::as_str) == Some("screen"); let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods); - if app.session.tool_id() == "rotateView" { - if let Err(e) = rotate_view_pointer(app, ctx, kind, vectorcraft_geom::Point::new(x, y), screen, mods.shift) { + if rotating { + if let Err(e) = rotate_view_pointer(app, kind, vectorcraft_geom::Point::new(x, y), screen, mods.shift) { return err(e); } continue; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 03acb7597..52ad116ba 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -186,6 +186,8 @@ pub struct VectorcraftApp { frame_rate: frame_rate::FrameRate, /// Canvas rect of the last frame (screen points), for control-channel coordinate mapping. pub canvas_rect: Option, + /// An unfinished control-channel Rotate View gesture, kept only for this app instance. + pub(crate) control_rotate_press: Option, /// Hover position in document coordinates. pub hover_doc: Option, /// System clipboard: SVG to publish next frame, the last SVG we published (so pasting it back diff --git a/crates/ui-egui/src/tests/rotateview.rs b/crates/ui-egui/src/tests/rotateview.rs index a709a767e..296ab7674 100644 --- a/crates/ui-egui/src/tests/rotateview.rs +++ b/crates/ui-egui/src/tests/rotateview.rs @@ -30,6 +30,13 @@ fn with_document() -> VectorcraftApp { app } +/// Send a control request and keep its success/error value for protocol assertions. +fn control_response(app: &mut VectorcraftApp, ctx: &egui::Context, method: &str, params: Value) -> Value { + let (request, _) = crate::control::ControlRequest::new(method, params); + let crate::control::Outcome::Done(reply) = crate::control::handle(app, ctx, &request) else { panic!("not done") }; + reply +} + /// Returns the labels of the View › Rotate View items that are checked. fn checked(app: &VectorcraftApp) -> Vec<&'static str> { ANGLES.iter().filter(|(_, a)| menus::checked(app, "view.rotate", &json!({ "angle": a })) == Some(true)).map(|(l, _)| *l).collect() @@ -69,6 +76,8 @@ fn control_rotate_view_gesture_turns_the_canvas_and_reset_restores_it() { let send = super::synthetic::control; send(&mut app, &ctx, "ui.tool.select", json!({"tool": "rotateView"})); send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "down", "x": 300, "y": 150}]})); + let mut frame = ctx.run_ui(Default::default(), |_| {}); + frame.textures_delta.clear(); send( &mut app, &ctx, @@ -83,10 +92,13 @@ fn control_rotate_view_gesture_turns_the_canvas_and_reset_restores_it() { assert_eq!(view.center, vectorcraft_geom::Point::new(200.0, 150.0)); assert_eq!(view.zoom, 1.0); assert_eq!(app.session.active().unwrap().revision, revision, "navigation does not edit the document"); - send(&mut app, &ctx, "engine.execute", json!({"command": "view.rotateReset", "params": {}})); + let reset = control_response(&mut app, &ctx, "engine.execute", json!({"command": "view.rotateReset", "params": {}})); + assert_eq!(reset["ok"], true); + assert!(reset["result"].is_null()); assert_eq!(app.view().unwrap().rotation, 0.0); } +/// Repeated document points stay fixed across requests, including Shift snapping from a turned view. #[test] fn control_rotate_view_keeps_press_coordinates_and_shift_snapping_across_requests() { let mut app = with_document(); @@ -109,6 +121,7 @@ fn control_rotate_view_keeps_press_coordinates_and_shift_snapping_across_request assert_eq!(app.view().unwrap().rotation, 45.0); } +/// Invalid coordinates and document changes cannot turn a different view. #[test] fn control_rotate_view_rejects_nonfinite_positions_and_a_changed_document() { let mut app = with_document(); @@ -128,6 +141,78 @@ fn control_rotate_view_rejects_nonfinite_positions_and_a_changed_document() { assert_eq!(app.view().unwrap().rotation, 0.0); } +/// An invalid packet leaves the angle unchanged and requires a fresh press, including errors +/// after an otherwise valid drag and malformed releases or hovers. +#[test] +fn control_rotate_view_rejects_malformed_packets_without_leaving_a_press() { + let drag = json!({"kind": "drag", "x": 200, "y": 250}); + for packet in [ + json!({"events": [drag.clone(), {"kind": "drag", "y": 250}]}), + json!({"events": [drag.clone(), {"kind": "drag", "x": "200", "y": 250}]}), + json!({"events": [drag.clone(), {"kind": "drag", "x": 200, "y": 250, "mods": {"shift": "yes"}}]}), + json!({"mods": "shift", "events": [drag.clone()]}), + json!({"events": [drag.clone(), {"kind": "drag", "x": 200, "y": 250, "mods": {"unknown": true}}]}), + json!({"events": [drag.clone(), {"kind": "drag", "x": 1e300, "y": 250}]}), + json!({"events": [drag.clone(), {"kind": "up", "x": 1e300, "y": 250}]}), + json!({"events": [drag.clone(), {"kind": "move", "x": 1e300, "y": 250}]}), + json!({"events": [drag.clone(), {"kind": "doubleclick", "x": 1e300, "y": 250}]}), + json!({"events": [drag.clone(), {"kind": "wiggle", "x": 200, "y": 250}]}), + json!({"events": [drag.clone(), {"kind": "drag", "x": 200, "y": 250, "space": "window"}]}), + json!({"events": null}), + ] { + let mut app = with_document(); + let ctx = egui::Context::default(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))); + let view = app.view_mut().unwrap(); + view.center = vectorcraft_geom::Point::new(200.0, 150.0); + view.zoom = 1.0; + super::synthetic::control(&mut app, &ctx, "ui.tool.select", json!({"tool": "rotateView"})); + super::synthetic::control(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "down", "x": 300, "y": 150}]})); + let reply = control_response(&mut app, &ctx, "ui.pointer", packet.clone()); + assert_eq!(reply["ok"], false, "{packet}: {reply}"); + assert_eq!(app.view().unwrap().rotation, 0.0, "{packet}"); + let resume = control_response(&mut app, &ctx, "ui.pointer", json!({"events": [drag.clone()]})); + assert_eq!(resume["ok"], false, "a failed packet cancels its press: {packet}"); + assert_eq!(app.view().unwrap().rotation, 0.0); + } +} + +/// Screen-space gestures survive frames, stop on release or a tool change, and start afresh +/// from the current angle. A press at the pivot uses the same finite convention as the mouse. +#[test] +fn control_rotate_view_screen_events_require_a_live_press() { + let mut app = with_document(); + let ctx = egui::Context::default(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))); + let view = app.view_mut().unwrap(); + view.center = vectorcraft_geom::Point::new(1200.0, 900.0); + view.zoom = 2.0; + let send = super::synthetic::control; + send(&mut app, &ctx, "ui.tool.select", json!({"tool": "rotateView"})); + let down = json!({"events": [{"kind": "down", "x": 500, "y": 300, "space": "screen"}]}); + let drag = json!({"events": [{"kind": "drag", "x": 400, "y": 400, "space": "screen"}]}); + assert_eq!(control_response(&mut app, &ctx, "ui.pointer", drag.clone())["ok"], false); + send(&mut app, &ctx, "ui.pointer", down.clone()); + let mut frame = ctx.run_ui(Default::default(), |_| {}); + frame.textures_delta.clear(); + send(&mut app, &ctx, "ui.pointer", drag.clone()); + assert!((app.view().unwrap().rotation - 90.0).abs() < 1e-3); + send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "up", "x": 400, "y": 400, "space": "screen"}]})); + assert_eq!(control_response(&mut app, &ctx, "ui.pointer", drag.clone())["ok"], false, "release ends the press"); + send(&mut app, &ctx, "ui.pointer", down.clone()); + send(&mut app, &ctx, "ui.tool.select", json!({"tool": "selection"})); + send(&mut app, &ctx, "ui.tool.select", json!({"tool": "rotateView"})); + assert_eq!(control_response(&mut app, &ctx, "ui.pointer", drag.clone())["ok"], false, "a tool change ends the press"); + send(&mut app, &ctx, "ui.pointer", down); + send(&mut app, &ctx, "ui.pointer", drag); + assert!((app.view().unwrap().rotation.abs() - 180.0).abs() < 1e-3, "a new press starts at the current angle"); + let before = app.view().unwrap().rotation; + send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "down", "x": 400, "y": 300, "space": "screen"}]})); + send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "drag", "x": 500, "y": 300, "space": "screen"}]})); + assert!(app.view().unwrap().rotation.is_finite()); + assert!((app.view().unwrap().rotation - before).abs() < 1e-3); +} + #[test] fn the_view_menu_lists_the_status_bar_angles_and_checks_the_current_one() { let mut app = with_document(); diff --git a/docs/mcp.md b/docs/mcp.md index d8a5f3759..28c62b279 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4014,6 +4014,8 @@ angle it set, normalized to (-180, 180], and `view.rotateReset` turns the view b Select the Rotate View Tool with `select_tool` and the tool id `rotateView`. Its `pointer_gesture` drags turn the canvas about its center; Shift snaps to 15°. Document coordinates for a rotation gesture use the view at the initial down event, including when the gesture spans several calls. +The control channel rejects malformed rotation packets without changing the angle and cancels +the current press. Start a new down event after an error. Reset Rotate View is the command `view.rotateReset`: ```json From f94b0f46d30f6be87a24b64c578f5e77d645bfd0 Mon Sep 17 00:00:00 2001 From: Christian Lappin <36496308+christianlappin@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:13:40 -0500 Subject: [PATCH 13/16] docs(tests): describe thumbnail queue regression (#6318) --- crates/ui-egui/src/panels/layers/thumbs.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/panels/layers/thumbs.rs b/crates/ui-egui/src/panels/layers/thumbs.rs index 2a6d010b9..2f1587813 100644 --- a/crates/ui-egui/src/panels/layers/thumbs.rs +++ b/crates/ui-egui/src/panels/layers/thumbs.rs @@ -928,6 +928,7 @@ mod tests { thumbs.receive(ctx, done); } + /// Prioritize the shown document’s thumbnails, requeue them when revisited, and retain cached rows. #[test] fn the_shown_documents_thumbnails_are_made_before_the_queued_ones_of_another() { let ctx = egui::Context::default(); From f68338482fe23149cc7328664b1ce9292a0ee33f Mon Sep 17 00:00:00 2001 From: Ahmed Khan Date: Mon, 9 Nov 2026 21:13:07 -0800 Subject: [PATCH 14/16] A full flush refused with ENOTTY falls back to a plain fsync (#1620) A folder shared into a macOS VM with virtiofs (a Tart VM's --dir share) refuses F_FULLFSYNC with ENOTTY (os error 25) rather than ENOTSUP, so full_flush_unsupported did not match it and every save there failed. It now matches ENOTSUP or ENOTTY, as craft-libs' atomic crate already does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VE6j3WRdUkYkmdJG6Q3Me5 --- crates/format/src/atomic.rs | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs index c208f1b52..863c6fdd3 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -10,7 +10,8 @@ //! is left as it was and the save fails. //! //! On macOS `File::sync_all` is a full flush (`F_FULLFSYNC`), which SMB shares refuse with -//! `ENOTSUP` while a plain `fsync` works: [`sync_file`] falls back to the plain one there (#1620). +//! `ENOTSUP` and virtiofs shares (folders shared into a macOS VM) with `ENOTTY`, while a plain +//! `fsync` works: [`sync_file`] falls back to the plain one there (#1620). use std::fs::{File, OpenOptions}; use std::io::{self, Write as _}; @@ -180,8 +181,9 @@ fn copy_new(from: &Path, path: &Path) -> io::Result<()> { } /// Flush `file` to stable storage (`sync_all`). On Apple platforms that is a full flush -/// (`F_FULLFSYNC`); where the volume refuses it with `ENOTSUP` (SMB shares, #1620) this falls back -/// to a plain `fsync`. Any other error, and a failed `fsync`, is returned as it is. +/// (`F_FULLFSYNC`); where the volume refuses it with `ENOTSUP` (SMB shares) or `ENOTTY` (virtiofs +/// shares, #1620) this falls back to a plain `fsync`. Any other error, and a failed `fsync`, is +/// returned as it is. pub fn sync_file(file: &File) -> io::Result<()> { match full_sync(file) { Err(e) if full_flush_unsupported(&e) => plain_fsync(file), @@ -190,9 +192,10 @@ pub fn sync_file(file: &File) -> io::Result<()> { } /// Whether `e` is a volume refusing a full flush. Never off Apple platforms: there `sync_all` is -/// already a plain `fsync`. Checked by its code because std maps `ENOTSUP` to `Uncategorized`. +/// already a plain `fsync`. Checked by its code because std maps `ENOTSUP` and `ENOTTY` to +/// `Uncategorized`. fn full_flush_unsupported(e: &io::Error) -> bool { - cfg!(target_vendor = "apple") && e.raw_os_error() == Some(ENOTSUP) + cfg!(target_vendor = "apple") && matches!(e.raw_os_error(), Some(ENOTSUP | ENOTTY)) } #[cfg(target_vendor = "apple")] @@ -232,6 +235,9 @@ fn full_sync(file: &File) -> io::Result<()> { const ENOTSUP: i32 = 45; #[cfg(not(target_vendor = "apple"))] const ENOTSUP: i32 = 95; +/// What macOS answers when a virtiofs share (a folder shared into a VM) is asked for a full flush. +/// The same number on every Unix. +const ENOTTY: i32 = 25; #[cfg(test)] thread_local! { @@ -414,6 +420,17 @@ mod tests { let _ = std::fs::remove_dir_all(&d); } + /// A save to a virtiofs share (#1620: a folder shared into a macOS VM): macOS refuses the full + /// flush there with `ENOTTY` (os error 25), which falls back to a plain `fsync` as `ENOTSUP` does. + /// Elsewhere neither is a refused full flush, and other errors never are. + #[test] + fn a_full_flush_refused_with_enotty_falls_back_like_enotsup() { + let apple = cfg!(target_vendor = "apple"); + assert_eq!(full_flush_unsupported(&io::Error::from_raw_os_error(25)), apple, "ENOTTY"); + assert_eq!(full_flush_unsupported(&io::Error::from_raw_os_error(ENOTSUP)), apple, "ENOTSUP"); + assert!(!full_flush_unsupported(&io::Error::other("the connection was lost"))); + } + fn denied() -> io::Result { Err(io::Error::new(io::ErrorKind::PermissionDenied, "refused by the share")) } From c8d7f2cce061205e06ea315b12cff994e03384be Mon Sep 17 00:00:00 2001 From: Julius van der Vaart Date: Tue, 10 Nov 2026 06:42:19 +0100 Subject: [PATCH 15/16] Stroke: Line Arrow's arms take the head's weight, at least 0.25 pt (#5421) --- crates/doc/src/appearance/stroke_style.rs | 4 ++-- docs/mcp.md | 5 +++-- docs/target-app-parity.md | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/doc/src/appearance/stroke_style.rs b/crates/doc/src/appearance/stroke_style.rs index 1bc88d2d0..b100a8a7b 100644 --- a/crates/doc/src/appearance/stroke_style.rs +++ b/crates/doc/src/appearance/stroke_style.rs @@ -98,8 +98,8 @@ pub enum Arrowhead { Kite, TriangleBar, Oval, - /// Two arms of the line's own weight meeting at right angles in a sharp tip, cut square at - /// their ends. Twice as wide as the other heads. + /// Two arms of the head's weight (the line's at 100 % scale) meeting at right angles in a + /// sharp tip, cut square at their ends. Twice as wide as the other heads. LineArrow, } diff --git a/docs/mcp.md b/docs/mcp.md index df33d3a26..e7c9b6025 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3240,8 +3240,9 @@ and `stroke.widthProfile.reset` removes every saved one. `stroke.set {profile}` none): arrows (`Arrow`, `LineArrow`, `Barbed`, `Concave`, `DoubleArrow`, `HalfArrowLeft`/`Right`, `Chevron`, `Feather`, `Swallowtail`…), filled and open shapes (`Triangle`, `Circle`, `Oval`, `Target`, `Tag`, `Diamond`, `Hexagon`, `Star`, the `…Open` rings…) and marks (`Bar`, `DoubleBar`, `DotOnBar`, `Slash`, `DoubleSlash`, `Bracket`, `Fork`, `Cross`, -`Plus`). Each head fits a box about four times its weight (stroke weight × `arrowScale`) long and wide; `LineArrow`, two arms -of the line's weight at right angles, spreads twice as wide. +`Plus`). Each head fits a box about four times its weight long and wide. A head's weight is the stroke weight scaled by +`arrowScale`, at least 0.25 pt. `LineArrow` has two arms of the head's weight (the line's at 100 %) at right angles and +spreads twice as wide. ## Stroke bounds and clicks diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 170402aea..638508877 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -42,7 +42,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| | 2026-11-10 | minor | Type advanced: right-click suggestions, Ignore All and Add to Dictionary on a word Auto Spell Check underlined, #4936; evidence added; the dialogs remain; scores and hours unchanged. | -| 2026-11-09 | minor | Stroke panel: Line Arrow, a 41st generated arrowhead: two arms as thick as the line at right angles, a sharp tip and square-cut ends, twice as wide as the other heads; bounds and clicks follow each head's own reach (`stroke.set` `LineArrow`, #5421); scores and estimates unchanged | +| 2026-11-09 | minor | Stroke panel: Line Arrow, a 41st generated arrowhead: two arms of the head's weight (the line's at 100 % arrowhead scale, at least 0.25 pt) at right angles, a sharp tip and square-cut ends, twice as wide as the other heads; bounds and clicks follow each head's own reach (`stroke.set` `LineArrow`, #5421); scores and estimates unchanged | | 2026-11-09 | minor | Canvas: a 3D effect (Extrude & Bevel, Inflate, Rotate, Revolve) changed on type or a group draws again where its art was and is, measured by that art; the canvas measures through its renderer and draws the part with the art it made to measure it. Exports and Object › Rasterize take the solid of 3D type. Fit to Artwork Bounds and per-artboard SVG export measure every artboard through one renderer, building each solid once. A 3D effect on a fill or stroke that builds nothing (Revolve after Roughen) no longer overflows the stack on the canvas, in Object › Rasterize and in raster and EPS exports: the fill or stroke draws as without it. Tests `tests::changed::a_3d_effect_changed_on_type_or_a_group_changes_where_its_solid_was`, `fx::tests::a_renderer_measures_the_art_on_each_artboard_building_each_solid_once`, `canvas::tests::an_edit_to_a_3d_effect_redraws_with_the_art_measured`, `fx::tests::a_3d_effect_that_builds_nothing_draws_the_item_as_without_it`. Scores and estimates unchanged. | | 2026-11-07 | minor | File formats and Type core (#6147): `.ai`/EPS type keeps its paragraphs' tab stops (positions and alignments; leaders aren't read; at most 1,024 a paragraph and 2^20 a story), and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops; centred and justified lines are aligned as a whole as before; evidence added. Scores and estimates unchanged. | | 2026-11-07 | minor | File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them, and a line a soft return ends is justified; evidence added. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. | @@ -1248,7 +1248,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Cmd/Ctrl-click on the Gradient panel's Gradient Fill box or on the Tools panel's Gradient button resets the gradient of the active Fill or Stroke to the linear White, Black gradient, as one undo step ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). With a gradient stop or a freeform gradient point selected, Alt-clicking a color swatch in the Swatches panel recolors it instead of the inactive Fill or Stroke. - **Freeform gradient colors in their own model:** Object ▸ Expand and Object ▸ Create Gradient Mesh (`object.mesh.create`) mix a freeform gradient's points in the color model they share: points of C50 M20 Y0 K10 give mesh points of C50 M20 Y0 K10, and RGB points keep their values in a working RGB space other than sRGB. The canvas, the gradient chips and the SVG, PDF, EPS and EMF writers mix the points in that model too. PDF and EPS write a gradient whose points are all CMYK, or all Gray in a CMYK file, as a CMYK image, which PDF's Output settings convert as they convert any CMYK image. Points in different models mix in display RGB (on the canvas in a CMYK document that shows transparency, as the inks of each point), and a CMYK document stores the result of Expand as CMYK through the color settings. Points colored along CMYK stops (`paint.editGradient {stops}`, or a CMYK gradient switched to freeform) are CMYK, and so are a point `paint.freeform.splitLine` adds between CMYK points and a point `paint.freeform.addPoint` adds without `color` to CMYK points. Public reference: [Gradients](https://web.archive.org/web/20250714042311/https://helpx.adobe.com/illustrator/using/gradients.html) and [Meshes](https://web.archive.org/web/20250812201019/https://helpx.adobe.com/illustrator/using/meshes.html), which do not state how colors in different models mix; the rule is VectorCraft's own. - - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty-one more arrowheads (among them Line Arrow: two arms of the line's own weight at right angles, cut square, #5421), and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. + - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty-one more arrowheads (among them Line Arrow: two arms of the head's weight (the line's at 100 % scale, at least 0.25 pt) at right angles, cut square, #5421), and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. - **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random, Pressure, Tilt, Bearing or Rotation with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values, Pressure from the first at the lightest pressure to the second at the heaviest, Tilt, Bearing or Rotation; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across, Overlap) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Art Brush Options › Overlap places art on each run between sharp corners so folds do not overlap (`ArtBrush.overlap`; `art_brush_overlap_splits_at_corners`). Still to come: Stylus Wheel, pen pressure on macOS where the windowing layer reports none, and tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web. Under Tints and Tints and Shades, art in black ink alone goes by its ink amount, and a process or gray stroke color's tints and shades stay in that color's model; these are VectorCraft's own rules. Type in a scatter brush's art takes the brush's colorization as its paths do: Tints, Tints and Shades and Hue Shift recolor the characters' solid fills and strokes in each copy the brush draws, and Brush Options' Key Color list, whose first color is Hue Shift's default key, counts the characters' fills and their strokes wider than 0. The Brushes page says that a scatter, art or pattern brush takes its colors from the current stroke color and the brush's colorization method () and does not mention type there; recoloring the characters' paints like the art's fills and strokes is VectorCraft's own rule. The Symbol Stainer mixes the characters' strokes of type toward the stain as it mixes their fills. Evidence: `tests::scatter_colorization_recolors_type`, `tests::art_colors_list_the_most_used_first`, `tests::stain_mixes_type_strokes_too` (vectorcraft-brush). From 89482ba940f6b7d7e0a96efbfcfe6a9e0a94b922 Mon Sep 17 00:00:00 2001 From: Julius van der Vaart Date: Tue, 10 Nov 2026 06:48:16 +0100 Subject: [PATCH 16/16] Stroke: Line Arrow's arms match the line at 100 % only from 0.25 pt (#5421) --- crates/doc/src/appearance/stroke_style.rs | 5 +++-- crates/effects/src/stroke/arrow.rs | 7 ++++--- docs/mcp.md | 4 ++-- docs/target-app-parity.md | 4 ++-- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/crates/doc/src/appearance/stroke_style.rs b/crates/doc/src/appearance/stroke_style.rs index b100a8a7b..46a0c30c3 100644 --- a/crates/doc/src/appearance/stroke_style.rs +++ b/crates/doc/src/appearance/stroke_style.rs @@ -98,8 +98,9 @@ pub enum Arrowhead { Kite, TriangleBar, Oval, - /// Two arms of the head's weight (the line's at 100 % scale) meeting at right angles in a - /// sharp tip, cut square at their ends. Twice as wide as the other heads. + /// Two arms of the head's weight (the line's weight at 100 % scale, for lines of 0.25 pt or + /// more) meeting at right angles in a sharp tip, cut square at their ends. Twice as wide as + /// the other heads. LineArrow, } diff --git a/crates/effects/src/stroke/arrow.rs b/crates/effects/src/stroke/arrow.rs index 603f46e28..c96dcb477 100644 --- a/crates/effects/src/stroke/arrow.rs +++ b/crates/effects/src/stroke/arrow.rs @@ -366,9 +366,10 @@ fn local(kind: Arrowhead) -> BezPath { } Arrowhead::Oval => p.extend(kurbo::Ellipse::new((2.0, 0.0), (2.0, 1.2), 0.0).path_elements(LOCAL_TOL)), Arrowhead::LineArrow => { - // Arms one unit thick (the line's weight at 100 %) at right angles to each other, from - // a sharp tip to their outer corners at (4, ±4), cut square across: a unit across an - // arm at 45° is ½√2 along each axis, and the arms' inner edges meet √2 behind the tip. + // Arms one unit thick (the head's weight: the line's at 100 % for lines of 0.25 pt or + // more) at right angles to each other, from a sharp tip to their outer corners at + // (4, ±4), cut square across: a unit across an arm at 45° is ½√2 along each axis, and + // the arms' inner edges meet √2 behind the tip. let t = FRAC_1_SQRT_2; poly(&mut p, &[(0.0, 0.0), (4.0, 4.0), (4.0 + t, 4.0 - t), (2.0 * t, 0.0), (4.0 + t, t - 4.0), (4.0, -4.0)]); } diff --git a/docs/mcp.md b/docs/mcp.md index e7c9b6025..77638be63 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3241,8 +3241,8 @@ none): arrows (`Arrow`, `LineArrow`, `Barbed`, `Concave`, `DoubleArrow`, `HalfAr `Swallowtail`…), filled and open shapes (`Triangle`, `Circle`, `Oval`, `Target`, `Tag`, `Diamond`, `Hexagon`, `Star`, the `…Open` rings…) and marks (`Bar`, `DoubleBar`, `DotOnBar`, `Slash`, `DoubleSlash`, `Bracket`, `Fork`, `Cross`, `Plus`). Each head fits a box about four times its weight long and wide. A head's weight is the stroke weight scaled by -`arrowScale`, at least 0.25 pt. `LineArrow` has two arms of the head's weight (the line's at 100 %) at right angles and -spreads twice as wide. +`arrowScale`, at least 0.25 pt. `LineArrow` has two arms of the head's weight (the line's weight at 100 % for lines of +0.25 pt or more) at right angles and spreads twice as wide. ## Stroke bounds and clicks diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 638508877..d7d2e6fc5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -42,7 +42,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| | 2026-11-10 | minor | Type advanced: right-click suggestions, Ignore All and Add to Dictionary on a word Auto Spell Check underlined, #4936; evidence added; the dialogs remain; scores and hours unchanged. | -| 2026-11-09 | minor | Stroke panel: Line Arrow, a 41st generated arrowhead: two arms of the head's weight (the line's at 100 % arrowhead scale, at least 0.25 pt) at right angles, a sharp tip and square-cut ends, twice as wide as the other heads; bounds and clicks follow each head's own reach (`stroke.set` `LineArrow`, #5421); scores and estimates unchanged | +| 2026-11-09 | minor | Stroke panel: Line Arrow, a 41st generated arrowhead: two arms of the head's weight (stroke weight × arrowhead scale, at least 0.25 pt: the line's weight at 100 % for lines of 0.25 pt or more) at right angles, a sharp tip and square-cut ends, twice as wide as the other heads; bounds and clicks follow each head's own reach (`stroke.set` `LineArrow`, #5421); scores and estimates unchanged | | 2026-11-09 | minor | Canvas: a 3D effect (Extrude & Bevel, Inflate, Rotate, Revolve) changed on type or a group draws again where its art was and is, measured by that art; the canvas measures through its renderer and draws the part with the art it made to measure it. Exports and Object › Rasterize take the solid of 3D type. Fit to Artwork Bounds and per-artboard SVG export measure every artboard through one renderer, building each solid once. A 3D effect on a fill or stroke that builds nothing (Revolve after Roughen) no longer overflows the stack on the canvas, in Object › Rasterize and in raster and EPS exports: the fill or stroke draws as without it. Tests `tests::changed::a_3d_effect_changed_on_type_or_a_group_changes_where_its_solid_was`, `fx::tests::a_renderer_measures_the_art_on_each_artboard_building_each_solid_once`, `canvas::tests::an_edit_to_a_3d_effect_redraws_with_the_art_measured`, `fx::tests::a_3d_effect_that_builds_nothing_draws_the_item_as_without_it`. Scores and estimates unchanged. | | 2026-11-07 | minor | File formats and Type core (#6147): `.ai`/EPS type keeps its paragraphs' tab stops (positions and alignments; leaders aren't read; at most 1,024 a paragraph and 2^20 a story), and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops; centred and justified lines are aligned as a whole as before; evidence added. Scores and estimates unchanged. | | 2026-11-07 | minor | File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them, and a line a soft return ends is justified; evidence added. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. | @@ -1248,7 +1248,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Cmd/Ctrl-click on the Gradient panel's Gradient Fill box or on the Tools panel's Gradient button resets the gradient of the active Fill or Stroke to the linear White, Black gradient, as one undo step ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). With a gradient stop or a freeform gradient point selected, Alt-clicking a color swatch in the Swatches panel recolors it instead of the inactive Fill or Stroke. - **Freeform gradient colors in their own model:** Object ▸ Expand and Object ▸ Create Gradient Mesh (`object.mesh.create`) mix a freeform gradient's points in the color model they share: points of C50 M20 Y0 K10 give mesh points of C50 M20 Y0 K10, and RGB points keep their values in a working RGB space other than sRGB. The canvas, the gradient chips and the SVG, PDF, EPS and EMF writers mix the points in that model too. PDF and EPS write a gradient whose points are all CMYK, or all Gray in a CMYK file, as a CMYK image, which PDF's Output settings convert as they convert any CMYK image. Points in different models mix in display RGB (on the canvas in a CMYK document that shows transparency, as the inks of each point), and a CMYK document stores the result of Expand as CMYK through the color settings. Points colored along CMYK stops (`paint.editGradient {stops}`, or a CMYK gradient switched to freeform) are CMYK, and so are a point `paint.freeform.splitLine` adds between CMYK points and a point `paint.freeform.addPoint` adds without `color` to CMYK points. Public reference: [Gradients](https://web.archive.org/web/20250714042311/https://helpx.adobe.com/illustrator/using/gradients.html) and [Meshes](https://web.archive.org/web/20250812201019/https://helpx.adobe.com/illustrator/using/meshes.html), which do not state how colors in different models mix; the rule is VectorCraft's own. - - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty-one more arrowheads (among them Line Arrow: two arms of the head's weight (the line's at 100 % scale, at least 0.25 pt) at right angles, cut square, #5421), and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. + - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty-one more arrowheads (among them Line Arrow: two arms of the head's weight (stroke weight × arrowhead scale, at least 0.25 pt: the line's weight at 100 % for lines of 0.25 pt or more) at right angles, cut square, #5421), and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. - **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random, Pressure, Tilt, Bearing or Rotation with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values, Pressure from the first at the lightest pressure to the second at the heaviest, Tilt, Bearing or Rotation; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across, Overlap) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Art Brush Options › Overlap places art on each run between sharp corners so folds do not overlap (`ArtBrush.overlap`; `art_brush_overlap_splits_at_corners`). Still to come: Stylus Wheel, pen pressure on macOS where the windowing layer reports none, and tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web. Under Tints and Tints and Shades, art in black ink alone goes by its ink amount, and a process or gray stroke color's tints and shades stay in that color's model; these are VectorCraft's own rules. Type in a scatter brush's art takes the brush's colorization as its paths do: Tints, Tints and Shades and Hue Shift recolor the characters' solid fills and strokes in each copy the brush draws, and Brush Options' Key Color list, whose first color is Hue Shift's default key, counts the characters' fills and their strokes wider than 0. The Brushes page says that a scatter, art or pattern brush takes its colors from the current stroke color and the brush's colorization method () and does not mention type there; recoloring the characters' paints like the art's fills and strokes is VectorCraft's own rule. The Symbol Stainer mixes the characters' strokes of type toward the stain as it mixes their fills. Evidence: `tests::scatter_colorization_recolors_type`, `tests::art_colors_list_the_most_used_first`, `tests::stain_mixes_type_strokes_too` (vectorcraft-brush).