From ba16468710a17bf4d87a491beb7827694c8a9d9e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 11:40:10 -0700 Subject: [PATCH 1/3] PDF export: a gradient that mixes a spot color with process colors keeps the spot ink A linear or radial gradient whose stops mix a spot color with process colors, or two spot colors, was written in process colors with a warning. It is now written as a DeviceN shading of the process inks its stops use and its spot inks, so each spot ink separates onto its own plate. An off-center midpoint becomes a stop with the inks halfway between its two stops. A gradient that mixes a spot color with RGB or Lab colors is still written without the spot ink, with a warning that says so. --- crates/engine/src/tests/linked_stops.rs | 32 ++++++++++- crates/pdf/src/export/paint.rs | 75 +++++++++++++++++++++++-- docs/target-app-parity.md | 3 +- 3 files changed, 102 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/tests/linked_stops.rs b/crates/engine/src/tests/linked_stops.rs index 2f25cf83c..ce9478464 100644 --- a/crates/engine/src/tests/linked_stops.rs +++ b/crates/engine/src/tests/linked_stops.rs @@ -1,5 +1,5 @@ //! Gradient swatches and gradient stops linked to global and spot swatches: the recorded links, -//! stops following swatch edits, spot stops on their plate and as Separation shadings in PDF. +//! stops following swatch edits, spot stops on their plate and as Separation and DeviceN shadings in PDF. use serde_json::{Value, json}; use vectorcraft_color::{Color, GradientPaint, GradientStop, Paint}; @@ -133,13 +133,39 @@ fn spot_gradients_export_as_separation_shadings() { assert!(pdf.contains("/ShadingType 2") && pdf.contains("/Separation/Ink/DeviceCMYK"), "an Ink → paper gradient is a Separation shading"); // Its function runs over the tints: 80 % (204 / 255) to 0 %. assert!(pdf.contains("/C0[0.8]/C1[0]"), "the shading's tints"); - // Mixing the spot ink with another colour falls back to process colours, with a warning. + // Mixing the spot ink with an RGB color writes the gradient without the spot ink, with a warning. run(&mut s, "paint.editGradient", json!({"stops": [{"offset": 0, "swatch": "Ink"}, {"offset": 1, "color": "#00ff00"}]})); let (pdf, warnings) = pdf_of(&s); - assert!(!pdf.contains("/Separation"), "no Separation for a mixed gradient"); + assert!(!pdf.contains("/Separation") && !pdf.contains("/DeviceN"), "no spot ink for a gradient with an RGB stop"); assert!(warnings.iter().any(|w| w.contains("spot")), "{warnings:?}"); } +#[test] +fn spot_gradients_mixed_with_other_inks_export_as_device_n_shadings() { + let (mut s, _) = setup(); + // Ink → process yellow: a DeviceN shading of Yellow and Ink, with no warning. + run(&mut s, "paint.editGradient", json!({"stops": [{"offset": 0, "swatch": "Ink"}, {"offset": 1, "color": {"c": 0, "m": 0, "y": 0.6, "k": 0}}]})); + let (pdf, warnings) = pdf_of(&s); + assert!(pdf.contains("/ShadingType 2") && pdf.contains("/DeviceN[/Yellow/Ink]/DeviceCMYK"), "a DeviceN shading of Yellow and Ink"); + assert!(pdf.contains("/C0[0 1]/C1[0.6 0]"), "the shading's tints: Yellow and Ink"); + assert!(!warnings.iter().any(|w| w.contains("spot")), "{warnings:?}"); + // Two spot inks, with an off-center midpoint: a stop halfway between the two inks. + run(&mut s, "swatch.new", json!({"name": "Leaf", "color": {"c": 1, "m": 0, "y": 1, "k": 0}, "spot": true})); + run( + &mut s, + "paint.editGradient", + json!({"stops": [{"offset": 0, "swatch": "Ink", "midpoint": 30}, {"offset": 1, "swatch": "Leaf", "tint": 50}]}), + ); + let (pdf, warnings) = pdf_of(&s); + assert!(pdf.contains("/DeviceN[/Ink/Leaf]/DeviceCMYK"), "a DeviceN shading of both spot inks"); + let functions: Vec<_> = pdf.match_indices("/C0[").filter_map(|(i, _)| pdf.get(i..i + 32)).collect(); + assert!( + pdf.contains("/C0[1 0]/C1[0.5019608 0.2509804]") && pdf.contains("/C0[0.5019608 0.2509804]/C1[0 0.5019608]"), + "the tints through the midpoint: {functions:?}" + ); + assert!(!warnings.iter().any(|w| w.contains("spot")), "{warnings:?}"); +} + #[test] fn linked_stops_round_trip() { let (mut s, id) = setup(); diff --git a/crates/pdf/src/export/paint.rs b/crates/pdf/src/export/paint.rs index 24a857797..b7c303487 100644 --- a/crates/pdf/src/export/paint.rs +++ b/crates/pdf/src/export/paint.rs @@ -65,8 +65,8 @@ impl Exporter<'_> { /// The stops of a gradient whose every stop is a tint of one spot ink (or paper white, 0% of /// it) as tints of that ink — a Separation shading — with midpoints as explicit stops (the - /// tint and opacity there are halfway). `None` for other gradients; one that mixes a spot ink - /// with other colours is written in process colours (the PDF writer has no DeviceN). + /// tint and opacity there are halfway). A gradient that mixes a spot ink with other colors + /// gets [`Self::device_n_stops`]. `None` for gradients without a spot ink. fn spot_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option> { let ink = g.stops.iter().find_map(|s| s.swatch.as_deref().filter(|n| self.doc.swatch(n).is_some_and(|w| w.spot)))?; let paper = |c: &Color| c.to_rgba8(1.0)[..3] == [255; 3]; @@ -80,8 +80,7 @@ impl Exporter<'_> { }) .collect(); let Some(tints) = tints else { - self.warn("gradients mixing a spot color with other colors are exported in process colors"); - return None; + return self.device_n_stops(g); }; let space = self.separation(ink)?; let tint = |t: f32| -> krilla::color::Color { SepColor::new(q(t), space.clone()).into() }; @@ -98,6 +97,74 @@ impl Exporter<'_> { Some(out) } + /// The stops of a gradient that mixes spot inks with process colors or with each other, in + /// a DeviceN space of the process inks the stops use (in CMYK order) and the spot inks (in + /// the order the stops name them), with midpoints as explicit stops (the tints and opacity + /// there are halfway). Paper white adds no ink. `None`, with a warning, when a stop is + /// written in RGB or Lab or the spot inks can't share a DeviceN space; the gradient is then + /// written in the colors of its stops. + fn device_n_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option> { + use krilla::color::device_n::{Color as DnColor, Colorant, DeviceNSpace, Process}; + let paper = |c: &Color| c.to_rgba8(1.0)[..3] == [255; 3]; + let doc = self.doc; + fn spot_of<'s>(doc: &vectorcraft_doc::Document, s: &'s vectorcraft_color::GradientStop) -> Option<&'s str> { + s.swatch.as_deref().filter(|n| doc.swatch(n).is_some_and(|w| w.spot)) + } + let mut spots: Vec<(String, [f32; 4])> = Vec::new(); + for s in &g.stops { + if let Some(n) = spot_of(doc, s) + && !spots.iter().any(|(m, _)| m == n) + { + spots.push(self.spot_ink(n)?); + } + } + // Each stop's ink coverage: the four process inks, then one tint per spot ink. + let mut inks: Vec> = Vec::with_capacity(g.stops.len()); + for s in &g.stops { + let mut v = vec![0.0; 4 + spots.len()]; + match spot_of(doc, s) { + Some(n) => { + let i = spots.iter().position(|(m, _)| m == n)?; + v[4 + i] = s.tint; + } + None if s.swatch.is_none() && paper(&s.color) => {} + None => match self.page_color(&s.color) { + Color::Cmyk { c, m, y, k } => v[..4].copy_from_slice(&[c, m, y, k]), + Color::Gray { k } => v[3] = k, + Color::Rgb { .. } | Color::Lab { .. } => { + self.warn("gradients mixing a spot color with RGB or Lab colors are exported without the spot ink"); + return None; + } + }, + } + inks.push(v); + } + let process: Vec = (0..4).filter(|&i| inks.iter().any(|v| q(v[i]) > 0)).collect(); + let colorants = process.iter().map(|&i| Colorant::Process(Process::ALL[i])).chain( + spots.iter().map(|(name, f)| Colorant::Spot { name: name.clone(), alternate: cmyk::Color::new(q(f[0]), q(f[1]), q(f[2]), q(f[3])) }), + ); + let Some(space) = DeviceNSpace::new(colorants) else { + self.warn("gradients whose spot inks can't share a DeviceN color space are exported without the spot inks"); + return None; + }; + let color = |v: &[f32]| -> Option { + let tints = process.iter().map(|&i| q(v[i])).chain(v[4..].iter().map(|&t| q(t))).collect(); + Some(DnColor::new(tints, space.clone())?.into()) + }; + let mut out = vec![]; + for (i, s) in g.stops.iter().enumerate() { + out.push((s.offset, color(&inks[i])?, s.opacity)); + if let Some(n) = g.stops.get(i + 1) + && (s.midpoint - 0.5).abs() > 1e-3 + { + let at = s.offset + (n.offset - s.offset) * s.midpoint; + let half: Vec = inks[i].iter().zip(&inks[i + 1]).map(|(a, b)| (a + b) / 2.0).collect(); + out.push((at, color(&half)?, (s.opacity + n.opacity) / 2.0)); + } + } + Some(out) + } + /// Warn when `effects` (an object's, a fill's or a stroke's) has a visible raster effect: the /// app renders them to images before export (`vectorcraft_engine::export_pdf`), so the ones /// still here (on `what`) can't be written. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 95c0395a7..6fde1dfbb 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -393,7 +393,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Selection, transform & align tools | 6 | 85% | Direct Selection segment click/Delete corrected (#1278); remaining: Free Transform/Scale/Reflect along a rotated box, Global Edit of similar groups, transform nuances (the rotated persistent bounding box is done) | 4–6 | | Drawing tools | 7 | 86% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; Pencil: Alt key toggles to Smooth Tool (#2001); remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, Blob Brush Merge Only with Selection, Touch Type, Live Corners' Relative rounding | 7–12 | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 | -| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) | +| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform gradients in SVG/PDF and mixed spot/process gradients in SVG (exported as stops; PDF export keeps the spot inks in a DeviceN shading), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) | | Strokes, brushes, width profiles | 5 | 90% | the Art brush's Overlap, auto-generated Pattern brush corners and new tile art in Pattern Brush Options (Brush Options for every brush type done), more built-in brush libraries (four generated in code are done), pen pressure on macOS (the windowing layer reports none there), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 8–15 | | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 | @@ -1027,6 +1027,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - PDF export and print composition use the optional shared guarded writer ([pdf-backend.md](pdf-backend.md)); app drawing and output policy and desktop printer submission remain in their existing layers. The shared reader keeps the app-owned import adapter; synthetic tests cover layer and overprint state across windows, long operands, inline images, failed decoding, document limits and incomplete tails. Empty and populated metadata key presence is regression-tested, and CI checks the immutable backend source. Invisible text in a PDF (rendering mode 3, which carries a scanned page's recognized text over the scan) opens as type with no fill or stroke, a run of its own beside visible text on the same line; text that can't come in as type (opened as outlines, Type 3 fonts) is left out with a warning. PDF export, and printing, write characters with no fill or stroke as real text in rendering mode 3, so they draw nothing but stay selectable and searchable, also in PDF/X-1a and PDF/A-2b (#2675, `pdf/src/tests/import_fidelity.rs`, `pdf/src/tests/realtext.rs`). A PDF, or a `.ai` read through its PDF part, whose spot inks have the same values but different names opens with a spot swatch for each ink. The solid fills and strokes of paths and type, and the stops of gradients on paths, link to the swatch of the ink they are painted in, the points of a gradient mesh in one of these inks keep the ink's color, and PDF export writes each of these inks as its own separation (`pdf/src/tests/import_color.rs`). Paint in these inks inside pattern cells, soft masks and type 3 glyphs, and gradients on type in these inks, still import as RGB. Type in one of these inks can also import as RGB when type in another Separation or DeviceN ink is shown with no path drawn between them. + PDF export writes a linear or radial gradient that mixes a spot color with process colors, or two spot colors, as a DeviceN shading of the spot inks and the process inks its stops use, so each spot ink prints on its own plate; an off-center midpoint is written as a stop with the inks halfway between its two stops. A gradient that mixes a spot color with RGB or Lab colors is still written without the spot ink, with a warning (`engine/src/tests/linked_stops.rs`). - `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492). Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620). A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604). A name typed in the desktop save panel without the file type's extension gets it, and when a file of that name exists, which the panel did not ask about, Save, Save As, Save a Copy, Save as Template, the exports and every other write through a save panel ask before replacing it (dialog `replaceFile`; Cancel writes nothing). A name returned by the desktop save panel with the extension of a format the save does not write has it replaced with the saved format's: Save As of a new document saves `card.ait` and `card.png` as `card.vectorcraft`, and Save as Template saves `card.pdf` as `card.vctemplate`. An extension that names no format stays part of the name (`notes.v2` is saved as `notes.v2.vectorcraft`). From 3c95ce529ec48bd8b2822955f2712e4b8966fd2d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 11:50:46 -0700 Subject: [PATCH 2/3] Docs: list the PDF gradients that still drop the spot ink The Feature areas row still listed mixed spot/process gradients as missing in PDF. It and the detailed row now name SVG, plus PDF gradients that mix a spot color with RGB or Lab stops, which are still written without the spot ink. --- docs/target-app-parity.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6fde1dfbb..9b987e789 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -123,7 +123,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Selection, transform & align tools | 6 | 85% | Direct Selection segment click/Delete corrected (#1278); open-path anchor Delete keeps gaps; Select › Start Global Edit finds the paths, compound paths and symbol instances of the selected object's shape (Appearance, Size and artboard options) and gives an edit of one to all of them in proportion, as one undo step (#3521), and on a group the groups like it, an edit inside one reaching the same object in the others (#4939); the Align panel menu's Align to Glyph Bounds › Point Text and Area Text measure type by its glyphs' ink for Align and Distribute (#4231); remaining: Free Transform/Scale/Reflect along a rotated box, Global Edit of rotated copies and excluding a match with Shift-click, transform nuances (no-op anchor Align history regression-tested, #1180 follow-up) | 4–6 | estimated; Global Edit regression-tested (`crates/engine/src/tests/globaledit.rs`; Quick Action, menu label and Escape in `panels::properties::tests::start_global_edit_quick_action_and_escape`) | | Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool; see [ui-parity.md](ui-parity.md) | 8–13 | estimated; Curvature point display regression-tested (#1236, in part); Polygon/Star exact-size count persistence regression-covered; Live Corners' Relative rounding done (#3091), score unchanged | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder on the reporter's file (#937: art past 4,096 segments is cut by lines again; unconfirmed on the file itself); gap detection shares Live Paint’s finder, with a 72 pt custom cap and the existing options; Live Paint Gap Options | 4–8 | estimated; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Simplify of a Direct Selection part of a path regression-tested (`tests/simplify_part.rs`); Divide open-cutting-path geometry and appearance regression-tested (#1307); the Shape Builder's edge-source grid is bounded in memory and files a segment once per cell (`planar.rs` test `rect_index_holds_a_bounded_number_of_references`); live compound shapes (Make, Alt-click, member Shape Modes, Release, Expand, saved and exported) regression-tested (#4522) | -| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient additions are done (dither, perceptual blending, presets); freeform and mixed spot/process gradients in SVG/PDF | 4–7 | estimated; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | +| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, and PDF gradients mixing a spot color with RGB or Lab stops | 4–7 | estimated; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | | Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (four generated libraries, User Defined, Other Library…, Save Brushes); remaining: Art brush Overlap, Pattern brush auto corners, pen pressure on macOS, tilt/bearing/rotation | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, 3D Classic, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | @@ -393,7 +393,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Selection, transform & align tools | 6 | 85% | Direct Selection segment click/Delete corrected (#1278); remaining: Free Transform/Scale/Reflect along a rotated box, Global Edit of similar groups, transform nuances (the rotated persistent bounding box is done) | 4–6 | | Drawing tools | 7 | 86% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; Pencil: Alt key toggles to Smooth Tool (#2001); remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, Blob Brush Merge Only with Selection, Touch Type, Live Corners' Relative rounding | 7–12 | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 | -| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform gradients in SVG/PDF and mixed spot/process gradients in SVG (exported as stops; PDF export keeps the spot inks in a DeviceN shading), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) | +| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform gradients in SVG/PDF and mixed spot/process gradients in SVG (exported as stops; PDF export keeps the spot inks in a DeviceN shading, except next to RGB or Lab stops), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) | | Strokes, brushes, width profiles | 5 | 90% | the Art brush's Overlap, auto-generated Pattern brush corners and new tile art in Pattern Brush Options (Brush Options for every brush type done), more built-in brush libraries (four generated in code are done), pen pressure on macOS (the windowing layer reports none there), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 8–15 | | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 | From f66b583b5b0a7ceb559df7c3e00a3013117b28f1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 12:51:05 -0700 Subject: [PATCH 3/3] Tests: mixed spot gradients export as DeviceN shadings when radial or in a Lab ink - A radial gradient from a spot swatch to a CMYK color is written as a DeviceN shading (ShadingType 3) of Yellow and the spot ink. - A Lab spot color used as a flat fill and in a mixed gradient is written as one Separation space with the Lab alternate, which the DeviceN space names under /Colorants. - docs/roadmap.md lists the gradients that still export without the spot ink: mixed spot/process gradients in SVG and PDF gradients with RGB or Lab stops. docs/target-app-parity.md also notes that a Perceptual gradient is written to PDF as an image. --- crates/engine/src/tests/labspots.rs | 24 ++++++++++++++++++++++++ crates/engine/src/tests/linked_stops.rs | 14 ++++++++++++++ docs/roadmap.md | 4 ++-- docs/target-app-parity.md | 2 +- 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/tests/labspots.rs b/crates/engine/src/tests/labspots.rs index 206411888..358a1e606 100644 --- a/crates/engine/src/tests/labspots.rs +++ b/crates/engine/src/tests/labspots.rs @@ -202,3 +202,27 @@ fn lab_spots_export_with_a_lab_alternate() { assert!(text.contains("/Separation/Lab#20Ink/DeviceCMYK") && !text.contains("/Lab<<")); assert_xref_valid(&pdf); } + +#[test] +fn lab_spots_in_device_n_gradients_keep_one_alternate() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let (mut s, _) = lab_ink(); + let g = NodeId(run(&mut s, "shape.rectangle", json!({"x": 50, "y": 10, "width": 30, "height": 30}))["id"].as_u64().unwrap()); + run( + &mut s, + "paint.setFill", + json!({"ids": [g.0], "gradient": {"stops": [{"offset": 0, "swatch": "Lab Ink"}, {"offset": 1, "color": {"c": 0, "m": 0, "y": 0.6, "k": 0}}]}}), + ); + let pdf = pdf_bytes(&s); + let text = String::from_utf8_lossy(&pdf); + // One Separation space for the ink, with the Lab alternate: the flat fill paints in it, and the + // gradient's DeviceN space names it as the ink's colorant. + assert_eq!(text.matches("[/Separation/Lab#20Ink").count(), 1, "one Separation space for the ink"); + assert!(!text.contains("/Separation/Lab#20Ink/DeviceCMYK"), "no CMYK Separation space for the ink"); + let at = text.find("[/Separation/Lab#20Ink[/Lab<<").expect("the ink's Lab Separation space"); + let obj = text[..at].trim_end().strip_suffix("obj").and_then(|h| h.trim_end().rsplit('\n').next()).expect("the space's object header"); + let id = obj.split_whitespace().next().expect("the space's object number"); + assert!(text.contains("/DeviceN[/Yellow/Lab#20Ink]"), "a DeviceN shading of Yellow and the ink"); + assert!(text.contains(&format!("/Colorants<>")), "the DeviceN space's colorant is the ink's Lab Separation space"); + assert_xref_valid(&pdf); +} diff --git a/crates/engine/src/tests/linked_stops.rs b/crates/engine/src/tests/linked_stops.rs index ce9478464..33c622ec7 100644 --- a/crates/engine/src/tests/linked_stops.rs +++ b/crates/engine/src/tests/linked_stops.rs @@ -166,6 +166,20 @@ fn spot_gradients_mixed_with_other_inks_export_as_device_n_shadings() { assert!(!warnings.iter().any(|w| w.contains("spot")), "{warnings:?}"); } +#[test] +fn radial_spot_gradients_mixed_with_other_inks_export_as_device_n_shadings() { + let (mut s, _) = setup(); + run( + &mut s, + "paint.editGradient", + json!({"kind": "radial", "stops": [{"offset": 0, "swatch": "Ink"}, {"offset": 1, "color": {"c": 0, "m": 0, "y": 0.6, "k": 0}}]}), + ); + let (pdf, warnings) = pdf_of(&s); + assert!(pdf.contains("/ShadingType 3") && pdf.contains("/DeviceN[/Yellow/Ink]/DeviceCMYK"), "a radial DeviceN shading of Yellow and Ink"); + assert!(pdf.contains("/C0[0 1]/C1[0.6 0]"), "the shading's tints: Yellow and Ink"); + assert!(!warnings.iter().any(|w| w.contains("spot")), "{warnings:?}"); +} + #[test] fn linked_stops_round_trip() { let (mut s, id) = setup(); diff --git a/docs/roadmap.md b/docs/roadmap.md index 5f6245138..979eb1121 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -86,10 +86,10 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V | M0 | Skeleton + vertical slice | ✅ done | — | | M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu | 12–18 | -| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | +| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | | M2 | Drawing tools + smart guides | ✅ mostly done (Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending) | 12–18 | | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) | -| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 | +| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; confirm the Scale Strokes & Effects default | 4–8 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9b987e789..d6fb2d9a0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1027,7 +1027,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - PDF export and print composition use the optional shared guarded writer ([pdf-backend.md](pdf-backend.md)); app drawing and output policy and desktop printer submission remain in their existing layers. The shared reader keeps the app-owned import adapter; synthetic tests cover layer and overprint state across windows, long operands, inline images, failed decoding, document limits and incomplete tails. Empty and populated metadata key presence is regression-tested, and CI checks the immutable backend source. Invisible text in a PDF (rendering mode 3, which carries a scanned page's recognized text over the scan) opens as type with no fill or stroke, a run of its own beside visible text on the same line; text that can't come in as type (opened as outlines, Type 3 fonts) is left out with a warning. PDF export, and printing, write characters with no fill or stroke as real text in rendering mode 3, so they draw nothing but stay selectable and searchable, also in PDF/X-1a and PDF/A-2b (#2675, `pdf/src/tests/import_fidelity.rs`, `pdf/src/tests/realtext.rs`). A PDF, or a `.ai` read through its PDF part, whose spot inks have the same values but different names opens with a spot swatch for each ink. The solid fills and strokes of paths and type, and the stops of gradients on paths, link to the swatch of the ink they are painted in, the points of a gradient mesh in one of these inks keep the ink's color, and PDF export writes each of these inks as its own separation (`pdf/src/tests/import_color.rs`). Paint in these inks inside pattern cells, soft masks and type 3 glyphs, and gradients on type in these inks, still import as RGB. Type in one of these inks can also import as RGB when type in another Separation or DeviceN ink is shown with no path drawn between them. - PDF export writes a linear or radial gradient that mixes a spot color with process colors, or two spot colors, as a DeviceN shading of the spot inks and the process inks its stops use, so each spot ink prints on its own plate; an off-center midpoint is written as a stop with the inks halfway between its two stops. A gradient that mixes a spot color with RGB or Lab colors is still written without the spot ink, with a warning (`engine/src/tests/linked_stops.rs`). + PDF export writes a linear or radial gradient that mixes a spot color with process colors, or two spot colors, as a DeviceN shading of the spot inks and the process inks its stops use, so each spot ink prints on its own plate; an off-center midpoint is written as a stop with the inks halfway between its two stops. A gradient that mixes a spot color with RGB or Lab colors is still written without the spot ink, with a warning, and a gradient with the Perceptual method is written as an image, without the spot ink. A Lab spot color in such a shading is named by the same Separation space, with the Lab alternate, as its flat fills (`engine/src/tests/linked_stops.rs`, `engine/src/tests/labspots.rs`). - `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492). Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620). A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604). A name typed in the desktop save panel without the file type's extension gets it, and when a file of that name exists, which the panel did not ask about, Save, Save As, Save a Copy, Save as Template, the exports and every other write through a save panel ask before replacing it (dialog `replaceFile`; Cancel writes nothing). A name returned by the desktop save panel with the extension of a format the save does not write has it replaced with the saved format's: Save As of a new document saves `card.ait` and `card.png` as `card.vectorcraft`, and Save as Template saves `card.pdf` as `card.vctemplate`. An extension that names no format stays part of the name (`notes.v2` is saved as `notes.v2.vectorcraft`).