From 47571008944b8e46322ad432ce30601002cad954 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 22:15:31 -0700 Subject: [PATCH 001/140] Outline Stroke: geometry effects reshape the path that is outlined, as on the canvas Outline Stroke put a stroke's geometry effects, such as Offset Path or Zig Zag, on the filled outline it made, and it put the object's geometry effects on the result: the outline, or the group that holds the outlines and the fill copies. The effects then reshaped the outline: an Offset Path of 10 pt on a 4 pt stroke gave a band 24 pt wide, while the canvas offsets the path and strokes it 4 pt wide. Outline Stroke now applies the object's visible geometry effects to the path first, and then applies each stroke's own geometry effects to the path that stroke outlines, as the canvas does. A brushed stroke's art follows that path too. The filled outline of a stroke without a brush keeps the stroke's other effects, and the result keeps the object's other effects. Expand's Stroke option and Flatten Transparency's Convert All Strokes to Outlines outline strokes the same way. Docs: docs/target-app-parity.md. --- crates/engine/src/cmd/pathops.rs | 42 ++++++- crates/engine/src/tests_outlinestroke.rs | 134 ++++++++++++++++++++++- docs/target-app-parity.md | 1 + 3 files changed, 174 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index a4e68f07e..37e142ec3 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -633,12 +633,18 @@ pub(crate) fn outlined_stroke( /// Path `l` with every stroke outlined, in appearance order: runs of fills stay on copies of /// the path, each stroke becomes its outline (or brush art), grouped when there are several. -/// `None` when `l` has no stroke to outline. +/// As on the canvas, the path's geometry effects ([`reshaped`]) shape the path first, and each +/// stroke's own geometry effects then shape the path that stroke outlines; [`outlined_stroke`] +/// gets the stroke without them. `None` when `l` has no stroke to outline. pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], l: &Node) -> Option { + use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, is_geometry}; if !l.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if outlinable(st))) { return None; } + let reshaped = reshaped(l); + let l = reshaped.as_ref().unwrap_or(l); let (path, rule) = node_path(l)?; + let ctx = GeomContext::of(l); let mut parts: Vec = vec![]; let mut fills: Vec = vec![]; for item in &l.appearance.items { @@ -648,7 +654,13 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br if !fills.is_empty() { parts.push(styled_copy(d, l, std::mem::take(&mut fills))); } - parts.extend(outlined_stroke(d, brushes, &path, rule, st)); + let mut plain = st.clone(); + plain.effects.retain(|e| !is_geometry(&e.id)); + let stroked = match path.bounds() { + Some(b) if has_geometry(&st.effects) => apply_geometry_with(&st.effects, &path, b, &ctx.item(item)), + _ => path.clone(), + }; + parts.extend(outlined_stroke(d, brushes, &stroked, rule, &plain)); } _ => {} } @@ -659,6 +671,32 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br assemble(d, l, parts) } +/// Path `l` as the canvas paints its fills and strokes: its visible geometry effects applied to its +/// path (a plain path then), and every geometry effect of its own removed. `None` when it has no +/// visible geometry effect. +pub(crate) fn reshaped(l: &Node) -> Option { + use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, is_geometry}; + if !has_geometry(&l.appearance.effects) { + return None; + } + let (path, _) = node_path(l)?; + let g = apply_geometry_with(&l.appearance.effects, &path, path.bounds()?, &GeomContext::of(l)); + let mut m = with_path(l, g); + m.appearance.effects.retain(|e| !is_geometry(&e.id)); + Some(m) +} + +/// Path or compound path `l` with `path` as its path, a plain path. +pub(crate) fn with_path(l: &Node, path: PathData) -> Node { + let mut m = l.clone(); + m.kind = match &l.kind { + NodeKind::Path { rule, clipping, guide, .. } => NodeKind::Path { path, rule: *rule, live: None, clipping: *clipping, guide: *guide }, + NodeKind::Compound { rule, .. } => NodeKind::Path { path, rule: *rule, live: None, clipping: false, guide: false }, + k => k.clone(), + }; + m +} + /// A plain copy of path `l` (new ids, no transparency, name or mask) painted with `items`. pub(crate) fn styled_copy(d: &mut Document, l: &Node, items: Vec) -> Node { let mut f = d.reid(l); diff --git a/crates/engine/src/tests_outlinestroke.rs b/crates/engine/src/tests_outlinestroke.rs index 6249df2e1..69f2d4567 100644 --- a/crates/engine/src/tests_outlinestroke.rs +++ b/crates/engine/src/tests_outlinestroke.rs @@ -1,6 +1,8 @@ //! Outline Stroke (`object.path.outlineStroke`) and the live Outline Stroke effect produce the //! stroke as the canvas paints it: dashes, arrowheads, width profiles, alignment, every stroke -//! item in order, with the stroke's opacity and blend mode moved to the new fill. +//! item in order, with the stroke's opacity and blend mode moved to the new fill. Outline Stroke, +//! Expand's Stroke option and Flatten Transparency's Convert All Strokes to Outlines apply the +//! object's geometry effects and each stroke's own to the path before outlining that stroke. use serde_json::{Value, json}; use vectorcraft_color::BlendMode; @@ -148,6 +150,136 @@ fn the_live_effect_outlines_the_stroke_it_sits_on() { assert!(close(g.bounds().unwrap(), Rect::new(0.0, 49.0, 100.0, 51.0), 0.01), "{:?}", g.bounds()); } +fn render(s: &Session) -> vectorcraft_render::Rendered { + let doc = s.doc().unwrap().doc.clone(); + let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + vectorcraft_render::Renderer::new().render(&doc, 400, 300, vectorcraft_geom::Affine::IDENTITY, &opts) +} + +/// Pixels whose color differs by more than `tol` in any channel. +fn differing(a: &vectorcraft_render::Rendered, b: &vectorcraft_render::Rendered, tol: u8) -> usize { + a.pixels + .as_chunks::<4>() + .0 + .iter() + .zip(b.pixels.as_chunks::<4>().0) + .filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > tol)) + .count() +} + +/// Does `n` or anything in it have a geometry effect, its own or a fill's or stroke's? +fn has_geometry_effects(n: &Node) -> bool { + let geometry = |e: &vectorcraft_doc::Effect| vectorcraft_render::effects::is_geometry(&e.id); + let mut found = false; + n.walk(&mut |m| found |= m.appearance.effects.iter().any(geometry) || m.appearance.items.iter().any(|i| i.effects().iter().any(geometry))); + found +} + +/// A selected 100 pt square at (150, 100) with a 4 pt black stroke and fill `fill` (paint.setFill's), +/// and `effects` (effect.apply's `effect` and `params`) on appearance item `item` (`null`: on the +/// object). +fn square_with(s: &mut Session, fill: Value, item: Value, effects: &[(&str, Value)]) { + s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap(); + s.execute("paint.setFill", &fill).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap(); + s.execute("stroke.set", &json!({"weight": 4})).unwrap(); + for (effect, params) in effects { + s.execute("effect.apply", &json!({"effect": effect, "params": params, "item": item})).unwrap(); + } +} + +/// The geometry effects the tests put on the stroke or on the object. +fn geometry_effects() -> [(&'static str, Value); 2] { + [("path.offsetPath", json!({"offset": 10})), ("distort.zigZag", json!({"size": 6, "ridges": 3}))] +} + +/// The commands that outline strokes: Outline Stroke, Expand with Stroke alone, and Flatten +/// Transparency with Convert All Strokes to Outlines. +fn outlining() -> [(&'static str, Value); 3] { + [ + ("object.path.outlineStroke", json!({})), + ("object.expand", json!({"object": false, "fill": false, "stroke": true})), + ("object.flattenTransparency", json!({"strokesToOutlines": true})), + ] +} + +/// What `command` with `params` leaves in place of the selected object. +fn outlined_by(s: &mut Session, command: &str, params: &Value) -> Node { + let r = s.execute(command, params).unwrap_or_else(|e| panic!("{command}: {e}")); + let id = NodeId(r["ids"][0].as_u64().unwrap()); + s.doc().unwrap().doc.node(id).cloned().unwrap() +} + +/// For each command in [`outlining`] and each of [`geometry_effects`] on `item` of a square filled +/// with `fill`: the pixels that differ by more than 64 from the canvas before, and whether a +/// geometry effect is left on the result. +fn outlined_as_drawn(fill: Value, item: Value) -> Vec<(&'static str, &'static str, usize, bool)> { + let mut results = vec![]; + for (command, p) in outlining() { + for (effect, params) in geometry_effects() { + let mut s = session(); + square_with(&mut s, fill.clone(), item.clone(), &[(effect, params)]); + let before = render(&s); + let n = outlined_by(&mut s, command, &p); + results.push((command, effect, differing(&before, &render(&s), 64), has_geometry_effects(&n))); + } + } + results +} + +/// What [`outlined_as_drawn`] gives when every outline looks as the canvas drew the object. +fn as_drawn() -> Vec<(&'static str, &'static str, usize, bool)> { + outlining().iter().flat_map(|(command, _)| geometry_effects().map(|(effect, _)| (*command, effect, 0, false))).collect() +} + +/// A stroke's own geometry effects reshape the path it strokes, as on the canvas: the outline is +/// the stroke of the offset or zig-zagged path, not the stroke's outline offset or zig-zagged. +#[test] +fn a_geometry_effect_on_the_stroke_shapes_the_path_it_outlines() { + assert_eq!(outlined_as_drawn(json!({"none": true}), json!(1)), as_drawn()); + // The stroke of the square offset by 10: 4 pt wide around a 120 pt square. + let mut s = session(); + square_with(&mut s, json!({"none": true}), json!(1), &[("path.offsetPath", json!({"offset": 10}))]); + let n = outline(&mut s); + assert!(close(n.geometric_bounds().unwrap(), Rect::new(138.0, 88.0, 262.0, 212.0), 0.01), "{:?}", n.geometric_bounds()); + assert!(near(area(&n), 124.0 * 124.0 - 116.0 * 116.0, 2.0), "{}", area(&n)); +} + +/// A drop shadow on a stroke with a geometry effect stays on the outline, which draws as the +/// stroke did. +#[test] +fn the_outline_of_a_reshaped_stroke_keeps_its_drop_shadow() { + let mut s = session(); + square_with(&mut s, json!({"none": true}), json!(1), &[("path.offsetPath", json!({"offset": 10})), ("stylize.dropShadow", json!({}))]); + let before = render(&s); + let n = outline(&mut s); + let kept: Vec<&str> = n.appearance.items.iter().flat_map(|i| i.effects()).map(|e| e.id.as_str()).collect(); + assert_eq!(kept, ["stylize.dropShadow"]); + assert_eq!(differing(&before, &render(&s), 64), 0, "the shadowed outline draws as the stroke did"); +} + +/// The brush art of a brushed stroke with a geometry effect follows the path the effect makes. +#[test] +fn a_brush_draws_along_the_path_the_stroke_effects_make() { + let mut s = session(); + square_with(&mut s, json!({"none": true}), json!(1), &[("path.offsetPath", json!({"offset": 10}))]); + s.execute("brush.apply", &json!({"name": "Tapered Stroke"})).unwrap(); + let id = s.doc().unwrap().selection.objects[0]; + let st = s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().cloned().unwrap(); + assert!(st.brush.is_some() && st.effects.len() == 1, "{st:?}"); + let before = render(&s); + let n = outline(&mut s); + assert!(!vectorcraft_brush::has_brush(&n) && !has_geometry_effects(&n), "brush art: {n:?}"); + assert_eq!(differing(&before, &render(&s), 64), 0, "the brush art draws as the brushed stroke did"); +} + +/// The object's geometry effects reshape the path its fills paint and its strokes outline, as on +/// the canvas, and do not stay on the result. +#[test] +fn a_geometry_effect_on_the_object_shapes_the_path_it_outlines() { + assert_eq!(outlined_as_drawn(json!({"color": "#33aa55"}), Value::Null), as_drawn()); +} + #[test] fn brushed_strokes_outline_to_their_brush_art() { let mut s = session(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7f666994a..220ec990b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -534,6 +534,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Geometry and effects:** - **Open-path anchor Delete:** Delete/Backspace removes selected anchors and their adjoining segments, leaving drawable runs as separate open subpaths of the original object. Surviving curves and appearance stay intact; one Undo restores the document and selection, and a second Delete removes the selected remainder. Explicit Remove Anchor Points still reconnects/refits. Four core regressions, one full-frame Delete/Backspace regression and three existing removal/segment controls pass, covering these distinctions, multiple deletions, transformed geometry and mixed whole-object selection. Behaviour references: the legacy public [Illustrator reference guide, Add or delete an anchor point](https://helpx.adobe.com/content/dam/help/en/pdf/illustrator_reference.pdf) distinguishes keyboard deletion from dedicated removal; the current [Add or remove anchor points](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/add-or-remove-anchor-points.html) describes the dedicated tools. Other G11 gaps and scores/hours remain unchanged. - Pathfinder (10 exact curve booleans; single-result shape operations retain source names and SVG IDs, #1293), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. With one group selected alone, the Pathfinder panel's buttons work on its members (among other selected objects a group still acts as one shape), and a greyed-out button says what it needs (#1450; behaviour reference: [combine objects using Pathfinder effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/combine-objects-using-pathfinder-effects.html); one plain path selected alone stays disabled, which the documentation doesn't settle). Split Into Grid takes the number, height or width, gutter and total of the rows and of the columns, Add Guides and Preview, and several selected paths become one grid painted as the topmost one (#1462, `object.path.splitIntoGrid`; behaviour reference: [divide or split objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/divide-or-split-objects.html); a typed height or width keeps the gutter and changes the total, our choice). Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart. In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635). + Outline Stroke, Expand's Stroke option and Flatten Transparency's Convert All Strokes to Outlines apply the object's visible geometry effects (Offset Path, Zig Zag and the others) to the path, and then apply each stroke's own geometry effects before outlining that stroke, as the canvas draws it. The filled outline of a stroke without a brush keeps the stroke's other effects, such as a drop shadow. - Divide uses open lines, bent paths and curves as cutting edges (#1307), preserving the frontmost filled face's appearance and holes. Engine tests cover implicit closure of filled open contours and unrelated objects; native automation confirms independent piece movement, one Undo/Redo, canvas/window captures and an exact saved-document read by a separate CLI process. The shared planar map retains its 4,096-segment cap and filled-area fallback; other Pathfinder operations are unchanged. - Type that Transform effects only move and copy (no scale, rotation, reflection or Random) exports as type in each place, and PDF export writes every copy as selectable, searchable text, when its characters are in solid colors without underline or strikethrough, the type object has no fills or strokes of its own, no inline graphics and no perspective, its transform does not scale or shear stroked characters, and the copies do not overlap. When the effect is on the type itself, the type can also have transparency, an opacity mask and raster effects. When the effect is on the group or layer holding the type, the type has no transparency or effects of its own and is not in a clipping path, the group or layer has no fill, stroke or Pathfinder effect of its own, and no group between them has a fill, stroke, Pathfinder or geometry effect. Type still exports as outlines when the effect stops its copies early, or when the copies would number more than 10,000 or take more than 20,000,000 bytes of document data, and so does other type under a geometry effect. - Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)). From 74f5f6e58f8a1b482bbc648179dcac035d58b2b5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 15:16:24 -0700 Subject: [PATCH 002/140] Actions: Batch's Override Export sends Export for Screens files to the destination folder With Override Export on, action.batch gave each document.export, document.exportSelection and document.exportForScreens step a path in the destination folder. document.exportForScreens reads folder and has no path parameter, so its files still went to the step's own folder, or came back as data when the step named none. assets.export was not overridden at all. Override Export now gives a document.exportForScreens or assets.export step the folder destinationFolder/, in place of the step's own folder or an export preset's. Each source file gets its own folder, named after the file without its extension, and each data set gets one named _. The export names the files in that folder as it does outside Batch. Two data sets, or two source files whose names without the extension differ, no longer write over each other's files when their artboards or assets have the same names. Other export steps are unchanged. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/fileio/action_batch.rs | 101 +++++++++++++++++-- crates/engine/src/cmd/fileio/mod.rs | 2 +- docs/mcp.md | 5 +- docs/target-app-parity.md | 1 + 4 files changed, 99 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index 7e757eb19..feebde275 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -248,7 +248,9 @@ fn write_result(s: &mut Session, path: &str, ext: &str, as_copy: bool) -> Result } /// Point Save / Export steps at `dest_folder/{stem}.{ext}` when overrides are on: `ext` is the -/// extension of the step's `format` ([`format_extension`]), else `default_ext`. +/// extension of the step's `format` ([`format_extension`]), else `default_ext`. Export for Screens +/// and Export Assets steps get `dest_folder/{stem}` as their `folder`, one folder for each source +/// file or data set, and name the files in it themselves. #[cfg(not(target_arch = "wasm32"))] fn rewrite_paths( steps: &[Value], @@ -267,18 +269,22 @@ fn rewrite_paths( let id = c.get("command").and_then(Value::as_str).unwrap_or(""); let mut c = c.clone(); let rewrite = (override_save && matches!(id, "document.save" | "file.save" | "file.saveAs")) - || (override_export && matches!(id, "document.export" | "document.exportForScreens" | "document.exportSelection")); + || (override_export && matches!(id, "document.export" | "document.exportForScreens" | "document.exportSelection" | "assets.export")); if !rewrite { return c; } let params = c.get("params").cloned().unwrap_or(json!({})); let mut params = params.as_object().cloned().unwrap_or_default(); - let ext = match params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()) { - Some(f) => format_extension(f), - None => default_ext.to_string(), - }; - let file = std::path::Path::new(folder).join(format!("{stem}.{ext}")); - params.insert("path".into(), json!(file.to_string_lossy())); + if matches!(id, "document.exportForScreens" | "assets.export") { + params.insert("folder".into(), json!(std::path::Path::new(folder).join(stem).to_string_lossy())); + } else { + let ext = match params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()) { + Some(f) => format_extension(f), + None => default_ext.to_string(), + }; + let file = std::path::Path::new(folder).join(format!("{stem}.{ext}")); + params.insert("path".into(), json!(file.to_string_lossy())); + } if let Some(o) = c.as_object_mut() { o.insert("params".into(), Value::Object(params)); } @@ -470,6 +476,85 @@ mod tests { assert!(file_names(&dest).is_empty(), "{r}"); } + /// Override Export writes an Export for Screens step's files into a folder for each source file + /// inside the destination folder, under the names the export gives them, whether the step names + /// a folder of its own, takes one from an export preset or names none. With Override Export off, + /// the files go to the step's folder. + #[test] + fn batch_override_export_sends_export_for_screens_files_to_the_destination_folder() { + let src = tmp("screens-src"); + write_svg(&src, "a.svg", "#f00"); + write_svg(&src, "b.svg", "#0f0"); + let own = tmp("screens-own"); + let svg = json!([{"format": "svg"}]); + let preset = json!({"name": "Own Folder", "settings": {"formats": svg, "folder": own.to_string_lossy()}}); + let steps = [json!({"folder": own.to_string_lossy(), "formats": svg}), json!({"exportPreset": "Own Folder"}), json!({"formats": svg})]; + for (i, params) in steps.into_iter().enumerate() { + let dest = tmp(&format!("screens-out-{i}")); + let mut s = Session::new(); + s.execute("document.exportPresets.save", &preset).unwrap(); + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "document.exportForScreens", "params": params}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{params}: {r}"); + assert_eq!(file_names(&dest), ["a", "b"], "{params}: {r}"); + for stem in ["a", "b"] { + assert_eq!(file_names(&dest.join(stem)), ["Artboard-1.svg"], "{params}: {r}"); + } + assert!(file_names(&own).is_empty(), "{params}: {r}"); + } + let dest = tmp("screens-out-off"); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "destinationFolder": dest.to_string_lossy(), + "overrideExport": false, + "commands": [{"command": "document.exportForScreens", "params": {"folder": own.to_string_lossy(), "formats": svg}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(file_names(&own), ["Artboard-1.svg"], "{r}"); + assert!(file_names(&dest).is_empty(), "{r}"); + } + + /// Override Export writes an Export Assets step's files into a folder for each data set inside + /// the destination folder, under the assets' names. + #[test] + fn batch_override_export_sends_asset_export_files_to_the_destination_folder() { + let own = tmp("assets-own"); + let dest = tmp("assets-out"); + let mut s = with_data_sets(&["a", "b"]); + let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("assets.add", &json!({"ids": [id], "name": "Logo"})).unwrap(); + let r = s + .execute( + "action.batch", + &json!({ + "source": "datasets", + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "assets.export", "params": {"folder": own.to_string_lossy(), "formats": [{"format": "svg"}]}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(file_names(&dest), ["Untitled_a", "Untitled_b"], "{r}"); + for stem in ["Untitled_a", "Untitled_b"] { + assert_eq!(file_names(&dest.join(stem)), ["Logo.svg"], "{r}"); + } + assert!(file_names(&own).is_empty(), "{r}"); + } + /// Override Save names each file with the extension of the format the step names, also over /// data sets. #[test] diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index dd3f54e7e..224f8e754 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -187,7 +187,7 @@ pub fn specs() -> Vec { "Batch…", [], None, - "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\`, `:`, `<`, `>`, `\"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and `-2`, `-3`… added to a name already used). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}", + "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\`, `:`, `<`, `>`, `\"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and `-2`, `-3`… added to a name already used). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}", always, action_batch::action_batch ), diff --git a/docs/mcp.md b/docs/mcp.md index 265c55663..e782fd13f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1154,7 +1154,10 @@ already walks Image Trace. Short agent entry points: Export end the file name of a step with `format` in that `format`, in lower case and without a leading dot, when it is one of the format's extensions (`.svg` and `SVG` give `.svg`, `.JPEG` gives `.jpeg`), and otherwise in the format's first extension (`png8` gives `.png`, `template` gives `.vctemplate`). A step without `format` keeps the - source file's extension (`.vectorcraft` over data sets). + source file's extension (`.vectorcraft` over data sets). Override Export gives a `document.exportForScreens` or + `assets.export` step the `folder` `destinationFolder/`, in place of the step's own folder or an export + preset's: one folder for each source file, named after the file without its extension, or for each data set, + named `_`. The export names the files in it as it does outside Batch. - Image Trace: `imageTrace.presets`, `imageTrace.make {id?, preset?, params?, view?}` (or `imageTrace.makeAndExpand`), `imageTrace.setView`, `imageTrace.expand`, `imageTrace.release`. - Live Paint: `livePaint.make`, `livePaint.merge`, `livePaint.fill {group?, point}`, `livePaint.strokeEdge`, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7b822f3f1..5b0689db7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -430,6 +430,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Variables: `dataset.select`, `dataset.next` and `dataset.prev` name the area type and type on a path whose text they rewrote and that doesn't fit, in the reply's `overflow` key, as `text.setText` does. - Variables: Lock Variables (`variable.lock`, in the Variables panel's bottom bar and menu) stops variables from being defined, deleted or renamed until they are unlocked, while objects can still be bound and unbound and data sets still change and apply; the lock is saved with the document. - Batch…: Override Save and Override Export name each file with the extension of the format the step writes, however the step spells the format (`.svg` and `SVG` give `.svg`, `png8` gives `.png`), in `action.batch`. + - Batch…: Override Export writes the files of an Export for Screens or Export Assets step into a folder for each source file or data set inside the destination folder, under the names the export gives them (`action.batch`). - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. - Batch… over data sets names each file after the document and the data set (`action.batch`). It writes `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set used. - Export for Screens, Save for Web (slice images, and files named after the document's title) and the Libraries panel name files with the characters Windows does not allow written as `-` and with a `-` after a Windows device name such as `CON`, on every platform (`document.exportForScreens`, `document.exportForWeb`, `library.create`). From 983df1a9d1b1b63ccee9449689254a2cf12982de Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 16:22:57 -0700 Subject: [PATCH 003/140] Character: Anti-aliasing Method sets how a type object's edges are drawn as pixels The reference app's Character panel has an anti-aliasing method for type, with the options None, Sharp, Crisp and Strong. VectorCraft had no such control or attribute. The Character panel has an Anti-aliasing Method dropdown under Character Rotation, and text.setFormat takes antiAlias. The method belongs to the whole type object and to every frame of its thread. It sets how the edges of the characters are drawn as pixels: on the canvas, in raster exports and when the type is rasterized. VectorCraft defines the methods. Sharp, the default, draws the edges as before. None paints a pixel only where the type covers at least half of it. Crisp shows a pixel that the type covers by a share c at 2c - 0.5, and Strong shows it at 2c, each kept between 0 and 1. The opacity of the type's paints is kept. In an export that supersamples its art, the type is drawn at the export's own resolution, and the averaging keeps its edges as the method draws them. Type is drawn as Sharp when it holds inline graphics, when its own fills or strokes include a brush stroke or a blend mode other than Normal, when it overprints while Overprint Preview is on, and on the canvas while it shows a substitution highlight. SVG, PDF and EPS export are unchanged. The method is saved only when it is not Sharp. Reset Panel sets Sharp, and it is now one undo step (it was two). Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/doc/src/text.rs | 44 ++++ crates/engine/src/cmd/fileio/load.rs | 2 + crates/engine/src/cmd/panelcmds.rs | 19 +- crates/engine/src/cmd/textedit.rs | 2 + crates/engine/src/cmd/threads.rs | 82 +++++++ crates/render/src/fx.rs | 8 +- crates/render/src/lib.rs | 199 ++++++++++++++++- crates/render/src/pattern.rs | 4 +- crates/render/src/placed_document.rs | 2 +- crates/render/src/tests_typeedges.rs | 291 +++++++++++++++++++++++++ crates/ui-egui/src/i18n/bg.tsv | 4 + crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/da.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/el.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/fi.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 4 + crates/ui-egui/src/i18n/id.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/ko.tsv | 4 + crates/ui-egui/src/i18n/nl.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/sv.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/vi.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/src/panels/character.rs | 122 ++++++++++- docs/mcp.md | 29 +++ docs/target-app-parity.md | 1 + 35 files changed, 872 insertions(+), 21 deletions(-) create mode 100644 crates/render/src/tests_typeedges.rs diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 611edc1c8..1c89aae1f 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -1150,6 +1150,44 @@ fn reverse_from_end(path: &mut PathData) { } } +/// The anti-aliasing method of a type object (Character panel › Anti-aliasing Method): how the +/// edges of its characters are drawn when the type is drawn as pixels (the canvas, raster exports, +/// rasterizing). SVG, PDF and EPS export ignore it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TypeAntiAlias { + /// Hard edges: a pixel takes the type's color where the type covers at least half of it, and + /// nothing elsewhere. + None, + /// Each edge pixel shows the share of it the type covers. + #[default] + Sharp, + /// An edge pixel covered by a share `c` shows `2c - 0.5`, between 0 and 1: edges about half as + /// wide as Sharp's. + Crisp, + /// An edge pixel covered by a share `c` shows `2c`, at most 1: heavier type. + Strong, +} + +impl TypeAntiAlias { + pub const ALL: [Self; 4] = [Self::None, Self::Sharp, Self::Crisp, Self::Strong]; + + /// The id used in command params and files (`none`, `sharp`, `crisp`, `strong`). + pub fn id(self) -> &'static str { + match self { + Self::None => "none", + Self::Sharp => "sharp", + Self::Crisp => "crisp", + Self::Strong => "strong", + } + } + + /// The method an id names. + pub fn from_id(id: &str) -> Option { + Self::ALL.into_iter().find(|a| a.id() == id) + } +} + /// A text object. `runs` split into paragraphs at `\n`. /// /// Paragraph attributes: `para` is paragraph 0's (and, while `paras` is empty, every @@ -1193,6 +1231,11 @@ pub struct TextObject { /// for new type and for documents from before it, and saved only when on. #[serde(default, rename = "opticalMarginAlignment", skip_serializing_if = "crate::skip::is_default")] pub optical_margin: bool, + /// Character panel › Anti-aliasing Method: how the type's edges are drawn as pixels. Every + /// frame of a thread has the same method. Sharp for new type and for documents from before it, + /// and saved only when it is another method. + #[serde(default, rename = "antiAlias", skip_serializing_if = "crate::skip::is_default")] + pub anti_alias: TypeAntiAlias, /// Cached layout bounds in text space, filled in by the layout engine (not serialized). #[serde(skip)] pub cached_bounds: Option, @@ -1259,6 +1302,7 @@ impl TextObject { path_spacing: 0.0, wrap: Vec::new(), optical_margin: false, + anti_alias: TypeAntiAlias::Sharp, cached_bounds: None, cached_baselines: Vec::new(), } diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 947cd0788..aff66cb54 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -276,6 +276,8 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result if !doc.text_threads.is_empty() && !restored { crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc); } else if !doc.text_threads.is_empty() { + // Each frame also takes its thread's first frame's anti-aliasing method, as `reflow` gives it. + crate::cmd::threads::share_anti_alias(&mut doc); crate::cmd::threads::share_optical_margin(&mut doc); } if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) { diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 3b5dc2e77..10e79cd40 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets the whole type object and every frame of its thread, with or without a range; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", has_doc, set_format ), @@ -257,6 +257,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "allCaps", "smallCaps", "position", + "antiAlias", "leftIndent", "rightIndent", "firstLineIndent", @@ -284,6 +285,15 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if !keys.iter().any(|k| p.get(*k).is_some()) { return Err(bad(C, "nothing to change")); } + // The anti-aliasing method belongs to the whole type object and to every frame of its thread. + let anti_alias = match p.get("antiAlias") { + None | Some(Value::Null) => None, + Some(v) => Some( + v.as_str() + .and_then(vectorcraft_doc::text::TypeAntiAlias::from_id) + .ok_or_else(|| bad(C, "`antiAlias` must be \"none\", \"sharp\", \"crisp\" or \"strong\""))?, + ), + }; let composer = match p.get("composer") { None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { @@ -460,6 +470,13 @@ fn set_format(s: &mut Session, p: &Value) -> Result { let scale = num("hScale").map(|v| v.clamp(1.0, 10000.0)); super::graph::capture_label_styles(d, &ids, &|st| chars(st, shift, scale)); } + if let Some(v) = anti_alias { + for id in super::threads::with_threads(d, &ids) { + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { + t.anti_alias = v; + } + } + } Ok(()) })?; Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 2ef5451f1..c660948eb 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -839,6 +839,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { path_spacing: 0.0, wrap: Vec::new(), optical_margin: false, + anti_alias: Default::default(), cached_bounds: None, cached_baselines: Vec::new(), }; @@ -1059,6 +1060,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option { path_spacing: 0.0, wrap: Vec::new(), optical_margin: false, + anti_alias: Default::default(), cached_bounds: None, cached_baselines: Vec::new(), }; diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index d599f60bb..0c7672c02 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -157,12 +157,45 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { } } if live.len() >= 2 { + if let Some(a) = anti_alias(before, doc, &live) { + give_anti_alias(doc, &live, a); + } kept.push(live); } } doc.text_threads = kept; } +/// The anti-aliasing method that every frame of a thread with the frames `live` takes: that of the +/// first frame whose method changed between `before` and `doc`, else the first frame's. +fn anti_alias(before: &Document, doc: &Document, live: &[NodeId]) -> Option { + let now = |id: &NodeId| text_of(doc, *id).map(|t| t.anti_alias); + let changed = live.iter().find(|id| text_of(before, **id).is_some_and(|t| Some(t.anti_alias) != now(id))); + changed.or(live.first()).and_then(now) +} + +/// Give each of the frames `live` that has another anti-aliasing method the method `a`. +fn give_anti_alias(doc: &mut Document, live: &[NodeId], a: vectorcraft_doc::text::TypeAntiAlias) { + for id in live { + if text_of(doc, *id).is_some_and(|t| t.anti_alias != a) + && let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind) + { + t.anti_alias = a; + } + } +} + +/// Give every frame of each thread the first frame's anti-aliasing method, without re-flowing the +/// story: for a document restored from a file as it was saved. +pub(crate) fn share_anti_alias(doc: &mut Document) { + for thread in doc.text_threads.clone() { + let live: Vec = thread.into_iter().filter(|id| text_of(doc, *id).is_some()).collect(); + if let Some(a) = anti_alias(doc, doc, &live) { + give_anti_alias(doc, &live, a); + } + } +} + /// The Optical Margin Alignment setting that every frame of a thread with the frames `live` takes: /// that of the first frame whose setting changed between `before` and `doc`, else the first frame's. fn optical_margin(before: &Document, doc: &Document, live: &[NodeId]) -> Option { @@ -735,6 +768,55 @@ mod tests { assert_eq!([a, b, c].map(|id| optical(&s, id).0), [true; 3]); } + /// The anti-aliasing method (`text.setFormat {antiAlias}`) belongs to the whole thread and is + /// saved only when not Sharp: set in one frame, with or without a range, it is set in every + /// frame in one undo step, a frame threaded on takes the thread's method, and a file whose + /// frames of one thread differ opens with the first frame's method in all of them. + #[test] + fn the_anti_aliasing_method_is_set_for_every_frame_of_a_thread() { + use vectorcraft_doc::text::TypeAntiAlias::{self, Crisp, None as Hard, Sharp, Strong}; + let method = |s: &Session, id: u64| text_of(&s.doc().unwrap().doc, NodeId(id)).map(|t| t.anti_alias); + let saved = + |s: &Session, id: u64| serde_json::to_value(text_of(&s.doc().unwrap().doc, NodeId(id)).unwrap()).unwrap().get("antiAlias").cloned(); + let (mut s, a, b) = quoted_thread(); + assert_eq!((method(&s, a), method(&s, b)), (Some(Sharp), Some(Sharp))); + assert_eq!(saved(&s, a), None, "Sharp is not saved"); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + let err = s.execute("text.setFormat", &json!({"antiAlias": "smooth"})).unwrap_err().to_string(); + assert!(err.contains("`antiAlias` must be"), "{err}"); + s.execute("text.setFormat", &json!({"antiAlias": "crisp"})).unwrap(); + assert_eq!((method(&s, a), method(&s, b)), (Some(Crisp), Some(Crisp)), "the first frame takes it too"); + assert_eq!(saved(&s, a), Some(json!("crisp"))); + // A range of characters sets the whole thread as well. + s.execute("text.setFormat", &json!({"ids": [a], "start": 0, "end": 1, "antiAlias": "none"})).unwrap(); + assert_eq!((method(&s, a), method(&s, b)), (Some(Hard), Some(Hard))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((method(&s, a), method(&s, b)), (Some(Crisp), Some(Crisp)), "one undo step"); + // A frame threaded on from the last one takes the thread's method. + s.execute("text.setFormat", &json!({"ids": [b], "antiAlias": "strong"})).unwrap(); + let c = new_id(s.execute("text.thread.add", &json!({"from": b, "at": [400, 10]})).unwrap()); + assert_eq!([a, b, c].map(|id| method(&s, id)), [Some(Strong); 3]); + s.execute("edit.undo", &json!({})).unwrap(); + let first: [TypeAntiAlias; 2] = [Crisp, Hard]; + for (one, name) in [(first, "old.vectorcraft"), ([Hard, Crisp], "old.vectorcraft"), (first, "old.eps"), ([Hard, Crisp], "old.eps")] { + for (id, m) in [(a, one[0]), (b, one[1])] { + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") }; + t.anti_alias = m; + } + let bytes = if name.ends_with(".eps") { + let r = s.execute("document.exportEps", &json!({})).unwrap(); + vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap() + } else { + vectorcraft_format::save(&s.doc().unwrap().doc, false) + }; + let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); + assert_eq!(r["restored"], name.ends_with(".eps"), "{r}"); + assert_eq!(threads(&s), [[a, b]], "{name}"); + assert_eq!((method(&s, a), method(&s, b)), (Some(one[0]), Some(one[0])), "{name}, first frame {:?}", one[0]); + } + } + /// A file saved with the frames of one thread set differently opens with the first frame's /// setting in every frame: a native file, and an EPS file restored from the document it carries. #[test] diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 87ba4e1c2..62b77dd7b 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -160,7 +160,8 @@ fn rasterize_bounds(symbols: &SymbolBoxes, n: &Node, rfx: &[RasterFx]) -> Option /// `f` with the content moved by (`dx`, `dy`) document units (a shadow's offset). fn shifted<'a>(f: &Frame<'a>, dx: f64, dy: f64) -> Frame<'a> { - Frame { view: f.view * Affine::translate((dx, dy)), visible: f.visible - Vec2::new(dx, dy), ..*f } + let moved = Affine::translate((dx, dy)); + Frame { view: f.view * moved, visible: f.visible - Vec2::new(dx, dy), output: f.output * moved, ..*f } } fn node_bezpath(n: &Node) -> Option { @@ -557,7 +558,7 @@ fn pcolor(ink: Ink, c: &vectorcraft_doc::color::Color) -> peniko::Color { /// Most pixels the raster of [`Renderer::pixel_content`] holds (a 4K screen's): larger content is /// cropped to the view when its effects only look nearby, else rendered at a lower resolution. -const PIXEL_BUDGET: f64 = 8.4e6; +pub(crate) const PIXEL_BUDGET: f64 = 8.4e6; /// Most Rasterize effects of one stack that [`Renderer::raster_fx`] draws (the last ones): each /// draws the content again inside the one after it. @@ -2051,7 +2052,8 @@ impl Renderer { (true, false) => crate::AntiAlias::Art, }; let opts = crate::RenderOptions { anti_alias, ..f.opts.clone() }; - let frame = Frame { mt: false, view, visible: rect, px: cell, opts: &opts, ..*f }; + // The image's pixels are the output's. + let frame = Frame { mt: false, view, visible: rect, px: cell, opts: &opts, output: view, ..*f }; let mut off = frame.offscreen_context(w, h); self.inside_layer(|s| paint(s, &mut off, &frame)); off.flush(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 9735c6d38..e224b80d5 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -24,6 +24,7 @@ pub mod proof; use std::collections::HashMap; use std::sync::Arc; +use vectorcraft_doc::text::TypeAntiAlias; use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, SymbolBoxes, TextObject, TraceView}; use vectorcraft_geom::{Affine, BezPath, FillRule, Rect, Shape}; use vello_cpu::kurbo; @@ -580,11 +581,16 @@ struct Frame<'a> { view: Affine, /// Visible region in document coordinates (for culling). visible: Rect, - /// Size of one output pixel in document units. + /// Size of one of this frame's pixels in document units. px: f64, opts: &'a RenderOptions, /// What colours paint as: screen colours, or one ink plane of a CMYK document (see [`ink`]). ink: Ink, + /// Document → pixels of the output being drawn: `view` itself, except while an export draws + /// its art at several times its size (the export's own pixels) and in offscreen layers (the + /// pixels they are placed back on). Type with an anti-aliasing method other than Sharp is + /// drawn on these pixels ([`Renderer::draw_type_edges`]). + output: Affine, } impl Frame<'_> { @@ -652,18 +658,21 @@ impl Renderer { /// Render `doc` into a `width`×`height` image using `view` (document → pixel transform). pub fn render(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions) -> Rendered { - self.render_as(doc, width, height, view, opts, false) + self.render_as(doc, width, height, view, opts, false, 1) } /// [`Self::render`], or with `inks` the ink amounts in the working CMYK space instead of - /// screen colours (4 bytes a pixel, see [`Self::render_region_inks`]). - fn render_as(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions, inks: bool) -> Rendered { + /// screen colours (4 bytes a pixel, see [`Self::render_region_inks`]). `cell` is the side of a + /// pixel of the output in pixels of this render: the supersampling factor while an export + /// draws its art at several times its size, else 1 ([`Frame::output`]). + #[allow(clippy::too_many_arguments)] + fn render_as(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions, inks: bool, cell: u32) -> Rendered { let start = now(); if opts.supersample && opts.anti_alias == AntiAlias::Art && opts.mask_view.is_none() { let (w, h) = (width.clamp(1, u16::MAX as u32), height.clamp(1, u16::MAX as u32)); if let Some(k) = supersample_factor(w, h) { let hard = RenderOptions { anti_alias: AntiAlias::None, supersample: false, ..opts.clone() }; - let big = self.render_as(doc, w * k, h * k, Affine::scale(f64::from(k)) * view, &hard, inks); + let big = self.render_as(doc, w * k, h * k, Affine::scale(f64::from(k)) * view, &hard, inks, k); let pixels = downsample(&big.pixels, big.width as usize, big.height as usize, k as usize); self.stats.micros = now().saturating_sub(start); return Rendered { width: w, height: h, pixels }; @@ -685,7 +694,8 @@ impl Renderer { let px = 1.0 / view.determinant().abs().sqrt().max(1e-12); let screen = if opts.rich_black { Ink::RichBlack } else { Ink::Display }; let symbols = doc.symbol_boxes().quick(); - let frame = Frame { mt: threads > 0, doc, symbols: &symbols, view, visible, px, opts, ink: screen }; + let output = Affine::scale(1.0 / f64::from(cell.max(1))) * view; + let frame = Frame { mt: threads > 0, doc, symbols: &symbols, view, visible, px, opts, ink: screen, output }; self.begin_frame(); // View Opacity Mask: the mask's coverage in greyscale instead of the artwork. if let Some(m) = opts.mask_view.and_then(|id| doc.node(id)).and_then(|n| n.extras().mask.as_deref()) { @@ -850,7 +860,7 @@ impl Renderer { pub fn render_region_inks(&mut self, doc: &Document, region: Rect, scale: f64, opts: &RenderOptions) -> Vec { let (w, h) = region_pixels(region, scale); let view = Affine::scale(scale) * Affine::translate((-region.x0, -region.y0)); - self.render_as(doc, w, h, view, opts, true).pixels + self.render_as(doc, w, h, view, opts, true, 1).pixels } /// Render a single node (thumbnails, previews) fitted into `size`×`size` pixels. @@ -868,7 +878,7 @@ impl Renderer { let mut ctx = single_threaded_context(w, h); let opts = RenderOptions::default(); let symbols = doc.symbol_boxes().quick(); - let frame = Frame { mt: false, doc, symbols: &symbols, view, visible, px, opts: &opts, ink: Ink::Display }; + let frame = Frame { mt: false, doc, symbols: &symbols, view, visible, px, opts: &opts, ink: Ink::Display, output: view }; self.begin_frame(); (self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None); self.clip_paths.clear(); @@ -911,7 +921,8 @@ impl Renderer { ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, w as f64)); } let symbols = doc.symbol_boxes().quick(); - let frame = Frame { mt: false, doc, symbols: &symbols, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts, ink: Ink::Display }; + let frame = + Frame { mt: false, doc, symbols: &symbols, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts, ink: Ink::Display, output: view }; self.begin_frame(); (self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None); self.clip_paths.clear(); @@ -1800,6 +1811,9 @@ impl Renderer { self.draw_inlines(ctx, f, t, g); return; } + if t.anti_alias != TypeAntiAlias::Sharp && self.draw_type_edges(ctx, f, n, t, g) { + return; + } let tb = t.xf.transform_rect_bbox(g.bounds); // Overprinting characters darken per draw, like overprinting fills and strokes // ([`proof`]); the object blend mode is Normal here or applied by an enclosing layer. @@ -1861,6 +1875,119 @@ impl Renderer { } } + /// Draw type `t` of node `n` (laid out in `g`) with its anti-aliasing method, None, Crisp or + /// Strong, on the pixels of the output ([`Frame::output`]): the type is drawn offscreen on those + /// pixels and placed back pixel for pixel. With None it is drawn with hard edges, so a pixel + /// takes the type's color where the type covers at least half of it. With Crisp and Strong it + /// is drawn as Sharp draws it, and again with the characters and the object's own fills and + /// strokes in opaque black, which gives the share `c` of each pixel they cover; the pixel's + /// color and opacity are then scaled by [`edge_coverage`] of `c` divided by `c`, so the opacity + /// of the paints is kept. Where the output's pixels would number more than the frame's there + /// and more than [`fx::PIXEL_BUDGET`] (a frame drawn coarser than the output), the frame's own + /// pixels are used. + /// + /// Returns false, having drawn nothing, for type that is drawn as Sharp draws it: type with + /// inline graphics, type whose own fills or strokes include a brush stroke or a blend mode + /// other than Normal, type that overprints while Overprint Preview is on, and type that shows a + /// substitution highlight on screen. Drawn offscreen, those would blend with nothing or take + /// the method on art that is not the characters'. + fn draw_type_edges(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject, g: &TextGeom) -> bool { + if proof::overprints(f.opts) && t.runs.iter().any(|r| r.style.overprint_fill || r.style.overprint_stroke) { + return false; + } + let normal = vectorcraft_color::BlendMode::Normal; + let brush = |i: &AppearanceItem| matches!(i, AppearanceItem::Stroke(st) if st.brush.is_some()); + if t.runs.iter().any(|r| r.inline.is_some()) || n.appearance.items.iter().any(|i| i.visible() && (i.blend() != normal || brush(i))) { + return false; + } + let setup = &f.doc.setup; + let highlights = [ + (setup.highlight_substituted_fonts, &g.substituted_fonts), + (setup.highlight_substituted_glyphs, &g.substituted_glyphs), + (f.opts.highlight_alternate_glyphs, &g.alternate_glyphs), + ]; + if f.opts.highlight_substitutions && highlights.iter().any(|(on, path)| *on && !path.elements().is_empty()) { + return false; + } + // Where the type draws: its glyphs, grown by the farthest its strokes reach, besides its + // bounds (an area type frame's, or a point type estimate before it is laid out). + let glyphs = type_reach(n, t, g); + let reach = fx::cull_bounds(f.symbols, n).map_or(glyphs, |b| b.union(glyphs)); + // This frame's pixels to the output's: a scale and an offset (a supersampled export's + // factor, an offscreen layer's crop). + let grid = f.output * f.view.inverse(); + let [sx, _, _, sy, _, _] = grid.as_coeffs(); + let reach = reach.inflate(2.0 * f.px / sx.abs().min(1.0), 2.0 * f.px / sy.abs().min(1.0)); + let on = f.view.transform_rect_bbox(reach).intersect(Rect::new(0.0, 0.0, f64::from(ctx.width()), f64::from(ctx.height()))); + if !on.is_finite() { + return false; + } + if on.width() <= 0.0 || on.height() <= 0.0 { + return true; + } + let grid = edge_grid(on, grid); + let output = grid * f.view; + let [sx, _, _, sy, _, _] = grid.as_coeffs(); + let on = grid.transform_rect_bbox(on); + // Whole pixels of the output. + let (x0, y0) = (on.x0.floor(), on.y0.floor()); + let (Ok(w), Ok(h)) = (u16::try_from((on.x1 - x0).ceil() as u64), u16::try_from((on.y1 - y0).ceil() as u64)) else { return false }; + let view = Affine::translate((-x0, -y0)) * output; + let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h))); + let px = 1.0 / view.determinant().abs().sqrt().max(1e-12); + let frame = Frame { mt: false, view, visible, px, output: view, ..*f }; + let threshold = match t.anti_alias { + TypeAntiAlias::None => AntiAlias::None.threshold(), + // The frame's pixels are smaller than the output's in a supersampled export, whose art + // is smooth on the output's pixels (Art Optimized). + _ if sx.abs() < 0.999 || sy.abs() < 0.999 => None, + _ => f.opts.anti_alias.threshold(), + }; + let draw = |r: &mut Self, frame: &Frame, n: &Node, t: &TextObject| { + let mut off = single_threaded_context(w, h); + off.set_aliasing_threshold(threshold); + r.inside_layer(|r| r.draw_text_geom(&mut off, frame, n, t, g)); + off.flush(); + let mut pm = Pixmap::new(w, h); + off.render(&mut pm, &mut r.resources); + pm + }; + let sharp = TextObject { anti_alias: TypeAntiAlias::Sharp, ..t.clone() }; + let mut pm = draw(self, &frame, n, &sharp); + if t.anti_alias != TypeAntiAlias::None { + let black = |p: &mut vectorcraft_color::Paint| { + if !p.is_none() { + *p = vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK); + } + }; + let mut ink = sharp; + for r in &mut ink.runs { + black(&mut r.style.fill); + black(&mut r.style.stroke); + } + let mut inked = n.clone(); + for item in &mut inked.appearance.items { + black(item.paint_mut()); + match item { + AppearanceItem::Fill(fl) => fl.opacity = 1.0, + AppearanceItem::Stroke(st) => st.opacity = 1.0, + } + } + let covered = draw(self, &frame, &inked, &ink); + for (p, c) in pm.data_mut().iter_mut().zip(covered.data()) { + *p = edge_pixel(*p, c.a, t.anti_alias); + } + } + ctx.set_transform(grid.inverse() * Affine::translate((x0, y0))); + // The pixmap maps onto the rect pixel for pixel, each one on a pixel of the output. + ctx.reset_paint_transform(); + let sampler = peniko::ImageSampler { quality: peniko::ImageQuality::Low, ..Default::default() }; + ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(Arc::new(pm)), sampler }); + ctx.fill_rect(&Rect::new(0.0, 0.0, f64::from(w), f64::from(h))); + ctx.set_transform(Affine::IDENTITY); + true + } + /// Paint some of type `n`'s own fills and strokes (`items`) on its glyph outlines `all` /// (document space; `tb` their bounds). Fills composite with their own opacity and blend mode; /// strokes take every stroke option, as on paths. @@ -2115,6 +2242,58 @@ pub(crate) fn single_threaded_context(w: u16, h: u16) -> RenderContext { RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }) } +/// How much of a pixel (out of 255) type that covers `c` of it shows with anti-aliasing method +/// `m`: `c` with Sharp; all of it from half coverage (127) up, else none, with None; `2c - 0.5` +/// with Crisp; and `2c` with Strong; each kept between 0 and all. +pub(crate) fn edge_coverage(m: TypeAntiAlias, c: u8) -> u8 { + let c = i32::from(c); + let shown = match m { + TypeAntiAlias::None => { + if c >= 127 { + 255 + } else { + 0 + } + } + TypeAntiAlias::Sharp => c, + TypeAntiAlias::Crisp => 2 * c - 128, + TypeAntiAlias::Strong => 2 * c, + }; + shown.clamp(0, 255) as u8 +} + +/// Where (document space) type `t` of node `n`, laid out in `g`, paints: its glyphs grown by the +/// farthest the characters' strokes reach in text space, transformed as the type is, then grown by +/// the farthest the object's own strokes reach in document space. A stroke's reach takes in its +/// miter spikes and square caps ([`StrokeLayer::reach`]). +fn type_reach(n: &Node, t: &TextObject, g: &TextGeom) -> Rect { + let chars = t.runs.iter().filter(|r| r.style.has_stroke()).map(|r| r.style.stroke_layer().reach()).fold(0.0, f64::max); + let own = n.appearance.outset(); + t.xf.transform_rect_bbox(g.bounds.inflate(chars, chars)).inflate(own, own) +} + +/// The map from a frame's pixels to the pixels [`Renderer::draw_type_edges`] draws type on, for +/// the region `on` of the frame's pixels: `grid`, which maps them to the output's, unless the +/// output's pixels there would number more than the frame's and more than [`fx::PIXEL_BUDGET`], +/// and then the frame's own pixels (the identity). +fn edge_grid(on: Rect, grid: Affine) -> Affine { + let area = |r: Rect| r.width() * r.height(); + if area(grid.transform_rect_bbox(on)) > area(on).max(fx::PIXEL_BUDGET) { Affine::IDENTITY } else { grid } +} + +/// Pixel `p` of type the anti-aliasing method `m` draws, where the type covers `c` (out of 255) of +/// the pixel: its color and opacity scaled by [`edge_coverage`] of `c` divided by `c`. A pixel the +/// type covers wholly or not at all is kept. +fn edge_pixel(p: vello_cpu::color::PremulRgba8, c: u8, m: TypeAntiAlias) -> vello_cpu::color::PremulRgba8 { + if c == 0 || c == 255 { + return p; + } + let (to, c) = (u32::from(edge_coverage(m, c)), u32::from(c)); + let scale = |v: u8| ((u32::from(v) * to + c / 2) / c).min(255) as u8; + let a = scale(p.a); + vello_cpu::color::PremulRgba8 { r: scale(p.r).min(a), g: scale(p.g).min(a), b: scale(p.b).min(a), a } +} + /// Exactly one visible painted item, a fill with its own Normal blend and full opacity (one /// paint operation, so an object blend mode can be applied per draw). fn single_plain_fill(n: &Node) -> bool { @@ -2459,3 +2638,5 @@ mod tests_setup; mod tests_strokegradient; #[cfg(test)] mod tests_tileedge; +#[cfg(test)] +mod tests_typeedges; diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index d1605377a..4eb34853c 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -181,6 +181,7 @@ fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w px: 1.0 / v.determinant().abs().sqrt().max(1e-12), opts: &opts, ink, + output: v, }; for a in &def.art { r.draw_arc(&mut ctx, &frame, a); @@ -311,7 +312,8 @@ impl Renderer { let comp = crate::group::Composite { opacity: dim, ..Default::default() }; self.group(ctx, f, comp, &mut |r, c, fr| { for o in def.preview_offsets() { - let frame = Frame { view: fr.view * Affine::translate(o), visible: fr.visible - o, ..*fr }; + let moved = Affine::translate(o); + let frame = Frame { view: fr.view * moved, visible: fr.visible - o, output: fr.output * moved, ..*fr }; for a in &children { r.draw_arc(c, &frame, a); } diff --git a/crates/render/src/placed_document.rs b/crates/render/src/placed_document.rs index b207e2c49..3f8641c41 100644 --- a/crates/render/src/placed_document.rs +++ b/crates/render/src/placed_document.rs @@ -293,7 +293,7 @@ impl Renderer { // The art in its own coordinates, with its own resources. let symbols = e.doc.symbol_boxes().quick(); let visible = m.inverse().transform_rect_bbox(f.visible); - let inner = Frame { doc: &e.doc, symbols: &symbols, view: f.view * m, visible, px: f.px / k, ..*f }; + let inner = Frame { doc: &e.doc, symbols: &symbols, view: f.view * m, visible, px: f.px / k, output: f.output * m, ..*f }; let _inside = Inside::enter(); self.draw_arc(ctx, &inner, &e.art); } diff --git a/crates/render/src/tests_typeedges.rs b/crates/render/src/tests_typeedges.rs new file mode 100644 index 000000000..bba22620c --- /dev/null +++ b/crates/render/src/tests_typeedges.rs @@ -0,0 +1,291 @@ +//! Character panel › Anti-aliasing Method: type drawn as pixels with None, Sharp, Crisp or Strong +//! edges. + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::text::{CharStyle, InlineArt, TextObject, TextRun, TypeAntiAlias}; +use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, NodeKind, TextKind}; +use vectorcraft_geom::Point; +use vectorcraft_geom::shapes::rectangle; + +use super::*; + +const W: u32 = 260; +const H: u32 = 90; + +/// A page with "Hamburg" in 40 pt black type drawn with `method`, and `fill` as the type object's +/// own fill (instead of the characters' fill) when given. +fn page(method: TypeAntiAlias, fill: Option) -> Document { + let mut d = Document::new(f64::from(W), f64::from(H)); + let mut t = TextObject::point(Point::new(10.3, 60.4), "Hamburg", CharStyle { size: 40.0, ..Default::default() }); + t.anti_alias = method; + let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + if let Some(fl) = fill { + if let NodeKind::Text(t) = &mut n.kind { + t.runs[0].style.fill = Paint::None; + } + n.appearance.items.push(AppearanceItem::Fill(fl)); + } + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d +} + +/// The opacity of each pixel of `d` drawn with `opts` on a transparent page. +fn alphas(d: &Document, opts: &RenderOptions) -> Vec { + Renderer::new().render(d, W, H, Affine::IDENTITY, opts).pixels.chunks(4).map(|p| p[3]).collect() +} + +/// Pixels the type covers only in part. +fn partial(a: &[u8]) -> usize { + a.iter().filter(|v| **v > 0 && **v < 255).count() +} + +/// On the canvas each method gives every pixel the share of it that `edge_coverage` gives the +/// share Sharp draws: None paints whole pixels from half coverage up, Crisp doubles the contrast +/// around half coverage, and Strong doubles the coverage. +#[test] +fn each_method_draws_the_share_of_each_pixel_it_defines() { + let opts = RenderOptions::default(); + let sharp = alphas(&page(TypeAntiAlias::Sharp, None), &opts); + assert!(partial(&sharp) > 200, "Sharp draws smooth edges: {} partial pixels", partial(&sharp)); + for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + let drawn = alphas(&page(m, None), &opts); + assert_ne!(drawn, sharp, "{m:?} changes the edges"); + let wrong: Vec<_> = sharp + .iter() + .zip(&drawn) + .enumerate() + .filter(|(_, (c, v))| { + // One step of rounding either way in the coverage. + let near = [c.saturating_sub(1), **c, c.saturating_add(1)].map(|c| edge_coverage(m, c)); + let (lo, hi) = (near.iter().min().copied().unwrap_or(0), near.iter().max().copied().unwrap_or(255)); + **v < lo.saturating_sub(1) || **v > hi.saturating_add(1) + }) + .map(|(i, (c, v))| (i, *c, *v)) + .take(5) + .collect(); + assert!(wrong.is_empty(), "{m:?}: (pixel, Sharp, drawn) {wrong:?}"); + } + assert_eq!(partial(&alphas(&page(TypeAntiAlias::None, None), &opts)), 0, "None paints whole pixels"); +} + +/// A raster export draws its art at several times its size and averages it down; type with None +/// still has hard edges on the exported pixels, Crisp has fewer partly covered pixels than Sharp, +/// and Strong draws heavier type. +#[test] +fn an_exports_pixels_take_the_method() { + let opts = RenderOptions { supersample: true, precise: true, ..Default::default() }; + let sharp = alphas(&page(TypeAntiAlias::Sharp, None), &opts); + let none = alphas(&page(TypeAntiAlias::None, None), &opts); + let crisp = alphas(&page(TypeAntiAlias::Crisp, None), &opts); + let strong = alphas(&page(TypeAntiAlias::Strong, None), &opts); + assert!(partial(&sharp) > 200, "the export smooths Sharp type: {}", partial(&sharp)); + assert_eq!(partial(&none), 0, "None type has hard edges in the export"); + assert!(none.iter().filter(|v| **v == 255).count() > 1000); + assert!(partial(&crisp) < partial(&sharp), "Crisp: {} partial pixels, Sharp {}", partial(&crisp), partial(&sharp)); + let ink = |a: &[u8]| a.iter().map(|v| u64::from(*v)).sum::(); + assert!(ink(&strong) > ink(&sharp), "Strong draws heavier type"); +} + +/// A frame drawn with hard edges (Anti-aliased Artwork off, an export's Anti-aliasing None) draws +/// the type of every method as it draws Sharp type. +#[test] +fn hard_edged_frames_draw_every_method_alike() { + for opts in [ + RenderOptions { anti_alias: AntiAlias::None, ..Default::default() }, + RenderOptions { anti_alias: AntiAlias::None, precise: true, supersample: true, ..Default::default() }, + ] { + let sharp = alphas(&page(TypeAntiAlias::Sharp, None), &opts); + assert_eq!(partial(&sharp), 0); + for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + assert_eq!(alphas(&page(m, None), &opts), sharp, "{m:?}"); + } + } +} + +/// Crisp and Strong reshape the edges of type that is drawn with a translucent fill, and keep +/// its opacity inside the characters; None keeps it too. +#[test] +fn the_methods_keep_the_opacity_of_the_paint() { + let half = FillLayer { opacity: 0.5, ..FillLayer::new(Paint::solid(Color::BLACK)) }; + let opts = RenderOptions::default(); + let sharp = alphas(&page(TypeAntiAlias::Sharp, Some(half.clone())), &opts); + let most = |a: &[u8]| a.iter().copied().max().unwrap_or(0); + assert!((126..=129).contains(&most(&sharp)), "half opaque: {}", most(&sharp)); + for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + let drawn = alphas(&page(m, Some(half.clone())), &opts); + assert!((126..=129).contains(&most(&drawn)), "{m:?} keeps the fill's opacity: {}", most(&drawn)); + assert_ne!(drawn, sharp, "{m:?} changes the edges"); + } +} + +/// Type whose own fill multiplies with the art below it is drawn as Sharp draws it, so the fill +/// still multiplies with that art. +#[test] +fn type_with_a_blended_fill_is_drawn_as_sharp() { + let multiply = FillLayer { blend: vectorcraft_color::BlendMode::Multiply, ..FillLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0))) }; + let on_orange = |m: TypeAntiAlias| { + let mut d = page(m, Some(multiply.clone())); + let orange = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.5, 0.0)), Paint::None, 0.0); + let back = Node::path(d.alloc_id(), rectangle(Rect::new(0.0, 0.0, f64::from(W), f64::from(H))), orange); + let l = d.layers[0].id; + d.insert(Some(l), 0, back).unwrap(); + Renderer::new().render(&d, W, H, Affine::IDENTITY, &RenderOptions::default()).pixels + }; + let sharp = on_orange(TypeAntiAlias::Sharp); + for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + assert_eq!(on_orange(m), sharp, "{m:?}"); + } +} + +/// Type with an inline graphic is drawn as Sharp draws it, so the graphic, which multiplies with +/// the art below it here, is drawn as before. +#[test] +fn type_with_an_inline_graphic_is_drawn_as_sharp() { + let on_orange = |m: TypeAntiAlias| { + let mut d = Document::new(f64::from(W), f64::from(H)); + let orange = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.5, 0.0)), Paint::None, 0.0); + let back = Node::path(d.alloc_id(), rectangle(Rect::new(0.0, 0.0, f64::from(W), f64::from(H))), orange); + let blue = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0); + let mut art = Node::path(d.alloc_id(), rectangle(Rect::new(0.0, -30.0, 30.0, 0.0)), blue); + art.blend = vectorcraft_color::BlendMode::Multiply; + d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) }); + let style = CharStyle { size: 40.0, ..Default::default() }; + let mut t = TextObject::point(Point::new(10.3, 60.4), "I", style.clone()); + t.runs.push(TextRun::inline(InlineArt::new("S"), style)); + t.anti_alias = m; + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let l = d.layers[0].id; + d.insert(Some(l), 0, back).unwrap(); + d.insert(Some(l), 1, n).unwrap(); + d.resolve_inline_art(); + Renderer::new().render(&d, W, H, Affine::IDENTITY, &RenderOptions::default()).pixels + }; + let sharp = on_orange(TypeAntiAlias::Sharp); + for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + assert_eq!(on_orange(m), sharp, "{m:?}"); + } +} + +/// In a supersampled export, None type in a group drawn offscreen (it isolates art that blends) +/// still has hard edges on the exported pixels, wherever the group's layer starts. +#[test] +fn type_in_an_offscreen_group_keeps_the_exports_pixels() { + let opts = RenderOptions { supersample: true, precise: true, ..Default::default() }; + for x in [10.3, 10.55, 10.8, 11.05] { + let mut d = Document::new(f64::from(W), f64::from(H)); + let mut t = TextObject::point(Point::new(x, 60.4), "Hamburg", CharStyle { size: 40.0, ..Default::default() }); + t.anti_alias = TypeAntiAlias::None; + let text = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let black = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0); + let mut square = Node::path(d.alloc_id(), rectangle(Rect::new(220.0, 10.0, 250.0, 30.0)), black); + square.blend = vectorcraft_color::BlendMode::Multiply; + let mut group = Node::group(d.alloc_id(), vec![Arc::new(square), Arc::new(text)]); + group.isolate = true; + let l = d.layers[0].id; + d.insert(Some(l), 0, group).unwrap(); + let a = alphas(&d, &opts); + assert!(a.iter().filter(|v| **v == 255).count() > 1000, "x {x}"); + assert_eq!(partial(&a), 0, "x {x}: None type has hard edges"); + } +} + +/// Type with a brush stroke of its own is drawn as Sharp draws it. +#[test] +fn type_with_a_brush_stroke_is_drawn_as_sharp() { + let draw = |m: TypeAntiAlias| { + let mut d = page(m, None); + let id = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); + let n = d.node_mut(id).unwrap(); + let mut st = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 2.0); + st.brush = Some("Charcoal".into()); + n.appearance.items.push(AppearanceItem::Stroke(st)); + alphas(&d, &RenderOptions::default()) + }; + let sharp = draw(TypeAntiAlias::Sharp); + for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + assert_eq!(draw(m), sharp, "{m:?}"); + } +} + +/// On screen, type in a font the system doesn't have draws as Sharp draws it while Document +/// Setup highlights substituted fonts, and with its method when nothing is highlighted. +#[test] +fn type_with_a_substitution_highlight_is_drawn_as_sharp() { + let draw = |m: TypeAntiAlias, highlight: bool| { + let mut d = Document::new(f64::from(W), f64::from(H)); + d.setup.highlight_substituted_fonts = true; + let style = CharStyle { size: 40.0, font_family: "No Such Family 7Q".into(), ..Default::default() }; + let mut t = TextObject::point(Point::new(10.3, 60.4), "Hamburg", style); + t.anti_alias = m; + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + alphas(&d, &RenderOptions { highlight_substitutions: highlight, ..Default::default() }) + }; + assert_eq!(draw(TypeAntiAlias::Strong, true), draw(TypeAntiAlias::Sharp, true)); + assert_ne!(draw(TypeAntiAlias::Strong, false), draw(TypeAntiAlias::Sharp, false)); +} + +/// The type is drawn on the output's pixels unless, in a frame coarser than the output, they +/// would number more than the frame's there and more than the effects' pixel budget. +#[test] +fn type_edges_are_drawn_on_at_most_the_budgets_pixels() { + let finer = Affine::scale(0.25); + assert_eq!(edge_grid(Rect::new(0.0, 0.0, 4000.0, 4000.0), finer), finer, "a supersampled export's pixels"); + let coarser = Affine::scale(4.0); + assert_eq!(edge_grid(Rect::new(0.0, 0.0, 100.0, 100.0), coarser), coarser, "160,000 pixels"); + assert_eq!(edge_grid(Rect::new(0.0, 0.0, 1000.0, 1000.0), coarser), Affine::IDENTITY, "16 million pixels"); +} + +/// The offscreen drawing takes in the farthest a stroke reaches: a mitered character stroke's +/// spikes up to its miter limit, transformed with the type (stretched along one axis too), and +/// the object's own strokes. +#[test] +fn the_offscreen_drawing_takes_in_the_strokes_reach() { + let style = CharStyle { + stroke: Paint::solid(Color::BLACK), + stroke_width: 4.0, + stroke_join: vectorcraft_doc::LineJoin::Miter, + stroke_miter_limit: 10.0, + ..Default::default() + }; + let mut t = TextObject::point(Point::new(0.0, 0.0), "V", style); + let n = |t: &TextObject| Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone()))); + // How far the reach goes past the transformed glyph bounds on the left and on the top. + let past = |n: &Node, t: &TextObject| { + let g = text_geom(t); + let (r, b) = (type_reach(n, t, &g), t.xf.transform_rect_bbox(g.bounds)); + (b.x0 - r.x0, b.y0 - r.y0) + }; + let near = |(a, b): (f64, f64), (x, y): (f64, f64)| (a - x).abs() < 1e-6 && (b - y).abs() < 1e-6; + assert!(near(past(&n(&t), &t), (20.0, 20.0)), "half the weight times the miter limit: {:?}", past(&n(&t), &t)); + t.xf = Affine::scale_non_uniform(10.0, 0.1); + assert!(near(past(&n(&t), &t), (200.0, 2.0)), "stretched with the type: {:?}", past(&n(&t), &t)); + t.xf = Affine::scale(2.0); + let mut own = n(&t); + let mut st = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 10.0); + st.miter_limit = 10.0; + own.appearance.items.push(AppearanceItem::Stroke(st)); + assert!(near(past(&own, &t), (90.0, 90.0)), "the object's own stroke after the characters': {:?}", past(&own, &t)); +} + +/// Area type draws its punctuation outside its frame with Optical Margin Alignment, and None type +/// draws it there too. +#[test] +fn type_outside_its_frame_is_drawn() { + let page = |m: TypeAntiAlias| { + let mut d = Document::new(f64::from(W), f64::from(H)); + let mut t = TextObject::point(Point::new(0.0, 0.0), "“Quoted.”", CharStyle { size: 30.0, ..Default::default() }); + t.kind = TextKind::Area { frame: rectangle(Rect::new(40.0, 10.0, 240.0, 80.0)) }; + t.optical_margin = true; + t.anti_alias = m; + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + alphas(&d, &RenderOptions::default()) + }; + let outside = |a: &[u8]| a.iter().enumerate().filter(|(i, v)| (*i as u32 % W) < 39 && **v > 0).count(); + assert!(outside(&page(TypeAntiAlias::Sharp)) > 20, "the opening quotation mark hangs outside the frame"); + assert!(outside(&page(TypeAntiAlias::None)) > 20, "None type draws it there too"); +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index f130be20e..d38be0cca 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -171,6 +171,10 @@ Anti-Alias Заглаждане Anti-alias Rasters Заглаждане на растери Anti-aliased Artwork Загладено произведение + Anti-aliasing Method Метод на заглаждане + Sharp Остро + Crisp Четко + Strong Силно Anti-aliasing: Заглаждане: Any Kind Всякакъв вид Appearance Външен вид diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 07457b69f..437f8a4fc 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2513,6 +2513,10 @@ lighting Light Světlo < 1 sec < 1 s [Unnamed] [Bez názvu] Amount: Míra: + Anti-aliasing Method Metoda vyhlazování + Sharp Ostré + Crisp Jasné + Strong Silné Anti-aliasing: Vyhlazování: Artboard: Pracovní plocha: Color Table Tabulka barev diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 09a0e61f3..c2c0c3ff0 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2333,6 +2333,10 @@ lighting Light Lys < 1 sec < 1 sek [Unnamed] [Unavngiven] Amount: Mængde: + Anti-aliasing Method Antialiaseringsmetode + Sharp Skarp + Crisp Klar + Strong Kraftig Anti-aliasing: Antialiasering: Artboard: Tegnebræt: Color Table Farvetabel diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e749b6748..c88c5c8c6 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -167,6 +167,10 @@ Anti-Alias Glätten Anti-alias Rasters Pixelbilder glätten Anti-aliased Artwork Geglättete Grafik + Anti-aliasing Method Glättungsmethode + Sharp Scharf + Crisp Klar + Strong Stark Anti-aliasing: Glättung: Any Kind Beliebige Art Appearance Aussehen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 040e0be39..c4a886223 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -165,6 +165,10 @@ Anti-Alias Εξομάλυνση ακμών Anti-alias Rasters Εξομάλυνση ακμών εικόνων ράστερ Anti-aliased Artwork Γραφικά με εξομαλυμένες ακμές + Anti-aliasing Method Μέθοδος εξομάλυνσης ακμών + Sharp Οξεία + Crisp Καθαρή + Strong Έντονη Anti-aliasing: Εξομάλυνση ακμών: Any Kind Οποιοσδήποτε τύπος Appearance Εμφάνιση diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 52423a283..6fce9e9a5 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -185,6 +185,10 @@ Anti-Alias Suavizado Anti-alias Rasters Suavizar imágenes ráster Anti-aliased Artwork Ilustración suavizada + Anti-aliasing Method Método de suavizado + Sharp Nítido + Crisp Definido + Strong Fuerte Anti-aliasing: Suavizado: Any Kind Cualquier tipo Appearance Apariencia diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index b7874cc13..34df16f5b 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2468,6 +2468,10 @@ lighting Light Valo < 1 sec < 1 s [Unnamed] [Nimetön] Amount: Määrä: + Anti-aliasing Method Reunanpehmennystapa + Sharp Terävä + Crisp Selkeä + Strong Vahva Anti-aliasing: Reunanpehmennys: Artboard: Piirtoalusta: Color Table Väritaulukko diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index de7424c8b..bef9e6d86 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -186,6 +186,10 @@ Anti-Alias Lissage Anti-alias Rasters Lisser les images pixellisées Anti-aliased Artwork Illustration lissée + Anti-aliasing Method Méthode de lissage + Sharp Net + Crisp Précis + Strong Fort Anti-aliasing: Lissage : Any Kind Tout type Appearance Aspect diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 8604228b4..58a4bd914 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -273,6 +273,10 @@ Anti-Alias Élsimítás Anti-alias Rasters Raszterek élsimítása Anti-aliased Artwork Élsimított grafika + Anti-aliasing Method Élsimítási módszer + Sharp Éles + Crisp Tiszta + Strong Erős Anti-aliasing: Élsimítás: Any except extracting pages Bármi, kivéve az oldalak kinyerését Any Kind Bármilyen típus diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 087cd8106..e16fd7fb6 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2333,6 +2333,10 @@ lighting Light Cahaya < 1 sec < 1 detik [Unnamed] [Tanpa nama] Amount: Jumlah: + Anti-aliasing Method Metode anti-aliasing + Sharp Tajam + Crisp Jelas + Strong Kuat Anti-aliasing: Anti-aliasing: Artboard: Artboard: Color Table Tabel warna diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a4105bd17..eb14c25e9 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -185,6 +185,10 @@ Anti-Alias Anti-aliasing Anti-alias Rasters Anti-aliasing per immagini raster Anti-aliased Artwork Grafica con anti-aliasing + Anti-aliasing Method Metodo di anti-aliasing + Sharp Nitido + Crisp Definito + Strong Forte Anti-aliasing: Anti-aliasing: Any Kind Qualsiasi tipo Appearance Aspetto diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a1838bc82..7d0b26fda 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -869,6 +869,10 @@ Anti-alias アンチエイリアス Anti-alias Rasters ラスターをアンチエイリアス Anti-aliased Artwork アートワークをアンチエイリアス + Anti-aliasing Method アンチエイリアスの方式 + Sharp シャープ + Crisp くっきり + Strong 太め Anti-aliasing: アンチエイリアス: Appearance Item アピアランス項目 Appearance Panel アピアランスパネル diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 200bbf6f9..33d75e115 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -864,6 +864,10 @@ Anti-alias 안티 앨리어싱 Anti-alias Rasters 래스터 안티 앨리어싱 Anti-aliased Artwork 안티 앨리어싱 아트워크 + Anti-aliasing Method 안티 앨리어싱 방식 + Sharp 날카롭게 + Crisp 또렷하게 + Strong 강하게 Anti-aliasing: 안티 앨리어싱: Appearance Item 모양새 항목 Appearance Panel 모양새 패널 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c274c26b5..66a35fc7e 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2347,6 +2347,10 @@ lighting Light Licht < 1 sec < 1 sec [Unnamed] [Naamloos] Amount: Hoeveelheid: + Anti-aliasing Method Anti-aliasingmethode + Sharp Scherp + Crisp Helder + Strong Krachtig Anti-aliasing: Anti-aliasing: Artboard: Tekenbord: Color Table Kleurentabel diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 5b0fc49d7..5e49ecde4 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2421,6 +2421,10 @@ lighting Light Światło < 1 sec < 1 s [Unnamed] [Bez nazwy] Amount: Wartość: + Anti-aliasing Method Metoda wygładzania + Sharp Ostre + Crisp Wyraźne + Strong Mocne Anti-aliasing: Wygładzanie: Artboard: Obszar kompozycji: Color Table Tablica kolorów diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index c40928a8b..4598bc59e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -190,6 +190,10 @@ Anti-alias Suavizar bordas Anti-alias Rasters Suavizar Rasters Anti-aliased Artwork Arte Suavizada + Anti-aliasing Method Método de Suavização + Sharp Nítido + Crisp Definido + Strong Forte Anti-aliasing: Suavização: Appearance Aparência Appearance Attribute Atributo de Aparência diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8bceed9ae..4b91ae41a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -183,6 +183,10 @@ Anti-Alias Сглаживание Anti-alias Rasters Сглаживать растровые изображения Anti-aliased Artwork Сглаженный рисунок + Anti-aliasing Method Метод сглаживания + Sharp Резкое + Crisp Чёткое + Strong Сильное Anti-aliasing: Сглаживание: Any Kind Любой тип Appearance Оформление diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 4da768cf8..65122579f 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -173,6 +173,10 @@ Anti-Alias Kantutjämning Anti-alias Rasters Kantutjämna rasterbilder Anti-aliased Artwork Kantutjämnad grafik + Anti-aliasing Method Kantutjämningsmetod + Sharp Skarp + Crisp Klar + Strong Kraftig Anti-aliasing: Kantutjämning: Any Kind Alla typer Appearance Utseende diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 07d5afdcf..816247930 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -179,6 +179,10 @@ Anti-Alias Згладжування Anti-alias Rasters Згладжувати растрові зображення Anti-aliased Artwork Згладжений малюнок + Anti-aliasing Method Метод згладжування + Sharp Різке + Crisp Чітке + Strong Сильне Anti-aliasing: Згладжування: Any Kind Будь-який тип Appearance Оформлення diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 98bee0dd7..575ba3e8d 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -209,6 +209,10 @@ Anti-Alias Khử răng cưa Anti-alias Rasters Khử răng cưa ảnh raster Anti-aliased Artwork Tác phẩm khử răng cưa + Anti-aliasing Method Phương pháp khử răng cưa + Sharp Sắc nét + Crisp Rõ nét + Strong Đậm Anti-aliasing: Khử răng cưa: Any Kind Bất kỳ loại Appearance Diện mạo diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 9b758330d..dbdbd2315 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -141,6 +141,10 @@ Anti-Alias 消除锯齿 Anti-alias Rasters 栅格消除锯齿 Anti-aliased Artwork 图稿抗锯齿 + Anti-aliasing Method 消除锯齿方法 + Sharp 锐利 + Crisp 清晰 + Strong 强 Anti-aliasing: 消除锯齿: Appearance 外观 Appearance Attribute 外观属性 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 866f28e1c..ebc897bbc 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -150,6 +150,10 @@ Anti-Alias 消除鋸齒 Anti-alias Rasters 點陣影像消除鋸齒 Anti-aliased Artwork 消除鋸齒的圖稿 + Anti-aliasing Method 消除鋸齒方法 + Sharp 銳利 + Crisp 清晰 + Strong 強 Anti-aliasing: 消除鋸齒: Any Kind 所有類型 Appearance 外觀 diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index fdad86744..883c39354 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -1,11 +1,12 @@ //! Character panel: font family / style, size, leading, kerning, tracking, vertical and horizontal -//! scale, baseline shift, character rotation and the All Caps, Small Caps, Superscript, Subscript, -//! Underline and Strikethrough toggles. +//! scale, baseline shift, character rotation, the anti-aliasing method and the All Caps, Small Caps, +//! Superscript, Subscript, Underline and Strikethrough toggles. use std::sync::{Mutex, OnceLock}; use egui::{Key, Ui, vec2}; use serde_json::{Value, json}; +use vectorcraft_doc::text::TypeAntiAlias; use vectorcraft_doc::{CharStyle, NodeId, NodeKind}; use vectorcraft_tools::{Mods, ToolKey}; @@ -452,9 +453,10 @@ fn cell(ui: &mut Ui, label: &str, tip: &str, add: impl FnOnce(&mut Ui)) { }); } -/// The Character panel's grid of numeric fields, `w` wide: Font Size, Leading, Kerning and -/// Tracking, then with `more` the scales, Baseline Shift and Character Rotation (`s`: the style -/// shown). The Properties panel shows its first rows. +/// The Character panel's grid of fields, `w` wide: Font Size, Leading, Kerning and Tracking, +/// then with `more` the scales, Baseline Shift and Character Rotation, and a row with the +/// Anti-aliasing Method dropdown under Character Rotation (`s`: the style shown). The Properties +/// panel shows its first rows. pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: &CharStyle, w: f32, more: bool) { let fw = ((w - 66.0) / 2.0).clamp(60.0, 100.0); let unit = app.session.type_unit(); @@ -507,9 +509,61 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: & } }); ui.end_row(); + // Anti-aliasing Method, under Character Rotation. + ui.label(""); + cell(ui, "aa", tl!("Anti-aliasing Method"), |ui| anti_alias_method(app, ui, fw)); + ui.end_row(); }); } +/// The Anti-aliasing Method dropdown, `width` wide: how the edges of the type objects the panel acts +/// on are drawn as pixels ([`set_anti_alias`]), blank where their methods differ. +fn anti_alias_method(app: &mut VectorcraftApp, ui: &mut Ui, width: f32) { + // The labels of [`TypeAntiAlias::ALL`], in its order. + let labels = [tl!("None"), tl!("Sharp"), tl!("Crisp"), tl!("Strong")]; + let current = shared_anti_alias(app); + let shown = TypeAntiAlias::ALL.iter().zip(labels).find(|(m, _)| Some(**m) == current).map_or("", |(_, l)| l); + if let Some(m) = widgets::dropdown_names(ui, "ch-anti-alias", shown, &labels, width).and_then(|i| TypeAntiAlias::ALL.get(i)) { + set_anti_alias(app, *m); + } +} + +/// The anti-aliasing method of the type objects the panel acts on: the one the Type tool edits, +/// else the selected type (groups' included). `None` where they differ, or with no type. +fn shared_anti_alias(app: &VectorcraftApp) -> Option { + let st = app.session.active()?; + let mut acc = Shared::None; + match text_editing(app) { + Some((id, _, _)) => { + if let Some(NodeKind::Text(t)) = st.doc.node(id).map(|n| &n.kind) { + acc.add(t.anti_alias); + } + } + None => { + for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) { + n.walk(&mut |c| { + if let NodeKind::Text(t) = &c.kind { + acc.add(t.anti_alias); + } + }); + } + } + } + acc.value() +} + +/// Set the anti-aliasing method `m` of the type objects the panel acts on, and of every frame of +/// their threads (`text.setFormat {antiAlias}`): the one the Type tool edits, whatever part of its +/// text is selected, else the selected type. +fn set_anti_alias(app: &mut VectorcraftApp, m: TypeAntiAlias) { + let mut p = json!({"antiAlias": m.id()}); + if let Some((id, _, _)) = text_editing(app) { + end_typing(app); + p["ids"] = json!([id.0]); + } + app.run("text.setFormat", p).ok(); +} + /// The Font Size combo (Character and Properties panels, Control bar): typed, stepped, scrubbed or /// picked from the presets, in the type unit, blank where the selected text's sizes differ. pub(crate) fn size_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, width: f32) { @@ -805,12 +859,16 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } } -/// Reset Panel: the characters the panel acts on ([`char_cmd`]) take tracking 0, Auto leading and +/// Reset Panel, in one undo step: the type objects the panel acts on take the Sharp anti-aliasing +/// method, and the characters the panel acts on ([`char_cmd`]) take tracking 0, Auto leading and /// kerning, no baseline shift, 100% scales, no rotation, no underline or strikethrough, All Caps /// and Small Caps off, the normal position (neither superscript nor subscript), Roman Baseline /// character alignment, Proportional Metrics off and the Default digits, whether or not Show /// East Asian Options and Show Indic Options are on. fn reset_panel(app: &mut VectorcraftApp) { + // Typing in progress stays an undo step of its own. + end_typing(app); + app.session.begin_named_undo_group(Some("Reset Panel".into())); let d = CharStyle::default(); style(app, json!({"tracking": 0, "leading": "auto"})); format( @@ -821,6 +879,8 @@ fn reset_panel(app: &mut VectorcraftApp) { "proportionalMetrics": d.proportional_metrics, "digits": d.digits }), ); + set_anti_alias(app, TypeAntiAlias::Sharp); + app.session.end_undo_group(false); } #[cfg(test)] @@ -1126,6 +1186,56 @@ mod tests { } } + /// The Anti-aliasing Method dropdown under Character Rotation shows the method of the selected + /// type, blank where the selected objects differ, and a pick sets it for the whole object, also + /// while the Type tool has part of its text selected. Reset Panel sets Sharp. + #[test] + fn the_anti_aliasing_method_dropdown_shows_and_sets_the_types_method() { + use super::super::tests_appearance::{click, frame_events, text_rect}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "Hamburg", "size": 12})).unwrap()["id"].as_u64().unwrap(); + let method = |app: &VectorcraftApp, id: u64| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.anti_alias, + _ => panic!("text"), + }; + let ctx = egui::Context::default(); + let texts = |app: &mut VectorcraftApp| frame_events(&ctx, app, vec![], show); + let shown = |app: &mut VectorcraftApp| { + texts(app).into_iter().map(|(t, _)| t).filter(|t| ["None", "Sharp", "Crisp", "Strong"].contains(&t.as_str())).collect::>() + }; + assert_eq!(shown(&mut app), ["Sharp"]); + // Open the dropdown and pick Crisp. + let at = text_rect(&texts(&mut app), "Sharp").center(); + click(&ctx, &mut app, at, show); + let listed = texts(&mut app); + let crisp = text_rect(&listed, "Crisp").center(); + click(&ctx, &mut app, crisp, show); + assert_eq!(method(&app, a), TypeAntiAlias::Crisp); + assert_eq!(shown(&mut app), ["Crisp"]); + // Another object with Sharp selected as well: blank. + let b = app.run("text.create", json!({"x": 20, "y": 120, "text": "Bremen", "size": 12})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + assert!(shown(&mut app).is_empty(), "the methods differ"); + // The Type tool with two letters selected: the whole object takes the method. + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + app.session.set_tool_option("select", &json!({"start": 1, "end": 3})); + assert!(text_editing(&app).is_some()); + set_anti_alias(&mut app, TypeAntiAlias::Strong); + assert_eq!(method(&app, a), TypeAntiAlias::Strong); + assert_eq!(shared_anti_alias(&app), Some(TypeAntiAlias::Strong)); + // Reset Panel sets Sharp, with the characters' attributes, in one undo step. + range_style(&mut app, json!({"tracking": 50})); + let tracking = |app: &VectorcraftApp| shared(app, |s| s.tracking); + assert_eq!(tracking(&app), Some(50.0)); + reset_panel(&mut app); + assert_eq!((method(&app, a), tracking(&app)), (TypeAntiAlias::Sharp, Some(0.0))); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!((method(&app, a), tracking(&app)), (TypeAntiAlias::Strong, Some(50.0)), "one undo step"); + } + /// Character Alignment and Proportional Metrics are in the panel menu with the East Asian /// options only (as Mojikumi Set and Top-to-Top Leading are in the Paragraph panel). #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 265c55663..e55ad4400 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3613,6 +3613,35 @@ Setting superscript on subscript characters replaces the subscript, and setting replaces the superscript. When `document.setup {superscript, subscript, smallCapsSize}` changes the proportions, the characters take the new ones. One undo step. {"name":"run_command","arguments":{"command":"text.setFormat","params":{"position":"superscript","smallCaps":false}}} + +## Anti-aliasing method + +`text.setFormat {antiAlias: "none"|"sharp"|"crisp"|"strong"}` (the selected type, or `ids`) sets the Character panel's +Anti-aliasing Method, which decides how the edges of the characters are drawn as pixels: on the canvas, in raster exports +(Export As PNG, JPEG and the other raster formats, Export for Screens, Save for Web) and when Object › Rasterize or +Effect › Rasterize draws the type. The method belongs to the whole type object and to every frame of its thread: a +`start`/`end` range sets the whole object, threading frames gives the thread its first frame's method, and a file +whose frames of one thread differ opens with the first frame's method in all of them. VectorCraft defines what each +method does, on the pixels of the picture being drawn (the canvas at its zoom, an export at its resolution): + +- `sharp`, the default, draws each edge pixel with the share of it the type covers, as before. +- `none` draws hard edges: a pixel takes the type's color where the type covers at least half of it, and nothing + elsewhere. +- `crisp` draws a pixel the type covers by a share c at `2c - 0.5`, kept between 0 and 1, so the edges are about half as + wide. +- `strong` draws a pixel the type covers by a share c at `2c`, at most 1, so the type looks heavier. + +The opacity of the type's paints stays as it is inside the characters. Type is drawn as Sharp draws it when it holds +inline graphics, when its own fills or strokes in the Appearance panel include a brush stroke or a blend mode other than +Normal, when it overprints while Overprint Preview is on, and on the canvas while it shows a substitution highlight. +With Preferences › General › Anti-aliased Artwork off, the canvas draws all its art with hard edges, type included, and +so does an export whose own Anti-aliasing option is None. SVG, PDF and EPS export are unchanged and carry no setting. +Reset Panel sets Sharp. Saved as `antiAlias` only when not `sharp`. One undo step. + +```json +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"antiAlias":"crisp"}}} +``` + ## Character Alignment `text.setFormat {charAlign}` (the selected type, or `ids`) and `text.setRangeStyle {id, start, end, charAlign}` (a range) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7b822f3f1..c7db3c6ef 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -611,6 +611,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Kerning across style runs:** characters that differ only in fill, stroke, underline, strikethrough, overprint, character style name, leading, baseline shift, rotation, Character Alignment, kerning, or tracking that keeps or drops ligatures alike are shaped together, so restyling one character keeps the font's kerning pairs with its neighbors. A character with manual kerning that follows one with automatic kerning keeps the font's kerning with it (unless All Caps or Small Caps sets it as two letters, as ß becomes SS), and its value replaces the font's kerning after it. A ligature does not form across a change of style, and a change of font, size or OpenType features still shapes the characters apart. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) (public documentation). Tests: `crates/text/src/tests_typo.rs`, `crates/text/src/tests_bidi.rs`. - **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. The Character panel's Variable Font sliders set each axis (see Fonts). Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). The commands that make or restyle type (and the MCP `add_text` tool) say in their result when area type does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471). Print and Print Preview warn about the area type and type on a path that prints without the text that doesn't fit, and give their ids. A tab stop in the Tabs panel drags from where the press landed, a stop just added included (#1487, fixes #1458). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607). Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656). Select › Same matches type on its characters' fill and stroke (#1746, #1737). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). The Character panel's Small Caps, Superscript and Subscript buttons and the panel menu's items of the same names turn small caps and the superscript or subscript position on and off for the selected type, or for the text the Type tool has selected (`text.setFormat` or `text.setRangeStyle` `{smallCaps, position}`), in Document Setup's proportions. Turning Superscript on turns Subscript off, and turning Subscript on turns Superscript off. A button or item shows as on when the first selected character (at a caret, the character before it) has its attribute. + The Character panel's Anti-aliasing Method dropdown, under Character Rotation, sets how the edges of the selected type objects are drawn as pixels (`text.setFormat {antiAlias}`), for the whole object and every frame of its thread, on the canvas, in raster exports and when the type is rasterized. VectorCraft defines the methods: Sharp, the default, draws edges as before; None paints a pixel only where the type covers at least half of it; Crisp shows a pixel covered by a share c at `2c - 0.5`, and Strong shows it at `2c`, each kept between 0 and 1. SVG, PDF and EPS export are unchanged. - **Paragraph alignment:** the shared Paragraph/Properties alignment row leaves every button unselected when the paragraphs it acts on differ. Auto resolves per paragraph; choosing an alignment uses the existing command. Headless paragraph_mixed_alignment_* regressions exercise mixed → Center → Undo, caret/uniform states, Unicode range boundaries, Auto/direction, split runs and grouped text. These regressions and full integration CI pass. Original captures confirm mixed → Center → Undo in the existing alignment-button layout. The [public Paragraph panel guide](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/paragraph-panel-overview.html) lists the seven controls, but does not specify mixed-state rendering. Other paragraph fields remain outside this change; scores and hours unchanged. For Auto Size area type in columns, the bisection for the frame's height runs on the lines of the one-column layout, and its result is checked with at most two layouts of the columns. When the text wraps around objects or does not fit the one-column layout, or when the check fails, the columns are laid out at every step of the bisection, as before. Typing at the start of a story of 50,000 characters in 2 columns took 1,670 ms a keystroke and now takes 292 ms (release build, `auto_height_in_columns_typing_timing`). Exports to PDF, EPS, SVG, EMF, WMF and the raster formats warn about the area type and type on a path that they draw without the text that doesn't fit, and give their ids, as Print and Print Preview do. From ab437188d1df3017961647ff39c658c2cf5b15c2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 18 Oct 2026 08:29:25 -0700 Subject: [PATCH 004/140] i18n: Georgian rows for the Anti-aliasing Method labels Georgian (ka) became a complete catalog on main. It gets rows for Anti-aliasing Method, Sharp, Crisp and Strong, next to its Anti-aliasing: row, as the other complete catalogs have them. --- crates/ui-egui/src/i18n/ka.tsv | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 164900ed6..074eef233 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -2228,6 +2228,10 @@ lighting Light სინათლე < 1 sec < 1 წმ [Unnamed] [უსახელო] Amount: რაოდენობა: + Anti-aliasing Method ანტიალიასინგის მეთოდი + Sharp მკვეთრი + Crisp მკაფიო + Strong ძლიერი Anti-aliasing: ანტიალიასინგი: Artboard: სამუშაო დაფა: Color Table ფერების ცხრილი From f3cfcb10df12ec142db897a4f5f0a5e73df4f578 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 18 Oct 2026 14:18:09 -0700 Subject: [PATCH 005/140] Type: the renderer reuses the pixels of non-Sharp type between frames Crisp and Strong type was drawn offscreen twice in every frame, and None type once, with no cache, so a pan or a drag drew the type offscreen again. The renderer now keeps those pixels per object and ink plane, within a 128 MiB budget. It places the kept pixels in a frame, without drawing the type offscreen, while the object, the output's zoom and rotation, the method, the edge smoothing and what the type reads are unchanged, the output has moved by whole pixels, and the kept pixels hold every pixel the frame needs. After a pan that brings more of the type into view, the renderer draws the type offscreen again. --- crates/render/src/fx.rs | 2 +- crates/render/src/lib.rs | 95 +++++++++++++++++++++++++--- crates/render/src/lru.rs | 2 + crates/render/src/tests_typeedges.rs | 68 ++++++++++++++++++++ 4 files changed, 156 insertions(+), 11 deletions(-) diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 825bb171b..e2df6b6f6 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -644,7 +644,7 @@ impl PixelEntry { } /// The four linear coefficients of `view` (zoom and rotation, not the pan), as cache keys. -fn linear_key(view: Affine) -> [u64; 4] { +pub(crate) fn linear_key(view: Affine) -> [u64; 4] { let c = view.as_coeffs(); [c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()] } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f9d8eb2a8..4cfe8388e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -430,6 +430,26 @@ struct PlacedEntry { fonts: Option, } +/// Type drawn with an anti-aliasing method other than Sharp, as [`Renderer::draw_type_edges`] drew +/// it offscreen on the pixels of the output ([`Frame::output`]). +struct TypeEdgeEntry { + /// A copy of the type's object: the pixels are drawn again only for an object equal to it. + node: Node, + /// The output's zoom and rotation ([`fx::linear_key`]). + linear: [u64; 4], + /// The method, the edge threshold, the frame's edge smoothing, whether the frame composites + /// precisely, and the opacity folded into the paints. + settings: (TypeAntiAlias, Option, AntiAlias, bool, u32), + /// What the type read from the document and the color settings ([`Renderer::raster_inputs`]). + inputs: fx::Inputs, + /// The output's offset: a frame whose offset differs from it by whole pixels draws the pixels + /// moved by them. + shift: kurbo::Vec2, + /// The output pixel at the image's top-left corner. + origin: (f64, f64), + image: Arc, +} + /// Reusable renderer (keeps the render context, geometry and glyph caches between frames; decoded /// images are shared by all renderers, see [`set_image_cache_budget`]). pub struct Renderer { @@ -485,6 +505,9 @@ pub struct Renderer { /// Objects' content run through Photoshop-style effects, per object and content slot (see /// `fx`): kept only while drawn. pixel_fx: PtrMap<(usize, usize, Ink), fx::PixelEntry>, + /// Type drawn with an anti-aliasing method other than Sharp, per object and ink (see + /// [`Self::draw_type_edges`]). + type_edges: FrameCache<(usize, Ink), TypeEdgeEntry>, /// What the contents of those rasters read from the document, and the color settings, as last /// found for a frame (`stamp`) and document (address) (see [`Self::doc_inputs`]). doc_inputs: Option<((u64, usize), Arc)>, @@ -562,6 +585,10 @@ pub struct FrameStats { /// How many threads composed the frame from its two ink planes, in a CMYK document that shows /// transparency; 0 for other frames. pub ink_threads: usize, + /// The type objects the frame drew offscreen with an anti-aliasing method other than Sharp + /// ([`Renderer::draw_type_edges`]). Type drawn from the pixels an earlier frame kept is not + /// counted. + pub type_edges: usize, pub micros: u64, } @@ -632,6 +659,7 @@ impl Renderer { brushes: Default::default(), shadows: PtrMap::default(), pixel_fx: PtrMap::default(), + type_edges: FrameCache::new(lru::TYPE_EDGE_BUDGET, 0), doc_inputs: None, transparency: None, live: live::LiveCache::default(), @@ -754,6 +782,7 @@ impl Renderer { } // Filtered rasters are large: only those of the last frame stay. self.pixel_fx.retain(|_, e| e.stamp == g); + self.type_edges.sweep(g); self.texts.sweep(g); self.clips.sweep(g); self.clip_paints.sweep(g); @@ -1888,6 +1917,12 @@ impl Renderer { /// and more than [`fx::PIXEL_BUDGET`] (a frame drawn coarser than the output), the frame's own /// pixels are used. /// + /// The pixels are kept (`type_edges`). A later frame draws them again, without drawing the + /// type offscreen, while the object, the output's zoom and rotation, the method and edge + /// smoothing and what the type reads ([`Self::raster_inputs`]) are unchanged, the output has + /// moved by whole pixels, and the kept pixels hold every pixel the frame needs: a pan moves + /// them, and a pan that brings more of the type into view draws it again. + /// /// Returns false, having drawn nothing, for type that is drawn as Sharp draws it: type with /// inline graphics, type whose own fills or strokes include a brush stroke or a blend mode /// other than Normal, type that overprints while Overprint Preview is on, and type that shows a @@ -1947,6 +1982,53 @@ impl Renderer { _ if sx.abs() < 0.999 || sy.abs() < 0.999 => None, _ => f.opts.anti_alias.threshold(), }; + // Pixels kept from an earlier frame, for the type of the node itself (not a copy of its + // text): drawn on output pixels of the same size, rotation and phase, holding those needed. + let own = matches!(&n.kind, NodeKind::Text(o) if std::ptr::eq(&**o, t)); + let key = (std::ptr::from_ref(n) as usize, f.ink); + let linear = fx::linear_key(output); + let shift = output.translation(); + let settings = (t.anti_alias, threshold, f.opts.anti_alias, f.opts.precise, self.alpha.to_bits()); + let inputs = own.then(|| self.raster_inputs(f, n, fx::Reads::of(n))); + let whole = |v: f64| (v - v.round()).abs() < 1e-6; + let kept = inputs.as_ref().and_then(|inputs| { + let e = self.type_edges.get(&key, self.stamp)?; + let d = shift - e.shift; + let (ox, oy) = (e.origin.0 + d.x.round(), e.origin.1 + d.y.round()); + let (iw, ih) = (f64::from(e.image.width()), f64::from(e.image.height())); + let holds = ox <= x0 && oy <= y0 && ox + iw >= x0 + f64::from(w) && oy + ih >= y0 + f64::from(h); + let fits = e.linear == linear && e.settings == settings && whole(d.x) && whole(d.y) && holds && e.inputs == *inputs && e.node == *n; + fits.then(|| (e.image.clone(), ox, oy)) + }); + let (image, ox, oy) = match kept { + Some(kept) => kept, + None => { + let pm = self.draw_type_pixels(&frame, n, t, g, (w, h), threshold); + self.stats.type_edges += 1; + let image = Arc::new(pm); + if let Some(inputs) = inputs { + let bytes = usize::from(w) * usize::from(h) * 4 + lru::node_bytes(n); + let e = TypeEdgeEntry { node: n.clone(), linear, settings, inputs, shift, origin: (x0, y0), image: image.clone() }; + self.type_edges.insert(key, e, bytes, self.stamp); + } + (image, x0, y0) + } + }; + ctx.set_transform(grid.inverse() * Affine::translate((ox, oy))); + // The pixmap maps onto the rect pixel for pixel, each one on a pixel of the output. + ctx.reset_paint_transform(); + let sampler = peniko::ImageSampler { quality: peniko::ImageQuality::Low, ..Default::default() }; + let (iw, ih) = (f64::from(image.width()), f64::from(image.height())); + ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(image), sampler }); + ctx.fill_rect(&Rect::new(0.0, 0.0, iw, ih)); + ctx.set_transform(Affine::IDENTITY); + true + } + + /// The pixels of type `t` of node `n` (laid out in `g`) with its anti-aliasing method, drawn + /// offscreen in `frame` on `w` × `h` pixels with edge threshold `threshold` + /// ([`Self::draw_type_edges`]). + fn draw_type_pixels(&mut self, frame: &Frame, n: &Node, t: &TextObject, g: &TextGeom, (w, h): (u16, u16), threshold: Option) -> Pixmap { let draw = |r: &mut Self, frame: &Frame, n: &Node, t: &TextObject| { let mut off = single_threaded_context(w, h); off.set_aliasing_threshold(threshold); @@ -1957,7 +2039,7 @@ impl Renderer { pm }; let sharp = TextObject { anti_alias: TypeAntiAlias::Sharp, ..t.clone() }; - let mut pm = draw(self, &frame, n, &sharp); + let mut pm = draw(self, frame, n, &sharp); if t.anti_alias != TypeAntiAlias::None { let black = |p: &mut vectorcraft_color::Paint| { if !p.is_none() { @@ -1977,19 +2059,12 @@ impl Renderer { AppearanceItem::Stroke(st) => st.opacity = 1.0, } } - let covered = draw(self, &frame, &inked, &ink); + let covered = draw(self, frame, &inked, &ink); for (p, c) in pm.data_mut().iter_mut().zip(covered.data()) { *p = edge_pixel(*p, c.a, t.anti_alias); } } - ctx.set_transform(grid.inverse() * Affine::translate((x0, y0))); - // The pixmap maps onto the rect pixel for pixel, each one on a pixel of the output. - ctx.reset_paint_transform(); - let sampler = peniko::ImageSampler { quality: peniko::ImageQuality::Low, ..Default::default() }; - ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(Arc::new(pm)), sampler }); - ctx.fill_rect(&Rect::new(0.0, 0.0, f64::from(w), f64::from(h))); - ctx.set_transform(Affine::IDENTITY); - true + pm } /// Paint some of type `n`'s own fills and strokes (`items`) on its glyph outlines `all` diff --git a/crates/render/src/lru.rs b/crates/render/src/lru.rs index d9fec8752..36846e8bb 100644 --- a/crates/render/src/lru.rs +++ b/crates/render/src/lru.rs @@ -43,6 +43,8 @@ pub(crate) const MESH_BUDGET: usize = 32 << 20; pub(crate) const PATTERN_BUDGET: usize = 128 << 20; /// Freeform gradient grids, per thread (`freeform::set_freeform_paint`). pub(crate) const FREEFORM_BUDGET: usize = 32 << 20; +/// Type drawn with an anti-aliasing method other than Sharp, as pixels (`Renderer::draw_type_edges`). +pub(crate) const TYPE_EDGE_BUDGET: usize = 128 << 20; /// Frames an entry may go unused before [`FrameCache::sweep`] drops it. const AGE: u64 = 3; diff --git a/crates/render/src/tests_typeedges.rs b/crates/render/src/tests_typeedges.rs index caf08aba1..0cd9cca51 100644 --- a/crates/render/src/tests_typeedges.rs +++ b/crates/render/src/tests_typeedges.rs @@ -314,3 +314,71 @@ fn type_outside_its_frame_is_drawn() { assert!(outside(&page(TypeAntiAlias::Sharp)) > 20, "the opening quotation mark hangs outside the frame"); assert!(outside(&page(TypeAntiAlias::None)) > 20, "None type draws it there too"); } + +/// A frame draws Crisp type from the pixels an earlier frame kept when the view only moved by +/// whole pixels, and they are the pixels a new renderer draws. Another zoom, another method, a pan +/// that brings more of the type into view, a pan by part of a pixel and other edge smoothing draw +/// the type again. +#[test] +fn a_pan_draws_the_type_from_the_kept_pixels() { + let opts = RenderOptions::default(); + let fonts = || vectorcraft_text::FontDb::global().generation(); + // How many type objects a renderer drew offscreen for `b` in `second` after drawing `a` in + // `first`. The second frame's pixels are a new renderer's, each channel within `near`. The + // frames are drawn again while the fonts change between them (other tests load fonts, and the + // type is then drawn again). + let drawn = |(a, first): (&Document, Affine), (b, second): (&Document, Affine), near: u8| { + for _ in 0..5 { + let before = fonts(); + let mut r = Renderer::new(); + r.render(a, W, H, first, &opts); + assert_eq!(r.stats.type_edges, 1, "{first:?}"); + let pixels = r.render(b, W, H, second, &opts).pixels; + if fonts() != before { + continue; + } + let want = Renderer::new().render(b, W, H, second, &opts).pixels; + let differ: Vec<_> = pixels + .chunks(4) + .zip(want.chunks(4)) + .enumerate() + .filter(|(_, (p, q))| p.iter().zip(*q).any(|(p, q)| p.abs_diff(*q) > near)) + .map(|(i, (p, q))| (i as u32 % W, i as u32 / W, p[3], q[3])) + .collect(); + assert!( + differ.is_empty(), + "{second:?}: {} pixels differ from a new renderer's, first (x, y, alpha, new alpha) {:?}", + differ.len(), + &differ[..differ.len().min(8)] + ); + return r.stats.type_edges; + } + panic!("the fonts changed in every try"); + }; + let (crisp, strong) = (page(TypeAntiAlias::Crisp, None), page(TypeAntiAlias::Strong, None)); + let at = |d, (x, y): (f64, f64)| (d, Affine::translate((x, y))); + for pan in [(7.0, -3.0), (2.0, 5.0), (-4.0, -1.0)] { + assert_eq!(drawn(at(&crisp, (0.0, 0.0)), at(&crisp, pan), 0), 0, "a pan by {pan:?}"); + } + assert_eq!(drawn(at(&crisp, (0.0, 0.0)), (&crisp, Affine::scale(1.5)), 0), 1, "another zoom"); + assert_eq!(drawn(at(&crisp, (0.0, 0.0)), at(&strong, (0.0, 0.0)), 0), 1, "another method"); + assert_eq!(drawn(at(&crisp, (0.0, 0.0)), at(&crisp, (0.5, 0.0)), 0), 1, "a pan by half a pixel"); + // At 400% the type fills the view, so the pixels kept hold every pixel of the next frame. + let zoomed = Affine::translate((-200.0, -150.0)) * Affine::scale(4.0); + let nudged = Affine::translate((0.3, 0.0)) * zoomed; + assert_eq!(drawn((&crisp, zoomed), (&crisp, nudged), 0), 1, "a pan by part of a pixel at 400%"); + assert_eq!(drawn(at(&strong, (-120.0, 0.0)), at(&strong, (0.0, 0.0)), 0), 1, "a pan that brings the rest into view"); + // Type the view cuts is drawn offscreen from the view's edge, and its coverage there can round + // a step the other way: Strong doubles the step, and the color takes a step of rounding. + assert_eq!(drawn(at(&strong, (0.0, 0.0)), at(&strong, (-120.0, 0.0)), 3), 0, "a pan that takes half of it out of view"); + // With hard edges (Anti-aliased Artwork off) and with type snapped to pixels (an export's Type + // Optimized), the frame draws the type again. + for smoothing in [AntiAlias::None, AntiAlias::Type] { + let other = RenderOptions { anti_alias: smoothing, ..Default::default() }; + let mut r = Renderer::new(); + r.render(&crisp, W, H, Affine::IDENTITY, &opts); + let pixels = r.render(&crisp, W, H, Affine::IDENTITY, &other).pixels; + assert_eq!(r.stats.type_edges, 1, "{smoothing:?}"); + assert!(pixels == Renderer::new().render(&crisp, W, H, Affine::IDENTITY, &other).pixels, "{smoothing:?}"); + } +} From 79a25544635afa9877b4b915afa7b564ab923d9d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 18 Oct 2026 14:18:09 -0700 Subject: [PATCH 006/140] Type: the type-edge cache's doc comments say where the kept pixels go The doc comments of `TypeEdgeEntry` and `Renderer::draw_type_edges` said that a later frame draws the kept pixels again, which reads as drawing the type offscreen again. They now say that the renderer places the kept pixels without drawing the type offscreen, and that it draws the type offscreen again after a pan that brings more of it into view. --- crates/render/src/lib.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 4cfe8388e..eeb0a5c35 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -433,7 +433,7 @@ struct PlacedEntry { /// Type drawn with an anti-aliasing method other than Sharp, as [`Renderer::draw_type_edges`] drew /// it offscreen on the pixels of the output ([`Frame::output`]). struct TypeEdgeEntry { - /// A copy of the type's object: the pixels are drawn again only for an object equal to it. + /// A copy of the type's object: the kept pixels are used only for an object equal to it. node: Node, /// The output's zoom and rotation ([`fx::linear_key`]). linear: [u64; 4], @@ -442,8 +442,8 @@ struct TypeEdgeEntry { settings: (TypeAntiAlias, Option, AntiAlias, bool, u32), /// What the type read from the document and the color settings ([`Renderer::raster_inputs`]). inputs: fx::Inputs, - /// The output's offset: a frame whose offset differs from it by whole pixels draws the pixels - /// moved by them. + /// The output's offset: in a frame whose offset differs from it by whole pixels, the pixels are + /// placed moved by them. shift: kurbo::Vec2, /// The output pixel at the image's top-left corner. origin: (f64, f64), @@ -1917,11 +1917,12 @@ impl Renderer { /// and more than [`fx::PIXEL_BUDGET`] (a frame drawn coarser than the output), the frame's own /// pixels are used. /// - /// The pixels are kept (`type_edges`). A later frame draws them again, without drawing the - /// type offscreen, while the object, the output's zoom and rotation, the method and edge + /// The pixels are kept (`type_edges`). In a later frame they are placed without drawing the + /// type offscreen while the object, the output's zoom and rotation, the method and edge /// smoothing and what the type reads ([`Self::raster_inputs`]) are unchanged, the output has - /// moved by whole pixels, and the kept pixels hold every pixel the frame needs: a pan moves - /// them, and a pan that brings more of the type into view draws it again. + /// moved by whole pixels, and the kept pixels hold every pixel the frame needs. After a pan, + /// the kept pixels are placed moved by it; after a pan that brings more of the type into view, + /// the type is drawn offscreen again. /// /// Returns false, having drawn nothing, for type that is drawn as Sharp draws it: type with /// inline graphics, type whose own fills or strokes include a brush stroke or a blend mode From ce5b0230682d914ceaa22ba5ddbfaff44a3938d9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 18 Oct 2026 14:18:09 -0700 Subject: [PATCH 007/140] Type: type edges are drawn offscreen on at most 32,768 pixels a side `Renderer::draw_type_edges` let its offscreen drawing grow to `u16::MAX` pixels a side, the size at which vello_cpu panics. In a frame drawn coarser than the output, a thin strip of type could take more than 32,768 output pixels a side while staying within the pixel budget. The offscreen drawing now uses the frame's own pixels when the output's would be more than `MAX_RASTER_SIDE` a side, and type whose offscreen drawing would still be larger than that is drawn as Sharp, as `fx.rs` limits its offscreen rasters to `MAX_RASTER_SIDE`. --- crates/render/src/lib.rs | 16 +++++++++++----- crates/render/src/tests_typeedges.rs | 17 ++++++++++++++++- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index eeb0a5c35..790874fb4 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1914,8 +1914,8 @@ impl Renderer { /// strokes in opaque black, which gives the share `c` of each pixel they cover; the pixel's /// color and opacity are then scaled by [`edge_coverage`] of `c` divided by `c`, so the opacity /// of the paints is kept. Where the output's pixels would number more than the frame's there - /// and more than [`fx::PIXEL_BUDGET`] (a frame drawn coarser than the output), the frame's own - /// pixels are used. + /// and more than [`fx::PIXEL_BUDGET`], or be more than [`MAX_RASTER_SIDE`] a side (a frame + /// drawn coarser than the output), the frame's own pixels are used. /// /// The pixels are kept (`type_edges`). In a later frame they are placed without drawing the /// type offscreen while the object, the output's zoom and rotation, the method and edge @@ -1928,7 +1928,8 @@ impl Renderer { /// inline graphics, type whose own fills or strokes include a brush stroke or a blend mode /// other than Normal, type that overprints while Overprint Preview is on, and type that shows a /// substitution highlight on screen. Drawn offscreen, those would blend with nothing or take - /// the method on art that is not the characters'. + /// the method on art that is not the characters'. It also returns false for type whose + /// offscreen drawing would be more than [`MAX_RASTER_SIDE`] pixels a side. fn draw_type_edges(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject, g: &TextGeom) -> bool { if proof::overprints(f.opts) && t.runs.iter().any(|r| r.style.overprint_fill || r.style.overprint_stroke) { return false; @@ -1972,6 +1973,10 @@ impl Renderer { // Whole pixels of the output. let (x0, y0) = (on.x0.floor(), on.y0.floor()); let (Ok(w), Ok(h)) = (u16::try_from((on.x1 - x0).ceil() as u64), u16::try_from((on.y1 - y0).ceil() as u64)) else { return false }; + // (vello_cpu panics drawing near `u16::MAX` pixels a side.) + if u32::from(w) > MAX_RASTER_SIDE || u32::from(h) > MAX_RASTER_SIDE { + return false; + } let view = Affine::translate((-x0, -y0)) * output; let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h))); let px = 1.0 / view.determinant().abs().sqrt().max(1e-12); @@ -2355,10 +2360,11 @@ fn type_reach(n: &Node, t: &TextObject, g: &TextGeom) -> Rect { /// The map from a frame's pixels to the pixels [`Renderer::draw_type_edges`] draws type on, for /// the region `on` of the frame's pixels: `grid`, which maps them to the output's, unless the /// output's pixels there would number more than the frame's and more than [`fx::PIXEL_BUDGET`], -/// and then the frame's own pixels (the identity). +/// or be more than [`MAX_RASTER_SIDE`] a side, and then the frame's own pixels (the identity). fn edge_grid(on: Rect, grid: Affine) -> Affine { let area = |r: Rect| r.width() * r.height(); - if area(grid.transform_rect_bbox(on)) > area(on).max(fx::PIXEL_BUDGET) { Affine::IDENTITY } else { grid } + let (out, side) = (grid.transform_rect_bbox(on), f64::from(MAX_RASTER_SIDE)); + if area(out) > area(on).max(fx::PIXEL_BUDGET) || out.width() > side || out.height() > side { Affine::IDENTITY } else { grid } } /// Pixel `p` of type the anti-aliasing method `m` draws, where the type covers `c` (out of 255) of diff --git a/crates/render/src/tests_typeedges.rs b/crates/render/src/tests_typeedges.rs index 0cd9cca51..e241367ac 100644 --- a/crates/render/src/tests_typeedges.rs +++ b/crates/render/src/tests_typeedges.rs @@ -253,7 +253,8 @@ fn type_with_an_alternate_glyph_highlight_is_drawn_as_sharp() { } /// The type is drawn on the output's pixels unless, in a frame coarser than the output, they -/// would number more than the frame's there and more than the effects' pixel budget. +/// would number more than the frame's there and more than the effects' pixel budget, or be more +/// than `MAX_RASTER_SIDE` a side. #[test] fn type_edges_are_drawn_on_at_most_the_budgets_pixels() { let finer = Affine::scale(0.25); @@ -261,6 +262,20 @@ fn type_edges_are_drawn_on_at_most_the_budgets_pixels() { let coarser = Affine::scale(4.0); assert_eq!(edge_grid(Rect::new(0.0, 0.0, 100.0, 100.0), coarser), coarser, "160,000 pixels"); assert_eq!(edge_grid(Rect::new(0.0, 0.0, 1000.0, 1000.0), coarser), Affine::IDENTITY, "16 million pixels"); + assert_eq!(edge_grid(Rect::new(0.0, 0.0, 10_000.0, 1.0), coarser), Affine::IDENTITY, "40,000 pixels a side"); +} + +/// Type whose offscreen drawing would be more than `MAX_RASTER_SIDE` pixels a side is drawn as +/// Sharp type is. +#[test] +fn type_wider_than_the_raster_limit_is_drawn_as_sharp() { + // "Hamburg" at 25,000%, about 37,000 pixels wide, on four rows through its letters. + let view = Affine::translate((-2_000.0, -12_500.0)) * Affine::scale(250.0); + let draw = |m| Renderer::new().render(&page(m, None), 40_000, 4, view, &RenderOptions::default()).pixels; + let sharp = draw(TypeAntiAlias::Sharp); + let a: Vec = sharp.chunks(4).map(|p| p[3]).collect(); + assert!(partial(&a) > 0, "the rows cross the edges of the letters"); + assert!(draw(TypeAntiAlias::None) == sharp, "None type is drawn as Sharp"); } /// The offscreen drawing takes in the farthest a stroke reaches: a mitered character stroke's From 73970cc8a649281be75b2aef7f6fe672d204b257 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 18 Oct 2026 20:45:35 -0700 Subject: [PATCH 008/140] Character: Reset Panel closes only the undo group it opens Reset Panel ended its undo step with end_undo_group, which closes the open undo group of every document. An undo group open in another document, such as an MCP batch, was closed by a reset in this one, and inside an undo group already open in this document the reset closed that group too. Reset Panel now opens its own group only when the active document has none open, and closes only that group. Inside an open undo group the reset is part of that group's step. --- crates/ui-egui/src/panels/character.rs | 51 ++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 7ea739eb5..6c2e77f4c 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -867,11 +867,15 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { /// kerning, no baseline shift, 100% scales, no rotation, no underline or strikethrough, All Caps /// and Small Caps off, the normal position (neither superscript nor subscript), Roman Baseline /// character alignment, Proportional Metrics off and the Default digits, whether or not Show -/// East Asian Options and Show Indic Options are on. +/// East Asian Options and Show Indic Options are on. Inside an undo group that is already open, +/// the reset is part of that group's step. fn reset_panel(app: &mut VectorcraftApp) { // Typing in progress stays an undo step of its own. end_typing(app); - app.session.begin_named_undo_group(Some("Reset Panel".into())); + let open = app.session.active().is_some_and(|st| st.undo_group.is_some()); + if !open { + app.session.begin_named_undo_group(Some("Reset Panel".into())); + } let d = CharStyle::default(); style(app, json!({"tracking": 0, "leading": "auto"})); format( @@ -883,7 +887,11 @@ fn reset_panel(app: &mut VectorcraftApp) { }), ); set_anti_alias(app, TypeAntiAlias::Sharp); - app.session.end_undo_group(false); + // Closes the active document's undo group only, so an undo group open in another document + // stays open. + if !open && let Some(st) = app.session.active_mut() { + st.undo_group = None; + } } #[cfg(test)] @@ -1247,6 +1255,43 @@ mod tests { assert_eq!((method(&app, a), tracking(&app)), (TypeAntiAlias::Strong, Some(50.0)), "one undo step"); } + /// Reset Panel closes only the undo group it opens: in an undo group that is already open, + /// the reset is part of that group's step, and an undo group open in another document stays + /// open, with that document's later edits part of its step. + #[test] + fn reset_panel_closes_only_its_own_undo_group() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "A"})).unwrap()["id"].clone(); + let steps = |st: &vectorcraft_engine::DocState| (st.history.undo.len(), st.history.undo.last().map(|e| e.label.clone())); + // An undo group open in the same document (MCP `command_batch` with `atomic`). + app.run("edit.undoGroup.begin", json!({"label": "Batch"})).unwrap(); + app.run("text.setFormat", json!({"id": a, "antiAlias": "crisp"})).unwrap(); + reset_panel(&mut app); + assert!(app.session.active().unwrap().undo_group.is_some(), "the batch's undo group stays open"); + app.run("text.setFormat", json!({"id": a, "antiAlias": "strong"})).unwrap(); + app.run("edit.undoGroup.end", json!({})).unwrap(); + let a_steps = steps(app.session.active().unwrap()); + assert_eq!(a_steps.1, Some("Batch".to_string()), "the reset is part of the Batch step"); + // An undo group open in another document. + app.run("edit.undoGroup.begin", json!({"label": "Batch"})).unwrap(); + app.run("text.setFormat", json!({"id": a, "antiAlias": "none"})).unwrap(); + let a_steps = steps(app.session.active().unwrap()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + assert_eq!(app.session.active_index(), Some(1)); + let b = app.run("text.create", json!({"x": 20, "y": 50, "text": "B"})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [b]})).unwrap(); + let b_steps = steps(app.session.active().unwrap()).0; + reset_panel(&mut app); + assert_eq!(steps(app.session.active().unwrap()), (b_steps + 1, Some("Reset Panel".to_string()))); + assert!(app.session.active().unwrap().undo_group.is_none()); + assert!(app.session.documents()[0].undo_group.is_some(), "the other document's undo group stays open"); + app.session.set_active(0); + app.run("text.setFormat", json!({"id": a, "antiAlias": "crisp"})).unwrap(); + app.run("edit.undoGroup.end", json!({})).unwrap(); + assert_eq!(steps(app.session.active().unwrap()), a_steps, "the later edit is part of the Batch step"); + } + /// Character Alignment and Proportional Metrics are in the panel menu with the East Asian /// options only (as Mojikumi Set and Top-to-Top Leading are in the Paragraph panel). #[test] From 5228beab637323e5ef36424322061ec16b12c8db Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 19 Oct 2026 05:24:31 -0700 Subject: [PATCH 009/140] Actions: Batch numbers source files that share a name Over a folder, action.batch named each source file's output after the file without its extension. Two files with the same name, such as a.svg and sub/a.svg with includeSubdirs, got the same Export for Screens folder, Override Save path and Folder destination file. The second file's output replaced the first's, and the batch reported no failure. Batch now adds -2, -3... to the name of each later file whose name was already used, compared without case, as it does for data set names. The numbers follow the order the batch processes the files. With the Folder destination, a.svg and a.ai in one folder now give a.svg and a-2.ai. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/fileio/action_batch.rs | 76 +++++++++++++++++++- crates/engine/src/cmd/fileio/mod.rs | 2 +- docs/mcp.md | 4 +- docs/target-app-parity.md | 2 +- 4 files changed, 78 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index feebde275..b7300931a 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -92,7 +92,7 @@ pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result { #[cfg(not(target_arch = "wasm32"))] fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: &Run<'_>) -> Result { - use super::{absolute_path, may_read}; + use super::{absolute_path, distinct_names, may_read}; let folder = str_param(p, "folder").map(absolute_path).ok_or_else(|| bad(cmd, "missing `folder`"))?; may_read(&folder)?; @@ -104,12 +104,15 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: if files.is_empty() { return Err(bad(cmd, "no readable files in the folder")); } + // Each file's name without its extension, with `-2`, `-3`… after a name an earlier file used + // (`a.svg` and `sub/a.svg`, or `a.svg` and `a.ai`), as data set names are numbered. + let stems = + distinct_names(files.iter().map(|path| std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("file").to_string())); let steps = filter_steps(run.steps, override_open, false); let run = Run { steps: &steps, ..*run }; let mut processed = Vec::new(); let mut failed = 0usize; - for path in &files { - let stem = std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("file"); + for (path, stem) in files.iter().zip(&stems) { let ext = super::extension(path); match process_one_file(s, &run, path, stem, &ext) { Ok(row) => processed.push(row), @@ -528,6 +531,73 @@ mod tests { assert!(file_names(&dest).is_empty(), "{r}"); } + /// Two source files with the same name in different subfolders get Export for Screens folders of + /// their own: the second one's folder is numbered, as a repeated data set name is. + #[test] + fn batch_override_export_numbers_export_for_screens_folders_of_files_with_the_same_name() { + let src = tmp("screens-same-src"); + std::fs::create_dir_all(src.join("sub")).unwrap(); + write_svg(&src, "a.svg", "#f00"); + write_svg(&src.join("sub"), "a.svg", "#0f0"); + let dest = tmp("screens-same-out"); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "includeSubdirs": true, + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}] + }), + ) + .unwrap(); + for folder in file_names(&dest) { + println!("{folder}/: {:?}", file_names(&dest.join(&folder))); + } + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(file_names(&dest), ["a", "a-2"], "{r}"); + for (folder, fill) in [("a", "#ff0000"), ("a-2", "#00ff00")] { + assert_eq!(file_names(&dest.join(folder)), ["Artboard-1.svg"], "{r}"); + let svg = std::fs::read_to_string(dest.join(folder).join("Artboard-1.svg")).unwrap(); + assert!(svg.contains(fill), "{folder}: {svg}"); + } + } + + /// Two source files with the same name in different subfolders are saved under names of their + /// own, by Override Save and by the Folder destination: the second one's name is numbered. + #[test] + fn batch_numbers_saved_files_of_source_files_with_the_same_name() { + let src = tmp("save-same-src"); + std::fs::create_dir_all(src.join("sub")).unwrap(); + write_svg(&src, "a.svg", "#f00"); + write_svg(&src.join("sub"), "a.svg", "#0f0"); + let save = json!([{"command": "file.saveAs", "params": {"format": "svg"}}]); + let unlock = json!([{"command": "object.unlockAll", "params": {}}]); + for (tag, destination, commands) in [("save", "none", save), ("folder", "folder", unlock)] { + let dest = tmp(&format!("save-same-{tag}")); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "includeSubdirs": true, + "destination": destination, + "destinationFolder": dest.to_string_lossy(), + "commands": commands + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{tag}: {r}"); + assert_eq!(file_names(&dest), ["a-2.svg", "a.svg"], "{tag}: {r}"); + for (file, fill) in [("a.svg", "#ff0000"), ("a-2.svg", "#00ff00")] { + let svg = std::fs::read_to_string(dest.join(file)).unwrap(); + assert!(svg.contains(fill), "{tag}: {file}: {svg}"); + } + } + } + /// Override Export writes an Export Assets step's files into a folder for each data set inside /// the destination folder, under the assets' names. #[test] diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 224f8e754..02c2278d5 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -187,7 +187,7 @@ pub fn specs() -> Vec { "Batch…", [], None, - "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\`, `:`, `<`, `>`, `\"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and `-2`, `-3`… added to a name already used). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}", + "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs; {stem} is the file's name without its extension, with `-2`, `-3`… added to a name an earlier file used), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\`, `:`, `<`, `>`, `\"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and `-2`, `-3`… added to a name already used). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}", always, action_batch::action_batch ), diff --git a/docs/mcp.md b/docs/mcp.md index e782fd13f..3b8320724 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1157,7 +1157,9 @@ already walks Image Trace. Short agent entry points: source file's extension (`.vectorcraft` over data sets). Override Export gives a `document.exportForScreens` or `assets.export` step the `folder` `destinationFolder/`, in place of the step's own folder or an export preset's: one folder for each source file, named after the file without its extension, or for each data set, - named `_`. The export names the files in it as it does outside Batch. + named `_`. The export names the files in it as it does outside Batch. When two source files + have the same name without the extension (`a.svg` and `sub/a.svg`, or `a.svg` and `a.ai`), the folder destination + and the overrides add `-2`, `-3`… to the name for each later file (compared without case), as for data sets. - Image Trace: `imageTrace.presets`, `imageTrace.make {id?, preset?, params?, view?}` (or `imageTrace.makeAndExpand`), `imageTrace.setView`, `imageTrace.expand`, `imageTrace.release`. - Live Paint: `livePaint.make`, `livePaint.merge`, `livePaint.fill {group?, point}`, `livePaint.strokeEdge`, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5b0689db7..47e0365b9 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -430,7 +430,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Variables: `dataset.select`, `dataset.next` and `dataset.prev` name the area type and type on a path whose text they rewrote and that doesn't fit, in the reply's `overflow` key, as `text.setText` does. - Variables: Lock Variables (`variable.lock`, in the Variables panel's bottom bar and menu) stops variables from being defined, deleted or renamed until they are unlocked, while objects can still be bound and unbound and data sets still change and apply; the lock is saved with the document. - Batch…: Override Save and Override Export name each file with the extension of the format the step writes, however the step spells the format (`.svg` and `SVG` give `.svg`, `png8` gives `.png`), in `action.batch`. - - Batch…: Override Export writes the files of an Export for Screens or Export Assets step into a folder for each source file or data set inside the destination folder, under the names the export gives them (`action.batch`). + - Batch…: Override Export writes the files of an Export for Screens or Export Assets step into a folder for each source file or data set inside the destination folder, under the names the export gives them (`action.batch`). When source files have the same name without the extension, Batch adds `-2`, `-3`… to the folder or file name of each later one. - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. - Batch… over data sets names each file after the document and the data set (`action.batch`). It writes `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set used. - Export for Screens, Save for Web (slice images, and files named after the document's title) and the Libraries panel name files with the characters Windows does not allow written as `-` and with a `-` after a Windows device name such as `CON`, on every platform (`document.exportForScreens`, `document.exportForWeb`, `library.create`). From 28405693d51c7c4415a3f30aad54ebd768683218 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 19 Oct 2026 05:22:52 -0700 Subject: [PATCH 010/140] Tests: non-Sharp type in a placed document or an offscreen layer takes the output's pixels A placed document draws its art in its own coordinates, and a layer that multiplies and holds art that blends is drawn offscreen over a copy of the art below it. Two tests check that type in either is drawn on the pixels of the page or the export: None, Crisp and Strong type in a placed document, and None type in such a layer. In a placed document shown at half and at twice its size, each method gives every pixel on the canvas the share edge_coverage defines, and None type has hard edges in a supersampled export. In such a layer, None type has hard edges in a supersampled export wherever the layer's pixels start. The share check of each_method_draws_the_share_of_each_pixel_it_defines moves into the off_the_share helper, which the placed-document test uses too. --- crates/render/src/tests_typeedges.rs | 107 +++++++++++++++++++++++---- 1 file changed, 94 insertions(+), 13 deletions(-) diff --git a/crates/render/src/tests_typeedges.rs b/crates/render/src/tests_typeedges.rs index e241367ac..4a4e9c1cb 100644 --- a/crates/render/src/tests_typeedges.rs +++ b/crates/render/src/tests_typeedges.rs @@ -40,6 +40,25 @@ fn partial(a: &[u8]) -> usize { a.iter().filter(|v| **v > 0 && **v < 255).count() } +/// The first pixels of `drawn` (type drawn with `m`) that are not the share `edge_coverage` gives +/// the same pixel of `sharp` (the type drawn with Sharp), within a step of rounding: (pixel, +/// Sharp, drawn). +fn off_the_share(m: TypeAntiAlias, sharp: &[u8], drawn: &[u8]) -> Vec<(usize, u8, u8)> { + sharp + .iter() + .zip(drawn) + .enumerate() + .filter(|(_, (c, v))| { + // One step of rounding either way in the coverage. + let near = [c.saturating_sub(1), **c, c.saturating_add(1)].map(|c| edge_coverage(m, c)); + let (lo, hi) = (near.iter().min().copied().unwrap_or(0), near.iter().max().copied().unwrap_or(255)); + **v < lo.saturating_sub(1) || **v > hi.saturating_add(1) + }) + .map(|(i, (c, v))| (i, *c, *v)) + .take(5) + .collect() +} + /// On the canvas each method gives every pixel the share of it that `edge_coverage` gives the /// share Sharp draws: None paints whole pixels from half coverage up, Crisp doubles the contrast /// around half coverage, and Strong doubles the coverage. @@ -51,19 +70,7 @@ fn each_method_draws_the_share_of_each_pixel_it_defines() { for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { let drawn = alphas(&page(m, None), &opts); assert_ne!(drawn, sharp, "{m:?} changes the edges"); - let wrong: Vec<_> = sharp - .iter() - .zip(&drawn) - .enumerate() - .filter(|(_, (c, v))| { - // One step of rounding either way in the coverage. - let near = [c.saturating_sub(1), **c, c.saturating_add(1)].map(|c| edge_coverage(m, c)); - let (lo, hi) = (near.iter().min().copied().unwrap_or(0), near.iter().max().copied().unwrap_or(255)); - **v < lo.saturating_sub(1) || **v > hi.saturating_add(1) - }) - .map(|(i, (c, v))| (i, *c, *v)) - .take(5) - .collect(); + let wrong = off_the_share(m, &sharp, &drawn); assert!(wrong.is_empty(), "{m:?}: (pixel, Sharp, drawn) {wrong:?}"); } assert_eq!(partial(&alphas(&page(TypeAntiAlias::None, None), &opts)), 0, "None paints whole pixels"); @@ -190,6 +197,80 @@ fn type_in_an_offscreen_group_keeps_the_exports_pixels() { } } +/// In a supersampled export, None type in a layer drawn offscreen over a copy of the art below it +/// (the layer multiplies, and art inside it blends) still has hard edges on the exported pixels, +/// wherever the layer's pixels start. +#[test] +fn type_in_an_offscreen_layer_keeps_the_exports_pixels() { + let opts = RenderOptions { supersample: true, precise: true, ..Default::default() }; + for x in [10.3, 10.55, 10.8, 11.05] { + let mut d = Document::new(f64::from(W), f64::from(H)); + let mut t = TextObject::point(Point::new(x, 60.4), "Hamburg", CharStyle { size: 40.0, ..Default::default() }); + t.anti_alias = TypeAntiAlias::None; + let text = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let black = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0); + let mut square = Node::path(d.alloc_id(), rectangle(Rect::new(220.0, 10.0, 250.0, 30.0)), black); + square.blend = vectorcraft_color::BlendMode::Multiply; + let l = d.layers[0].id; + d.insert(Some(l), 0, square).unwrap(); + d.insert(Some(l), 1, text).unwrap(); + Arc::make_mut(&mut d.layers[0]).blend = vectorcraft_color::BlendMode::Multiply; + let a = alphas(&d, &opts); + assert!(a.iter().filter(|v| **v == 255).count() > 1000, "x {x}"); + assert_eq!(partial(&a), 0, "x {x}: None type has hard edges"); + } +} + +/// Type in a placed document, placed at half and at twice its size with its frame starting part +/// of a pixel in, is drawn on the page's pixels: on the canvas each method gives every pixel the +/// share of it `edge_coverage` defines, and in a supersampled export None type has hard edges. +#[test] +fn type_in_a_placed_document_takes_the_pages_pixels() { + use vectorcraft_doc::{ImageBlob, LinkInfo, PlacedDocument}; + // A page showing a file that holds "Hamburg" at 40 / `k` pt drawn with `m`, placed at `k` times + // its size, so the type is 40 pt on the page. + let placing = |m: TypeAntiAlias, k: f64| { + let frame = Rect::new(0.0, 0.0, f64::from(W) / k, f64::from(H) / k); + let mut t = TextObject::point(Point::new(10.0 / k, 60.0 / k), "Hamburg", CharStyle { size: 40.0 / k, ..Default::default() }); + t.anti_alias = m; + let text = Node::new(NodeId(1), NodeKind::Text(Box::new(t))); + let art = Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(text)], clip: false }); + let key = format!("typeedges-{m:?}-{k}"); + let blob = ImageBlob::new("application/json", key.clone().into_bytes()); + let p = PlacedDocument { + link: LinkInfo::new("/art/type.vectorcraft"), + key: key.clone(), + bounding: false, + width: f64::from(W), + height: f64::from(H), + xf: Affine::translate((0.3, 0.4)), + placement: Default::default(), + }; + // The file as read, kept for the object, so drawing it needs no loader. + vectorcraft_doc::placed_document::prime(&p, blob.bytes.clone(), (Document::new(frame.width(), frame.height()), art, frame)); + let mut d = Document::new(f64::from(W), f64::from(H)); + d.images.insert(key, blob); + let n = Node::new(d.alloc_id(), NodeKind::PlacedDocument(Box::new(p))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d + }; + let export = RenderOptions { supersample: true, precise: true, ..Default::default() }; + for k in [0.5, 2.0] { + let sharp = alphas(&placing(TypeAntiAlias::Sharp, k), &RenderOptions::default()); + assert!(partial(&sharp) > 200, "k {k}: Sharp draws smooth edges: {} partial pixels", partial(&sharp)); + for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + let drawn = alphas(&placing(m, k), &RenderOptions::default()); + assert_ne!(drawn, sharp, "k {k}: {m:?} changes the edges"); + let wrong = off_the_share(m, &sharp, &drawn); + assert!(wrong.is_empty(), "k {k}, {m:?}: (pixel, Sharp, drawn) {wrong:?}"); + } + let none = alphas(&placing(TypeAntiAlias::None, k), &export); + assert!(none.iter().filter(|v| **v == 255).count() > 1000, "k {k}"); + assert_eq!(partial(&none), 0, "k {k}: None type has hard edges in the export"); + } +} + /// Type with a brush stroke of its own is drawn as Sharp draws it. #[test] fn type_with_a_brush_stroke_is_drawn_as_sharp() { From 47b5a3b52a4075c134046fa3f94c99288f00ebb1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 19 Oct 2026 06:12:55 -0700 Subject: [PATCH 011/140] Tests: non-Sharp type in a drop shadow, a Rasterize or a pattern takes the output's pixels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A drop shadow draws its object moved by the shadow's offset, Effect › Rasterize draws it on the pixels of its image, and a pattern draws its tile on the pixels of the tile's image and, in pattern editing mode, draws copies of the tile moved by the tile's size. Four tests draw type through each of these: a shadow whose offset (3.4, 45.3 pt) is not a whole number of pixels, a Rasterize at 36 ppi whose image lies on the canvas's pixel grid, a pattern swatch that shows copies of the tile 80.25 pixels away, and the copies pattern editing mode draws around the tile. In each, None, Crisp and Strong type give every pixel the share edge_coverage defines from the pixel Sharp type gets there. The new each_method_takes_the_share helper holds that check. --- crates/render/src/tests_typeedges.rs | 112 ++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/crates/render/src/tests_typeedges.rs b/crates/render/src/tests_typeedges.rs index 4a4e9c1cb..edf9fbb80 100644 --- a/crates/render/src/tests_typeedges.rs +++ b/crates/render/src/tests_typeedges.rs @@ -1,9 +1,11 @@ //! Character panel › Anti-aliasing Method: type drawn as pixels with None, Sharp, Crisp or Strong //! edges. +use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::pattern::{PatternDef, PatternEdit}; use vectorcraft_doc::text::{CharStyle, InlineArt, TextObject, TextRun, TypeAntiAlias}; -use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, NodeKind, TextKind}; +use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeKind, TextKind}; use vectorcraft_geom::Point; use vectorcraft_geom::shapes::rectangle; @@ -271,6 +273,114 @@ fn type_in_a_placed_document_takes_the_pages_pixels() { } } +/// The opacity of each pixel of `d` drawn at `view` on a transparent canvas `w` × `h` pixels. +fn alphas_at(d: &Document, (w, h): (u32, u32), view: Affine) -> Vec { + Renderer::new().render(d, w, h, view, &RenderOptions::default()).pixels.chunks(4).map(|p| p[3]).collect() +} + +/// `page(m, None)` with the effect `id` (parameters `params`) on the type object, and the object's +/// id. +fn page_with_effect(m: TypeAntiAlias, id: &str, params: Value) -> (Document, NodeId) { + let mut d = page(m, None); + let n = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); + d.node_mut(n).unwrap().appearance.effects.push(Effect { id: id.into(), params, visible: true }); + (d, n) +} + +/// "Ham" in 30 pt type drawn with `m`, its baseline at (5.3, 30.4). +fn ham(d: &mut Document, m: TypeAntiAlias) -> Node { + let mut t = TextObject::point(Point::new(5.3, 30.4), "Ham", CharStyle { size: 30.0, ..Default::default() }); + t.anti_alias = m; + Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))) +} + +/// `draw` gives the pixels of type drawn with a method: Sharp type has smooth edges there, and +/// each other method changes them and gives every pixel the share of it `edge_coverage` defines. +fn each_method_takes_the_share(what: &str, draw: impl Fn(TypeAntiAlias) -> Vec) { + let sharp = draw(TypeAntiAlias::Sharp); + assert!(partial(&sharp) > 200, "{what}: Sharp draws smooth edges: {} partial pixels", partial(&sharp)); + for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + let drawn = draw(m); + assert_ne!(drawn, sharp, "{what}: {m:?} changes the edges"); + let wrong = off_the_share(m, &sharp, &drawn); + assert!(wrong.is_empty(), "{what}, {m:?}: (pixel, Sharp, drawn) {wrong:?}"); + } +} + +/// A drop shadow whose offset is not a whole number of pixels draws the type on the canvas's +/// pixels: each method gives every pixel, the shadow's below the type included, the share of it +/// `edge_coverage` defines. +#[test] +fn type_in_a_drop_shadow_takes_the_canvas_pixels() { + let shadow = json!({"mode": "normal", "opacity": 100, "x": 3.4, "y": 45.3, "blur": 0, "color": "#000000"}); + let draw = |m| alphas_at(&page_with_effect(m, "stylize.dropShadow", shadow.clone()).0, (W, 130), Affine::IDENTITY); + // Below the type's descender, the shadow alone. + let below = |a: &[u8]| a[(W * 75) as usize..].iter().filter(|v| **v == 255).count(); + assert!(below(&draw(TypeAntiAlias::Sharp)) > 500, "the shadow is drawn below the type"); + each_method_takes_the_share("drop shadow", draw); +} + +/// Effect › Rasterize draws the type on the pixels of its image: at 36 ppi each pixel of the image +/// is 2 × 2 pixels of a canvas whose grid it lies on, and each method gives every pixel the share +/// of it `edge_coverage` defines. +#[test] +fn type_in_a_rasterize_takes_the_images_pixels() { + let draw = |m| { + let (d, id) = page_with_effect(m, "rasterize", json!({"useDocumentResolution": false, "resolution": 36})); + let image = drawn_bounds(&d, d.node(id).unwrap()).unwrap(); + // The image's corner on a corner of a canvas pixel. + let view = Affine::translate((image.x0.ceil() - image.x0, image.y0.ceil() - image.y0)); + alphas_at(&d, (W, H), view) + }; + each_method_takes_the_share("rasterize", draw); +} + +/// A pattern tile holding type draws it on the pixels of the tile's image, also in the copies of +/// the tile 80.25 pixels away: in the swatch each method gives every pixel the share of it +/// `edge_coverage` defines. +#[test] +fn type_in_a_pattern_tile_takes_the_tiles_pixels() { + let draw = |m| { + let mut d = Document::new(f64::from(W), f64::from(H)); + let text = ham(&mut d, m); + let mut def = PatternDef::new("Type", vec![Arc::new(text)]); + // The swatch draws 2 pixels a point and shows the copies above and below the tile, 80.25 + // pixels away. + def.tile = Rect::new(0.0, 0.0, 120.5, 40.125); + d.patterns.push(def); + let swatch = render_pattern_swatch(&d, "Type", 241).unwrap(); + swatch.pixels.chunks(4).map(|p| p[3]).collect::>() + }; + // The rows of the copy above the tile. + let above = |a: &[u8]| a[..241 * 60].iter().filter(|v| **v == 255).count(); + assert!(above(&draw(TypeAntiAlias::Sharp)) > 200, "the swatch shows the copy above the tile"); + each_method_takes_the_share("pattern tile", draw); +} + +/// Pattern editing mode draws the copies of the tile, 120.25 pixels apart across and 40.125 pixels +/// apart down, on the canvas's pixels: each method gives every pixel the share of it +/// `edge_coverage` defines. +#[test] +fn type_in_the_copies_of_an_edited_tile_takes_the_canvas_pixels() { + let draw = |m| { + let mut d = Document::new(400.0, 200.0); + let text = ham(&mut d, m); + let mut def = PatternDef::new("Type", vec![Arc::new(text.clone())]); + def.tile = Rect::new(0.0, 0.0, 120.25, 40.125); + // The eight copies around the tile, at full opacity. + (def.copies, def.dim_copies, def.show_tile_edge) = (3, 100.0, false); + d.patterns.push(def); + let layer = d.add_layer(Some("Pattern Editing Mode")); + d.insert(Some(layer), 0, text).unwrap(); + d.pattern_edit = Some(PatternEdit { pattern: "Type".into(), layer, original: None }); + alphas_at(&d, (380, 130), Affine::translate((125.0, 45.0))) + }; + // The rows of the copies above the tile. + let above = |a: &[u8]| a[..380 * 40].iter().filter(|v| **v == 255).count(); + assert!(above(&draw(TypeAntiAlias::Sharp)) > 200, "the copies above the tile are drawn"); + each_method_takes_the_share("pattern editing", draw); +} + /// Type with a brush stroke of its own is drawn as Sharp draws it. #[test] fn type_with_a_brush_stroke_is_drawn_as_sharp() { From a5f3298a0c04afd4dfed2ae52cc3efcc8e735b36 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 20 Oct 2026 20:08:18 -0700 Subject: [PATCH 012/140] Outline Stroke: use Expand's reshaped helper instead of a second copy outline_strokes in pathops.rs now imports reshaped from expand.rs, which has had it since #2616, and the copies of reshaped and with_path that this branch added to pathops.rs are removed. The two copies were identical, so Outline Stroke and Expand behave as before. --- crates/engine/src/cmd/expand.rs | 2 +- crates/engine/src/cmd/pathops.rs | 27 +-------------------------- 2 files changed, 2 insertions(+), 27 deletions(-) diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index ebeacf63a..f1c841ff8 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -351,7 +351,7 @@ fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &F /// Path `l` as the canvas paints its fills and strokes: its visible geometry effects applied to its /// path (a plain path then), and every geometry effect of its own removed. `None` when it has no /// visible geometry effect. -fn reshaped(l: &Node) -> Option { +pub(crate) fn reshaped(l: &Node) -> Option { use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, is_geometry}; if !has_geometry(&l.appearance.effects) { return None; diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index e2ba627ca..d400ad217 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -13,6 +13,7 @@ use vectorcraft_pathops as po; use vectorcraft_pathops::{BoolOp, PathfinderOp}; use super::edit::selected_roots; +use super::expand::reshaped; use super::*; pub fn specs() -> Vec { @@ -679,32 +680,6 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br assemble(d, l, parts) } -/// Path `l` as the canvas paints its fills and strokes: its visible geometry effects applied to its -/// path (a plain path then), and every geometry effect of its own removed. `None` when it has no -/// visible geometry effect. -pub(crate) fn reshaped(l: &Node) -> Option { - use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, is_geometry}; - if !has_geometry(&l.appearance.effects) { - return None; - } - let (path, _) = node_path(l)?; - let g = apply_geometry_with(&l.appearance.effects, &path, path.bounds()?, &GeomContext::of(l)); - let mut m = with_path(l, g); - m.appearance.effects.retain(|e| !is_geometry(&e.id)); - Some(m) -} - -/// Path or compound path `l` with `path` as its path, a plain path. -pub(crate) fn with_path(l: &Node, path: PathData) -> Node { - let mut m = l.clone(); - m.kind = match &l.kind { - NodeKind::Path { rule, clipping, guide, .. } => NodeKind::Path { path, rule: *rule, live: None, clipping: *clipping, guide: *guide }, - NodeKind::Compound { rule, .. } => NodeKind::Path { path, rule: *rule, live: None, clipping: false, guide: false }, - k => k.clone(), - }; - m -} - /// A plain copy of path `l` (new ids, no transparency, name or mask) painted with `items`. pub(crate) fn styled_copy(d: &mut Document, l: &Node, items: Vec) -> Node { let mut f = d.reid(l); From 8afb606a2eb8f992cfe1488d115910f4eeffd5a5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 20 Oct 2026 20:31:22 -0700 Subject: [PATCH 013/140] Outline Stroke: each Transform copy of a gradient path keeps its own gradient A path whose Transform effect makes copies and that has a gradient fill or stroke is drawn on the canvas as separate copies, each painting its gradient by itself. Outline Stroke merged the copies into one path, so the gradient stretched across all of them. It now outlines the strokes of the art the canvas draws, as Expand Appearance does. Expand's Stroke option and Flatten Transparency's Convert All Strokes to Outlines change the same way. --- crates/engine/src/cmd/pathops.rs | 23 +++++++++++++++++++++-- crates/engine/src/tests_outlinestroke.rs | 23 +++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index d400ad217..795925259 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -644,12 +644,19 @@ pub(crate) fn outlined_stroke( /// the path, each stroke becomes its outline (or brush art), grouped when there are several. /// As on the canvas, the path's geometry effects ([`reshaped`]) shape the path first, and each /// stroke's own geometry effects then shape the path that stroke outlines; [`outlined_stroke`] -/// gets the stroke without them. `None` when `l` has no stroke to outline. +/// gets the stroke without them. A path whose Transform effect copies it with a gradient becomes +/// the art the canvas draws for it, each copy with its own gradient and its strokes outlined. +/// `None` when `l` has no stroke to outline. pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], l: &Node) -> Option { - use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, is_geometry}; + use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, has_gradient_copies, is_geometry, reshape}; if !l.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if outlinable(st))) { return None; } + if has_gradient_copies(l) { + let mut art = d.reid(&reshape(l, None)?); + outline_member_strokes(d, brushes, &mut art); + return Some(art); + } let reshaped = reshaped(l); let l = reshaped.as_ref().unwrap_or(l); let (path, rule) = node_path(l)?; @@ -680,6 +687,18 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br assemble(d, l, parts) } +/// Outline the strokes of every member of group `n`, however deep ([`outline_strokes`]). +fn outline_member_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &mut Node) { + let Some(children) = n.children_mut() else { return }; + for c in children.iter_mut() { + if let Some(o) = outline_strokes(d, brushes, c) { + *c = Arc::new(o); + } else if c.children().is_some() { + outline_member_strokes(d, brushes, Arc::make_mut(c)); + } + } +} + /// A plain copy of path `l` (new ids, no transparency, name or mask) painted with `items`. pub(crate) fn styled_copy(d: &mut Document, l: &Node, items: Vec) -> Node { let mut f = d.reid(l); diff --git a/crates/engine/src/tests_outlinestroke.rs b/crates/engine/src/tests_outlinestroke.rs index 62d315850..c6f6a11f9 100644 --- a/crates/engine/src/tests_outlinestroke.rs +++ b/crates/engine/src/tests_outlinestroke.rs @@ -280,6 +280,29 @@ fn a_geometry_effect_on_the_object_shapes_the_path_it_outlines() { assert_eq!(outlined_as_drawn(json!({"color": "#33aa55"}), Value::Null), as_drawn()); } +/// The canvas paints a gradient fill or stroke on each copy that the object's Transform effect +/// makes by itself; the outlined result draws the same, with the gradient fitted to each copy. +#[test] +fn each_transform_copy_keeps_its_own_gradient() { + let mut differ = vec![]; + for on_stroke in [false, true] { + for (command, p) in outlining() { + let mut s = session(); + let fill = if on_stroke { json!({"none": true}) } else { json!({"gradient": {"kind": "linear"}}) }; + square_with(&mut s, fill, Value::Null, &[("distort.transform", json!({"moveH": 120, "copies": 1}))]); + if on_stroke { + s.execute("paint.setStroke", &json!({"gradient": {"kind": "linear"}})).unwrap(); + s.execute("stroke.set", &json!({"weight": 12})).unwrap(); + } + let before = render(&s); + outlined_by(&mut s, command, &p); + differ.push((on_stroke, command, differing(&before, &render(&s), 64))); + } + } + let none_differ: Vec<_> = [false, true].into_iter().flat_map(|on_stroke| outlining().map(|(command, _)| (on_stroke, command, 0))).collect(); + assert_eq!(differ, none_differ); +} + #[test] fn brushed_strokes_outline_to_their_brush_art() { let mut s = session(); From ffbf35bd1ce179da8423570dc96fbf5d85053d84 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 20 Oct 2026 23:44:32 -0700 Subject: [PATCH 014/140] Character: test each anti-aliasing method's share against literal values The canvas and export tests compute their expected pixels with edge_coverage, the function the renderer uses, so a change to it went unnoticed. edge_coverage_gives_each_methods_documented_share checks its output for None, Sharp, Crisp and Strong at eleven coverages, including the half-coverage threshold and the points where Crisp and Strong reach 0 and 255. --- crates/render/src/tests_typeedges.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/render/src/tests_typeedges.rs b/crates/render/src/tests_typeedges.rs index edf9fbb80..2ba140bf4 100644 --- a/crates/render/src/tests_typeedges.rs +++ b/crates/render/src/tests_typeedges.rs @@ -78,6 +78,31 @@ fn each_method_draws_the_share_of_each_pixel_it_defines() { assert_eq!(partial(&alphas(&page(TypeAntiAlias::None, None), &opts)), 0, "None paints whole pixels"); } +/// `edge_coverage` gives each method the share its documentation defines: None shows all of a +/// pixel from half coverage (127) up and none of it below, Sharp shows the coverage, Crisp shows +/// `2c - 0.5` and Strong shows `2c`, each kept between 0 and all. +#[test] +fn edge_coverage_gives_each_methods_documented_share() { + // Coverage, then what None, Sharp, Crisp and Strong show of the pixel (out of 255). + let table: [(u8, [u8; 4]); 11] = [ + (0, [0, 0, 0, 0]), + (1, [0, 1, 0, 2]), + (63, [0, 63, 0, 126]), + (64, [0, 64, 0, 128]), + (65, [0, 65, 2, 130]), + (126, [0, 126, 124, 252]), + (127, [255, 127, 126, 254]), + (128, [255, 128, 128, 255]), + (191, [255, 191, 254, 255]), + (192, [255, 192, 255, 255]), + (255, [255, 255, 255, 255]), + ]; + for (c, shown) in table { + let got = [TypeAntiAlias::None, TypeAntiAlias::Sharp, TypeAntiAlias::Crisp, TypeAntiAlias::Strong].map(|m| edge_coverage(m, c)); + assert_eq!(got, shown, "coverage {c}: None, Sharp, Crisp, Strong"); + } +} + /// A raster export draws its art at several times its size and averages it down; type with None /// still has hard edges on the exported pixels, Crisp has fewer partly covered pixels than Sharp, /// and Strong draws heavier type. From 33917ff2fd3a5e385bfc67dcb9785c351d799141 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 21 Oct 2026 00:16:52 -0700 Subject: [PATCH 015/140] Outline Stroke: random Transform effects keep the values the canvas drew Random in a Transform effect takes its seed from the object's id. The copies of a gradient path that its Transform effect makes were given new ids before their strokes were outlined, so a random Transform on the stroke moved each outline elsewhere than the canvas drew it. The copies are now outlined first and given new ids after. A fill with a random Transform effect stayed on a copy of the path with a new id, so it drew elsewhere too. It now paints the outline its geometry effects make of the path, on a copy of its own. outlines_keep_the_random_transforms_the_canvas_drew checks Outline Stroke, Expand and Flatten Transparency against the canvas for both cases and for a random Transform that makes the copies. --- crates/engine/src/cmd/expand.rs | 9 ++++--- crates/engine/src/cmd/pathops.rs | 27 ++++++++++++++----- crates/engine/src/tests_outlinestroke.rs | 33 ++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index f1c841ff8..bd8dfff69 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -366,7 +366,7 @@ pub(crate) fn reshaped(l: &Node) -> Option { /// Path `l` (outline `path`, box `bounds`) as its fill `item` paints it on the canvas, with its /// outline and box: when the fill has visible geometry effects, `l` with the outline they make of /// `path` as a plain path, and that outline's box. -fn fill_shape(l: &Node, item: &AppearanceItem, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect)> { +pub(crate) fn fill_shape(l: &Node, item: &AppearanceItem, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect)> { use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry}; if !has_geometry(item.effects()) { return Some((l.clone(), path.clone(), bounds)); @@ -595,10 +595,13 @@ fn moves_only(a: Affine) -> bool { (m0 - 1.0).abs() <= eps && m1.abs() <= eps && m2.abs() <= eps && (m3 - 1.0).abs() <= eps } +/// Is `e` a visible Transform effect with Random on (which takes its seed from the object's id)? +pub(crate) fn seeded(e: &vectorcraft_doc::Effect) -> bool { + e.visible && e.id == "distort.transform" && e.params.get("random").and_then(Value::as_bool) == Some(true) +} + /// Does `n` have a visible Transform effect with Random on, its own or a fill's or stroke's? fn seeded_here(n: &Node) -> bool { - let seeded = - |e: &vectorcraft_doc::Effect| e.visible && e.id == "distort.transform" && e.params.get("random").and_then(Value::as_bool) == Some(true); n.appearance.effects.iter().any(seeded) || n.appearance.items.iter().any(|i| i.effects().iter().any(seeded)) } diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 795925259..658b368f6 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -13,7 +13,7 @@ use vectorcraft_pathops as po; use vectorcraft_pathops::{BoolOp, PathfinderOp}; use super::edit::selected_roots; -use super::expand::reshaped; +use super::expand::{fill_shape, reshaped, seeded}; use super::*; pub fn specs() -> Vec { @@ -644,8 +644,11 @@ pub(crate) fn outlined_stroke( /// the path, each stroke becomes its outline (or brush art), grouped when there are several. /// As on the canvas, the path's geometry effects ([`reshaped`]) shape the path first, and each /// stroke's own geometry effects then shape the path that stroke outlines; [`outlined_stroke`] -/// gets the stroke without them. A path whose Transform effect copies it with a gradient becomes -/// the art the canvas draws for it, each copy with its own gradient and its strokes outlined. +/// gets the stroke without them. A fill with a random Transform effect paints the outline its +/// geometry effects make of the path, on a copy of its own. A path whose Transform effect copies +/// it with a gradient becomes the art the canvas draws for it, each copy with its own gradient and +/// its strokes outlined before the art gets new ids. Random in a Transform effect takes its seed +/// from the object's id, so these effects give the outline the values the canvas drew. /// `None` when `l` has no stroke to outline. pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], l: &Node) -> Option { use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, has_gradient_copies, is_geometry, reshape}; @@ -653,9 +656,9 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br return None; } if has_gradient_copies(l) { - let mut art = d.reid(&reshape(l, None)?); + let mut art = reshape(l, None)?; outline_member_strokes(d, brushes, &mut art); - return Some(art); + return Some(d.reid(&art)); } let reshaped = reshaped(l); let l = reshaped.as_ref().unwrap_or(l); @@ -665,7 +668,19 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br let mut fills: Vec = vec![]; for item in &l.appearance.items { match item { - AppearanceItem::Fill(f) if f.visible && !f.paint.is_none() => fills.push(item.clone()), + AppearanceItem::Fill(f) if f.visible && !f.paint.is_none() => { + let shaped = f.effects.iter().any(seeded).then(|| path.bounds().and_then(|b| fill_shape(l, item, &path, b))).flatten(); + let Some((shape, ..)) = shaped else { + fills.push(item.clone()); + continue; + }; + if !fills.is_empty() { + parts.push(styled_copy(d, l, std::mem::take(&mut fills))); + } + let mut plain = item.clone(); + plain.effects_mut().retain(|e| !is_geometry(&e.id)); + parts.push(styled_copy(d, &shape, vec![plain])); + } AppearanceItem::Stroke(st) if outlinable(st) => { if !fills.is_empty() { parts.push(styled_copy(d, l, std::mem::take(&mut fills))); diff --git a/crates/engine/src/tests_outlinestroke.rs b/crates/engine/src/tests_outlinestroke.rs index c6f6a11f9..84428432a 100644 --- a/crates/engine/src/tests_outlinestroke.rs +++ b/crates/engine/src/tests_outlinestroke.rs @@ -303,6 +303,39 @@ fn each_transform_copy_keeps_its_own_gradient() { assert_eq!(differ, none_differ); } +/// Random in a Transform effect takes its seed from the object's id. The outline draws as the +/// canvas drew the object with Random in a Transform effect that copies a gradient square, in a +/// Transform effect on the stroke of such a square, and in a Transform effect on the fill of a +/// plain square. +#[test] +fn outlines_keep_the_random_transforms_the_canvas_drew() { + let random = json!({"moveH": 30, "moveV": 30, "rotate": 40, "random": true}); + let cases = [ + ("random copies", json!({"gradient": {"kind": "linear"}}), json!({"moveH": 120, "rotate": 30, "copies": 1, "random": true}), None), + ("copies, random stroke", json!({"gradient": {"kind": "linear"}}), json!({"moveH": 120, "copies": 1}), Some(1)), + ("random fill", json!({"color": "#33aa55"}), json!({}), Some(0)), + ]; + let mut differ = vec![]; + for (case, fill, object, item) in &cases { + for (command, p) in outlining() { + let mut s = session(); + square_with(&mut s, fill.clone(), Value::Null, &[]); + if object != &json!({}) { + s.execute("effect.apply", &json!({"effect": "distort.transform", "params": object})).unwrap(); + } + s.execute("stroke.set", &json!({"weight": 12})).unwrap(); + if let Some(item) = item { + s.execute("effect.apply", &json!({"effect": "distort.transform", "params": random, "item": item})).unwrap(); + } + let before = render(&s); + outlined_by(&mut s, command, &p); + differ.push((*case, command, differing(&before, &render(&s), 64))); + } + } + let none_differ: Vec<_> = cases.iter().flat_map(|(case, ..)| outlining().map(|(command, _)| (*case, command, 0))).collect(); + assert_eq!(differ, none_differ); +} + #[test] fn brushed_strokes_outline_to_their_brush_art() { let mut s = session(); From 6ac62214eb4476542ab58cc4a7cb3db91204d00c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 23 Oct 2026 01:35:44 -0700 Subject: [PATCH 016/140] Fix #3748: in isolation mode, art outside a clip group's mask can be selected and dragged In isolation mode the mask of the isolated clip group still limited clicks and the Selection tool's marquee. Art dragged outside the mask could not be clicked or dragged with the Selection, Direct Selection or Group Selection tool, and a press there started a marquee. When the isolated object is a clip group, a layer with a clipping mask or a single object in one, those tools now hit the art the mask hides, after the mask's outline. A marquee there selects it too, where #2630 had left it out, and the context menu's Unlock list follows the same rule. Outside isolation mode, and for the tools that act on what is drawn, clipped art is still hit only inside the mask. Docs: docs/target-app-parity.md. --- crates/doc/src/hit.rs | 67 ++++++++++++++++++++++++++++++-------- crates/tools/src/direct.rs | 17 ++++++++++ crates/tools/src/select.rs | 20 ++++++++++++ docs/target-app-parity.md | 1 + 4 files changed, 92 insertions(+), 13 deletions(-) diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 18deed56b..bc5607634 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -246,7 +246,7 @@ pub fn locked_objects_at(doc: &Document, p: Point, opt: HitOptions) -> Vec) { let Some(children) = parent.children() else { return }; - if parent.clips() && children.first().is_some_and(|clip| !clip_contains(clip, p)) { + if parent.clips() && !edits_inside(parent, opt.scope) && children.first().is_some_and(|clip| !clip_contains(clip, p)) { return; } for i in hit_order(parent, children, p) { @@ -420,6 +420,15 @@ fn clip_outline_hit(parent: &Node, clip: &Node, p: Point, opt: HitOptions, symbo clip_outline(clip, p, opt) } +/// Does isolation mode (`scope`) edit what `parent` holds: is `parent` the isolated container, or +/// the parent of the single object isolated? Then the selection tools hit the art that the +/// clipping mask of `parent` hides, and the Unlock submenu lists it (#3748). The reference +/// app's help says that the parts outside a clipping mask can still be edited +/// (). +fn edits_inside(parent: &Node, scope: Option) -> bool { + scope.is_some_and(|s| s == parent.id || parent.children().is_some_and(|c| c.iter().any(|c| c.id == s))) +} + fn hit_children( parent: &Node, p: Point, @@ -437,8 +446,10 @@ fn hit_children( chain.push(clip.id); return Some(Hit { leaf: clip.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 }); } - // Elsewhere a clip group (or a layer with a clipping mask) only hits inside it. - if !clip_contains(clip, p) { + // Elsewhere a clip group (or a layer with a clipping mask) only hits inside it, except for + // the selection tools in isolation mode within it. + let edited = opt.clip_outlines && editable_only && edits_inside(parent, opt.scope); + if !edited && !clip_contains(clip, p) { return None; } } @@ -607,21 +618,22 @@ pub fn compound_part_at(n: &Node, p: Point, tol: f64) -> Option { /// out those on a locked artboard ([`crate::LockedArtboards::holds_id`]). Symbol instances are measured /// by their art ([`Node::geometric_bounds_with`]). The art of a clip group or of a layer with a /// clipping mask counts only when the marquee meets the region its clipping path clips to, and then -/// only within that region's bounds. +/// only within that region's bounds. In isolation mode the clipping mask of the isolated container, +/// or of the parent of the single object isolated, does not limit the marquee, as it does not limit +/// the selection tools' clicks. pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> Vec { let mut out = Vec::new(); // `tops` pairs each object with the part of `r` in which the marquee can touch it. let tops: Vec<(&std::sync::Arc, Rect)> = match scope.and_then(|s| doc.node(s)) { - Some(s) => match (s.children(), marquee_in(s, r)) { - (Some(c), Some(r)) => c.iter().enumerate().filter(|(i, _)| !shaper_hides(s, *i, scope)).map(|(_, c)| (c, r)).collect(), - (Some(_), None) => vec![], + Some(s) => match s.children() { + Some(c) => c.iter().enumerate().filter(|(i, _)| !shaper_hides(s, *i, scope)).map(|(_, c)| (c, r)).collect(), // A single object isolated: only it. - (None, _) => doc + None => doc .parent_of(s.id) .and_then(|p| doc.node(p)) - .and_then(|p| Some((p.children()?, marquee_in(p, r)?))) + .and_then(|p| p.children()) .into_iter() - .flat_map(|(c, r)| c.iter().filter(|c| c.id == s.id).map(move |c| (c, r))) + .flat_map(|c| c.iter().filter(|c| c.id == s.id).map(|c| (c, r))) .collect(), }, // The objects of the layers, looking through sublayers (hidden, locked and template ones @@ -814,7 +826,9 @@ mod tests { /// #2345: a marquee touches a clip group only where its art shows. Over the clipped-away /// half alone it selects nothing, and around a rectangle drawn beside the group's mask it /// takes that rectangle alone; reaching into the mask takes the group. The same holds for - /// Group Selection's leaves and in isolation mode. + /// Group Selection's leaves. In isolation mode (#3748) the isolated group's own mask does not + /// limit the marquee, as it does not limit clicks: with the group or the art isolated, the + /// marquee takes the hidden art. #[test] fn a_marquee_leaves_a_clip_group_by_its_clipped_away_art() { let mut d = Document::new(300.0, 200.0); @@ -829,8 +843,9 @@ mod tests { let hidden = Rect::new(150.0, -10.0, 210.0, 50.0); assert!(marquee(&d, hidden, None, false).is_empty()); assert!(marquee(&d, hidden, None, true).is_empty()); - assert!(marquee(&d, hidden, Some(g), false).is_empty()); - assert!(marquee(&d, hidden, Some(art_id), false).is_empty()); + assert_eq!(marquee(&d, hidden, Some(g), false), vec![art_id]); + assert_eq!(marquee(&d, hidden, Some(g), true), vec![art_id]); + assert_eq!(marquee(&d, hidden, Some(art_id), false), vec![art_id]); // This marquee surrounds the rectangle beside and crosses the hidden art's bottom edge. assert_eq!(marquee(&d, Rect::new(140.0, 70.0, 195.0, 140.0), None, false), vec![beside]); // This marquee crosses the shown half's top edge and goes on into the hidden half. @@ -1096,6 +1111,32 @@ mod tests { assert_eq!(objects_at(&d, Point::new(138.5, 90.0), HitOptions::default(), None), vec![art, clip]); } + /// #3748: in isolation mode the selection tools hit the art that the isolated clip group (or + /// the isolated layer with a clipping mask) hides outside its mask, and the art when it is the + /// single object isolated; the mask's outline is still hit first. Outside isolation mode, and + /// for the tools that act on what is drawn, the hidden art is not hit. The Unlock submenu + /// lists the hidden art when it is locked. + #[test] + fn isolation_reaches_the_art_a_clipping_mask_hides() { + let hidden = Point::new(170.0, 90.0); + for layer in [false, true] { + let (mut d, g, clip, art) = masked_square(layer); + let isolated = HitOptions { scope: Some(g), ..SELECTING }; + assert!(hit_test(&d, hidden, SELECTING).is_none(), "layer {layer}: not isolated"); + let h = hit_test(&d, hidden, isolated); + assert_eq!(h.map(|h| (h.leaf, h.top_object(Some(g)))), Some((art, art)), "layer {layer}"); + assert_eq!(objects_at(&d, hidden, SELECTING, Some(g)), vec![art], "layer {layer}"); + assert_eq!(hit_test(&d, Point::new(140.0, 90.0), isolated).map(|h| h.leaf), Some(clip), "layer {layer}: the outline first"); + let one = HitOptions { scope: Some(art), ..SELECTING }; + assert_eq!(hit_test(&d, hidden, one).map(|h| h.leaf), Some(art), "layer {layer}: the art isolated"); + assert!(hit_test(&d, hidden, HitOptions { scope: Some(g), ..HitOptions::default() }).is_none(), "layer {layer}: drawn art only"); + assert!(hit_test_visible(&d, hidden, isolated).is_none(), "layer {layer}: sampling"); + d.node_mut(art).unwrap().locked = true; + assert_eq!(locked_objects_at(&d, hidden, HitOptions { scope: Some(g), ..HitOptions::default() }), vec![art], "layer {layer}"); + assert!(locked_objects_at(&d, hidden, HitOptions::default()).is_empty(), "layer {layer}: not isolated"); + } + } + /// Art in a sublayer is an object of its own: a click or a marquee takes it, not the /// sublayer; hidden, locked and template sublayers block both. #[test] diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index 71fc26e28..9f7570904 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -1108,6 +1108,23 @@ mod tests { assert!(names(&a, art) && !names(&a, clip), "{a:?}"); } + /// #3748: in isolation mode Direct Selection and Group Selection take the art that the + /// isolated clip group hides outside its mask; outside isolation mode a press there does not. + #[test] + fn isolation_lets_direct_selection_take_the_art_a_clip_group_hides() { + let (d, g, _, art) = doc_with_masked_square(); + let (s, p) = (Selection::default(), paint()); + let names = + |actions: &[Action]| actions.iter().any(|a| matches!(a, Action::Exec(_, v) if v["id"] == json!(art.0) || v["ids"] == json!([art.0]))); + for group in [false, true] { + let isolated = ToolContext { isolation: Some(g), ..cx(&d, &s, &p) }; + let a = DirectSelectionTool::new(group).pointer(&isolated, &PointerEvent::new(PointerKind::Down, 530.0, 450.0)); + assert!(names(&a), "group {group}: {a:?}"); + let a = DirectSelectionTool::new(group).pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 530.0, 450.0)); + assert!(!names(&a), "group {group}, not isolated: {a:?}"); + } + } + /// A blend of two 20 pt squares centred on (110, 310) and (210, 310), selected. fn blend_doc() -> (vectorcraft_doc::Document, NodeId, NodeId) { let (mut d, _) = doc_with_rect(); diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 51ffd51da..c60629be0 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -1012,6 +1012,26 @@ mod tests { assert!(!a.iter().any(|a| matches!(a, Action::Exec(c, _) if c == "select.set")), "{a:?}"); } + /// #3748: in isolation mode a press on the art that the isolated clip group hides outside its + /// mask selects that art, and a drag from there moves it. Outside isolation mode the same + /// press starts a marquee. + #[test] + fn isolation_drags_the_art_a_clip_group_hides() { + let (d, g, _, art) = doc_with_masked_square(); + let (none, p) = (Selection::default(), paint()); + let mut picked = Selection::default(); + picked.add(art); + let mut t = SelectionTool::default(); + let a = t.pointer(&ToolContext { isolation: Some(g), ..cx(&d, &none, &p) }, &ev(PointerKind::Down, 530.0, 450.0)); + assert_eq!(a, vec![Action::Exec("select.set".into(), json!({ "ids": [art.0] }))]); + let a = t.pointer(&ToolContext { isolation: Some(g), ..cx(&d, &picked, &p) }, &ev(PointerKind::Drag, 430.0, 450.0)); + let Some(Action::Preview(c, v)) = a.get(1) else { panic!("{a:?}") }; + assert_eq!((c.as_str(), v["matrix"][4].as_f64(), v["matrix"][5].as_f64()), ("object.transform", Some(-100.0), Some(0.0))); + let mut t = SelectionTool::default(); + assert!(t.pointer(&cx(&d, &none, &p), &ev(PointerKind::Down, 530.0, 450.0)).is_empty()); + assert!(matches!(t.state, State::Marquee { .. }), "not isolated"); + } + /// #955: grabbed by an anchor and dropped near another object's anchor, the anchor lands /// exactly on it with Smart Guides, even where lining the bounds up would leave it beside it. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ac75ebff1..8b05058cd 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -601,6 +601,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With the Selection or Group Selection tool, pressing E while dragging a marquee makes the marquee select only the objects that lie entirely inside it. Pressing E again switches the marquee back to its default selection. The Lasso picks anchor points only on art a click can pick, and the Add Anchor Point, Delete Anchor Point, Anchor Point and Scissors tools pick anchor points and segments only on the selected paths and on art a click can pick: not on a Shaper Group's original shapes outside Construction mode, and in isolation mode only inside the isolated group or object. Smart Guides and Snap to Point do not snap to a Shaper Group's original shapes, and the reference point of the transform tools snaps to one only when it is selected, or under the pointer in Construction mode. A marquee selects a clip group, or an object on a layer with a clipping mask, only when it reaches the area inside the clipping path, and it measures the art only within that path's bounds. Within the bounds of a mask that is not a rectangle, it can still select art that the mask hides. Direct Selection's marquee still selects the hidden anchor points inside it (#2345). + When a clip group, a layer with a clipping mask or a single object in one is isolated, the Selection, Direct Selection and Group Selection tools also select the art that the mask hides, by a click or a marquee, and a drag from that art moves it (#3748). Outside isolation mode a click selects clipped art only inside the mask. A marquee selects a Shaper Group only when it reaches the art the group shows, not the original shapes kept under it, and Direct Selection's clicks and marquee do not select those shapes' anchor points. In Construction mode a marquee selects the original shapes, and Direct Selection selects their anchor points. A marquee selects an Intertwine group only when it reaches one of the group's members, as for a plain group, and it selects a repeat only when it reaches one of the copies the repeat draws. Direct and Group Selection's marquees select the anchor points of an Intertwine group's members, not the group, and Group Selection's E marquee selects the members inside it. A key object needs another selected object outside it. A Shift-click or any other change that leaves one object selected clears the key, and `select.key` sets none on one object or on a group selected with only its own members. With one object selected, the Align panel, the Control bar and the Properties panel align it to the artboard whatever Align To is set to. From a103f6e97b74cfac8dac2ff8b8fa293bf19f9e91 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 23 Oct 2026 03:37:34 -0700 Subject: [PATCH 017/140] Tests: the type-edge placed-document test sets page_box PlacedDocument has a page_box field on main, so the struct literal in tests_typeedges.rs sets it to its default, as tests_placed_document.rs does. --- crates/render/src/tests_typeedges.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/render/src/tests_typeedges.rs b/crates/render/src/tests_typeedges.rs index 2ba140bf4..509f93ce7 100644 --- a/crates/render/src/tests_typeedges.rs +++ b/crates/render/src/tests_typeedges.rs @@ -268,6 +268,7 @@ fn type_in_a_placed_document_takes_the_pages_pixels() { link: LinkInfo::new("/art/type.vectorcraft"), key: key.clone(), bounding: false, + page_box: Default::default(), width: f64::from(W), height: f64::from(H), xf: Affine::translate((0.3, 0.4)), From 4d6873d1f7b35b7d82171162c81a5f7d79a61d77 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 23 Oct 2026 03:57:35 -0700 Subject: [PATCH 018/140] Tests: type across the widest canvas is drawn with its method The renderer now limits each render context to MAX_CONTEXT_SIDE (32,768) pixels a side, so a 40,000-pixel canvas is drawn 32,768 pixels wide and the type's offscreen drawing is never wider than that. type_wider_than_the_raster_limit_is_drawn_as_sharp expected None type on that canvas to be drawn as Sharp and failed. type_across_the_widest_canvas_is_drawn_with_its_method replaces it: "Hamburg" at 25,000% runs past both sides of a canvas MAX_CONTEXT_SIDE pixels wide, and None type paints whole pixels across it, each the share None gives the pixel of Sharp type. --- crates/render/src/tests_typeedges.rs | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/crates/render/src/tests_typeedges.rs b/crates/render/src/tests_typeedges.rs index 509f93ce7..855f20d46 100644 --- a/crates/render/src/tests_typeedges.rs +++ b/crates/render/src/tests_typeedges.rs @@ -482,17 +482,24 @@ fn type_edges_are_drawn_on_at_most_the_budgets_pixels() { assert_eq!(edge_grid(Rect::new(0.0, 0.0, 10_000.0, 1.0), coarser), Affine::IDENTITY, "40,000 pixels a side"); } -/// Type whose offscreen drawing would be more than `MAX_RASTER_SIDE` pixels a side is drawn as -/// Sharp type is. +/// Type across a canvas of the largest side the renderer draws (`MAX_CONTEXT_SIDE` pixels) is +/// drawn with its method on every pixel. #[test] -fn type_wider_than_the_raster_limit_is_drawn_as_sharp() { - // "Hamburg" at 25,000%, about 37,000 pixels wide, on four rows through its letters. - let view = Affine::translate((-2_000.0, -12_500.0)) * Affine::scale(250.0); - let draw = |m| Renderer::new().render(&page(m, None), 40_000, 4, view, &RenderOptions::default()).pixels; +fn type_across_the_widest_canvas_is_drawn_with_its_method() { + // "Hamburg" at 25,000%, about 37,000 pixels wide, past both sides of the canvas, on four rows + // through its letters. + let view = Affine::translate((-3_000.0, -12_500.0)) * Affine::scale(250.0); + let draw = |m| { + let pixels = Renderer::new().render(&page(m, None), MAX_CONTEXT_SIDE, 4, view, &RenderOptions::default()).pixels; + pixels.chunks(4).map(|p| p[3]).collect::>() + }; let sharp = draw(TypeAntiAlias::Sharp); - let a: Vec = sharp.chunks(4).map(|p| p[3]).collect(); - assert!(partial(&a) > 0, "the rows cross the edges of the letters"); - assert!(draw(TypeAntiAlias::None) == sharp, "None type is drawn as Sharp"); + assert!(partial(&sharp) > 0, "the rows cross the edges of the letters"); + let none = draw(TypeAntiAlias::None); + assert_eq!(none.len(), sharp.len()); + assert_eq!(partial(&none), 0, "None paints whole pixels"); + let wrong = off_the_share(TypeAntiAlias::None, &sharp, &none); + assert!(wrong.is_empty(), "(pixel, Sharp, drawn) {wrong:?}"); } /// The offscreen drawing takes in the farthest a stroke reaches: a mitered character stroke's From 347c99c7b3c4764aa350b4ec5d1e439578d4ea2e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 23 Oct 2026 04:17:01 -0700 Subject: [PATCH 019/140] Character: type with color glyphs is drawn as Sharp Color glyphs (bitmap glyphs in sbix or CBDT, and COLR layers) paint their own pixels and layers. The renderer draws them unchanged in the black drawing that measures coverage, so their own opacity counted as the characters' coverage: Strong made a half-transparent pixel inside a glyph opaque, and Crisp could erase faint detail. draw_type_edges now returns false for type with color glyphs, as it does for type with inline graphics, and docs/mcp.md lists them. --- crates/render/src/lib.rs | 15 ++++++++----- crates/render/src/tests_typeedges.rs | 33 ++++++++++++++++++++++++++++ docs/mcp.md | 5 +++-- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 5ee64398e..dea7cf764 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1958,11 +1958,12 @@ impl Renderer { /// the type is drawn offscreen again. /// /// Returns false, having drawn nothing, for type that is drawn as Sharp draws it: type with - /// inline graphics, type whose own fills or strokes include a brush stroke or a blend mode - /// other than Normal, type that overprints while Overprint Preview is on, and type that shows a - /// substitution highlight on screen. Drawn offscreen, those would blend with nothing or take - /// the method on art that is not the characters'. It also returns false for type whose - /// offscreen drawing would be more than [`MAX_RASTER_SIDE`] pixels a side. + /// inline graphics, type with color glyphs (bitmap or COLR), type whose own fills or strokes + /// include a brush stroke or a blend mode other than Normal, type that overprints while + /// Overprint Preview is on, and type that shows a substitution highlight on screen. Drawn + /// offscreen, those would blend with nothing or take the method on art that is not the + /// characters'. It also returns false for type whose offscreen drawing would be more than + /// [`MAX_RASTER_SIDE`] pixels a side. fn draw_type_edges(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject, g: &TextGeom) -> bool { if proof::overprints(f.opts) && t.runs.iter().any(|r| r.style.overprint_fill || r.style.overprint_stroke) { return false; @@ -1972,6 +1973,10 @@ impl Renderer { if t.runs.iter().any(|r| r.inline.is_some()) || n.appearance.items.iter().any(|i| i.visible() && (i.blend() != normal || brush(i))) { return false; } + // Color glyphs paint their own pixels and layers, whose opacity is not the characters' coverage. + if !g.images.is_empty() || !g.colours.is_empty() { + return false; + } if f.opts.highlight_substitutions { let setup = &f.doc.setup; let shows = |on: bool, path: &BezPath| on && !path.elements().is_empty(); diff --git a/crates/render/src/tests_typeedges.rs b/crates/render/src/tests_typeedges.rs index 855f20d46..7d5219539 100644 --- a/crates/render/src/tests_typeedges.rs +++ b/crates/render/src/tests_typeedges.rs @@ -201,6 +201,39 @@ fn type_with_an_inline_graphic_is_drawn_as_sharp() { } } +/// Type with a color glyph (a bitmap in `sbix` or `CBDT`, or COLR v0 or v1 layers) is drawn as +/// Sharp draws it, so the glyph's own opacity is kept. +#[test] +fn type_with_a_color_glyph_is_drawn_as_sharp() { + use vectorcraft_text::test_fonts::{COLOUR_CHAR, colour_bitmap_family, colour_bitmap_font, colr_family, colr_font}; + let _adding = crate::tests_fontchange::adding_fonts(); + let fonts = [ + (colour_bitmap_family(true), colour_bitmap_font(true)), + (colour_bitmap_family(false), colour_bitmap_font(false)), + (colr_family(false), colr_font(false)), + (colr_family(true), colr_font(true)), + ]; + for (family, font) in fonts { + let Some(font) = font else { panic!("{family}: the test font is built") }; + vectorcraft_text::FontDb::global().add_font(font); + let draw = |m: TypeAntiAlias| { + let mut d = Document::new(160.0, 160.0); + let style = CharStyle { font_family: family.into(), size: 100.0, ..Default::default() }; + let mut t = TextObject::point(Point::new(20.3, 120.4), &COLOUR_CHAR.to_string(), style); + t.anti_alias = m; + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + Renderer::new().render(&d, 160, 160, Affine::IDENTITY, &RenderOptions::default()).pixels + }; + let sharp = draw(TypeAntiAlias::Sharp); + assert!(sharp.chunks(4).any(|p| p[3] > 0), "{family}: the glyph is drawn"); + for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + assert!(draw(m) == sharp, "{family}, {m:?}"); + } + } +} + /// In a supersampled export, None type in a group drawn offscreen (it isolates art that blends) /// still has hard edges on the exported pixels, wherever the group's layer starts. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 3a7f1b313..3bdebb39d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4251,8 +4251,9 @@ method does, on the pixels of the picture being drawn (the canvas at its zoom, a - `strong` draws a pixel the type covers by a share c at `2c`, at most 1, so the type looks heavier. The opacity of the type's paints stays as it is inside the characters. Type is drawn as Sharp draws it when it holds -inline graphics, when its own fills or strokes in the Appearance panel include a brush stroke or a blend mode other than -Normal, when it overprints while Overprint Preview is on, and on the canvas while it shows a substitution highlight. +inline graphics or color glyphs (bitmap or COLR), when its own fills or strokes in the Appearance panel include a brush +stroke or a blend mode other than Normal, when it overprints while Overprint Preview is on, and on the canvas while it +shows a substitution highlight. With Preferences › General › Anti-aliased Artwork off, the canvas draws all its art with hard edges, type included, and so does an export whose own Anti-aliasing option is None. SVG, PDF and EPS export are unchanged and carry no setting. Reset Panel sets Sharp. Saved as `antiAlias` only when not `sharp`. One undo step. From 2e09b9bc4732f4d8b40cf234b0b4d02472c8938b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 23 Oct 2026 18:57:53 -0700 Subject: [PATCH 020/140] Docs: the Unlock submenu lists locked art a mask hides in isolation mode --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8b05058cd..530d55420 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -601,7 +601,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With the Selection or Group Selection tool, pressing E while dragging a marquee makes the marquee select only the objects that lie entirely inside it. Pressing E again switches the marquee back to its default selection. The Lasso picks anchor points only on art a click can pick, and the Add Anchor Point, Delete Anchor Point, Anchor Point and Scissors tools pick anchor points and segments only on the selected paths and on art a click can pick: not on a Shaper Group's original shapes outside Construction mode, and in isolation mode only inside the isolated group or object. Smart Guides and Snap to Point do not snap to a Shaper Group's original shapes, and the reference point of the transform tools snaps to one only when it is selected, or under the pointer in Construction mode. A marquee selects a clip group, or an object on a layer with a clipping mask, only when it reaches the area inside the clipping path, and it measures the art only within that path's bounds. Within the bounds of a mask that is not a rectangle, it can still select art that the mask hides. Direct Selection's marquee still selects the hidden anchor points inside it (#2345). - When a clip group, a layer with a clipping mask or a single object in one is isolated, the Selection, Direct Selection and Group Selection tools also select the art that the mask hides, by a click or a marquee, and a drag from that art moves it (#3748). Outside isolation mode a click selects clipped art only inside the mask. + When a clip group, a layer with a clipping mask or a single object in one is isolated, the Selection, Direct Selection and Group Selection tools also select the art that the mask hides, by a click or a marquee, and a drag from that art moves it (#3748). In isolation mode the canvas Unlock submenu also lists that art when it is locked. Outside isolation mode a click selects clipped art only inside the mask. A marquee selects a Shaper Group only when it reaches the art the group shows, not the original shapes kept under it, and Direct Selection's clicks and marquee do not select those shapes' anchor points. In Construction mode a marquee selects the original shapes, and Direct Selection selects their anchor points. A marquee selects an Intertwine group only when it reaches one of the group's members, as for a plain group, and it selects a repeat only when it reaches one of the copies the repeat draws. Direct and Group Selection's marquees select the anchor points of an Intertwine group's members, not the group, and Group Selection's E marquee selects the members inside it. A key object needs another selected object outside it. A Shift-click or any other change that leaves one object selected clears the key, and `select.key` sets none on one object or on a group selected with only its own members. With one object selected, the Align panel, the Control bar and the Properties panel align it to the artboard whatever Align To is set to. From 23619155c8afef59f2a3b999c194ed2e9d6b05ae Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 23 Oct 2026 22:35:20 -0700 Subject: [PATCH 021/140] Outline Stroke: a Transform effect that scales strokes or moves patterns outlines as drawn main's reshape takes the document as its first argument, and since #885 a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on is drawn through its art (has_transform_art). Outline Stroke, Expand's Stroke option and Flatten Transparency's Convert All Strokes to Outlines now take that art for such a path too, so a 4 pt stroke that the effect scales to 200% outlines at the 8 pt the canvas draws. --- crates/engine/src/cmd/pathops.rs | 11 ++++++----- crates/engine/src/tests_outlinestroke.rs | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index e399d5390..7173e3245 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -720,17 +720,18 @@ pub(crate) fn outlined_stroke( /// stroke's own geometry effects then shape the path that stroke outlines; [`outlined_stroke`] /// gets the stroke without them. A fill with a random Transform effect paints the outline its /// geometry effects make of the path, on a copy of its own. A path whose Transform effect copies -/// it with a gradient becomes the art the canvas draws for it, each copy with its own gradient and -/// its strokes outlined before the art gets new ids. Random in a Transform effect takes its seed +/// it with a gradient, or has Scale Strokes & Effects or Transform Patterns on, becomes the art the +/// canvas draws for it, each copy with its own gradient, stroke width and pattern, and its strokes +/// outlined before the art gets new ids. Random in a Transform effect takes its seed /// from the object's id, so these effects give the outline the values the canvas drew. /// `None` when `l` has no stroke to outline. pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], l: &Node) -> Option { - use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, has_gradient_copies, is_geometry, reshape}; + use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, has_transform_art, is_geometry, reshape}; if !l.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if outlinable(st))) { return None; } - if has_gradient_copies(l) { - let mut art = reshape(l, None)?; + if has_transform_art(l) { + let mut art = reshape(Some(&*d), l, None)?; outline_member_strokes(d, brushes, &mut art); return Some(d.reid(&art)); } diff --git a/crates/engine/src/tests_outlinestroke.rs b/crates/engine/src/tests_outlinestroke.rs index 84428432a..7ea96d375 100644 --- a/crates/engine/src/tests_outlinestroke.rs +++ b/crates/engine/src/tests_outlinestroke.rs @@ -303,6 +303,23 @@ fn each_transform_copy_keeps_its_own_gradient() { assert_eq!(differ, none_differ); } +/// With Scale Strokes & Effects on, the canvas draws the stroke of a solid square that its +/// Transform effect scales to 200% at 8 pt; the outlined result draws the same. +#[test] +fn a_transform_that_scales_strokes_outlines_the_scaled_stroke() { + let scale = json!({"scaleH": 200, "scaleV": 200, "strokes": true}); + let mut differ = vec![]; + for (command, p) in outlining() { + let mut s = session(); + square_with(&mut s, json!({"color": "#33aa55"}), Value::Null, &[("distort.transform", scale.clone())]); + let before = render(&s); + outlined_by(&mut s, command, &p); + differ.push((command, differing(&before, &render(&s), 64))); + } + let none_differ: Vec<_> = outlining().map(|(command, _)| (command, 0)).into_iter().collect(); + assert_eq!(differ, none_differ); +} + /// Random in a Transform effect takes its seed from the object's id. The outline draws as the /// canvas drew the object with Random in a Transform effect that copies a gradient square, in a /// Transform effect on the stroke of such a square, and in a Transform effect on the fill of a From 060e96824663dc74e5da5278abbbb8f32bbf5ecc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 23 Oct 2026 23:57:20 -0700 Subject: [PATCH 022/140] Image Trace: keepColors traces only the listed palette colors Image Trace had no way to trace some of an image's colors and leave the rest out. Ignore Color drops one color, so tracing only the line work of a multi-color image took a full trace and then deleting the other colors' paths by hand. With "keepColors": ["#rrggbb", ...] in the params of imageTrace.make and imageTrace.makeAndExpand, only the palette colors within about 30 levels of a listed color are traced, the distance Ignore Color matches by. With Method Abutting, the kept colors' paths are the same as in a trace of every color. With Method Overlapping, a kept color's paths do not reach under the colors left out, as with Ignore Color. An empty list traces every color, and a value that is not a six-digit hex color is refused. Flat Logo mode does not use the list. The reply's warnings name each listed color that matches none of the colors the trace found, and say when nothing was traced; the status bar shows the first. The warnings are translated in every complete catalog. The params help of imageTrace.make lists keepColors and the new warnings, and the help of imageTrace.makeAndExpand lists the warnings it returns. --- crates/engine/src/cmd/buildcmds.rs | 28 +++++++- crates/engine/src/tests_build.rs | 85 +++++++++++++++++++++++++ crates/trace/src/lib.rs | 49 ++++++++++++-- crates/trace/src/tests.rs | 99 +++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/cs.tsv | 4 ++ crates/ui-egui/src/i18n/de.tsv | 4 ++ crates/ui-egui/src/i18n/el.tsv | 4 ++ crates/ui-egui/src/i18n/es.tsv | 4 ++ crates/ui-egui/src/i18n/fi.tsv | 4 ++ crates/ui-egui/src/i18n/fr.tsv | 4 ++ crates/ui-egui/src/i18n/hu.tsv | 4 ++ crates/ui-egui/src/i18n/it.tsv | 4 ++ crates/ui-egui/src/i18n/ja.tsv | 4 ++ crates/ui-egui/src/i18n/ka.tsv | 4 ++ crates/ui-egui/src/i18n/ko.tsv | 4 ++ crates/ui-egui/src/i18n/nl.tsv | 4 ++ crates/ui-egui/src/i18n/pl.tsv | 4 ++ crates/ui-egui/src/i18n/pt-br.tsv | 4 ++ crates/ui-egui/src/i18n/ru.tsv | 4 ++ crates/ui-egui/src/i18n/sv.tsv | 4 ++ crates/ui-egui/src/i18n/tests.rs | 34 ++++++++++ crates/ui-egui/src/i18n/tr.tsv | 4 ++ crates/ui-egui/src/i18n/uk.tsv | 4 ++ crates/ui-egui/src/i18n/vi.tsv | 4 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 4 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 4 ++ docs/image-trace-adjustments.md | 13 ++++ docs/mcp.md | 3 +- docs/target-app-parity.md | 1 + 29 files changed, 386 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index a441d3a07..1b7d98ba7 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -130,7 +130,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Image Trace"], None, - "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down)}", + "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), keepColors?: [\"#rrggbb\"…] (not mode logo: only the palette colors within about 30 levels of a listed color are traced; empty: every color), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; or a keepColors color matches none the trace found, or nothing was traced)}", has_selection_or_ids, |s, p| trace_make(s, p, false) ), @@ -139,7 +139,7 @@ pub fn specs() -> Vec { "Make and Expand", ["Object", "Image Trace"], None, - "{…as imageTrace.make} same as imageTrace.make, then expand → {id, paths, anchors, colors}", + "{…as imageTrace.make} same as imageTrace.make, then expand → {id, paths, anchors, colors, warnings: [text…] (as imageTrace.make)}", has_selection_or_ids, |s, p| trace_make(s, p, true) ), @@ -1121,7 +1121,8 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { let raster = tr::Raster::decode_to_fit(&blob.bytes).map_err(|e| EngineError::Other(e.to_string()))?; let library = trace_library(s, &mut params)?; let res = tr::trace_within(&raster, ¶ms, MAX_TRACE_ANCHORS).map_err(|e| EngineError::Other(e.to_string()))?; - let warnings = trace_warnings((img.width, img.height), (raster.width, raster.height)); + let mut warnings = keep_warnings(¶ms, &res); + warnings.extend(trace_warnings((img.width, img.height), (raster.width, raster.height))); // Pixel space of the decoded raster → the image object's pixel space → document. let sx = img.width.max(1) as f64 / raster.width.max(1) as f64; let sy = img.height.max(1) as f64 / raster.height.max(1) as f64; @@ -1221,6 +1222,27 @@ pub(crate) fn trace_warnings(size: (u32, u32), traced: (u32, u32)) -> Vec Vec { + if params.keep_colors.is_empty() { + return vec![]; + } + if params.mode == tr::Mode::Logo { + return vec!["Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds.".to_string()]; + } + if res.paths.is_empty() && res.keep_unmatched.len() == params.keep_colors.len() { + return vec![format!("Nothing was traced: none of the colors the trace found matches keepColors ({}).", params.keep_colors.join(", "))]; + } + let mut out = vec![]; + if res.paths.is_empty() && params.ignore_white { + out.push("Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists.".to_string()); + } + out.extend(res.keep_unmatched.iter().map(|c| format!("keepColors: none of the colors the trace found matches {c}, so nothing of it is traced."))); + out +} + fn trace_targets(s: &Session, p: &Value) -> Result> { let d = &s.doc()?.doc; let v: Vec = root_ids(s, p)?.into_iter().filter(|i| d.node(*i).is_some_and(is_trace_group)).collect(); diff --git a/crates/engine/src/tests_build.rs b/crates/engine/src/tests_build.rs index 3464c3c77..20a3070ab 100644 --- a/crates/engine/src/tests_build.rs +++ b/crates/engine/src/tests_build.rs @@ -2047,6 +2047,91 @@ fn image_trace_make_places_paths_over_the_image() { assert_eq!(paths[0].appearance.fill_paint(), Paint::solid(Color::rgb8(0, 0, 0))); } +#[test] +fn image_trace_keep_colors_traces_only_the_colors_listed() { + // A light field with a black ring, a red square and a blue bar on it. + let art = vectorcraft_trace::Raster::from_fn(200, 150, |x, y| { + let r = (f64::from(x) + 0.5 - 70.0).hypot(f64::from(y) + 0.5 - 60.0); + if (34.0..=40.0).contains(&r) { + [0, 0, 0, 255] + } else if (130..170).contains(&x) && (30..70).contains(&y) { + [200, 40, 50, 255] + } else if (30..170).contains(&x) && (110..130).contains(&y) { + [40, 80, 200, 255] + } else { + [240, 240, 220, 255] + } + }); + let traced = |keep: Value| { + let mut s = session(); + add_image(&mut s, &art, Affine::IDENTITY); + let params = json!({"params": {"mode": "color", "palette": "limited", "colors": 4, "keepColors": keep}}); + let r = s.execute("imageTrace.makeAndExpand", ¶ms)?; + let g = node(&s, NodeId(r["id"].as_u64().unwrap())); + let fills: Vec = g.children().unwrap().iter().map(|n| n.appearance.fill_paint()).collect(); + Ok::<_, EngineError>((r["colors"].clone(), fills)) + }; + let (black, red, blue, light) = (Color::rgb8(0, 0, 0), Color::rgb8(200, 40, 50), Color::rgb8(40, 80, 200), Color::rgb8(240, 240, 220)); + let has = |fills: &[Paint], c: Color| fills.contains(&Paint::solid(c)); + // Nothing listed: every color. + let (colors, fills) = traced(json!([])).unwrap(); + assert_eq!(colors, 4); + assert!([black, red, blue, light].into_iter().all(|c| has(&fills, c)), "{fills:?}"); + // The black and the red only. + let (colors, fills) = traced(json!(["#000000", "#c8283c"])).unwrap(); + assert_eq!(colors, 2); + assert!(has(&fills, black) && has(&fills, red), "{fills:?}"); + assert!(fills.iter().all(|f| *f == Paint::solid(black) || *f == Paint::solid(red)), "{fills:?}"); + // A color that is none is refused. + let e = traced(json!(["#000000", "black"])).unwrap_err().to_string(); + assert!(e.contains("\"black\"") && e.contains("#rrggbb"), "{e}"); + assert!(find_command("imageTrace.make").unwrap().params.contains("keepColors?: [\"#rrggbb\"…]")); + assert!(find_command("imageTrace.makeAndExpand").unwrap().params.contains("warnings: [text…] (as imageTrace.make)")); +} + +#[test] +fn image_trace_keep_colors_warns_about_colors_that_match_nothing() { + // A light field with a black ring and a red square on it. + let art = vectorcraft_trace::Raster::from_fn(200, 150, |x, y| { + let r = (f64::from(x) + 0.5 - 70.0).hypot(f64::from(y) + 0.5 - 60.0); + if (34.0..=40.0).contains(&r) { + [0, 0, 0, 255] + } else if (130..170).contains(&x) && (30..70).contains(&y) { + [200, 40, 50, 255] + } else { + [240, 240, 220, 255] + } + }); + let traced = |params: Value| { + let mut s = session(); + add_image(&mut s, &art, Affine::IDENTITY); + let r = s.execute("imageTrace.makeAndExpand", &json!({ "params": params })).unwrap(); + (r["paths"].as_u64().unwrap(), r["warnings"].clone()) + }; + let color = |keep: Value| traced(json!({"mode": "color", "palette": "limited", "colors": 3, "keepColors": keep})); + // Every color listed is in the image: no warning. + let (paths, warnings) = color(json!(["#000000", "#c8283c"])); + assert!(paths > 0); + assert_eq!(warnings, json!([])); + // No color matches: nothing is traced, and the reply says why. + assert_eq!( + color(json!(["#ff00ff", "#00ffff"])), + (0, json!(["Nothing was traced: none of the colors the trace found matches keepColors (#ff00ff, #00ffff)."])) + ); + // One color matches nothing: the others are traced, and that one is named. + let (paths, warnings) = color(json!(["#000000", "#00ffff"])); + assert!(paths > 0); + assert_eq!(warnings, json!(["keepColors: none of the colors the trace found matches #00ffff, so nothing of it is traced."])); + // Ignore Color leaves out the only color listed. + let ignored = + traced(json!({"mode": "color", "palette": "limited", "colors": 3, "keepColors": ["#000000"], "ignoreWhite": true, "ignoreColor": "#000000"})); + assert_eq!(ignored, (0, json!(["Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists."]))); + // Flat Logo mode does not use the list. + let (paths, warnings) = traced(json!({"mode": "logo", "keepColors": ["#ff00ff"]})); + assert!(paths > 0); + assert_eq!(warnings, json!(["Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds."])); +} + /// Shapes: the disc of the test image comes out as a live ellipse where the image lies, the same /// circle as its traced path; without the option it is a plain path. #[test] diff --git a/crates/trace/src/lib.rs b/crates/trace/src/lib.rs index 1e3849bf4..644fe918c 100644 --- a/crates/trace/src/lib.rs +++ b/crates/trace/src/lib.rs @@ -295,6 +295,11 @@ pub struct TraceParams { /// The colour Ignore Color leaves out (`#rrggbb`): the palette colours within about 30 levels /// of it. `None` is white. (Flat Logo mode leaves out white only.) pub ignore_color: Option, + /// Keep Colors: the colors to trace (`#rrggbb`). Only the palette colors within about 30 + /// levels of one of them (as Ignore Color matches a color) are traced, a gradient by its middle + /// color, and their paths are the ones the trace of every color has. Empty traces every color. + /// (Flat Logo mode does not use it: it traces the colors named in `logo_colors`.) + pub keep_colors: Vec, /// Replace nearly-straight curves with straight lines. In Logo mode this also snaps tight arcs /// to true circles. pub snap_curves_to_lines: bool, @@ -336,6 +341,7 @@ impl Default for TraceParams { method: Method::Abutting, ignore_white: false, ignore_color: None, + keep_colors: Vec::new(), snap_curves_to_lines: false, shapes: false, auto_grouping: false, @@ -484,6 +490,9 @@ pub struct TraceResult { /// option). Each path drawn together with those inside it, in the order they have here, paints /// what `paths` paints in its order. pub parents: Vec>, + /// Keep Colors: the colors listed in [`TraceParams::keep_colors`] (as written) that no color + /// the trace found matches, so that nothing of them is traced. (Flat Logo mode: empty.) + pub keep_unmatched: Vec, } impl TraceResult { @@ -504,6 +513,12 @@ fn is_ignored(c: [u8; 3], target: Option<[u8; 3]>) -> bool { } } +/// Whether Keep Colors traces palette color `c`: every color when `keep` is empty, else those +/// Ignore Color would match to one of `keep`. +fn is_kept(c: [u8; 3], keep: &[[u8; 3]]) -> bool { + keep.is_empty() || keep.iter().any(|&k| is_ignored(c, Some(k))) +} + /// Trace `img` with `params`. Failures (an image Logo mode refuses, say) give an empty result: use /// [`trace_within`] to see why. pub fn trace(img: &Raster, params: &TraceParams) -> TraceResult { @@ -530,6 +545,12 @@ pub fn trace_within(img: &Raster, params: &TraceParams, max_anchors: usize) -> R fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result { let (w, h) = (img.width as usize, img.height as usize); + // The colors Keep Colors traces (checked in every mode). + let listed = params + .keep_colors + .iter() + .map(|name| logo::parse_hex(name).ok_or_else(|| TraceError::BadPalette(name.clone()))) + .collect::, _>>()?; if params.mode == Mode::Logo { return logo::trace_logo(img, params, max_anchors); } @@ -611,14 +632,26 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result for (r, &i) in order.iter().enumerate() { rank[i] = r; } - let layers: Vec<(usize, usize)> = - order.iter().copied().enumerate().filter(|&(_, ci)| !(params.ignore_white && is_ignored(q.palette[ci], ignore))).collect(); + // Layers left out: Ignore Color's, and those Keep Colors does not list. Abutting, the others + // are traced as they are in the trace of every color; stacked, they do not reach under the + // colors left out (see `Layers::ignored`). + let dropped = |ci: usize| (params.ignore_white && is_ignored(q.palette[ci], ignore)) || !is_kept(q.palette[ci], &listed); + let layers: Vec<(usize, usize)> = order.iter().copied().enumerate().filter(|&(_, ci)| !dropped(ci)).collect(); + // The listed colors that match none of the colors found. + let keep_unmatched = params + .keep_colors + .iter() + .zip(&listed) + .filter(|&(_, &k)| !order.iter().any(|&ci| is_kept(q.palette[ci], &[k]))) + .map(|(name, _)| name.clone()) + .collect(); let job = Layers { q: &q, rank: &rank, params, img, ignore, + keep: &listed, boxes, gradients: &gradients, bw_tones: bw_tones(img, &q), @@ -628,7 +661,7 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result anchors: AtomicUsize::new(0), max_anchors, }; - let mut out = TraceResult::default(); + let mut out = TraceResult { keep_unmatched, ..TraceResult::default() }; // A layer takes about ten bytes a pixel of its box while it is traced (its mask, the labels // of its components, the stack that fills them, its edges): as many layers at a time as fit // in half a gigabyte, so that a large image is not traced at the cost of gigabytes. @@ -681,6 +714,8 @@ struct Layers<'a> { img: &'a Raster, /// The colour Ignore Color leaves out, if not white. ignore: Option<[u8; 3]>, + /// The colors Keep Colors lists (empty: every color). + keep: &'a [[u8; 3]], /// The box each palette entry's pixels lie in: `(x0, y0, x1, y1)`, the far sides excluded. boxes: Vec>, /// Gradients: the gradient each palette entry is, if it is one. @@ -697,10 +732,11 @@ struct Layers<'a> { } impl Layers<'_> { - /// Ignore White: white is no part of any layer (stacked layers reach under the layers above - /// them, and must not reach under white that is left out). + /// Ignore White, and the colors Keep Colors does not list: such a color is no part of any + /// layer (stacked layers reach under the layers above them, and must not reach under a color + /// that is left out). fn ignored(&self, label: u16) -> bool { - self.params.ignore_white && self.q.palette.get(label as usize).is_some_and(|c| is_ignored(*c, self.ignore)) + self.q.palette.get(label as usize).is_some_and(|c| (self.params.ignore_white && is_ignored(*c, self.ignore)) || !is_kept(*c, self.keep)) } /// The region across a crack, for cutting outlines at their junctions: the outside pixel's /// colour. Stacked layers end on many colours at once and share no edge: one neighbour. @@ -1058,6 +1094,7 @@ pub(crate) fn shift_probe(img: &Raster, params: &TraceParams) -> Vec<[f64; 4]> { params, img, ignore: None, + keep: &[], boxes: vec![None; n], gradients: &gradients, bw_tones: bw_tones(img, &q), diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index ba8c20856..7b540c3cd 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -1095,6 +1095,105 @@ fn ignore_color_leaves_out_the_colour_picked() { assert_eq!(serde_json::to_value(¶ms).unwrap()["ignoreColor"], serde_json::Value::Null); } +/// A navy field with a yellow disc, a green bar and a red square on it, hard-edged. +fn four_color_art() -> Raster { + Raster::from_fn(160, 120, |x, y| { + let (fx, fy) = (f64::from(x) + 0.5, f64::from(y) + 0.5); + if Point::new(fx, fy).distance(Point::new(50.0, 45.0)) <= 30.0 { + [250, 200, 40, 255] + } else if (20.0..140.0).contains(&fx) && (90.0..108.0).contains(&fy) { + [40, 160, 90, 255] + } else if (100.0..140.0).contains(&fx) && (20.0..60.0).contains(&fy) { + [200, 40, 50, 255] + } else { + [20, 40, 90, 255] + } + }) +} + +#[test] +fn keep_colors_traces_only_the_colors_listed() { + let near = |c: [u8; 3], want: [u8; 3]| (0..3).all(|k| c[k].abs_diff(want[k]) <= 3); + let (yellow, red, navy) = ([250, 200, 40], [200, 40, 50], [20, 40, 90]); + let img = four_color_art(); + let pixels = |res: &TraceResult, c: [u8; 3]| res.paths.iter().filter(|p| near(p.color, c)).map(|p| p.pixels).sum::(); + let abutting = TraceParams { mode: Mode::Color, palette: Palette::Limited, colors: 4, ..TraceParams::default() }; + let abutting_all = trace(&img, &abutting); + for method in [Method::Abutting, Method::Overlapping] { + let params = TraceParams { method, ..abutting.clone() }; + let all = trace(&img, ¶ms); + assert_eq!(all.palette.len(), 4, "{method:?}: {:?}", all.palette); + // The yellow and the red, each picked a little off. + let res = trace(&img, &TraceParams { keep_colors: vec!["#f0c832".into(), "#c8283c".into()], ..params.clone() }); + assert_eq!(res.palette.len(), 2, "{method:?}: {:?}", res.palette); + if method == Method::Abutting { + // Their paths as the whole trace has them. + let want: Vec = all.paths.iter().filter(|p| near(p.color, yellow) || near(p.color, red)).cloned().collect(); + assert!(want.iter().any(|p| near(p.color, yellow)) && want.iter().any(|p| near(p.color, red))); + assert_eq!(res.paths, want); + } else { + // Stacked, the red reaches under the yellow above it, not under the green left out. + assert!(res.paths.iter().all(|p| near(p.color, yellow) || near(p.color, red))); + assert_eq!(pixels(&res, red), pixels(&abutting_all, red) + pixels(&abutting_all, yellow)); + assert_eq!(pixels(&res, yellow), pixels(&abutting_all, yellow)); + // The navy field alone, at the bottom of the stack: no part of the colors left out. + let field = trace(&img, &TraceParams { keep_colors: vec!["#14285a".into()], ..params.clone() }); + assert!(!field.paths.is_empty()); + assert_eq!(pixels(&field, navy), pixels(&abutting_all, navy)); + assert!(pixels(&all, navy) > pixels(&abutting_all, navy)); + } + // Every color listed: the trace of every color, as with none listed. + let every = vec!["#fac828".into(), "#28a05a".into(), "#c82832".into(), "#14285a".into()]; + assert_eq!(trace(&img, &TraceParams { keep_colors: every, ..params.clone() }), all, "{method:?}"); + // A color the image does not have: nothing is traced. + assert!(trace(&img, &TraceParams { keep_colors: vec!["#ff00ff".into()], ..params.clone() }).paths.is_empty(), "{method:?}"); + // With Ignore Color as well, a kept color it matches is left out. + let both = TraceParams { + keep_colors: vec!["#fac828".into(), "#14285a".into()], + ignore_white: true, + ignore_color: Some("#14285a".into()), + ..params.clone() + }; + let res = trace(&img, &both); + assert!(!res.paths.is_empty() && res.paths.iter().all(|p| near(p.color, yellow)), "{method:?}"); + } + // A color that is none is refused, in Flat Logo mode too. + for mode in [Mode::Color, Mode::Logo] { + let bad = trace_within(&img, &TraceParams { mode, keep_colors: vec!["#c8283c".into(), "red".into()], ..TraceParams::default() }, usize::MAX); + assert!(matches!(&bad, Err(TraceError::BadPalette(name)) if name == "red"), "{mode:?}"); + } + // Old documents have no such setting: every color. + let old: TraceParams = serde_json::from_str(r#"{"mode":"color"}"#).unwrap(); + assert!(old.keep_colors.is_empty()); + let on: TraceParams = serde_json::from_str(r##"{"keepColors":["#c8283c"]}"##).unwrap(); + assert_eq!(on.keep_colors, ["#c8283c"]); +} + +#[test] +fn keep_colors_names_the_colors_nothing_matches() { + let img = four_color_art(); + let params = TraceParams { mode: Mode::Color, palette: Palette::Limited, colors: 4, ..TraceParams::default() }; + let unmatched = |keep: &[&str], params: &TraceParams| { + let res = trace(&img, &TraceParams { keep_colors: keep.iter().map(|k| k.to_string()).collect(), ..params.clone() }); + (res.keep_unmatched, res.paths.len()) + }; + // Every color listed matches one the trace found. + assert_eq!(unmatched(&["#f0c832", "#c8283c"], ¶ms).0, Vec::::new()); + // A color the image does not have is named, as written, beside the ones traced. + let (names, paths) = unmatched(&["#f0c832", "#FF00FF", "#c8283c"], ¶ms); + assert_eq!(names, ["#FF00FF"]); + assert!(paths > 0); + // None matches: every one is named, and nothing is traced. + assert_eq!(unmatched(&["#ff00ff", "#00ffff"], ¶ms), (vec!["#ff00ff".to_string(), "#00ffff".to_string()], 0)); + // A color Ignore Color leaves out still matches one the trace found. + let ignoring = TraceParams { ignore_white: true, ignore_color: Some("#14285a".into()), ..params.clone() }; + assert_eq!(unmatched(&["#14285a"], &ignoring), (Vec::new(), 0)); + // With none listed, none is named. + assert!(trace(&img, ¶ms).keep_unmatched.is_empty()); + // Flat Logo mode does not use the list. + assert!(trace(&img, &TraceParams { mode: Mode::Logo, keep_colors: vec!["#ff00ff".into()], ..params }).keep_unmatched.is_empty()); +} + #[test] fn snap_curves_to_lines_levels_a_rectangle_a_degree_askew() { // 120 × 60, turned 1.5° about its centre. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5110aa03e..185ab2cbf 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5385,6 +5385,10 @@ mark-style Japanese Japonské Snap to Perpendicular Přichytávat ke kolmici @msg unknown snap option `{key}` (use {options}) neznámá možnost přichytávání `{key}` (použijte {options}) Gap Length: Délka mezery: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Režim Ploché logo nepoužívá keepColors: vektorizuje barvy uvedené v logoColors nebo barvy, které najde. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Nic nebylo vektorizováno: žádná z barev, které vektorizace našla, neodpovídá keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Nic nebylo vektorizováno: Ignorovat barvu vynechává všechny oblasti barev uvedených v keepColors. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: žádná z barev, které vektorizace našla, neodpovídá {c}, takže z ní není nic vektorizováno. Artboard {n} of {total} Pracovní plocha {n} z {total} File {n} of {total} Soubor {n} z {total} Encoding and writing the file… Kódování a zápis souboru… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 899a9b7c4..0b9203fb8 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4964,6 +4964,10 @@ effect Soft Weich Snap to Perpendicular An Senkrechter ausrichten @msg unknown snap option `{key}` (use {options}) unbekannte Einrastoption `{key}` (möglich: {options}) Gap Length: Lückenlänge: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Der Modus „Flaches Logo“ verwendet keepColors nicht: Er zeichnet die in logoColors genannten Farben nach oder die Farben, die er findet. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Nichts wurde nachgezeichnet: Keine der beim Nachzeichnen gefundenen Farben entspricht keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Nichts wurde nachgezeichnet: „Farbe ignorieren“ lässt jeden Bereich der in keepColors aufgeführten Farben aus. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: Keine der beim Nachzeichnen gefundenen Farben entspricht {c}, daher wird davon nichts nachgezeichnet. Artboard {n} of {total} Zeichenfläche {n} von {total} File {n} of {total} Datei {n} von {total} Encoding and writing the file… Datei wird codiert und geschrieben… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 98bf3c71b..21a691710 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5337,6 +5337,10 @@ effect Points Σημεία Snap to Perpendicular Προσκόλληση σε κάθετο @msg unknown snap option `{key}` (use {options}) άγνωστη επιλογή προσκόλλησης `{key}` (χρησιμοποιήστε {options}) Gap Length: Μήκος κενού: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Η λειτουργία Επίπεδο λογότυπο δεν χρησιμοποιεί το keepColors: ανιχνεύει τα χρώματα που ορίζονται στο logoColors ή τα χρώματα που βρίσκει. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Δεν ανιχνεύτηκε τίποτα: κανένα από τα χρώματα που βρήκε η ανίχνευση δεν αντιστοιχεί στο keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Δεν ανιχνεύτηκε τίποτα: η Παράβλεψη χρώματος παραλείπει κάθε περιοχή των χρωμάτων που ορίζει το keepColors. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: κανένα από τα χρώματα που βρήκε η ανίχνευση δεν αντιστοιχεί στο {c}, οπότε τίποτα από αυτό δεν ανιχνεύεται. Artboard {n} of {total} Πίνακας σχεδίασης {n} από {total} File {n} of {total} Αρχείο {n} από {total} Encoding and writing the file… Κωδικοποίηση και εγγραφή του αρχείου… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3105cc9f9..c54504571 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5327,6 +5327,10 @@ mark-style Japanese Japonesas Snap to Perpendicular Ajustar a perpendicular @msg unknown snap option `{key}` (use {options}) opción de ajuste desconocida `{key}` (use {options}) Gap Length: Longitud del hueco: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. El modo Logotipo plano no usa keepColors: calca los colores indicados en logoColors o los colores que encuentra. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). No se calcó nada: ninguno de los colores que encontró el calco coincide con keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. No se calcó nada: Ignorar color omite todas las áreas de los colores que indica keepColors. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: ninguno de los colores que encontró el calco coincide con {c}, así que no se calca nada de él. Artboard {n} of {total} Mesa de trabajo {n} de {total} File {n} of {total} Archivo {n} de {total} Encoding and writing the file… Codificando y escribiendo el archivo… diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 9a0c1d1d7..4bdc2a580 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5349,6 +5349,10 @@ mark-style Japanese Japanilainen Snap to Perpendicular Kohdista kohtisuoraan @msg unknown snap option `{key}` (use {options}) tuntematon tartuntavalinta `{key}` (käytä: {options}) Gap Length: Aukon pituus: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Tasainen logo -tila ei käytä keepColors-asetusta: se jäljittää logoColors-asetuksessa nimetyt värit tai löytämänsä värit. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Mitään ei jäljitetty: mikään jäljityksen löytämistä väreistä ei vastaa keepColors-asetusta ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Mitään ei jäljitetty: Ohita väri jättää pois kaikki keepColors-asetuksen värien alueet. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: mikään jäljityksen löytämistä väreistä ei vastaa väriä {c}, joten siitä ei jäljitetä mitään. Artboard {n} of {total} Piirtoalusta {n}/{total} File {n} of {total} Tiedosto {n}/{total} Encoding and writing the file… Tiedostoa koodataan ja kirjoitetaan… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index aa3e999f6..5805e5c86 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5384,6 +5384,10 @@ theme Appearance Apparence Snap to Perpendicular Magnétisme de la perpendiculaire @msg unknown snap option `{key}` (use {options}) option de magnétisme inconnue `{key}` (utilisez {options}) Gap Length: Longueur de lacune : +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Le mode Logo en aplats n’utilise pas keepColors : il vectorise les couleurs indiquées dans logoColors ou celles qu’il trouve. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Rien n’a été vectorisé : aucune des couleurs trouvées par la vectorisation ne correspond à keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Rien n’a été vectorisé : Ignorer la couleur omet toutes les zones des couleurs indiquées dans keepColors. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors : aucune des couleurs trouvées par la vectorisation ne correspond à {c}, donc rien n’en est vectorisé. Artboard {n} of {total} Plan de travail {n} sur {total} File {n} of {total} Fichier {n} sur {total} Encoding and writing the file… Encodage et écriture du fichier… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a1f499126..c056ee9d0 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5371,6 +5371,10 @@ imageTrace Outline Körvonal Snap to Perpendicular Illesztés merőlegesre @msg unknown snap option `{key}` (use {options}) ismeretlen illesztési beállítás: `{key}` (használható: {options}) Gap Length: Rés hossza: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Az Egyszínű logó mód nem használja a keepColors beállítást: a logoColors beállításban megadott színeket vagy az általa talált színeket vektorizálja. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Semmi sem lett vektorizálva: a vektorizálás által talált színek egyike sem felel meg a keepColors beállításnak ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Semmi sem lett vektorizálva: a Szín figyelmen kívül hagyása kihagyja a keepColors beállításban felsorolt színek minden területét. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: a vektorizálás által talált színek egyike sem felel meg ennek: {c}, így belőle semmi sem lesz vektorizálva. Artboard {n} of {total} Rajztábla: {n} / {total} File {n} of {total} Fájl: {n} / {total} Encoding and writing the file… A fájl kódolása és írása… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 5c2ba5757..45fcb5a11 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4998,6 +4998,10 @@ lighting Light Luce Snap to Perpendicular Aggancia alla perpendicolare @msg unknown snap option `{key}` (use {options}) opzione di aggancio sconosciuta `{key}` (usare {options}) Gap Length: Lunghezza lacuna: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. La modalità Logo piatto non usa keepColors: vettorializza i colori indicati in logoColors o i colori che trova. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Non è stato vettorializzato nulla: nessuno dei colori trovati dalla vettorializzazione corrisponde a keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Non è stato vettorializzato nulla: Ignora colore esclude ogni area dei colori elencati in keepColors. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: nessuno dei colori trovati dalla vettorializzazione corrisponde a {c}, quindi non ne viene vettorializzato nulla. Artboard {n} of {total} Tavola da disegno {n} di {total} File {n} of {total} File {n} di {total} Encoding and writing the file… Codifica e scrittura del file… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6735831f0..6099ff7ae 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5038,6 +5038,10 @@ lighting Light ライト Snap to Perpendicular 垂線にスナップ @msg unknown snap option `{key}` (use {options}) 不明なスナップオプション `{key}` ({options} を使用してください) Gap Length: ギャップの長さ: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. フラットロゴモードは keepColors を使いません。logoColors で指定したカラー、または検出したカラーをトレースします。 +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). 何もトレースされませんでした。トレースで検出したカラーのどれも keepColors ({_1}) に一致しません。 +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. 何もトレースされませんでした。「カラーを無視」が keepColors に指定したカラーの領域をすべて除外します。 +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: トレースで検出したカラーのどれも {c} に一致しないため、このカラーは何もトレースされません。 Artboard {n} of {total} アートボード {n} / {total} File {n} of {total} ファイル {n} / {total} Encoding and writing the file… ファイルをエンコードして書き込み中… diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index f41eb3993..b66ff55ee 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5225,6 +5225,10 @@ imageTrace Outline კონტური @msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it „შენახვა“ წერს ფორმატს {_1} და არა {_2}: ამისთვის გამოიყენეთ file.saveAsTemplate @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. გამოსახულება არის {_1} × {_2} პიქსელი, ანუ ერთ მხარეზე {LARGE_TRACE_SIDE}-ზე მეტი: ის ვექტორიზდება, მაგრამ უფრო ნელა. ნაკლები სიზუსტის პრესეტი ან გამოსახულების უფრო პატარა ასლი უფრო სწრაფად ვექტორიზდება. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). გამოსახულება ვექტორიზდა {_1} × {_2} პიქსელზე, შემცირდა {_3} × {_4}-დან (მაქსიმუმ {_5} მეგაპიქსელი). +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. ბრტყელი ლოგოს რეჟიმი keepColors-ს არ იყენებს: ის ვექტორიზებს logoColors-ში მითითებულ ფერებს ან მის მიერ ნაპოვნ ფერებს. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). არაფერი ვექტორიზდა: ვექტორიზაციის მიერ ნაპოვნი არცერთი ფერი არ შეესაბამება keepColors-ს ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. არაფერი ვექტორიზდა: ფერის იგნორირება გამოტოვებს keepColors-ში ჩამოთვლილი ფერების ყველა არეს. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: ვექტორიზაციის მიერ ნაპოვნი არცერთი ფერი არ შეესაბამება {c}-ს, ამიტომ მისგან არაფერი ვექტორიზდება. @msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` ბიბლიოთეკის პანელი არ არის (იხ. window.swatchLibrary) @msg `{key}` must be a #rrggbb color `{key}` უნდა იყოს #rrggbb ფერი @msg a key object sits on that point ამ წერტილზე საკვანძო ობიექტი მდებარეობს diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index ca100edf6..f409236f2 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4961,6 +4961,10 @@ lighting Light 광원 Snap to Perpendicular 수직선에 스냅 @msg unknown snap option `{key}` (use {options}) 알 수 없는 스냅 옵션 `{key}` ({options} 중에서 사용하세요) Gap Length: 간격 길이: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. 단색 로고 모드는 keepColors를 사용하지 않습니다. logoColors에 지정한 색상 또는 찾은 색상을 추적합니다. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). 아무것도 추적하지 않았습니다. 추적에서 찾은 색상 중 keepColors({_1})와 일치하는 색상이 없습니다. +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. 아무것도 추적하지 않았습니다. 색상 무시가 keepColors에 나열된 색상의 모든 영역을 제외합니다. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: 추적에서 찾은 색상 중 {c}와 일치하는 색상이 없어 이 색상은 추적하지 않습니다. Artboard {n} of {total} 아트보드 {n} / {total} File {n} of {total} 파일 {n} / {total} Encoding and writing the file… 파일을 인코딩하고 쓰는 중… diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 81ac2af5a..c80ec45b3 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5378,6 +5378,10 @@ swatch Bright Violet Felviolet @msg too many library panels were opened in this session er zijn in deze sessie te veel bibliotheekpanelen geopend @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. De afbeelding is {_1} × {_2} pixels, meer dan {LARGE_TRACE_SIDE} aan één zijde: ze wordt getraceerd, maar langzamer. Een voorinstelling met minder detail of een kleinere kopie van de afbeelding traceert sneller. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). De afbeelding is getraceerd op {_1} × {_2} pixels, verkleind van {_3} × {_4} (maximaal {_5} megapixels). +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. De modus Plat logo gebruikt keepColors niet: hij traceert de kleuren die in logoColors staan, of de kleuren die hij vindt. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Er is niets getraceerd: geen van de kleuren die het traceren vond, komt overeen met keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Er is niets getraceerd: Kleur negeren laat elk gebied van de kleuren in keepColors weg. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: geen van de kleuren die het traceren vond, komt overeen met {c}, dus daarvan wordt niets getraceerd. Artboard {n} of {total} Tekengebied {n} van {total} File {n} of {total} Bestand {n} van {total} Encoding and writing the file… Bestand coderen en schrijven… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index b60324fd1..fcf984de5 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5237,6 +5237,10 @@ mark-style Japanese Japońskie Snap to Perpendicular Przyciągaj do prostopadłych @msg unknown snap option `{key}` (use {options}) nieznana opcja przyciągania `{key}` (użyj: {options}) Gap Length: Długość przerwy: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Tryb Płaskie logo nie używa keepColors: obrysowuje kolory podane w logoColors lub kolory, które znajdzie. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Nic nie zostało obrysowane: żaden z kolorów znalezionych podczas obrysowania nie pasuje do keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Nic nie zostało obrysowane: Ignoruj kolor pomija każdy obszar kolorów wymienionych w keepColors. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: żaden z kolorów znalezionych podczas obrysowania nie pasuje do {c}, więc nic z niego nie jest obrysowywane. Artboard {n} of {total} Obszar kompozycji {n} z {total} File {n} of {total} Plik {n} z {total} Encoding and writing the file… Kodowanie i zapisywanie pliku… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 945e43bc3..45dc4ac5f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5302,6 +5302,10 @@ mark-style Japanese Japonesas Snap to Perpendicular Ajustar à Perpendicular @msg unknown snap option `{key}` (use {options}) opção de ajuste desconhecida `{key}` (use {options}) Gap Length: Comprimento da Lacuna: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. O modo Logotipo Plano não usa keepColors: ele traça as cores indicadas em logoColors ou as cores que encontra. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Nada foi traçado: nenhuma das cores que o traçado encontrou corresponde a keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Nada foi traçado: Ignorar Cor deixa de fora todas as áreas das cores listadas em keepColors. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: nenhuma das cores que o traçado encontrou corresponde a {c}, então nada dela é traçado. Artboard {n} of {total} Mesa de Trabalho {n} de {total} File {n} of {total} Arquivo {n} de {total} Encoding and writing the file… Codificando e gravando o arquivo… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 2d9f41824..23a0dc8e5 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4994,6 +4994,10 @@ lighting Light Свет Snap to Perpendicular Привязать к перпендикуляру @msg unknown snap option `{key}` (use {options}) неизвестный параметр привязки `{key}` (допустимо: {options}) Gap Length: Длина зазора: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Режим «Плоский логотип» не использует keepColors: он трассирует цвета, указанные в logoColors, или найденные им цвета. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Ничего не трассировано: ни один из найденных при трассировке цветов не соответствует keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Ничего не трассировано: «Игнорировать цвет» исключает все области цветов, перечисленных в keepColors. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: ни один из найденных при трассировке цветов не соответствует {c}, поэтому из него ничего не трассируется. Artboard {n} of {total} Монтажная область {n} из {total} File {n} of {total} Файл {n} из {total} Encoding and writing the file… Кодирование и запись файла… diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index b8c197520..9b688fb42 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5341,6 +5341,10 @@ theme Appearance Utseende Snap to Perpendicular Fäst mot vinkelrät @msg unknown snap option `{key}` (use {options}) okänt fästalternativ `{key}` (använd {options}) Gap Length: Lucklängd: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Läget Platt logotyp använder inte keepColors: det vektoriserar färgerna som anges i logoColors eller färgerna det hittar. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Ingenting vektoriserades: ingen av färgerna som vektoriseringen hittade motsvarar keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Ingenting vektoriserades: Ignorera färg utelämnar alla områden med färgerna som keepColors anger. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: ingen av färgerna som vektoriseringen hittade motsvarar {c}, så ingenting av den vektoriseras. Artboard {n} of {total} Rityta {n} av {total} File {n} of {total} Fil {n} av {total} Encoding and writing the file… Kodar och skriver filen… diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 9a444f72d..a815a7bba 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1390,6 +1390,40 @@ fn width(p: &Value, cmd: &str, key: &str) -> Result { assert_eq!(c.message("`width` must be a number").map(|m| m.1), Some(vec![("key", "width")])); } +/// The warnings of an Image Trace with keepColors, which the status bar shows and the scan above +/// doesn't read, are translated in each language of [`COMPLETE_MESSAGES`], with the colors kept. +#[test] +fn the_keep_colors_trace_warnings_are_translated() { + use serde_json::json; + let mut s = vectorcraft_engine::Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + // A white field with a black square in it. + let img = image::RgbaImage::from_fn(40, 40, |x, y| { + image::Rgba(if (10..30).contains(&x) && (10..30).contains(&y) { [0, 0, 0, 255] } else { [255, 255, 255, 255] }) + }); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let data = vectorcraft_format::base64_encode(&png); + let mut warnings = vec![]; + for (params, shown) in [ + (json!({"mode": "color", "palette": "limited", "colors": 2, "keepColors": ["#ff00ff", "#00ffff"]}), "#ff00ff, #00ffff"), + (json!({"mode": "color", "palette": "limited", "colors": 2, "keepColors": ["#000000", "#00ffff"]}), "#00ffff"), + (json!({"mode": "color", "palette": "limited", "colors": 2, "keepColors": ["#000000"], "ignoreWhite": true, "ignoreColor": "#000000"}), ""), + (json!({"mode": "logo", "keepColors": ["#000000"]}), ""), + ] { + s.execute("file.place", &json!({"name": "square.png", "dataBase64": data, "link": false})).unwrap(); + let r = s.execute("imageTrace.makeAndExpand", &json!({ "params": params })).unwrap(); + let w = r["warnings"].as_array().and_then(|w| w.first()).and_then(|w| w.as_str()).unwrap_or_else(|| panic!("{params}: {r}")); + warnings.push((w.to_string(), shown)); + } + for (warning, shown) in &warnings { + for code in COMPLETE_MESSAGES { + let tr = message(Lang::from_code(code).unwrap(), warning); + assert!(tr != *warning && tr.contains(shown) && tr.contains("keepColors"), "{code}: {tr}"); + } + } +} + /// The parameter errors of the blend spine commands (Move Spine Point, Reshape Spine Segment, Add /// Spine Point and Delete Spine Point, which Direct Selection, the Pen and the Anchor Point tool /// send) say what is wrong. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 33874a03a..ec232c0f2 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5379,6 +5379,10 @@ theme Appearance Görünüm @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Görüntü {_1} × {_2} piksel, bir kenarı {LARGE_TRACE_SIDE} değerinden büyük: izlenir, ancak daha yavaş. Daha düşük doğruluklu bir ön ayar veya görüntünün daha küçük bir kopyası daha hızlı izlenir. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Görüntü {_3} × {_4} boyutundan küçültülerek {_1} × {_2} pikselde izlendi (en fazla {_5} megapiksel). Gap Length: Boşluk Uzunluğu: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Düz Logo modu keepColors kullanmaz: logoColors içinde adı verilen renkleri veya bulduğu renkleri izler. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Hiçbir şey izlenmedi: izlemenin bulduğu renklerin hiçbiri keepColors ({_1}) ile eşleşmiyor. +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Hiçbir şey izlenmedi: Rengi Yoksay, keepColors içinde listelenen renklerin tüm alanlarını dışarıda bırakıyor. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: izlemenin bulduğu renklerin hiçbiri {c} ile eşleşmiyor, bu yüzden bu renkten hiçbir şey izlenmiyor. @plural {n} more export waiting|{n} more exports waiting {n} dışa aktarma daha bekliyor|{n} dışa aktarma daha bekliyor @msg Export cancelled Dışa aktarma iptal edildi Add filters from the list above Yukarıdaki listeden filtre ekleyin diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ea830de1c..f3dd68e58 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4978,6 +4978,10 @@ lighting Light Світло Snap to Perpendicular Прив’язати до перпендикуляра @msg unknown snap option `{key}` (use {options}) невідомий параметр прив’язки `{key}` (використовуйте {options}) Gap Length: Довжина проміжку: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Режим «Плоский логотип» не використовує keepColors: він трасує кольори, вказані в logoColors, або знайдені ним кольори. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Нічого не трасовано: жоден із кольорів, знайдених під час трасування, не відповідає keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Нічого не трасовано: «Ігнорувати колір» пропускає всі області кольорів, перелічених у keepColors. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: жоден із кольорів, знайдених під час трасування, не відповідає {c}, тому з нього нічого не трасується. Artboard {n} of {total} Монтажна область {n} з {total} File {n} of {total} Файл {n} з {total} Encoding and writing the file… Кодування та запис файлу… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 53216bd00..c9094d3c3 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4978,6 +4978,10 @@ theme Appearance Giao diện Snap to Perpendicular Hút vào đường vuông góc @msg unknown snap option `{key}` (use {options}) tùy chọn hút không hợp lệ `{key}` (dùng {options}) Gap Length: Độ dài khoảng hở: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Chế độ Logo phẳng không dùng keepColors: chế độ này dò các màu được nêu trong logoColors hoặc các màu nó tìm thấy. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Không có gì được dò: không màu nào mà quá trình dò tìm thấy khớp với keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Không có gì được dò: Bỏ qua màu bỏ đi mọi vùng của các màu mà keepColors liệt kê. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: không màu nào mà quá trình dò tìm thấy khớp với {c}, nên không có gì của màu đó được dò. Artboard {n} of {total} Bảng vẽ {n} / {total} File {n} of {total} Tệp {n} / {total} Encoding and writing the file… Đang mã hóa và ghi tệp… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index f3b42de41..b30e34335 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5217,6 +5217,10 @@ lighting Light 光源 Snap to Perpendicular 对齐垂线 @msg unknown snap option `{key}` (use {options}) 未知的对齐选项 `{key}`(可用:{options}) Gap Length: 间隙长度: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. “扁平徽标”模式不使用 keepColors:它描摹 logoColors 中指定的颜色,或它找到的颜色。 +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). 未描摹任何内容:描摹找到的颜色中没有一种与 keepColors ({_1}) 匹配。 +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. 未描摹任何内容:“忽略颜色”排除了 keepColors 所列颜色的所有区域。 +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors:描摹找到的颜色中没有一种与 {c} 匹配,因此该颜色未被描摹。 # Links and Attributes: files and URLs handed to other apps. @msg Edit Original opens image and document files only; “{name}” is not one 编辑原稿只能打开图像和文档文件;“{name}” 不是其中之一 @msg only http, https and mailto URLs open in the browser, not “{url}” 浏览器中只会打开 http、https 和 mailto 的 URL,不会打开 “{url}” diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index f090d9dad..1b5d56fd9 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2364,6 +2364,10 @@ file-filter Templates 範本 @msg too many library panels were opened in this session 本次工作階段開啟的資料庫面板過多 @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 影像為 {_1} × {_2} 像素,有一邊超過 {LARGE_TRACE_SIDE}:仍會描圖,但速度較慢。使用精確度較低的預設集或較小的影像拷貝,描圖會較快。 @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). 影像已以 {_1} × {_2} 像素描圖,由 {_3} × {_4} 縮小而來 (最多 {_5} 百萬像素)。 +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. 「扁平標誌」模式不使用 keepColors:它會描圖 logoColors 中指定的顏色,或它找到的顏色。 +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). 未描圖任何內容:描圖找到的顏色中沒有一種與 keepColors ({_1}) 相符。 +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. 未描圖任何內容:「忽略顏色」排除了 keepColors 所列顏色的所有區域。 +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors:描圖找到的顏色中沒有一種與 {c} 相符,因此該顏色未被描圖。 # Links and Attributes: files and URLs handed to other apps. @msg Edit Original opens image and document files only; “{name}” is not one 「編輯原稿」只能開啟影像與文件檔案;「{name}」不屬於這兩類 @msg only http, https and mailto URLs open in the browser, not “{url}” 只有 http、https 和 mailto URL 會在瀏覽器中開啟,「{url}」不會 diff --git a/docs/image-trace-adjustments.md b/docs/image-trace-adjustments.md index 071284c59..2da612c4f 100644 --- a/docs/image-trace-adjustments.md +++ b/docs/image-trace-adjustments.md @@ -32,6 +32,19 @@ white beside a cream), at most six; scattered specks of a colour add none. Adobe its own palettes are chosen; this is VectorCraft's own method (see [image-trace-quality.md](image-trace-quality.md#auto-color-keeps-rare-colours)). +**Keep Colors.** `"keepColors": ["#rrggbb", …]` in the params of `imageTrace.make` or +`imageTrace.makeAndExpand` traces only the palette colors within about 30 levels of a listed color, +the distance Ignore Color matches by, and leaves the other colors out. With Method Abutting, the paths +of the kept colors are the same as in a trace of every color. With Method Overlapping, a kept color's +paths reach under the kept colors above it in the stack and not under the colors left out, as with +Ignore Color. An empty list traces every color, and a value that is not a six-digit hex color is +refused. With Ignore Color as well, a kept color that Ignore Color matches is still left out. Flat +Logo does not use `keepColors`: it traces the colors named in `logoColors`, or the colors it finds. +The reply's `warnings` name each listed color that matches none of the colors the trace found. When +nothing is traced because no listed color matches, or because Ignore Color leaves out every area of +the ones that match, the first warning says so, and the status bar shows it. In Flat Logo mode the +warning says that `keepColors` is not used. + **Large images.** An image of more than 1500 pixels on a side is traced as any other, only more slowly, and the trace says so: `imageTrace.make` returns the note in `warnings` and the status bar shows it (the [results optimization](https://helpx.adobe.com/illustrator/using/image-trace-results-optimization.html) diff --git a/docs/mcp.md b/docs/mcp.md index 17399747f..0149fa5ec 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1447,7 +1447,8 @@ already walks Image Trace. Short agent entry points: format's first extension (`png8` gives `.png`, `template` gives `.vctemplate`). A step without `format` keeps the source file's extension (`.vectorcraft` over data sets). - Image Trace: `imageTrace.presets`, `imageTrace.make {id?, preset?, params?, view?}` (or `imageTrace.makeAndExpand`), - `imageTrace.setView`, `imageTrace.expand`, `imageTrace.release`. + `imageTrace.setView`, `imageTrace.expand`, `imageTrace.release`. With `keepColors` in `params`, the reply's + `warnings` name each listed color that no traced color matches, and say when nothing was traced. - Live Paint: `livePaint.make`, `livePaint.merge`, `livePaint.fill {group?, point}`, `livePaint.strokeEdge`, `livePaint.info {group}`, `livePaint.release`, `livePaint.expand`, and `livePaint.gapOptions {ids?, detect?, size?, color?, closeGaps?}` (Object › Live Paint › Gap Options). With `detect: true`, paint stops at the gaps that run diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ca2480de9..44c328914 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -792,6 +792,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, measured:** `crates/trace/tests/quality` traces drawings made from known vector art with every preset and [`image-trace-quality.md`](image-trace-quality.md) records the numbers against the baseline. Black and White, Grayscale and Color place each edge by the blend of the pixels across it (to a fraction of a pixel on anti-aliased and blurred images; hard-edged images are read as staircases) and fit outlines as Flat Logo does. *Measured* on each preset's own inputs, all sizes and variants: Default's outlines went from 0.31 px off on average to 0.09 (largest 1.51 to 1.02), Black and White Logo's from 0.27 to 0.06 with 0.3 kinks per trace where it had 15, and the gaps between abutting paths from 64 px to 22. On clean curved shapes Black and White Logo is 0.02 px off (the open tracer measured beside it: 0.20). The palette is made of the picture's own colours (blends along edges take none of it, colours that noise made of one colour merge, a blend pixel goes to one of the two colours it lies between; #1504): 16 Colors on flat art, all variants, went from 70 paths to 4.2 and from 5.1 px off to 0.13, with no path left in a colour the art doesn't have (5.9 before). Photos are smoothed within their tones before they are quantised and stacked layers go by lightness (#1516): High Fidelity Photo went from 3,653 paths to 121 with its colour error down from 2.30 to 1.75, and a 16 megapixel photo-like image is traced in 3.5 s where 4 megapixels were refused. Since then, by the same harness: outlines are cut at their junctions and each shared piece is fitted once (#1535: gaps and overlaps between abutting paths 22 to 50 px of area down to under 2); layers are traced within the box of their pixels and a few at a time (#1546: a 16 megapixel photo 3.9 GB to 0.9 GB); black and white are read against the image's own black and the paper near each edge (#1563, for thin lines in blurred and scanned art); Ignore Color and the Auto Color and Outline presets (#1574); Snap Curves To Lines levels lines within 2.5 degrees of level or upright (#1593, the angle our choice); Create: Strokes puts centre lines in the middle of their ink and makes them as wide as it (#1595: Outline 0.295 to 0.052 px from the true centre line, 18.4 to 2.7 anchors a stroke); sharp corners whose pixel is mostly outside the shape stay corners (#1609: Default 69% to 76% kept). Three options, each off unless asked for as the legacy presets have them: Shapes (#1609: 57 to 62% of plain circles, ellipses, rectangles and regular polygons come out as live shapes, rounded rectangles rarely), Auto Grouping (#1618: paths grouped with what lies inside them, the same picture) and Gradients (#1627: linear gradients traced as one gradient-filled path; on gradient inputs 6 Colors' colour error 2.12 to 0.62 with 6.4 to 3.4 paths, none found in flat art). Snap Curves To Lines moves no end of a line by more than 1.6 px, so long lines a degree or two off stay as the image has them (#1647: Technical Drawing's largest distance 4.21 px to 1.46). Flat Logo takes the tones a scan's uneven paper makes of one colour for one colour (#1657: on scans 470 paths and 21 px off to 8.5 paths and 0.09 px). Shapes finds rounded rectangles and shapes in aliased, blurred and scanned art (#1845: 57 to 62% of the plain shapes up to 98 to 100%, no false shape but one line 2 px wide). Stroked lines get square caps where their ink is cut square, and a wide end that turned towards a corner goes back on its line (#1846: Outline's largest distance 2.94 px to 0.88, 14 anchors to 10). Each of the 14 documented presets has an enhanced version with Auto Grouping and, in all but High Fidelity Photo, Shapes (#1847), measured beside its preset: equal or better within 0.005 px and 1% of edge ΔE on every preset's own inputs (Default 0.091 to 0.083 px); Auto Grouping keeps traced order in its groups, which removed a light seam along the counters of letters. Gradients grows a gradient over the pixels noise and soft rims took off it (#1848: blurred and noisy linear gradients 3 to 11 paths beside the gradient down to 0.5 with 6 Colors, 24 to 56 down to 0.5 to 1.5 with High Fidelity Photo). Silhouettes is 0.52 px off because its threshold is 200 (0.11 px at 128). Still weak, by the same numbers: rounded rectangles and other line-and-arc outlines take 12 to 16 anchors (the fitter does not split lines from arcs), blurred and scanned polygons lose corners, hairlines under half a pixel are lost to the threshold, aliased art is about 0.25 px off, a scan's uneven paper splits into tones when more colours are asked for than the art has, stroke junctions stay at their skeleton pixels, a three-stop gradient in a small or scanned disc is not found, radial gradients stay bands (a draft, #1849, traces them; the documented option is for linear ones, so it waits for a decision), near-regular polygons a few dozen pixels across are made regular with Shapes (up to 0.5 px off), and aliased stroked lines are 0.8 px off at worst. - **Gradient mesh from an image:** Object › Create Gradient Mesh (`object.mesh.create`) on an embedded image makes a mesh of 1 to 50 rows and columns over the picture's rectangle, placed with the image's transform; each point takes the alpha-weighted average colour and the opacity of the area around it, and Appearance and Highlight apply. A linked image is refused with a message to embed it (#1676). Scores and hours unchanged. - **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. Line art whose only solid area is white paper (strokes a pixel or two wide) takes its colours from the strokes' cores, with the lighter shades along a stroke's anti-aliased edge folded into its ink; a page whose ink (a channel at least 48 below white) covers under 1% of it traces to nothing, as blank paper does. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original. +- **Image Trace, Keep Colors:** `"keepColors": ["#rrggbb", …]` traces only the palette colors near a listed color (Abutting: each with the paths the trace of every color gives it; Overlapping: not reaching under the colors left out), in Black and White, Grayscale and Color; code-drawn tests in `crates/trace` and `crates/engine`. - **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own, and regions they bound merge without seams where the lines cross at rounded coordinates (#893); filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points is refused with advice rather than running out of memory, an image over 64 megapixels is traced scaled down to that size (aspect kept, paths at the image's own size) and one over 192 megapixels is refused, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). A single selected mirror repeat shows a prominent axis with a center move handle and two rotation handles. Drag the line or center to change spacing, or an end to change angle; Shift snaps rotation to 45°. The original artwork stays in place, the reflected copy previews live, Escape cancels and each drag takes one undo step through `object.repeat.options`. Controls follow zoom/view rotation and hide with edges or the bounding box. Engine and headless canvas regressions passed for gestures, history and visibility; native move, rotation and Escape screenshots were reviewed. [Public mirror-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-mirror-repeat-options.html). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). Window › Blend exposes easing Type, Ramp and independent color Shift through `object.blend.options`. The four polynomial distributions are original approximations of the public description, not measured From f3f9e1035dff10b63a6fbc1754303acdc3060c1e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 03:48:30 -0700 Subject: [PATCH 023/140] Image Trace: keepColors says when the matching colors produce no paths A trace whose kept colors were traced and produced no paths gave no warning, or, with Ignore Color on, said that Ignore Color left those colors out. The Ignore Color warning now shows only when no kept color was traced at all, and otherwise the first warning says that the colors that match keepColors produce no paths. The new message has a row in each complete-message catalog. Whether Ignore Color or keepColors leaves a palette entry out is now decided once per entry, so tracing a layer no longer scans the keepColors list for each pixel. Docs: docs/image-trace-adjustments.md. --- crates/engine/src/cmd/buildcmds.rs | 30 +++++++++++++++++++++++++---- crates/trace/src/lib.rs | 24 ++++++++++++----------- crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tests.rs | 2 ++ crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/image-trace-adjustments.md | 5 +++-- 25 files changed, 65 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 1b7d98ba7..073a90359 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -1223,8 +1223,9 @@ pub(crate) fn trace_warnings(size: (u32, u32), traced: (u32, u32)) -> Vec Vec { if params.keep_colors.is_empty() { return vec![]; @@ -1236,8 +1237,11 @@ pub(crate) fn keep_warnings(params: &tr::TraceParams, res: &tr::TraceResult) -> return vec![format!("Nothing was traced: none of the colors the trace found matches keepColors ({}).", params.keep_colors.join(", "))]; } let mut out = vec![]; - if res.paths.is_empty() && params.ignore_white { + // The trace's palette holds each color it traced, also one that produced no paths. + if res.paths.is_empty() && params.ignore_white && res.palette.is_empty() { out.push("Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists.".to_string()); + } else if res.paths.is_empty() { + out.push("Nothing was traced: the colors that match keepColors produce no paths.".to_string()); } out.extend(res.keep_unmatched.iter().map(|c| format!("keepColors: none of the colors the trace found matches {c}, so nothing of it is traced."))); out @@ -1312,7 +1316,25 @@ fn trace_expand(s: &mut Session, p: &Value) -> Result { #[cfg(test)] mod trace_limit_tests { - use super::{MAX_TRACE_ANCHORS, trace_warnings}; + use super::{MAX_TRACE_ANCHORS, keep_warnings, trace_warnings}; + use vectorcraft_trace as tr; + + #[test] + fn keep_warnings_name_matched_colors_that_produce_no_paths() { + let params = tr::TraceParams { keep_colors: vec!["#ff0000".into()], ..tr::TraceParams::default() }; + // A listed color was traced, and produced no paths. + let traced = tr::TraceResult { palette: vec![[255, 0, 0]], ..tr::TraceResult::default() }; + let none = vec!["Nothing was traced: the colors that match keepColors produce no paths.".to_string()]; + assert_eq!(keep_warnings(¶ms, &traced), none); + // With Ignore Color, too: Ignore Color did not leave that color out. + let ignoring = tr::TraceParams { ignore_white: true, ..params.clone() }; + assert_eq!(keep_warnings(&ignoring, &traced), none); + // Ignore Color left out every color that matches: none was traced. + assert_eq!( + keep_warnings(&ignoring, &tr::TraceResult::default()), + vec!["Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists.".to_string()] + ); + } #[test] fn trace_warnings_name_a_large_or_scaled_down_image() { diff --git a/crates/trace/src/lib.rs b/crates/trace/src/lib.rs index 644fe918c..8f3d12ad6 100644 --- a/crates/trace/src/lib.rs +++ b/crates/trace/src/lib.rs @@ -519,6 +519,12 @@ fn is_kept(c: [u8; 3], keep: &[[u8; 3]]) -> bool { keep.is_empty() || keep.iter().any(|&k| is_ignored(c, Some(k))) } +/// For each color of `palette`, whether it is left out of the trace: Ignore Color's (`ignore`, +/// `None` for white) and those Keep Colors does not list (`keep`). +fn left_out(palette: &[[u8; 3]], params: &TraceParams, ignore: Option<[u8; 3]>, keep: &[[u8; 3]]) -> Vec { + palette.iter().map(|&c| (params.ignore_white && is_ignored(c, ignore)) || !is_kept(c, keep)).collect() +} + /// Trace `img` with `params`. Failures (an image Logo mode refuses, say) give an empty result: use /// [`trace_within`] to see why. pub fn trace(img: &Raster, params: &TraceParams) -> TraceResult { @@ -635,8 +641,8 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result // Layers left out: Ignore Color's, and those Keep Colors does not list. Abutting, the others // are traced as they are in the trace of every color; stacked, they do not reach under the // colors left out (see `Layers::ignored`). - let dropped = |ci: usize| (params.ignore_white && is_ignored(q.palette[ci], ignore)) || !is_kept(q.palette[ci], &listed); - let layers: Vec<(usize, usize)> = order.iter().copied().enumerate().filter(|&(_, ci)| !dropped(ci)).collect(); + let left_out = left_out(&q.palette, params, ignore, &listed); + let layers: Vec<(usize, usize)> = order.iter().copied().enumerate().filter(|&(_, ci)| !left_out.get(ci).copied().unwrap_or(false)).collect(); // The listed colors that match none of the colors found. let keep_unmatched = params .keep_colors @@ -650,8 +656,7 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result rank: &rank, params, img, - ignore, - keep: &listed, + left_out, boxes, gradients: &gradients, bw_tones: bw_tones(img, &q), @@ -712,10 +717,8 @@ struct Layers<'a> { rank: &'a [usize], params: &'a TraceParams, img: &'a Raster, - /// The colour Ignore Color leaves out, if not white. - ignore: Option<[u8; 3]>, - /// The colors Keep Colors lists (empty: every color). - keep: &'a [[u8; 3]], + /// For each palette entry, whether Ignore Color or Keep Colors leaves it out (see [`left_out`]). + left_out: Vec, /// The box each palette entry's pixels lie in: `(x0, y0, x1, y1)`, the far sides excluded. boxes: Vec>, /// Gradients: the gradient each palette entry is, if it is one. @@ -736,7 +739,7 @@ impl Layers<'_> { /// layer (stacked layers reach under the layers above them, and must not reach under a color /// that is left out). fn ignored(&self, label: u16) -> bool { - self.q.palette.get(label as usize).is_some_and(|c| (self.params.ignore_white && is_ignored(*c, self.ignore)) || !is_kept(*c, self.keep)) + self.left_out.get(label as usize).copied().unwrap_or(false) } /// The region across a crack, for cutting outlines at their junctions: the outside pixel's /// colour. Stacked layers end on many colours at once and share no edge: one neighbour. @@ -1093,8 +1096,7 @@ pub(crate) fn shift_probe(img: &Raster, params: &TraceParams) -> Vec<[f64; 4]> { rank: &rank, params, img, - ignore: None, - keep: &[], + left_out: left_out(&q.palette, params, None, &[]), boxes: vec![None; n], gradients: &gradients, bw_tones: bw_tones(img, &q), diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 185ab2cbf..f7138f6ee 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5388,6 +5388,7 @@ mark-style Japanese Japonské @msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Režim Ploché logo nepoužívá keepColors: vektorizuje barvy uvedené v logoColors nebo barvy, které najde. @msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Nic nebylo vektorizováno: žádná z barev, které vektorizace našla, neodpovídá keepColors ({_1}). @msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Nic nebylo vektorizováno: Ignorovat barvu vynechává všechny oblasti barev uvedených v keepColors. +@msg Nothing was traced: the colors that match keepColors produce no paths. Nic nebylo vektorizováno: z barev odpovídajících keepColors nevznikly žádné cesty. @msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: žádná z barev, které vektorizace našla, neodpovídá {c}, takže z ní není nic vektorizováno. Artboard {n} of {total} Pracovní plocha {n} z {total} File {n} of {total} Soubor {n} z {total} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0b9203fb8..77e3ace5f 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4967,6 +4967,7 @@ effect Soft Weich @msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Der Modus „Flaches Logo“ verwendet keepColors nicht: Er zeichnet die in logoColors genannten Farben nach oder die Farben, die er findet. @msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Nichts wurde nachgezeichnet: Keine der beim Nachzeichnen gefundenen Farben entspricht keepColors ({_1}). @msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Nichts wurde nachgezeichnet: „Farbe ignorieren“ lässt jeden Bereich der in keepColors aufgeführten Farben aus. +@msg Nothing was traced: the colors that match keepColors produce no paths. Nichts wurde nachgezeichnet: Die Farben, die keepColors entsprechen, ergeben keine Pfade. @msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: Keine der beim Nachzeichnen gefundenen Farben entspricht {c}, daher wird davon nichts nachgezeichnet. Artboard {n} of {total} Zeichenfläche {n} von {total} File {n} of {total} Datei {n} von {total} diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 21a691710..c8687dde0 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5340,6 +5340,7 @@ effect Points Σημεία @msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Η λειτουργία Επίπεδο λογότυπο δεν χρησιμοποιεί το keepColors: ανιχνεύει τα χρώματα που ορίζονται στο logoColors ή τα χρώματα που βρίσκει. @msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Δεν ανιχνεύτηκε τίποτα: κανένα από τα χρώματα που βρήκε η ανίχνευση δεν αντιστοιχεί στο keepColors ({_1}). @msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Δεν ανιχνεύτηκε τίποτα: η Παράβλεψη χρώματος παραλείπει κάθε περιοχή των χρωμάτων που ορίζει το keepColors. +@msg Nothing was traced: the colors that match keepColors produce no paths. Δεν ανιχνεύτηκε τίποτα: τα χρώματα που ταιριάζουν με το keepColors δεν δίνουν καμία διαδρομή. @msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: κανένα από τα χρώματα που βρήκε η ανίχνευση δεν αντιστοιχεί στο {c}, οπότε τίποτα από αυτό δεν ανιχνεύεται. Artboard {n} of {total} Πίνακας σχεδίασης {n} από {total} File {n} of {total} Αρχείο {n} από {total} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index c54504571..309e8ac7f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5330,6 +5330,7 @@ mark-style Japanese Japonesas @msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. El modo Logotipo plano no usa keepColors: calca los colores indicados en logoColors o los colores que encuentra. @msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). No se calcó nada: ninguno de los colores que encontró el calco coincide con keepColors ({_1}). @msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. No se calcó nada: Ignorar color omite todas las áreas de los colores que indica keepColors. +@msg Nothing was traced: the colors that match keepColors produce no paths. No se calcó nada: los colores que coinciden con keepColors no producen ningún trazado. @msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: ninguno de los colores que encontró el calco coincide con {c}, así que no se calca nada de él. Artboard {n} of {total} Mesa de trabajo {n} de {total} File {n} of {total} Archivo {n} de {total} diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 4bdc2a580..3a5d2f252 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5352,6 +5352,7 @@ mark-style Japanese Japanilainen @msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Tasainen logo -tila ei käytä keepColors-asetusta: se jäljittää logoColors-asetuksessa nimetyt värit tai löytämänsä värit. @msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Mitään ei jäljitetty: mikään jäljityksen löytämistä väreistä ei vastaa keepColors-asetusta ({_1}). @msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Mitään ei jäljitetty: Ohita väri jättää pois kaikki keepColors-asetuksen värien alueet. +@msg Nothing was traced: the colors that match keepColors produce no paths. Mitään ei jäljitetty: keepColors-asetusta vastaavista väreistä ei synny polkuja. @msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: mikään jäljityksen löytämistä väreistä ei vastaa väriä {c}, joten siitä ei jäljitetä mitään. Artboard {n} of {total} Piirtoalusta {n}/{total} File {n} of {total} Tiedosto {n}/{total} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 5805e5c86..7a9607cad 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5387,6 +5387,7 @@ theme Appearance Apparence @msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Le mode Logo en aplats n’utilise pas keepColors : il vectorise les couleurs indiquées dans logoColors ou celles qu’il trouve. @msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Rien n’a été vectorisé : aucune des couleurs trouvées par la vectorisation ne correspond à keepColors ({_1}). @msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Rien n’a été vectorisé : Ignorer la couleur omet toutes les zones des couleurs indiquées dans keepColors. +@msg Nothing was traced: the colors that match keepColors produce no paths. Rien n’a été vectorisé : les couleurs correspondant à keepColors ne produisent aucun tracé. @msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors : aucune des couleurs trouvées par la vectorisation ne correspond à {c}, donc rien n’en est vectorisé. Artboard {n} of {total} Plan de travail {n} sur {total} File {n} of {total} Fichier {n} sur {total} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c056ee9d0..45c51dcd8 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5374,6 +5374,7 @@ imageTrace Outline Körvonal @msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Az Egyszínű logó mód nem használja a keepColors beállítást: a logoColors beállításban megadott színeket vagy az általa talált színeket vektorizálja. @msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Semmi sem lett vektorizálva: a vektorizálás által talált színek egyike sem felel meg a keepColors beállításnak ({_1}). @msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Semmi sem lett vektorizálva: a Szín figyelmen kívül hagyása kihagyja a keepColors beállításban felsorolt színek minden területét. +@msg Nothing was traced: the colors that match keepColors produce no paths. Semmi sem lett vektorizálva: a keepColors beállításnak megfelelő színekből nem jön létre útvonal. @msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: a vektorizálás által talált színek egyike sem felel meg ennek: {c}, így belőle semmi sem lesz vektorizálva. Artboard {n} of {total} Rajztábla: {n} / {total} File {n} of {total} Fájl: {n} / {total} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 45fcb5a11..c3f918dbc 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5001,6 +5001,7 @@ lighting Light Luce @msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. La modalità Logo piatto non usa keepColors: vettorializza i colori indicati in logoColors o i colori che trova. @msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Non è stato vettorializzato nulla: nessuno dei colori trovati dalla vettorializzazione corrisponde a keepColors ({_1}). @msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Non è stato vettorializzato nulla: Ignora colore esclude ogni area dei colori elencati in keepColors. +@msg Nothing was traced: the colors that match keepColors produce no paths. Non è stato vettorializzato nulla: i colori che corrispondono a keepColors non producono tracciati. @msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: nessuno dei colori trovati dalla vettorializzazione corrisponde a {c}, quindi non ne viene vettorializzato nulla. Artboard {n} of {total} Tavola da disegno {n} di {total} File {n} of {total} File {n} di {total} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6099ff7ae..982f00038 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5041,6 +5041,7 @@ lighting Light ライト @msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. フラットロゴモードは keepColors を使いません。logoColors で指定したカラー、または検出したカラーをトレースします。 @msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). 何もトレースされませんでした。トレースで検出したカラーのどれも keepColors ({_1}) に一致しません。 @msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. 何もトレースされませんでした。「カラーを無視」が keepColors に指定したカラーの領域をすべて除外します。 +@msg Nothing was traced: the colors that match keepColors produce no paths. 何もトレースされませんでした。keepColors に一致するカラーからパスができません。 @msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: トレースで検出したカラーのどれも {c} に一致しないため、このカラーは何もトレースされません。 Artboard {n} of {total} アートボード {n} / {total} File {n} of {total} ファイル {n} / {total} diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index b66ff55ee..8573a616e 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5228,6 +5228,7 @@ imageTrace Outline კონტური @msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. ბრტყელი ლოგოს რეჟიმი keepColors-ს არ იყენებს: ის ვექტორიზებს logoColors-ში მითითებულ ფერებს ან მის მიერ ნაპოვნ ფერებს. @msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). არაფერი ვექტორიზდა: ვექტორიზაციის მიერ ნაპოვნი არცერთი ფერი არ შეესაბამება keepColors-ს ({_1}). @msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. არაფერი ვექტორიზდა: ფერის იგნორირება გამოტოვებს keepColors-ში ჩამოთვლილი ფერების ყველა არეს. +@msg Nothing was traced: the colors that match keepColors produce no paths. არაფერი ვექტორიზდა: keepColors-ის შესაბამისი ფერებიდან არცერთი კონტური არ მიიღება. @msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: ვექტორიზაციის მიერ ნაპოვნი არცერთი ფერი არ შეესაბამება {c}-ს, ამიტომ მისგან არაფერი ვექტორიზდება. @msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` ბიბლიოთეკის პანელი არ არის (იხ. window.swatchLibrary) @msg `{key}` must be a #rrggbb color `{key}` უნდა იყოს #rrggbb ფერი diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index f409236f2..3bc866c8c 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4964,6 +4964,7 @@ lighting Light 광원 @msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. 단색 로고 모드는 keepColors를 사용하지 않습니다. logoColors에 지정한 색상 또는 찾은 색상을 추적합니다. @msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). 아무것도 추적하지 않았습니다. 추적에서 찾은 색상 중 keepColors({_1})와 일치하는 색상이 없습니다. @msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. 아무것도 추적하지 않았습니다. 색상 무시가 keepColors에 나열된 색상의 모든 영역을 제외합니다. +@msg Nothing was traced: the colors that match keepColors produce no paths. 아무것도 추적하지 않았습니다. keepColors와 일치하는 색상에서 패스가 만들어지지 않습니다. @msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: 추적에서 찾은 색상 중 {c}와 일치하는 색상이 없어 이 색상은 추적하지 않습니다. Artboard {n} of {total} 아트보드 {n} / {total} File {n} of {total} 파일 {n} / {total} diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c80ec45b3..034e47d57 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5381,6 +5381,7 @@ swatch Bright Violet Felviolet @msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. De modus Plat logo gebruikt keepColors niet: hij traceert de kleuren die in logoColors staan, of de kleuren die hij vindt. @msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Er is niets getraceerd: geen van de kleuren die het traceren vond, komt overeen met keepColors ({_1}). @msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Er is niets getraceerd: Kleur negeren laat elk gebied van de kleuren in keepColors weg. +@msg Nothing was traced: the colors that match keepColors produce no paths. Er is niets getraceerd: de kleuren die overeenkomen met keepColors leveren geen paden op. @msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: geen van de kleuren die het traceren vond, komt overeen met {c}, dus daarvan wordt niets getraceerd. Artboard {n} of {total} Tekengebied {n} van {total} File {n} of {total} Bestand {n} van {total} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index fcf984de5..93cd59b60 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5240,6 +5240,7 @@ mark-style Japanese Japońskie @msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Tryb Płaskie logo nie używa keepColors: obrysowuje kolory podane w logoColors lub kolory, które znajdzie. @msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Nic nie zostało obrysowane: żaden z kolorów znalezionych podczas obrysowania nie pasuje do keepColors ({_1}). @msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Nic nie zostało obrysowane: Ignoruj kolor pomija każdy obszar kolorów wymienionych w keepColors. +@msg Nothing was traced: the colors that match keepColors produce no paths. Nic nie zostało obrysowane: kolory pasujące do keepColors nie dają żadnych ścieżek. @msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: żaden z kolorów znalezionych podczas obrysowania nie pasuje do {c}, więc nic z niego nie jest obrysowywane. Artboard {n} of {total} Obszar kompozycji {n} z {total} File {n} of {total} Plik {n} z {total} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 45dc4ac5f..6fb6ff4e3 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5305,6 +5305,7 @@ mark-style Japanese Japonesas @msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. O modo Logotipo Plano não usa keepColors: ele traça as cores indicadas em logoColors ou as cores que encontra. @msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Nada foi traçado: nenhuma das cores que o traçado encontrou corresponde a keepColors ({_1}). @msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Nada foi traçado: Ignorar Cor deixa de fora todas as áreas das cores listadas em keepColors. +@msg Nothing was traced: the colors that match keepColors produce no paths. Nada foi traçado: as cores que correspondem a keepColors não geram nenhum caminho. @msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: nenhuma das cores que o traçado encontrou corresponde a {c}, então nada dela é traçado. Artboard {n} of {total} Mesa de Trabalho {n} de {total} File {n} of {total} Arquivo {n} de {total} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 23a0dc8e5..ecc0efc6b 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4997,6 +4997,7 @@ lighting Light Свет @msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Режим «Плоский логотип» не использует keepColors: он трассирует цвета, указанные в logoColors, или найденные им цвета. @msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Ничего не трассировано: ни один из найденных при трассировке цветов не соответствует keepColors ({_1}). @msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Ничего не трассировано: «Игнорировать цвет» исключает все области цветов, перечисленных в keepColors. +@msg Nothing was traced: the colors that match keepColors produce no paths. Ничего не трассировано: цвета, соответствующие keepColors, не дают ни одного контура. @msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: ни один из найденных при трассировке цветов не соответствует {c}, поэтому из него ничего не трассируется. Artboard {n} of {total} Монтажная область {n} из {total} File {n} of {total} Файл {n} из {total} diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 9b688fb42..7b53eabcb 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5344,6 +5344,7 @@ theme Appearance Utseende @msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Läget Platt logotyp använder inte keepColors: det vektoriserar färgerna som anges i logoColors eller färgerna det hittar. @msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Ingenting vektoriserades: ingen av färgerna som vektoriseringen hittade motsvarar keepColors ({_1}). @msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Ingenting vektoriserades: Ignorera färg utelämnar alla områden med färgerna som keepColors anger. +@msg Nothing was traced: the colors that match keepColors produce no paths. Ingenting vektoriserades: färgerna som matchar keepColors ger inga banor. @msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: ingen av färgerna som vektoriseringen hittade motsvarar {c}, så ingenting av den vektoriseras. Artboard {n} of {total} Rityta {n} av {total} File {n} of {total} Fil {n} av {total} diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index a815a7bba..203dc0c82 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1416,6 +1416,8 @@ fn the_keep_colors_trace_warnings_are_translated() { let w = r["warnings"].as_array().and_then(|w| w.first()).and_then(|w| w.as_str()).unwrap_or_else(|| panic!("{params}: {r}")); warnings.push((w.to_string(), shown)); } + // Listed colors that were traced and produced no paths (no square image gives that). + warnings.push(("Nothing was traced: the colors that match keepColors produce no paths.".to_string(), "")); for (warning, shown) in &warnings { for code in COMPLETE_MESSAGES { let tr = message(Lang::from_code(code).unwrap(), warning); diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index ec232c0f2..f0885afd6 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5382,6 +5382,7 @@ theme Appearance Görünüm @msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Düz Logo modu keepColors kullanmaz: logoColors içinde adı verilen renkleri veya bulduğu renkleri izler. @msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Hiçbir şey izlenmedi: izlemenin bulduğu renklerin hiçbiri keepColors ({_1}) ile eşleşmiyor. @msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Hiçbir şey izlenmedi: Rengi Yoksay, keepColors içinde listelenen renklerin tüm alanlarını dışarıda bırakıyor. +@msg Nothing was traced: the colors that match keepColors produce no paths. Hiçbir şey izlenmedi: keepColors ile eşleşen renkler hiçbir yol oluşturmuyor. @msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: izlemenin bulduğu renklerin hiçbiri {c} ile eşleşmiyor, bu yüzden bu renkten hiçbir şey izlenmiyor. @plural {n} more export waiting|{n} more exports waiting {n} dışa aktarma daha bekliyor|{n} dışa aktarma daha bekliyor @msg Export cancelled Dışa aktarma iptal edildi diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f3dd68e58..da9930688 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4981,6 +4981,7 @@ lighting Light Світло @msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Режим «Плоский логотип» не використовує keepColors: він трасує кольори, вказані в logoColors, або знайдені ним кольори. @msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Нічого не трасовано: жоден із кольорів, знайдених під час трасування, не відповідає keepColors ({_1}). @msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Нічого не трасовано: «Ігнорувати колір» пропускає всі області кольорів, перелічених у keepColors. +@msg Nothing was traced: the colors that match keepColors produce no paths. Нічого не трасовано: кольори, що відповідають keepColors, не дають жодного контуру. @msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: жоден із кольорів, знайдених під час трасування, не відповідає {c}, тому з нього нічого не трасується. Artboard {n} of {total} Монтажна область {n} з {total} File {n} of {total} Файл {n} з {total} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index c9094d3c3..c4ad10d2f 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4981,6 +4981,7 @@ theme Appearance Giao diện @msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Chế độ Logo phẳng không dùng keepColors: chế độ này dò các màu được nêu trong logoColors hoặc các màu nó tìm thấy. @msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Không có gì được dò: không màu nào mà quá trình dò tìm thấy khớp với keepColors ({_1}). @msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Không có gì được dò: Bỏ qua màu bỏ đi mọi vùng của các màu mà keepColors liệt kê. +@msg Nothing was traced: the colors that match keepColors produce no paths. Không có gì được dò: các màu khớp với keepColors không tạo ra đường dẫn nào. @msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: không màu nào mà quá trình dò tìm thấy khớp với {c}, nên không có gì của màu đó được dò. Artboard {n} of {total} Bảng vẽ {n} / {total} File {n} of {total} Tệp {n} / {total} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b30e34335..6136c05fc 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5220,6 +5220,7 @@ lighting Light 光源 @msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. “扁平徽标”模式不使用 keepColors:它描摹 logoColors 中指定的颜色,或它找到的颜色。 @msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). 未描摹任何内容:描摹找到的颜色中没有一种与 keepColors ({_1}) 匹配。 @msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. 未描摹任何内容:“忽略颜色”排除了 keepColors 所列颜色的所有区域。 +@msg Nothing was traced: the colors that match keepColors produce no paths. 未描摹任何内容:与 keepColors 匹配的颜色未生成任何路径。 @msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors:描摹找到的颜色中没有一种与 {c} 匹配,因此该颜色未被描摹。 # Links and Attributes: files and URLs handed to other apps. @msg Edit Original opens image and document files only; “{name}” is not one 编辑原稿只能打开图像和文档文件;“{name}” 不是其中之一 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 1b5d56fd9..4a877dd26 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2367,6 +2367,7 @@ file-filter Templates 範本 @msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. 「扁平標誌」模式不使用 keepColors:它會描圖 logoColors 中指定的顏色,或它找到的顏色。 @msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). 未描圖任何內容:描圖找到的顏色中沒有一種與 keepColors ({_1}) 相符。 @msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. 未描圖任何內容:「忽略顏色」排除了 keepColors 所列顏色的所有區域。 +@msg Nothing was traced: the colors that match keepColors produce no paths. 未描圖任何內容:與 keepColors 相符的顏色未產生任何路徑。 @msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors:描圖找到的顏色中沒有一種與 {c} 相符,因此該顏色未被描圖。 # Links and Attributes: files and URLs handed to other apps. @msg Edit Original opens image and document files only; “{name}” is not one 「編輯原稿」只能開啟影像與文件檔案;「{name}」不屬於這兩類 diff --git a/docs/image-trace-adjustments.md b/docs/image-trace-adjustments.md index 2da612c4f..7706ec420 100644 --- a/docs/image-trace-adjustments.md +++ b/docs/image-trace-adjustments.md @@ -41,8 +41,9 @@ Ignore Color. An empty list traces every color, and a value that is not a six-di refused. With Ignore Color as well, a kept color that Ignore Color matches is still left out. Flat Logo does not use `keepColors`: it traces the colors named in `logoColors`, or the colors it finds. The reply's `warnings` name each listed color that matches none of the colors the trace found. When -nothing is traced because no listed color matches, or because Ignore Color leaves out every area of -the ones that match, the first warning says so, and the status bar shows it. In Flat Logo mode the +nothing is traced because no listed color matches, because Ignore Color leaves out every area of the +ones that match, or because the ones that match produce no paths, the first warning says so, and the +status bar shows it. In Flat Logo mode the warning says that `keepColors` is not used. **Large images.** An image of more than 1500 pixels on a side is traced as any other, only more From af313beb7e82655af41dca49d031d19eadf955cb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 16:42:16 -0700 Subject: [PATCH 024/140] Character: non-Sharp type keeps its character stroke outlines `main` now passes `draw_text_geom` the kept glyph geometry as an `Arc` and whether `texts` keeps it, so that character stroke outlines are expanded once. `draw_type_edges` and `draw_type_pixels` pass both on to their offscreen drawings, so None, Crisp and Strong type with a character stroke expands the stroke once for both drawings, as Sharp type does. The new test `crisp_type_keeps_its_character_stroke_outline` checks that one outline is kept and that the pixels match a renderer that keeps no glyph geometry. `a_pan_draws_the_type_from_the_kept_pixels` now holds `fonts_steady`, as the other tests of what a renderer keeps between frames do. --- crates/render/src/lib.rs | 26 +++++++++++++++++++------- crates/render/src/tests_typeedges.rs | 24 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index ce105b88f..1cc003ad0 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1987,7 +1987,7 @@ impl Renderer { self.draw_inlines(ctx, f, t, g); return; } - if t.anti_alias != TypeAntiAlias::Sharp && self.draw_type_edges(ctx, f, n, t, g) { + if t.anti_alias != TypeAntiAlias::Sharp && self.draw_type_edges(ctx, f, n, t, g, kept) { return; } let tb = t.xf.transform_rect_bbox(g.bounds); @@ -2078,7 +2078,8 @@ impl Renderer { /// smoothing and what the type reads ([`Self::raster_inputs`]) are unchanged, the output has /// moved by whole pixels, and the kept pixels hold every pixel the frame needs. After a pan, /// the kept pixels are placed moved by it; after a pan that brings more of the type into view, - /// the type is drawn offscreen again. + /// the type is drawn offscreen again. The outlines of its character strokes are cached when + /// `g` is kept in `texts` (`geom_kept`). /// /// Returns false, having drawn nothing, for type that is drawn as Sharp draws it: type with /// inline graphics, type with color glyphs (bitmap or COLR), type whose own fills or strokes @@ -2087,7 +2088,7 @@ impl Renderer { /// offscreen, those would blend with nothing or take the method on art that is not the /// characters'. It also returns false for type whose offscreen drawing would be more than /// [`MAX_RASTER_SIDE`] pixels a side. - fn draw_type_edges(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject, g: &TextGeom) -> bool { + fn draw_type_edges(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject, g: &Arc, geom_kept: bool) -> bool { if proof::overprints(f.opts) && t.runs.iter().any(|r| r.style.overprint_fill || r.style.overprint_stroke) { return false; } @@ -2170,7 +2171,7 @@ impl Renderer { let (image, ox, oy) = match kept { Some(kept) => kept, None => { - let pm = self.draw_type_pixels(&frame, n, t, g, (w, h), threshold); + let pm = self.draw_type_pixels(&frame, n, t, g, geom_kept, (w, h), threshold); self.stats.type_edges += 1; let image = Arc::new(pm); if let Some(inputs) = inputs { @@ -2194,12 +2195,23 @@ impl Renderer { /// The pixels of type `t` of node `n` (laid out in `g`) with its anti-aliasing method, drawn /// offscreen in `frame` on `w` × `h` pixels with edge threshold `threshold` - /// ([`Self::draw_type_edges`]). - fn draw_type_pixels(&mut self, frame: &Frame, n: &Node, t: &TextObject, g: &TextGeom, (w, h): (u16, u16), threshold: Option) -> Pixmap { + /// ([`Self::draw_type_edges`]). The outlines of its character strokes are cached when `g` is + /// kept in `texts` (`kept`). + #[allow(clippy::too_many_arguments)] + fn draw_type_pixels( + &mut self, + frame: &Frame, + n: &Node, + t: &TextObject, + g: &Arc, + kept: bool, + (w, h): (u16, u16), + threshold: Option, + ) -> Pixmap { let draw = |r: &mut Self, frame: &Frame, n: &Node, t: &TextObject| { let mut off = single_threaded_context(w, h); off.set_aliasing_threshold(threshold); - r.inside_layer(|r| r.draw_text_geom(&mut off, frame, n, t, g)); + r.inside_layer(|r| r.draw_text_geom(&mut off, frame, n, t, g, kept)); off.flush(); let mut pm = Pixmap::new(w, h); off.render(&mut pm, &mut r.resources); diff --git a/crates/render/src/tests_typeedges.rs b/crates/render/src/tests_typeedges.rs index 7d5219539..b4b18d6d3 100644 --- a/crates/render/src/tests_typeedges.rs +++ b/crates/render/src/tests_typeedges.rs @@ -567,6 +567,29 @@ fn the_offscreen_drawing_takes_in_the_strokes_reach() { assert!(near(past(&own, &t), (90.0, 90.0)), "the object's own stroke after the characters': {:?}", past(&own, &t)); } +/// Crisp type with a character stroke expands the stroke once for both of its offscreen drawings, +/// and keeps the outline as Sharp type does. Its pixels are those of a renderer that keeps no +/// glyph geometry, which expands the stroke in every drawing. +#[test] +fn crisp_type_keeps_its_character_stroke_outline() { + let _fonts = crate::tests_fontchange::fonts_steady(); + let mut d = page(TypeAntiAlias::Crisp, None); + let id = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); + if let Some(Node { kind: NodeKind::Text(t), .. }) = d.node_mut(id) { + t.runs[0].style.stroke = Paint::solid(Color::BLACK); + t.runs[0].style.stroke_width = 1.0; + } + let opts = RenderOptions::default(); + let mut r = Renderer::new(); + let pixels = r.render(&d, W, H, Affine::IDENTITY, &opts).pixels; + assert_eq!(r.stats.type_edges, 1, "the type is drawn offscreen"); + assert_eq!(r.char_strokes.len(), 1, "one outline for the one run's stroke, used by both drawings"); + let mut uncached = Renderer::new(); + uncached.texts = FrameCache::new(0, 0); + assert!(uncached.render(&d, W, H, Affine::IDENTITY, &opts).pixels == pixels, "the kept outline draws the same pixels"); + assert_eq!(uncached.char_strokes.len(), 0); +} + /// Area type draws its punctuation outside its frame with Optical Margin Alignment, and None type /// draws it there too. #[test] @@ -593,6 +616,7 @@ fn type_outside_its_frame_is_drawn() { /// the type again. #[test] fn a_pan_draws_the_type_from_the_kept_pixels() { + let _fonts = crate::tests_fontchange::fonts_steady(); let opts = RenderOptions::default(); let fonts = || vectorcraft_text::FontDb::global().generation(); // How many type objects a renderer drew offscreen for `b` in `second` after drawing `a` in From 3b8fa19df723fb390e0d746704bdcd0de05c1261 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 17:47:01 -0700 Subject: [PATCH 025/140] Character: the Reset Panel undo-group test opens the second document first `main` now opens an undo group in each document created between edit.undoGroup.begin and edit.undoGroup.end, so the second document of `reset_panel_closes_only_its_own_undo_group` had a Batch group of its own and the reset joined it. The test now creates that document before the group begins and switches to it, which opens no group there, so it still checks that Reset Panel leaves the other document's group open. --- crates/ui-egui/src/panels/character.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 48aa34968..366a6aeac 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -1405,13 +1405,17 @@ mod tests { app.run("edit.undoGroup.end", json!({})).unwrap(); let a_steps = steps(app.session.active().unwrap()); assert_eq!(a_steps.1, Some("Batch".to_string()), "the reset is part of the Batch step"); - // An undo group open in another document. - app.run("edit.undoGroup.begin", json!({"label": "Batch"})).unwrap(); - app.run("text.setFormat", json!({"id": a, "antiAlias": "none"})).unwrap(); - let a_steps = steps(app.session.active().unwrap()); + // An undo group open in another document. The second document is open before the group + // begins, so switching to it opens no group there. app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); assert_eq!(app.session.active_index(), Some(1)); let b = app.run("text.create", json!({"x": 20, "y": 50, "text": "B"})).unwrap()["id"].clone(); + app.session.set_active(0); + app.run("edit.undoGroup.begin", json!({"label": "Batch"})).unwrap(); + app.run("text.setFormat", json!({"id": a, "antiAlias": "none"})).unwrap(); + let a_steps = steps(app.session.active().unwrap()); + app.session.set_active(1); + assert!(app.session.active().unwrap().undo_group.is_none()); app.run("select.set", json!({"ids": [b]})).unwrap(); let b_steps = steps(app.session.active().unwrap()).0; reset_panel(&mut app); From befa4f3c2625402fbf3b85e9b4b128f0c3c31e61 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 25 Oct 2026 10:29:16 -0700 Subject: [PATCH 026/140] Image Trace: keepColors tells Ignore Color apart without the palette Since main leaves a color with no paths out of the trace's palette, an empty palette no longer shows that Ignore Color left out every color keepColors matches. The trace result now says so in keep_ignored, and the warning reads it, so a matched color that produces no paths is reported as such with Ignore White on. --- crates/engine/src/cmd/buildcmds.rs | 9 ++++----- crates/trace/src/lib.rs | 7 ++++++- crates/trace/src/tests.rs | 8 ++++++++ 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 8fb73898b..7ab4829eb 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -1256,8 +1256,7 @@ pub(crate) fn keep_warnings(params: &tr::TraceParams, res: &tr::TraceResult) -> return vec![format!("Nothing was traced: none of the colors the trace found matches keepColors ({}).", params.keep_colors.join(", "))]; } let mut out = vec![]; - // The trace's palette holds each color it traced, also one that produced no paths. - if res.paths.is_empty() && params.ignore_white && res.palette.is_empty() { + if res.paths.is_empty() && res.keep_ignored { out.push("Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists.".to_string()); } else if res.paths.is_empty() { out.push("Nothing was traced: the colors that match keepColors produce no paths.".to_string()); @@ -1341,8 +1340,8 @@ mod trace_limit_tests { #[test] fn keep_warnings_name_matched_colors_that_produce_no_paths() { let params = tr::TraceParams { keep_colors: vec!["#ff0000".into()], ..tr::TraceParams::default() }; - // A listed color was traced, and produced no paths. - let traced = tr::TraceResult { palette: vec![[255, 0, 0]], ..tr::TraceResult::default() }; + // A listed color was traced, and produced no paths: the palette lists no color without paths. + let traced = tr::TraceResult::default(); let none = vec!["Nothing was traced: the colors that match keepColors produce no paths.".to_string()]; assert_eq!(keep_warnings(¶ms, &traced), none); // With Ignore Color, too: Ignore Color did not leave that color out. @@ -1350,7 +1349,7 @@ mod trace_limit_tests { assert_eq!(keep_warnings(&ignoring, &traced), none); // Ignore Color left out every color that matches: none was traced. assert_eq!( - keep_warnings(&ignoring, &tr::TraceResult::default()), + keep_warnings(&ignoring, &tr::TraceResult { keep_ignored: true, ..tr::TraceResult::default() }), vec!["Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists.".to_string()] ); } diff --git a/crates/trace/src/lib.rs b/crates/trace/src/lib.rs index 657365051..6f5776d7c 100644 --- a/crates/trace/src/lib.rs +++ b/crates/trace/src/lib.rs @@ -493,6 +493,9 @@ pub struct TraceResult { /// Keep Colors: the colors listed in [`TraceParams::keep_colors`] (as written) that no color /// the trace found matches, so that nothing of them is traced. (Flat Logo mode: empty.) pub keep_unmatched: Vec, + /// Keep Colors: whether Ignore Color left out every color the trace found that a listed color + /// matches, so that none of them was traced. (Flat Logo mode: false.) + pub keep_ignored: bool, /// Flat Logo: the size in pixels the image was traced at when it was averaged down first; /// `None` when it was traced at its own size. pub worked_at: Option<(u32, u32)>, @@ -658,6 +661,8 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result .filter(|&(_, &k)| !order.iter().any(|&ci| is_kept(q.palette[ci], &[k]))) .map(|(name, _)| name.clone()) .collect(); + // Every layer is left out although a listed color matches one: Ignore Color left those out. + let keep_ignored = !listed.is_empty() && layers.is_empty() && order.iter().any(|&ci| is_kept(q.palette[ci], &listed)); let job = Layers { q: &q, rank: &rank, @@ -673,7 +678,7 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result anchors: AtomicUsize::new(0), max_anchors, }; - let mut out = TraceResult { keep_unmatched, ..TraceResult::default() }; + let mut out = TraceResult { keep_unmatched, keep_ignored, ..TraceResult::default() }; // A layer takes about ten bytes a pixel of its box while it is traced (its mask, the labels // of its components, the stack that fills them, its edges): as many layers at a time as fit // in half a gigabyte, so that a large image is not traced at the cost of gigabytes. diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 602fe85b2..73b903486 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -1310,6 +1310,14 @@ fn keep_colors_names_the_colors_nothing_matches() { // A color Ignore Color leaves out still matches one the trace found. let ignoring = TraceParams { ignore_white: true, ignore_color: Some("#14285a".into()), ..params.clone() }; assert_eq!(unmatched(&["#14285a"], &ignoring), (Vec::new(), 0)); + // The result says that Ignore Color left out every color that matches, and only then. + let keep = |keep: &[&str], params: &TraceParams| { + trace(&img, &TraceParams { keep_colors: keep.iter().map(|k| k.to_string()).collect(), ..params.clone() }) + }; + assert!(keep(&["#14285a"], &ignoring).keep_ignored); + assert!(!keep(&["#14285a", "#f0c832"], &ignoring).keep_ignored); + assert!(!keep(&["#14285a"], ¶ms).keep_ignored); + assert!(!keep(&["#ff00ff"], &ignoring).keep_ignored); // With none listed, none is named. assert!(trace(&img, ¶ms).keep_unmatched.is_empty()); // Flat Logo mode does not use the list. From d50ac14da256c455c93866f728f00b4dc028437b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 27 Oct 2026 14:31:17 -0700 Subject: [PATCH 027/140] Outline Stroke: a stroke whose geometry effects leave no path is outlined to nothing A stroke-only path whose Offset Path or other geometry effect leaves no path paints nothing on the canvas. Outline Stroke reported "no stroked paths selected" for it, and Expand and Flatten Transparency kept the live stroke. An empty group now takes the path's place. --- crates/engine/src/cmd/pathops.rs | 9 ++++++++- crates/engine/src/tests/outlinestroke.rs | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index b32c8d3f2..8542024ed 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -843,7 +843,8 @@ pub(crate) fn outlined_stroke( /// it with a gradient, or has Scale Strokes & Effects or Transform Patterns on, becomes the art the /// canvas draws for it, each copy with its own gradient, stroke width and pattern, and its strokes /// outlined before the art gets new ids. Random in a Transform effect takes its seed -/// from the object's id, so these effects give the outline the values the canvas drew. +/// from the object's id, so these effects give the outline the values the canvas drew. When the +/// geometry effects leave nothing to paint, an empty group takes the path's place. /// `None` when `l` has no stroke to outline. pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], l: &Node) -> Option { use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, has_transform_art, is_geometry, reshape}; @@ -894,6 +895,12 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br if !fills.is_empty() { parts.push(styled_copy(d, l, fills)); } + let reshapes = reshaped.is_some() + || l.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if outlinable(st) && has_geometry(&st.effects))); + if parts.is_empty() && reshapes { + // The geometry effects leave nothing to paint, as on the canvas: an empty group takes the path's place. + parts.push(Node::group(d.alloc_id(), vec![])); + } assemble(d, l, parts) } diff --git a/crates/engine/src/tests/outlinestroke.rs b/crates/engine/src/tests/outlinestroke.rs index 06e144112..7e14859a5 100644 --- a/crates/engine/src/tests/outlinestroke.rs +++ b/crates/engine/src/tests/outlinestroke.rs @@ -245,6 +245,25 @@ fn a_geometry_effect_on_the_stroke_shapes_the_path_it_outlines() { assert!(near(area(&n), 124.0 * 124.0 - 116.0 * 116.0, 2.0), "{}", area(&n)); } +/// A stroke whose geometry effect leaves no path paints nothing on the canvas, and outlining it +/// replaces the live stroke with an empty group. +#[test] +fn a_stroke_its_effects_empty_is_outlined_to_nothing() { + for (command, p) in outlining() { + let mut s = session(); + square_with(&mut s, json!({"none": true}), json!(1), &[("path.offsetPath", json!({"offset": -60}))]); + let before = render(&s); + let n = outlined_by(&mut s, command, &p); + assert!(matches!(&n.kind, NodeKind::Group { children, .. } if children.is_empty()), "{command}: {n:?}"); + let mut stroked = false; + for layer in &s.doc().unwrap().doc.layers { + layer.walk(&mut |m| stroked |= m.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_)))); + } + assert!(!stroked, "{command} left a live stroke"); + assert_eq!(differing(&before, &render(&s), 64), 0, "{command}"); + } +} + /// A drop shadow on a stroke with a geometry effect stays on the outline, which draws as the /// stroke did. #[test] From 8f377bac81925619d3093759e0dbfe39cbd6dc1d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 23:35:18 -0700 Subject: [PATCH 028/140] Batch: drop trailing periods from export folder names before numbering A source file named `a..svg` gave the folder `a.` and a data set named `a.` gave `Untitled_a.`. Windows drops a trailing period from a folder name, so these folders were the same as the ones for `a.svg` and the data set `a`, and the second export replaced the first one's files. Batch now passes each name through `safe_file_name` before it numbers repeated names, so the second folder is `a-2` or `Untitled_a-2`. --- crates/engine/src/cmd/fileio/action_batch.rs | 75 +++++++++++++++++--- crates/engine/src/cmd/fileio/commands.rs | 2 +- docs/mcp.md | 7 +- 3 files changed, 71 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index 5b58481e8..b6f921b1a 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -98,6 +98,7 @@ pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result { #[cfg(not(target_arch = "wasm32"))] fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: &Run<'_>) -> Result { + use super::screens::safe_file_name; use super::{absolute_path, distinct_names, may_read}; let folder = str_param(p, "folder").map(absolute_path).ok_or_else(|| bad(cmd, "missing `folder`"))?; @@ -110,10 +111,13 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: if files.is_empty() { return Err(bad(cmd, "no readable files in the folder")); } - // Each file's name without its extension, with `-2`, `-3`… after a name an earlier file used - // (`a.svg` and `sub/a.svg`, or `a.svg` and `a.ai`), as data set names are numbered. - let stems = - distinct_names(files.iter().map(|path| std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("file").to_string())); + // Each file's name without its extension, through `safe_file_name` (`a..svg` gives `a`, the + // folder Windows makes for `a.`), with `-2`, `-3`… after a name an earlier file used (`a.svg` and + // `sub/a.svg`, or `a.svg` and `a.ai`), as data set names are numbered. + let stems = distinct_names(files.iter().map(|path| { + let stem = safe_file_name(std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("")); + if stem.is_empty() { "file".to_string() } else { stem } + })); let steps = filter_steps(run.steps, override_open, false); let run = Run { steps: &steps, ..*run }; let mut processed = Vec::new(); @@ -137,7 +141,7 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: #[cfg(not(target_arch = "wasm32"))] fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result { use super::distinct_names; - use super::screens::file_part; + use super::screens::safe_file_name; let st = s.doc()?; let names: Vec = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect(); @@ -149,10 +153,11 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result { } let doc_stem = st.path.as_deref().and_then(|p| std::path::Path::new(p).file_stem().and_then(|s| s.to_str())).unwrap_or("Untitled").to_string(); // Data set files are named File + Data Set Name (helpx, for Override Save/Export; the folder - // destination uses the same name). `file_part` writes the characters Windows does not allow in a - // file name (`/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters) in the data set's - // name as `-`, and `distinct_names` adds `-2`, `-3`… to a name an earlier data set used. - let stems = distinct_names(names.iter().map(|name| format!("{doc_stem}_{}", file_part(name)))); + // destination uses the same name). `safe_file_name` writes the characters Windows does not allow + // in a file name (`/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters) as `-` and + // drops trailing periods and spaces, which Windows drops from a folder name (`a.` and `a` give + // one folder), and `distinct_names` adds `-2`, `-3`… to a name an earlier data set used. + let stems = distinct_names(names.iter().map(|name| safe_file_name(&format!("{doc_stem}_{name}")))); let steps = filter_steps(run.steps, true, false); let run = Run { steps: &steps, ..*run }; let mut processed = Vec::new(); @@ -570,6 +575,58 @@ mod tests { } } + /// A source file whose name without its extension ends in a period (`a..svg`) gets the folder + /// name without it, numbered after the folder of `a.svg`: Windows drops the period from a folder + /// name, so `a.` and `a` would be one folder there. + #[test] + fn batch_override_export_numbers_folders_that_differ_by_a_trailing_period() { + let src = tmp("screens-period-src"); + write_svg(&src, "a.svg", "#f00"); + write_svg(&src, "a..svg", "#0f0"); + let dest = tmp("screens-period-out"); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(file_names(&dest), ["a", "a-2"], "{r}"); + for folder in ["a", "a-2"] { + assert_eq!(file_names(&dest.join(folder)), ["Artboard-1.svg"], "{r}"); + } + } + + /// Data sets whose names differ only by a trailing period (`a` and `a.`) get Export Assets + /// folders of their own: the second one's folder is numbered. + #[test] + fn batch_override_export_numbers_data_set_folders_that_differ_by_a_trailing_period() { + let dest = tmp("assets-period-out"); + let mut s = with_data_sets(&["a", "a."]); + let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("assets.add", &json!({"ids": [id], "name": "Logo"})).unwrap(); + let r = s + .execute( + "action.batch", + &json!({ + "source": "datasets", + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "assets.export", "params": {"formats": [{"format": "svg"}]}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(file_names(&dest), ["Untitled_a", "Untitled_a-2"], "{r}"); + for folder in ["Untitled_a", "Untitled_a-2"] { + assert_eq!(file_names(&dest.join(folder)), ["Logo.svg"], "{r}"); + } + } + /// Two source files with the same name in different subfolders are saved under names of their /// own, by Override Save and by the Folder destination: the second one's name is numbered. #[test] diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 8abb6d3b0..9950df754 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -99,7 +99,7 @@ pub fn specs() -> Vec { "Batch…", [], None, - "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs; {stem} is the file's name without its extension, with `-2`, `-3`… added to a name an earlier file used), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\`, `:`, `<`, `>`, `\"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and `-2`, `-3`… added to a name already used). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}", + "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs; {stem} is the file's name without its extension and without trailing periods and spaces, with `-2`, `-3`… added to a name an earlier file used), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\`, `:`, `<`, `>`, `\"`, `|`, `?`, `*` and control characters in the data set's name as `-`, trailing periods and spaces dropped, and `-2`, `-3`… added to a name already used). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}", always, action_batch::action_batch ), diff --git a/docs/mcp.md b/docs/mcp.md index 2c8636b34..a59572ad1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1519,9 +1519,10 @@ already walks Image Trace. Short agent entry points: source file's extension (`.vectorcraft` over data sets). Override Export gives a `document.exportForScreens` or `assets.export` step the `folder` `destinationFolder/`, in place of the step's own folder or an export preset's: one folder for each source file, named after the file without its extension, or for each data set, - named `_`. The export names the files in it as it does outside Batch. When two source files - have the same name without the extension (`a.svg` and `sub/a.svg`, or `a.svg` and `a.ai`), the folder destination - and the overrides add `-2`, `-3`… to the name for each later file (compared without case), as for data sets. + named `_`. Both names drop trailing periods and spaces (`a..svg` gives `a`). The export names + the files in it as it does outside Batch. When two source files have the same name without the extension + (`a.svg` and `sub/a.svg`, `a.svg` and `a.ai`, or `a.svg` and `a..svg`), the folder destination and the overrides + add `-2`, `-3`… to the name for each later file (compared without case), as for data sets. - Image Trace: `imageTrace.presets`, `imageTrace.make {id?, preset?, params?, view?}` (or `imageTrace.makeAndExpand`), `imageTrace.setView`, `imageTrace.expand`, `imageTrace.release`. A key inside `params` that is not one of the trace settings, such as `colours`, is an error that names it and the nearest setting (`colors`), as for a command's own keys. From d29db00c074f8105e47e0d9dcfacb726fe6aca5f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 02:21:10 -0700 Subject: [PATCH 029/140] Type: import the anti-aliasing types the merge with main needs main made the render and doc crates' imports explicit, so the merged branch named TypeAntiAlias, TypeEdgeEntry, Pixmap, AntiAlias, single_threaded_context and MAX_RASTER_SIDE without importing them. --- crates/doc/src/text/object.rs | 2 +- crates/render/src/frame.rs | 5 ++++- crates/render/src/renderer/text.rs | 2 ++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs index c9ced2cf0..9cfff8284 100644 --- a/crates/doc/src/text/object.rs +++ b/crates/doc/src/text/object.rs @@ -1,7 +1,7 @@ use super::area::{AreaOptions, PathAlign, PathEffect}; use super::char_style::CharStyle; use super::para_style::ParaStyle; -use super::runs::{RubySpan, TextKind, TextRun, move_anchors, reverse_from_end}; +use super::runs::{RubySpan, TextKind, TextRun, TypeAntiAlias, move_anchors, reverse_from_end}; use super::tabs_lists::ParaList; use super::{Affine, PathData, Point, Rect, TextObject, TextStyleScale, Vec2}; diff --git a/crates/render/src/frame.rs b/crates/render/src/frame.rs index 6a1fc5e4d..32b76615d 100644 --- a/crates/render/src/frame.rs +++ b/crates/render/src/frame.rs @@ -1,6 +1,9 @@ use super::options::{AntiAlias, RenderOptions}; use super::threads::single_threaded_context; -use super::{Affine, Arc, BezPath, Document, FillRule, HashMap, Ink, Node, Rect, RenderContext, SymbolBoxes, TextObject, live}; +use super::{ + Affine, Arc, BezPath, Document, FillRule, HashMap, Ink, Node, Pixmap, Rect, RenderContext, SymbolBoxes, TextObject, TypeAntiAlias, fx, kurbo, + live, +}; /// Hasher for pointer-keyed caches: a multiply-xorshift (SipHash showed up in frame profiles). #[derive(Default, Clone, Copy)] diff --git a/crates/render/src/renderer/text.rs b/crates/render/src/renderer/text.rs index 2940eab0f..799759c94 100644 --- a/crates/render/src/renderer/text.rs +++ b/crates/render/src/renderer/text.rs @@ -3,7 +3,9 @@ use super::{ PlacedKey, Rect, RenderContext, Renderer, SUBSTITUTED, Shape, TextGeom, TextObject, blend_mode, blob_pixmap, depends_on_document, fx, holds_type, image_mip, ink, kurbo, lru, peniko, place, proof, stain_bits, text_geom, text_geom_snapped, vello_cpu, }; +use super::{AntiAlias, Pixmap, single_threaded_context}; use crate::paint; +use crate::{MAX_RASTER_SIDE, TypeAntiAlias, TypeEdgeEntry}; impl Renderer { pub(super) fn draw_text(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject) { From 8a7d83e00f29d734dea58b9dc91f0fa4be746391 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 02:42:05 -0700 Subject: [PATCH 030/140] Batch: number export folders that have the name of a file an earlier source wrote With the Folder destination, `a.svg` is saved as `destinationFolder/a.svg`. A later source `a.svg.svg` then got the Export for Screens folder `a.svg`, which already existed as a file, and its export failed. Batch now numbers a source's name when its folder or any file it writes in the destination folder has the name of a file or folder an earlier source wrote, so that folder is `a.svg-2`. --- crates/engine/src/cmd/fileio/action_batch.rs | 104 +++++++++++++++++-- crates/engine/src/cmd/fileio/commands.rs | 2 +- docs/mcp.md | 4 +- 3 files changed, 99 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index b6f921b1a..cc160c159 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -99,7 +99,7 @@ pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result { #[cfg(not(target_arch = "wasm32"))] fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: &Run<'_>) -> Result { use super::screens::safe_file_name; - use super::{absolute_path, distinct_names, may_read}; + use super::{absolute_path, may_read}; let folder = str_param(p, "folder").map(absolute_path).ok_or_else(|| bad(cmd, "missing `folder`"))?; may_read(&folder)?; @@ -113,13 +113,15 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: } // Each file's name without its extension, through `safe_file_name` (`a..svg` gives `a`, the // folder Windows makes for `a.`), with `-2`, `-3`… after a name an earlier file used (`a.svg` and - // `sub/a.svg`, or `a.svg` and `a.ai`), as data set names are numbered. - let stems = distinct_names(files.iter().map(|path| { - let stem = safe_file_name(std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("")); - if stem.is_empty() { "file".to_string() } else { stem } - })); + // `sub/a.svg`, or `a.svg` and `a.ai`) or after a name whose files or folder an earlier file + // wrote ([`distinct_stems`]), as data set names are numbered. let steps = filter_steps(run.steps, override_open, false); let run = Run { steps: &steps, ..*run }; + let stems = distinct_stems(files.iter().map(|path| { + let stem = safe_file_name(std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("")); + let (exts, folder) = outputs(&run, &super::extension(path)); + (if stem.is_empty() { "file".to_string() } else { stem }, exts, folder) + })); let mut processed = Vec::new(); let mut failed = 0usize; for (path, stem) in files.iter().zip(&stems) { @@ -140,7 +142,6 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: #[cfg(not(target_arch = "wasm32"))] fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result { - use super::distinct_names; use super::screens::safe_file_name; let st = s.doc()?; @@ -156,10 +157,11 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result { // destination uses the same name). `safe_file_name` writes the characters Windows does not allow // in a file name (`/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters) as `-` and // drops trailing periods and spaces, which Windows drops from a folder name (`a.` and `a` give - // one folder), and `distinct_names` adds `-2`, `-3`… to a name an earlier data set used. - let stems = distinct_names(names.iter().map(|name| safe_file_name(&format!("{doc_stem}_{name}")))); + // one folder), and `distinct_stems` adds `-2`, `-3`… to a name an earlier data set used. let steps = filter_steps(run.steps, true, false); let run = Run { steps: &steps, ..*run }; + let (exts, folder) = outputs(&run, "vectorcraft"); + let stems = distinct_stems(names.iter().map(|name| (safe_file_name(&format!("{doc_stem}_{name}")), exts.clone(), folder))); let mut processed = Vec::new(); let mut failed = 0usize; for (name, stem) in names.iter().zip(&stems) { @@ -177,6 +179,62 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result { Ok(json!({"processed": processed, "count": processed.len(), "failed": failed, "stopped": false})) } +/// What one source writes into the destination folder: the extension of each file a Save or +/// Export step or the Folder destination writes there as `{stem}.{ext}`, and whether an Export for +/// Screens or Export Assets step makes the folder `{stem}` there. Follows [`rewrite_paths`] and +/// [`finish_destination`]. +#[cfg(not(target_arch = "wasm32"))] +fn outputs(run: &Run<'_>, default_ext: &str) -> (Vec, bool) { + let mut exts = Vec::new(); + let mut folder = false; + if run.dest_folder.is_some() { + for c in run.steps { + let id = c.get("command").and_then(Value::as_str).unwrap_or(""); + if run.override_export && matches!(id, "document.exportForScreens" | "assets.export") { + folder = true; + } else if (run.override_save && matches!(id, "document.save" | "file.save" | "file.saveAs")) + || (run.override_export && matches!(id, "document.export" | "document.exportSelection")) + { + let format = c.get("params").and_then(|p| p.get("format")).and_then(Value::as_str).filter(|f| !f.is_empty()); + exts.push(format.map_or_else(|| default_ext.to_string(), format_extension)); + } + } + } + if run.destination == "folder" { + exts.push(if default_ext.is_empty() { "vectorcraft".to_string() } else { default_ext.to_string() }); + } + (exts, folder) +} + +/// The stem of each source, given as (name, extensions, folder) by [`outputs`]: the name, with +/// `-2`, `-3`… after it when an earlier source used it or when one of the files or the folder it +/// writes has the name of a file or folder an earlier source wrote (`a.svg` writes the file +/// `a.svg`, so the Export for Screens folder of `a.svg.svg` is `a.svg-2`). Names are compared +/// without case. +#[cfg(not(target_arch = "wasm32"))] +fn distinct_stems(sources: impl IntoIterator, bool)>) -> Vec { + let mut stems = std::collections::HashSet::new(); + let mut written = std::collections::HashSet::new(); + sources + .into_iter() + .map(|(base, exts, folder)| { + let mut name = base.clone(); + let mut n = 1usize; + loop { + let lower = name.to_lowercase(); + let names: Vec = exts.iter().map(|e| format!("{lower}.{}", e.to_lowercase())).chain(folder.then(|| lower.clone())).collect(); + if !stems.contains(&lower) && names.iter().all(|f| !written.contains(f)) { + stems.insert(lower); + written.extend(names); + return name; + } + n += 1; + name = format!("{base}-{n}"); + } + }) + .collect() +} + /// Drop Open (and optionally Close) steps when the batch supplies the file itself. #[cfg(not(target_arch = "wasm32"))] fn filter_steps(cmds: &[Value], drop_open: bool, drop_close: bool) -> Vec { @@ -602,6 +660,34 @@ mod tests { } } + /// A source file's Export for Screens folder does not take the name of a file the Folder + /// destination saved for an earlier source: `a.svg` is saved as `a.svg`, so the folder of + /// `a.svg.svg` is numbered. + #[test] + fn batch_override_export_numbers_folders_named_like_a_saved_file() { + let src = tmp("screens-file-src"); + write_svg(&src, "a.svg", "#f00"); + write_svg(&src, "a.svg.svg", "#0f0"); + let dest = tmp("screens-file-out"); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "destination": "folder", + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(file_names(&dest), ["a", "a.svg", "a.svg-2", "a.svg-2.svg"], "{r}"); + for folder in ["a", "a.svg-2"] { + assert_eq!(file_names(&dest.join(folder)), ["Artboard-1.svg"], "{r}"); + } + } + /// Data sets whose names differ only by a trailing period (`a` and `a.`) get Export Assets /// folders of their own: the second one's folder is numbered. #[test] diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 9950df754..0a4805104 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -99,7 +99,7 @@ pub fn specs() -> Vec { "Batch…", [], None, - "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs; {stem} is the file's name without its extension and without trailing periods and spaces, with `-2`, `-3`… added to a name an earlier file used), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\`, `:`, `<`, `>`, `\"`, `|`, `?`, `*` and control characters in the data set's name as `-`, trailing periods and spaces dropped, and `-2`, `-3`… added to a name already used). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}", + "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs; {stem} is the file's name without its extension and without trailing periods and spaces, with `-2`, `-3`… added to a name an earlier file used or whose file or folder has the name of one an earlier file wrote), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\`, `:`, `<`, `>`, `\"`, `|`, `?`, `*` and control characters in the data set's name as `-`, trailing periods and spaces dropped, and `-2`, `-3`… added to a name already used). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}", always, action_batch::action_batch ), diff --git a/docs/mcp.md b/docs/mcp.md index a59572ad1..8e4bf30a6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1522,7 +1522,9 @@ already walks Image Trace. Short agent entry points: named `_`. Both names drop trailing periods and spaces (`a..svg` gives `a`). The export names the files in it as it does outside Batch. When two source files have the same name without the extension (`a.svg` and `sub/a.svg`, `a.svg` and `a.ai`, or `a.svg` and `a..svg`), the folder destination and the overrides - add `-2`, `-3`… to the name for each later file (compared without case), as for data sets. + add `-2`, `-3`… to the name for each later file (compared without case), as for data sets. They also add it when a + later file's folder or file would have the name of a file or folder an earlier one wrote: with the folder + destination, `a.svg` is saved as `a.svg`, so the Export for Screens folder of `a.svg.svg` is `a.svg-2`. - Image Trace: `imageTrace.presets`, `imageTrace.make {id?, preset?, params?, view?}` (or `imageTrace.makeAndExpand`), `imageTrace.setView`, `imageTrace.expand`, `imageTrace.release`. A key inside `params` that is not one of the trace settings, such as `colours`, is an error that names it and the nearest setting (`colors`), as for a command's own keys. From a00647c604e05c83c827791430e5494301856336 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 02:49:31 -0700 Subject: [PATCH 031/140] Type: None, Crisp and Strong type composite precisely in exports The offscreen pass that draws type with an anti-aliasing method other than Sharp rendered with the default 8-bit compositing, also in exports that composite with floating point. It now renders with the frame's rasterizer settings, so translucent fills inside the characters draw as Sharp type draws them. --- crates/render/src/renderer/text.rs | 2 +- crates/render/src/tests/typeedges.rs | 30 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/render/src/renderer/text.rs b/crates/render/src/renderer/text.rs index 799759c94..a88757581 100644 --- a/crates/render/src/renderer/text.rs +++ b/crates/render/src/renderer/text.rs @@ -357,7 +357,7 @@ impl Renderer { r.inside_layer(|r| r.draw_text_geom(&mut off, frame, n, t, g, kept)); off.flush(); let mut pm = Pixmap::new(w, h); - off.render(&mut pm, &mut r.resources); + off.render_with(&mut pm, &mut r.resources, r.raster); pm }; let sharp = TextObject { anti_alias: TypeAntiAlias::Sharp, ..t.clone() }; diff --git a/crates/render/src/tests/typeedges.rs b/crates/render/src/tests/typeedges.rs index c174c85d8..3645c7317 100644 --- a/crates/render/src/tests/typeedges.rs +++ b/crates/render/src/tests/typeedges.rs @@ -154,6 +154,36 @@ fn the_methods_keep_the_opacity_of_the_paint() { } } +/// In an export that composites with floating point, None, Crisp and Strong type composite its +/// translucent fills as precisely as Sharp type: inside the characters each method draws the +/// pixels Sharp draws, within the one step of rounding that keeping the type's pixels as 8-bit +/// values adds. +#[test] +fn the_methods_keep_precise_compositing() { + let fills = [(Color::rgb(0.9, 0.2, 0.1), 0.37), (Color::rgb(0.1, 0.6, 0.8), 0.53), (Color::rgb(0.3, 0.1, 0.7), 0.29)]; + let opts = RenderOptions { precise: true, ..Default::default() }; + let draw = |m: TypeAntiAlias| { + let mut d = page(m, Some(FillLayer { opacity: 0.41, ..FillLayer::new(Paint::solid(Color::rgb(0.2, 0.7, 0.3))) })); + let id = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); + let n = d.node_mut(id).unwrap(); + for (c, o) in fills { + n.appearance.items.push(AppearanceItem::Fill(FillLayer { opacity: o, ..FillLayer::new(Paint::solid(c)) })); + } + Renderer::new().render(&d, W, H, Affine::IDENTITY, &opts).pixels + }; + let sharp = draw(TypeAntiAlias::Sharp); + let most = sharp.chunks(4).map(|p| p[3]).max().unwrap_or(0); + let inside: Vec = sharp.chunks(4).enumerate().filter(|(_, p)| p[3] == most).map(|(i, _)| i).collect(); + assert!(inside.len() > 200, "{} pixels inside the characters", inside.len()); + for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + let drawn = draw(m); + let px = |p: &[u8], i: usize| [p[i * 4], p[i * 4 + 1], p[i * 4 + 2], p[i * 4 + 3]]; + let off = |i: usize| px(&drawn, i).iter().zip(px(&sharp, i)).any(|(a, b)| a.abs_diff(b) > 1); + let wrong: Vec<_> = inside.iter().filter(|i| off(**i)).take(5).map(|i| (*i, px(&sharp, *i), px(&drawn, *i))).collect(); + assert!(wrong.is_empty(), "{m:?}: (pixel, Sharp, drawn) {wrong:?}"); + } +} + /// Type whose own fill multiplies with the art below it is drawn as Sharp draws it, so the fill /// still multiplies with that art. #[test] From 9a05e8ad36c96d4dadef03e0122803920251ce38 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 03:43:25 -0700 Subject: [PATCH 032/140] Batch: do not save over another source file in the destination folder With the destination folder the same as the source folder, `a..svg` gets the name `a` and was saved as `a.svg`, replacing the source `a.svg` before Batch opened it. Batch now keeps the names of the source files in the destination folder for their own output, so `a..svg` is saved as `a-2.svg` and `a.svg` is saved over itself. --- crates/engine/src/cmd/fileio/action_batch.rs | 81 ++++++++++++++++---- crates/engine/src/cmd/fileio/commands.rs | 2 +- docs/mcp.md | 4 +- 3 files changed, 69 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index cc160c159..abeaa322c 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -114,14 +114,20 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: // Each file's name without its extension, through `safe_file_name` (`a..svg` gives `a`, the // folder Windows makes for `a.`), with `-2`, `-3`… after a name an earlier file used (`a.svg` and // `sub/a.svg`, or `a.svg` and `a.ai`) or after a name whose files or folder an earlier file - // wrote ([`distinct_stems`]), as data set names are numbered. + // wrote ([`distinct_stems`]), as data set names are numbered. A source file in the destination + // folder itself keeps its name for its own output: no other file's output takes it. let steps = filter_steps(run.steps, override_open, false); let run = Run { steps: &steps, ..*run }; - let stems = distinct_stems(files.iter().map(|path| { - let stem = safe_file_name(std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("")); - let (exts, folder) = outputs(&run, &super::extension(path)); - (if stem.is_empty() { "file".to_string() } else { stem }, exts, folder) - })); + let in_dest: Vec> = files.iter().map(|path| name_in_folder(path, run.dest_folder)).collect(); + let reserved = in_dest.iter().flatten().cloned().collect(); + let stems = distinct_stems( + files.iter().zip(&in_dest).map(|(path, own)| { + let stem = safe_file_name(std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("")); + let (exts, makes_folder) = outputs(&run, &super::extension(path)); + (if stem.is_empty() { "file".to_string() } else { stem }, exts, makes_folder, own.clone()) + }), + reserved, + ); let mut processed = Vec::new(); let mut failed = 0usize; for (path, stem) in files.iter().zip(&stems) { @@ -161,7 +167,10 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result { let steps = filter_steps(run.steps, true, false); let run = Run { steps: &steps, ..*run }; let (exts, folder) = outputs(&run, "vectorcraft"); - let stems = distinct_stems(names.iter().map(|name| (safe_file_name(&format!("{doc_stem}_{name}")), exts.clone(), folder))); + let stems = distinct_stems( + names.iter().map(|name| (safe_file_name(&format!("{doc_stem}_{name}")), exts.clone(), folder, None)), + std::collections::HashSet::new(), + ); let mut processed = Vec::new(); let mut failed = 0usize; for (name, stem) in names.iter().zip(&stems) { @@ -206,24 +215,36 @@ fn outputs(run: &Run<'_>, default_ext: &str) -> (Vec, bool) { (exts, folder) } -/// The stem of each source, given as (name, extensions, folder) by [`outputs`]: the name, with -/// `-2`, `-3`… after it when an earlier source used it or when one of the files or the folder it -/// writes has the name of a file or folder an earlier source wrote (`a.svg` writes the file -/// `a.svg`, so the Export for Screens folder of `a.svg.svg` is `a.svg-2`). Names are compared -/// without case. +/// The name of the file at `path` in lower case when the file is directly inside `folder`. #[cfg(not(target_arch = "wasm32"))] -fn distinct_stems(sources: impl IntoIterator, bool)>) -> Vec { +fn name_in_folder(path: &str, folder: Option<&str>) -> Option { + let folder = std::fs::canonicalize(folder?).ok()?; + let path = std::path::Path::new(path); + let parent = std::fs::canonicalize(path.parent()?).ok()?; + (parent == folder).then(|| path.file_name().and_then(|n| n.to_str()).map(str::to_lowercase)).flatten() +} + +/// The stem of each source, given as (name, extensions, folder, own file) with [`outputs`]: the +/// name, with `-2`, `-3`… after it when an earlier source used it or when one of the files or the +/// folder it writes has the name of a file or folder an earlier source wrote (`a.svg` writes the +/// file `a.svg`, so the Export for Screens folder of `a.svg.svg` is `a.svg-2`) or a name in +/// `reserved` other than its own file's. Names are compared without case. +#[cfg(not(target_arch = "wasm32"))] +fn distinct_stems( + sources: impl IntoIterator, bool, Option)>, + reserved: std::collections::HashSet, +) -> Vec { let mut stems = std::collections::HashSet::new(); - let mut written = std::collections::HashSet::new(); + let mut written = reserved; sources .into_iter() - .map(|(base, exts, folder)| { + .map(|(base, exts, folder, own)| { let mut name = base.clone(); let mut n = 1usize; loop { let lower = name.to_lowercase(); let names: Vec = exts.iter().map(|e| format!("{lower}.{}", e.to_lowercase())).chain(folder.then(|| lower.clone())).collect(); - if !stems.contains(&lower) && names.iter().all(|f| !written.contains(f)) { + if !stems.contains(&lower) && names.iter().all(|f| !written.contains(f) || own.as_ref() == Some(f)) { stems.insert(lower); written.extend(names); return name; @@ -688,6 +709,34 @@ mod tests { } } + /// With the destination folder the same as the source folder, a file's output does not replace + /// another source file before Batch opens it: `a..svg` would be saved as `a.svg`, so it is saved + /// as `a-2.svg`, and `a.svg` is saved over itself. + #[test] + fn batch_does_not_save_over_another_source_file_in_the_destination_folder() { + let src = tmp("in-place"); + write_svg(&src, "a..svg", "#f00"); + write_svg(&src, "a.svg", "#0f0"); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "destination": "folder", + "destinationFolder": src.to_string_lossy(), + "commands": [{"command": "object.unlockAll", "params": {}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(file_names(&src), ["a-2.svg", "a..svg", "a.svg"], "{r}"); + for (file, fill) in [("a-2.svg", "#ff0000"), ("a.svg", "#00ff00")] { + let svg = std::fs::read_to_string(src.join(file)).unwrap(); + assert!(svg.contains(fill), "{file}: {svg}"); + } + } + /// Data sets whose names differ only by a trailing period (`a` and `a.`) get Export Assets /// folders of their own: the second one's folder is numbered. #[test] diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 0a4805104..805bed2c5 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -99,7 +99,7 @@ pub fn specs() -> Vec { "Batch…", [], None, - "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs; {stem} is the file's name without its extension and without trailing periods and spaces, with `-2`, `-3`… added to a name an earlier file used or whose file or folder has the name of one an earlier file wrote), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\`, `:`, `<`, `>`, `\"`, `|`, `?`, `*` and control characters in the data set's name as `-`, trailing periods and spaces dropped, and `-2`, `-3`… added to a name already used). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}", + "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs; {stem} is the file's name without its extension and without trailing periods and spaces, with `-2`, `-3`… added to a name an earlier file used or whose file or folder has the name of one an earlier file wrote or of another source file in destinationFolder), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\`, `:`, `<`, `>`, `\"`, `|`, `?`, `*` and control characters in the data set's name as `-`, trailing periods and spaces dropped, and `-2`, `-3`… added to a name already used). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}", always, action_batch::action_batch ), diff --git a/docs/mcp.md b/docs/mcp.md index 8e4bf30a6..823a7fc18 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1524,7 +1524,9 @@ already walks Image Trace. Short agent entry points: (`a.svg` and `sub/a.svg`, `a.svg` and `a.ai`, or `a.svg` and `a..svg`), the folder destination and the overrides add `-2`, `-3`… to the name for each later file (compared without case), as for data sets. They also add it when a later file's folder or file would have the name of a file or folder an earlier one wrote: with the folder - destination, `a.svg` is saved as `a.svg`, so the Export for Screens folder of `a.svg.svg` is `a.svg-2`. + destination, `a.svg` is saved as `a.svg`, so the Export for Screens folder of `a.svg.svg` is `a.svg-2`. When + `destinationFolder` holds source files, only a file's own output takes its name: with `a..svg` and `a.svg` saved + in place, `a..svg` is saved as `a-2.svg` and `a.svg` over itself. - Image Trace: `imageTrace.presets`, `imageTrace.make {id?, preset?, params?, view?}` (or `imageTrace.makeAndExpand`), `imageTrace.setView`, `imageTrace.expand`, `imageTrace.release`. A key inside `params` that is not one of the trace settings, such as `colours`, is an error that names it and the nearest setting (`colors`), as for a command's own keys. From f5d8329cb135720332a26ebb388fe9f35f80ef4d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 04:43:40 -0700 Subject: [PATCH 033/140] Batch: do not export into a subfolder that holds source files With `includeSubdirs` and the destination folder the same as the source folder, the Export for Screens folder of `a.svg` was `a`, and its `Artboard-1.svg` replaced the source `a/Artboard-1.svg` before Batch opened it. Batch now keeps the names of subfolders of the destination folder that hold source files, so that export folder is `a-2`. --- crates/engine/src/cmd/fileio/action_batch.rs | 54 ++++++++++++++++---- crates/engine/src/cmd/fileio/commands.rs | 2 +- docs/mcp.md | 5 +- 3 files changed, 47 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index abeaa322c..ba46e96d6 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -114,17 +114,19 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: // Each file's name without its extension, through `safe_file_name` (`a..svg` gives `a`, the // folder Windows makes for `a.`), with `-2`, `-3`… after a name an earlier file used (`a.svg` and // `sub/a.svg`, or `a.svg` and `a.ai`) or after a name whose files or folder an earlier file - // wrote ([`distinct_stems`]), as data set names are numbered. A source file in the destination - // folder itself keeps its name for its own output: no other file's output takes it. + // wrote ([`distinct_stems`]), as data set names are numbered. No output takes the name of a + // source file in the destination folder, other than that file's own output, or of a subfolder + // of the destination folder that holds source files. let steps = filter_steps(run.steps, override_open, false); let run = Run { steps: &steps, ..*run }; - let in_dest: Vec> = files.iter().map(|path| name_in_folder(path, run.dest_folder)).collect(); - let reserved = in_dest.iter().flatten().cloned().collect(); + let in_dest: Vec> = files.iter().map(|path| name_in_folder(path, run.dest_folder)).collect(); + let reserved = in_dest.iter().flatten().map(|(name, _)| name.clone()).collect(); let stems = distinct_stems( - files.iter().zip(&in_dest).map(|(path, own)| { + files.iter().zip(&in_dest).map(|(path, place)| { let stem = safe_file_name(std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("")); let (exts, makes_folder) = outputs(&run, &super::extension(path)); - (if stem.is_empty() { "file".to_string() } else { stem }, exts, makes_folder, own.clone()) + let own = place.as_ref().filter(|(_, own)| *own).map(|(name, _)| name.clone()); + (if stem.is_empty() { "file".to_string() } else { stem }, exts, makes_folder, own) }), reserved, ); @@ -215,13 +217,15 @@ fn outputs(run: &Run<'_>, default_ext: &str) -> (Vec, bool) { (exts, folder) } -/// The name of the file at `path` in lower case when the file is directly inside `folder`. +/// When the file at `path` is inside `folder`: the name in lower case of the file, when it is +/// directly inside, or of the subfolder of `folder` that holds it, and whether it is the file's own. #[cfg(not(target_arch = "wasm32"))] -fn name_in_folder(path: &str, folder: Option<&str>) -> Option { +fn name_in_folder(path: &str, folder: Option<&str>) -> Option<(String, bool)> { let folder = std::fs::canonicalize(folder?).ok()?; - let path = std::path::Path::new(path); - let parent = std::fs::canonicalize(path.parent()?).ok()?; - (parent == folder).then(|| path.file_name().and_then(|n| n.to_str()).map(str::to_lowercase)).flatten() + let path = std::fs::canonicalize(path).ok()?; + let mut parts = path.strip_prefix(&folder).ok()?.components(); + let first = parts.next()?.as_os_str().to_str()?.to_lowercase(); + Some((first, parts.next().is_none())) } /// The stem of each source, given as (name, extensions, folder, own file) with [`outputs`]: the @@ -737,6 +741,34 @@ mod tests { } } + /// With the destination folder the same as the source folder and `includeSubdirs`, an Export for + /// Screens folder does not take the name of a subfolder that holds source files: the folder of + /// `a.svg` is `a-2`, so `a/Artboard-1.svg` is opened as it was. + #[test] + fn batch_does_not_export_into_a_source_subfolder() { + let src = tmp("in-place-sub"); + std::fs::create_dir_all(src.join("a")).unwrap(); + write_svg(&src, "a.svg", "#f00"); + write_svg(&src.join("a"), "Artboard-1.svg", "#0f0"); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "includeSubdirs": true, + "destinationFolder": src.to_string_lossy(), + "commands": [{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + let source = std::fs::read_to_string(src.join("a").join("Artboard-1.svg")).unwrap(); + assert!(source.contains("fill=\"#0f0\""), "{r}: {source}"); + let export = std::fs::read_to_string(src.join("a-2").join("Artboard-1.svg")).unwrap(); + assert!(export.contains("#ff0000"), "{r}: {export}"); + } + /// Data sets whose names differ only by a trailing period (`a` and `a.`) get Export Assets /// folders of their own: the second one's folder is numbered. #[test] diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 805bed2c5..c4736ea18 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -99,7 +99,7 @@ pub fn specs() -> Vec { "Batch…", [], None, - "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs; {stem} is the file's name without its extension and without trailing periods and spaces, with `-2`, `-3`… added to a name an earlier file used or whose file or folder has the name of one an earlier file wrote or of another source file in destinationFolder), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\`, `:`, `<`, `>`, `\"`, `|`, `?`, `*` and control characters in the data set's name as `-`, trailing periods and spaces dropped, and `-2`, `-3`… added to a name already used). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}", + "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs; {stem} is the file's name without its extension and without trailing periods and spaces, with `-2`, `-3`… added to a name an earlier file used or whose file or folder has the name of one an earlier file wrote or of another source file or a folder of source files in destinationFolder), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\`, `:`, `<`, `>`, `\"`, `|`, `?`, `*` and control characters in the data set's name as `-`, trailing periods and spaces dropped, and `-2`, `-3`… added to a name already used). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}", always, action_batch::action_batch ), diff --git a/docs/mcp.md b/docs/mcp.md index 823a7fc18..5105237ba 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1525,8 +1525,9 @@ already walks Image Trace. Short agent entry points: add `-2`, `-3`… to the name for each later file (compared without case), as for data sets. They also add it when a later file's folder or file would have the name of a file or folder an earlier one wrote: with the folder destination, `a.svg` is saved as `a.svg`, so the Export for Screens folder of `a.svg.svg` is `a.svg-2`. When - `destinationFolder` holds source files, only a file's own output takes its name: with `a..svg` and `a.svg` saved - in place, `a..svg` is saved as `a-2.svg` and `a.svg` over itself. + `destinationFolder` holds source files, only a file's own output takes its name, and no export folder takes the + name of a subfolder that holds source files: with `a..svg` and `a.svg` saved in place, `a..svg` is saved as + `a-2.svg` and `a.svg` over itself. - Image Trace: `imageTrace.presets`, `imageTrace.make {id?, preset?, params?, view?}` (or `imageTrace.makeAndExpand`), `imageTrace.setView`, `imageTrace.expand`, `imageTrace.release`. A key inside `params` that is not one of the trace settings, such as `colours`, is an error that names it and the nearest setting (`colors`), as for a command's own keys. From 72c7610a399721c70b5acc990ad40202c6e2d3db Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 12:34:14 -0700 Subject: [PATCH 034/140] Type: set the output transform in main's new shadow-filter test frame Main added uncached_darkness_shadow_subtracts_before_separate_opacity, which builds a Frame without the output field this branch adds, so the merged tree did not compile. --- crates/render/src/fx/renderer.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/render/src/fx/renderer.rs b/crates/render/src/fx/renderer.rs index 6e6fa1cab..c415cce02 100644 --- a/crates/render/src/fx/renderer.rs +++ b/crates/render/src/fx/renderer.rs @@ -716,6 +716,7 @@ mod tests { px: 1.0, opts: &opts, ink: Ink::Display, + output: Affine::IDENTITY, }; let mut ctx = frame.offscreen_context(100, 100); let mut r = Renderer::new(); From 42c0eac2778cb62b5a81e41e6f2cc3706b419632 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 12:03:55 -0700 Subject: [PATCH 035/140] Type: each frame of a thread keeps its own anti-aliasing method text.setFormat {antiAlias} and the Character panel set the method of the targeted type objects only. Threading frames, reflowing a thread and opening a file no longer give every frame of a thread the first frame's method. --- crates/doc/src/text.rs | 4 +-- crates/engine/src/cmd/fileio/load.rs | 2 -- crates/engine/src/cmd/panelcmds.rs | 8 ++--- crates/engine/src/cmd/threads.rs | 33 ----------------- crates/engine/src/cmd/threads/tests.rs | 37 +++++++++++++------- crates/ui-egui/src/panels/character.rs | 5 ++- crates/ui-egui/src/panels/character/tests.rs | 1 + docs/mcp.md | 8 ++--- docs/target-app-parity.md | 2 +- 9 files changed, 38 insertions(+), 62 deletions(-) diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 1b89b1b8d..026530151 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -76,8 +76,8 @@ pub struct TextObject { /// Off for new type and for documents from before it, and saved only when on. #[serde(default, rename = "opticalMarginAlignment", skip_serializing_if = "crate::skip::is_default")] pub optical_margin: bool, - /// Character panel › Anti-aliasing Method: how the type's edges are drawn as pixels. Every - /// frame of a thread has the same method. Sharp for new type and for documents from before it, + /// Character panel › Anti-aliasing Method: how the type's edges are drawn as pixels. Each + /// frame of a thread keeps its own method. Sharp for new type and for documents from before it, /// and saved only when it is another method. #[serde(default, rename = "antiAlias", skip_serializing_if = "crate::skip::is_default")] pub anti_alias: TypeAntiAlias, diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 4e8c39798..41503e062 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -366,8 +366,6 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result if !doc.text_threads.is_empty() && !restored { crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc); } else if !doc.text_threads.is_empty() { - // Each frame also takes its thread's first frame's anti-aliasing method, as `reflow` gives it. - crate::cmd::threads::share_anti_alias(&mut doc); crate::cmd::threads::share_optical_margin(&mut doc); // A frame whose first paragraph continues from the frame before draws it without the // paragraph's first-line indent and list marker. diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 733012131..7aaa32672 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets the whole type object and every frame of its thread, with or without a range; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\" (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\" (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", has_doc, set_format ), @@ -376,7 +376,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if !keys.iter().any(|k| p.get(*k).is_some()) { return Err(bad(C, "nothing to change")); } - // The anti-aliasing method belongs to the whole type object and to every frame of its thread. + // The anti-aliasing method belongs to the whole type object; each frame keeps its own method. let anti_alias = match p.get("antiAlias") { None | Some(Value::Null) => None, Some(v) => Some( @@ -658,8 +658,8 @@ fn set_format(s: &mut Session, p: &Value) -> Result { super::graph::capture_label_styles(d, &ids, &|st| chars(st, shift, scale)); } if let Some(v) = anti_alias { - for id in super::threads::with_threads(d, &ids) { - if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { + for id in &ids { + if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) { t.anti_alias = v; } } diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index d941729d5..3bb35b979 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -181,45 +181,12 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { } } if live.len() >= 2 { - if let Some(a) = anti_alias(before, doc, &live) { - give_anti_alias(doc, &live, a); - } kept.push(live); } } doc.text_threads = kept; } -/// The anti-aliasing method that every frame of a thread with the frames `live` takes: that of the -/// first frame whose method changed between `before` and `doc`, else the first frame's. -fn anti_alias(before: &Document, doc: &Document, live: &[NodeId]) -> Option { - let now = |id: &NodeId| text_of(doc, *id).map(|t| t.anti_alias); - let changed = live.iter().find(|id| text_of(before, **id).is_some_and(|t| Some(t.anti_alias) != now(id))); - changed.or(live.first()).and_then(now) -} - -/// Give each of the frames `live` that has another anti-aliasing method the method `a`. -fn give_anti_alias(doc: &mut Document, live: &[NodeId], a: vectorcraft_doc::text::TypeAntiAlias) { - for id in live { - if text_of(doc, *id).is_some_and(|t| t.anti_alias != a) - && let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind) - { - t.anti_alias = a; - } - } -} - -/// Give every frame of each thread the first frame's anti-aliasing method, without re-flowing the -/// story: for a document restored from a file as it was saved. -pub(crate) fn share_anti_alias(doc: &mut Document) { - for thread in doc.text_threads.clone() { - let live: Vec = thread.into_iter().filter(|id| text_of(doc, *id).is_some()).collect(); - if let Some(a) = anti_alias(doc, doc, &live) { - give_anti_alias(doc, &live, a); - } - } -} - /// Type that is not a frame of a thread is a story of its own: its numbered list paragraphs count /// from 1. Each such type object with another [`TextObject::list_count`] (a copy of a frame, a /// frame taken out of its thread, a frame converted to point type, which [`reflow`] takes out of diff --git a/crates/engine/src/cmd/threads/tests.rs b/crates/engine/src/cmd/threads/tests.rs index 93f2a8499..ef83bca89 100644 --- a/crates/engine/src/cmd/threads/tests.rs +++ b/crates/engine/src/cmd/threads/tests.rs @@ -985,12 +985,11 @@ fn a_thread_takes_its_first_frames_optical_margin_alignment() { assert_eq!([a, b, c].map(|id| optical(&s, id).0), [true; 3]); } -/// The anti-aliasing method (`text.setFormat {antiAlias}`) belongs to the whole thread and is -/// saved only when not Sharp: set in one frame, with or without a range, it is set in every -/// frame in one undo step, a frame threaded on takes the thread's method, and a file whose -/// frames of one thread differ opens with the first frame's method in all of them. +/// The anti-aliasing method (`text.setFormat {antiAlias}`) belongs to each frame and is saved +/// only when not Sharp. A range sets its frame in one undo step. Threading and opening preserve +/// different methods on the frames of a thread. #[test] -fn the_anti_aliasing_method_is_set_for_every_frame_of_a_thread() { +fn the_anti_aliasing_method_is_set_for_each_frame_of_a_thread() { use vectorcraft_doc::text::TypeAntiAlias::{self, Crisp, None as Hard, Sharp, Strong}; let method = |s: &Session, id: u64| text_of(&s.doc().unwrap().doc, NodeId(id)).map(|t| t.anti_alias); let saved = |s: &Session, id: u64| serde_json::to_value(text_of(&s.doc().unwrap().doc, NodeId(id)).unwrap()).unwrap().get("antiAlias").cloned(); @@ -1001,18 +1000,30 @@ fn the_anti_aliasing_method_is_set_for_every_frame_of_a_thread() { let err = s.execute("text.setFormat", &json!({"antiAlias": "smooth"})).unwrap_err().to_string(); assert!(err.contains("`antiAlias` must be"), "{err}"); s.execute("text.setFormat", &json!({"antiAlias": "crisp"})).unwrap(); - assert_eq!((method(&s, a), method(&s, b)), (Some(Crisp), Some(Crisp)), "the first frame takes it too"); - assert_eq!(saved(&s, a), Some(json!("crisp"))); - // A range of characters sets the whole thread as well. + assert_eq!((method(&s, a), method(&s, b)), (Some(Sharp), Some(Crisp)), "only the selected frame takes it"); + assert_eq!(saved(&s, b), Some(json!("crisp"))); + // A range of characters sets its whole frame. s.execute("text.setFormat", &json!({"ids": [a], "start": 0, "end": 1, "antiAlias": "none"})).unwrap(); - assert_eq!((method(&s, a), method(&s, b)), (Some(Hard), Some(Hard))); + assert_eq!((method(&s, a), method(&s, b)), (Some(Hard), Some(Crisp))); s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!((method(&s, a), method(&s, b)), (Some(Crisp), Some(Crisp)), "one undo step"); - // A frame threaded on from the last one takes the thread's method. + assert_eq!((method(&s, a), method(&s, b)), (Some(Sharp), Some(Crisp)), "one undo step"); + // A new frame does not change the methods of the existing frames. s.execute("text.setFormat", &json!({"ids": [b], "antiAlias": "strong"})).unwrap(); let c = new_id(s.execute("text.thread.add", &json!({"from": b, "at": [400, 10]})).unwrap()); - assert_eq!([a, b, c].map(|id| method(&s, id)), [Some(Strong); 3]); + assert_eq!([a, b, c].map(|id| method(&s, id)), [Some(Sharp), Some(Strong), Some(Strong)]); s.execute("edit.undo", &json!({})).unwrap(); + // Threading existing frames with different methods preserves both. + let (mut joined, first_id, second_id) = quoted_thread(); + joined.execute("text.thread.remove", &json!({})).unwrap(); + joined.execute("text.setFormat", &json!({"ids": [first_id], "antiAlias": "crisp"})).unwrap(); + joined.execute("text.setFormat", &json!({"ids": [second_id], "antiAlias": "strong"})).unwrap(); + joined.execute("select.set", &json!({"ids": [first_id, second_id]})).unwrap(); + joined.execute("text.thread.create", &json!({})).unwrap(); + assert_eq!((method(&joined, first_id), method(&joined, second_id)), (Some(Crisp), Some(Strong))); + joined.execute("text.editRange", &json!({"id": first_id, "start": 0, "end": 0, "insert": "New "})).unwrap(); + assert_eq!((method(&joined, first_id), method(&joined, second_id)), (Some(Crisp), Some(Strong)), "reflow keeps both methods"); + joined.execute("text.thread.remove", &json!({})).unwrap(); + assert_eq!((method(&joined, first_id), method(&joined, second_id)), (Some(Crisp), Some(Strong)), "unthreading keeps both methods"); let first: [TypeAntiAlias; 2] = [Crisp, Hard]; for (one, name) in [(first, "old.vectorcraft"), ([Hard, Crisp], "old.vectorcraft"), (first, "old.eps"), ([Hard, Crisp], "old.eps")] { for (id, m) in [(a, one[0]), (b, one[1])] { @@ -1029,7 +1040,7 @@ fn the_anti_aliasing_method_is_set_for_every_frame_of_a_thread() { let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); assert_eq!(r["restored"], name.ends_with(".eps"), "{r}"); assert_eq!(threads(&s), [[a, b]], "{name}"); - assert_eq!((method(&s, a), method(&s, b)), (Some(one[0]), Some(one[0])), "{name}, first frame {:?}", one[0]); + assert_eq!((method(&s, a), method(&s, b)), (Some(one[0]), Some(one[1])), "{name}, frame methods"); } } diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 1cbebfa0f..fbf3ce092 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -723,9 +723,8 @@ fn shared_anti_alias(app: &VectorcraftApp) -> Option { acc.value() } -/// Set the anti-aliasing method `m` of the type objects the panel acts on, and of every frame of -/// their threads (`text.setFormat {antiAlias}`): the one the Type tool edits, whatever part of its -/// text is selected, else the selected type. +/// Set the anti-aliasing method `m` of the frame the Type tool edits, whatever part of its text +/// is selected, else the selected type objects (`text.setFormat {antiAlias}`). fn set_anti_alias(app: &mut VectorcraftApp, m: TypeAntiAlias) { let mut p = json!({"antiAlias": m.id()}); if let Some((id, _, _)) = text_editing(app) { diff --git a/crates/ui-egui/src/panels/character/tests.rs b/crates/ui-egui/src/panels/character/tests.rs index 65a8d6d9d..948f337b5 100644 --- a/crates/ui-egui/src/panels/character/tests.rs +++ b/crates/ui-egui/src/panels/character/tests.rs @@ -428,6 +428,7 @@ fn the_anti_aliasing_method_dropdown_shows_and_sets_the_types_method() { assert!(text_editing(&app).is_some()); set_anti_alias(&mut app, TypeAntiAlias::Strong); assert_eq!(method(&app, a), TypeAntiAlias::Strong); + assert_eq!(method(&app, b), TypeAntiAlias::Sharp, "the other selected type is unchanged while editing"); assert_eq!(shared_anti_alias(&app), Some(TypeAntiAlias::Strong)); // Reset Panel sets Sharp, with the characters' attributes, in one undo step. range_style(&mut app, json!({"tracking": 50})); diff --git a/docs/mcp.md b/docs/mcp.md index 467964228..e0a9861b5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4875,10 +4875,10 @@ characters take the new ones. One undo step. `text.setFormat {antiAlias: "none"|"sharp"|"crisp"|"strong"}` (the selected type, or `ids`) sets the Character panel's Anti-aliasing Method, which decides how the edges of the characters are drawn as pixels: on the canvas, in raster exports (Export As PNG, JPEG and the other raster formats, Export for Screens, Save for Web) and when Object › Rasterize or -Effect › Rasterize draws the type. The method belongs to the whole type object and to every frame of its thread: a -`start`/`end` range sets the whole object, threading frames gives the thread its first frame's method, and a file -whose frames of one thread differ opens with the first frame's method in all of them. VectorCraft defines what each -method does, on the pixels of the picture being drawn (the canvas at its zoom, an export at its resolution): +Effect › Rasterize draws the type. The method belongs to the whole type object, and each frame of a thread keeps its +own method: a `start`/`end` range sets the whole frame, and threading frames or opening a file keeps each frame's +method. VectorCraft defines what each method does, on the pixels of the picture being drawn (the canvas at its zoom, +an export at its resolution): - `sharp`, the default, draws each edge pixel with the share of it the type covers, as before. - `none` draws hard edges: a pixel takes the type's color where the type covers at least half of it, and nothing diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 563603cb8..2d182d3a4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -817,7 +817,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`), and an anchor of the path of type on a path outside perspective, the type reflowing along it (`text.reshapePath`, #3980). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. The Character panel's Variable Font sliders set each axis (see Fonts). Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). The commands that make or restyle type (and the MCP `add_text` tool) say in their result when area type does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471). Cmd+Shift+C (Align center) works on Windows and Linux too (#1940). A double-click on area type's bottom middle handle turns on Auto Size (#1940). Print and Print Preview warn about the area type and type on a path that prints without the text that doesn't fit, and give their ids. A tab stop in the Tabs panel drags from where the press landed, a stop just added included (#1487, fixes #1458). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607). Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656). Select › Same matches type on its characters' fill and stroke (#1746, #1737). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). The Every-line Composer composes a paragraph whose characters differ in line metrics (type size, font, leading or vertical scale, or an inline graphic taller than the text) in rectangular area type, rows and columns included. In area type of another shape, or with text wrap, such a paragraph is broken with the Single-line Composer. The Character panel's Small Caps, Superscript and Subscript buttons and the panel menu's items of the same names turn small caps and the superscript or subscript position on and off for the selected type, or for the text the Type tool has selected (`text.setFormat` or `text.setRangeStyle` `{smallCaps, position}`), in Document Setup's proportions. Turning Superscript on turns Subscript off, and turning Subscript on turns Superscript off. A button or item shows as on when the first selected character (at a caret, the character before it) has its attribute. - The Character panel's Anti-aliasing Method dropdown, under Character Rotation, sets how the edges of the selected type objects are drawn as pixels (`text.setFormat {antiAlias}`), for the whole object and every frame of its thread, on the canvas, in raster exports and when the type is rasterized. VectorCraft defines the methods: Sharp, the default, draws edges as before; None paints a pixel only where the type covers at least half of it; Crisp shows a pixel covered by a share c at `2c - 0.5`, and Strong shows it at `2c`, each kept between 0 and 1. SVG, PDF and EPS export are unchanged. + The Character panel's Anti-aliasing Method dropdown, under Character Rotation, sets how the edges of the selected type objects are drawn as pixels (`text.setFormat {antiAlias}`), for the whole object, with each frame of a thread keeping its own method, on the canvas, in raster exports and when the type is rasterized. VectorCraft defines the methods: Sharp, the default, draws edges as before; None paints a pixel only where the type covers at least half of it; Crisp shows a pixel covered by a share c at `2c - 0.5`, and Strong shows it at `2c`, each kept between 0 and 1. SVG, PDF and EPS export are unchanged. - **Paragraph alignment:** the shared Paragraph/Properties alignment row leaves every button unselected when the paragraphs it acts on differ. Auto resolves per paragraph; choosing an alignment uses the existing command. Headless paragraph_mixed_alignment_* regressions exercise mixed → Center → Undo, caret/uniform states, Unicode range boundaries, Auto/direction, split runs and grouped text. These regressions and full integration CI pass. Original captures confirm mixed → Center → Undo in the existing alignment-button layout. The [public Paragraph panel guide](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/paragraph-panel-overview.html) lists the seven controls, but does not specify mixed-state rendering. Other paragraph fields remain outside this change; scores and hours unchanged. For Auto Size area type in columns, the bisection for the frame's height runs on the lines of the one-column layout, and its result is checked with at most two layouts of the columns. When the text wraps around objects or does not fit the one-column layout, or when the check fails, the columns are laid out at every step of the bisection, as before. Typing at the start of a story of 50,000 characters in 2 columns took 1,670 ms a keystroke and now takes 292 ms (release build, `auto_height_in_columns_typing_timing`). Exports to PDF, EPS, SVG, EMF, WMF and the raster formats warn about the area type and type on a path that they draw without the text that doesn't fit, and give their ids, as Print and Print Preview do. From 65dad5060ff75992a634077c7624d7b2f3d69491 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 12:48:58 -0700 Subject: [PATCH 036/140] i18n: Bosnian rows for the Anti-aliasing Method labels --- crates/ui-egui/src/i18n/bs.tsv | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index b20bb4f29..c855f1b57 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -2605,6 +2605,10 @@ lighting Light Svjetlo < 1 sec < 1 sek. [Unnamed] [Bez naziva] Amount: Količina: + Anti-aliasing Method Metoda zaglađivanja ivica + Sharp Oštro + Crisp Jasno + Strong Snažno Anti-aliasing: Zaglađivanje ivica: Artboard: Radna ploča: Color Table Tabela boja From 9bb38208c1dfae03ebf8a4ff911071fdb2ce9e14 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 19:17:08 -0700 Subject: [PATCH 037/140] Character: type whose method would draw past the pixel budget is drawn as Sharp None, Crisp and Strong type is drawn offscreen on the output's pixels. The offscreen drawing was limited to 32,768 pixels a side, but not in area, so type across a large raster export held several more images of the export's size. Type whose offscreen drawing would hold more than the renderer's pixel budget (8.4 million pixels) is now drawn as Sharp draws it. --- crates/render/src/renderer/text.rs | 7 ++++--- crates/render/src/tests/typeedges.rs | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/render/src/renderer/text.rs b/crates/render/src/renderer/text.rs index 1642fd193..9db9ba8e3 100644 --- a/crates/render/src/renderer/text.rs +++ b/crates/render/src/renderer/text.rs @@ -233,7 +233,7 @@ impl Renderer { /// Overprint Preview is on, and type that shows a substitution highlight on screen. Drawn /// offscreen, those would blend with nothing or take the method on art that is not the /// characters'. It also returns false for type whose offscreen drawing would be more than - /// [`MAX_RASTER_SIDE`] pixels a side. + /// [`MAX_RASTER_SIDE`] pixels a side or more than [`fx::PIXEL_BUDGET`] pixels in all. fn draw_type_edges(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject, g: &Arc, geom_kept: bool) -> bool { if proof::overprints(f.opts) && t.runs.iter().any(|r| r.style.overprint_fill || r.style.overprint_stroke) { return false; @@ -280,8 +280,9 @@ impl Renderer { // Whole pixels of the output. let (x0, y0) = (on.x0.floor(), on.y0.floor()); let (Ok(w), Ok(h)) = (u16::try_from((on.x1 - x0).ceil() as u64), u16::try_from((on.y1 - y0).ceil() as u64)) else { return false }; - // (vello_cpu panics drawing near `u16::MAX` pixels a side.) - if u32::from(w) > MAX_RASTER_SIDE || u32::from(h) > MAX_RASTER_SIDE { + // (vello_cpu panics drawing near `u16::MAX` pixels a side.) Past the pixel budget the type + // is drawn as Sharp draws it. + if u32::from(w) > MAX_RASTER_SIDE || u32::from(h) > MAX_RASTER_SIDE || f64::from(w) * f64::from(h) > fx::PIXEL_BUDGET { return false; } let view = Affine::translate((-x0, -y0)) * output; diff --git a/crates/render/src/tests/typeedges.rs b/crates/render/src/tests/typeedges.rs index 3ea7e1735..b98dfe60f 100644 --- a/crates/render/src/tests/typeedges.rs +++ b/crates/render/src/tests/typeedges.rs @@ -546,6 +546,27 @@ fn type_edges_are_drawn_on_at_most_the_budgets_pixels() { assert_eq!(edge_grid(Rect::new(0.0, 0.0, 10_000.0, 1.0), coarser), Affine::IDENTITY, "40,000 pixels a side"); } +/// Type whose offscreen drawing would hold more pixels than the effects' pixel budget (in a large +/// raster export) is drawn as Sharp draws it, without the offscreen drawing; just within the +/// budget it is drawn offscreen with its method. +#[test] +fn type_past_the_pixel_budget_is_drawn_as_sharp() { + // "Hamburg" at 10,000%, its letters across every pixel of the render. + let draw = |m: TypeAntiAlias, side: u32| { + let half = f64::from(side) / 2.0; + let view = Affine::translate((half - 9_000.0, half - 4_500.0)) * Affine::scale(100.0); + let mut r = Renderer::new(); + let pixels = r.render(&page(m, None), side, side, view, &RenderOptions::default()).pixels; + (r.stats.type_edges, pixels) + }; + // 3,000 × 3,000 pixels, past the 8.4 million of the budget. + let (drawn, strong) = draw(TypeAntiAlias::Strong, 3_000); + assert_eq!(drawn, 0, "the type is not drawn offscreen"); + assert!(strong == draw(TypeAntiAlias::Sharp, 3_000).1, "the type is drawn as Sharp draws it"); + // 2,800 × 2,800 pixels, within it. + assert_eq!(draw(TypeAntiAlias::Strong, 2_800).0, 1, "the type is drawn offscreen"); +} + /// Type across a canvas of the largest side the renderer draws (`MAX_CONTEXT_SIDE` pixels) is /// drawn with its method on every pixel. #[test] From faf62c515d8b2df0d2e1eb30efae7859e4a56306 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 19:51:22 -0700 Subject: [PATCH 038/140] Character: Crisp and Strong reshape the edges of each of the type's paints on its own Crisp and Strong scaled each edge pixel of the composited type by the share the method gives the characters' coverage. Where translucent paints overlap, that drew the edges too opaque: two 50% fills at half coverage came out at 0.875 with Strong instead of 0.75. Each of the object's own fills and strokes, and each fill and stroke of a run, is now drawn offscreen on its own and takes the method from its own coverage, and the results are composited in paint order. Fills and strokes of consecutive runs in solid colors are drawn together while the colors are opaque. Type with more than eight such groups is drawn as one group, as before. A pixel that every group covers wholly or not at all keeps the pixel Sharp draws, so faint fills composite inside the characters before their colors are rounded. --- crates/render/src/renderer/text.rs | 133 ++++++++++++++++++++++++--- crates/render/src/tests/typeedges.rs | 63 +++++++++++++ 2 files changed, 182 insertions(+), 14 deletions(-) diff --git a/crates/render/src/renderer/text.rs b/crates/render/src/renderer/text.rs index 9db9ba8e3..95728dbde 100644 --- a/crates/render/src/renderer/text.rs +++ b/crates/render/src/renderer/text.rs @@ -211,11 +211,13 @@ impl Renderer { /// Draw type `t` of node `n` (laid out in `g`) with its anti-aliasing method, None, Crisp or /// Strong, on the pixels of the output ([`Frame::output`]): the type is drawn offscreen on those /// pixels and placed back pixel for pixel. With None it is drawn with hard edges, so a pixel - /// takes the type's color where the type covers at least half of it. With Crisp and Strong it - /// is drawn as Sharp draws it, and again with the characters and the object's own fills and - /// strokes in opaque black, which gives the share `c` of each pixel they cover; the pixel's - /// color and opacity are then scaled by [`edge_coverage`] of `c` divided by `c`, so the opacity - /// of the paints is kept. Where the output's pixels would number more than the frame's there + /// takes the type's color where the type covers at least half of it. With Crisp and Strong each + /// group of its paints ([`type_paints`]: the object's own fills and strokes one at a time, and + /// the characters' fills and strokes) is drawn on its own as Sharp draws it, and again in + /// opaque black, which gives the share `c` of each pixel the group covers; the pixel's color and + /// opacity are then scaled by [`edge_coverage`] of `c` divided by `c`, so the opacity of the + /// paints is kept, and the groups are composited in the order they are drawn. A pixel that + /// every group covers wholly or not at all is the pixel Sharp draws. Where the output's pixels would number more than the frame's there /// and more than [`fx::PIXEL_BUDGET`], or be more than [`MAX_RASTER_SIDE`] a side (a frame /// drawn coarser than the output), the frame's own pixels are used. /// @@ -364,19 +366,27 @@ impl Renderer { pm }; let sharp = TextObject { anti_alias: TypeAntiAlias::Sharp, ..t.clone() }; - let mut pm = draw(self, frame, n, &sharp); - if t.anti_alias != TypeAntiAlias::None { - let black = |p: &mut vectorcraft_color::Paint| { - if !p.is_none() { - *p = vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK); - } + if t.anti_alias == TypeAntiAlias::None { + return draw(self, frame, n, &sharp); + } + let black = |p: &mut vectorcraft_color::Paint| { + if !p.is_none() { + *p = vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK); + } + }; + // A group of paints drawn on its own, its pixels taking the method from its own coverage, + // and that coverage. + let reshaped = |r: &mut Self, group: Option<&[TypePaint]>| { + let (pn, pt) = match group { + Some(keep) => type_paint_only(n, &sharp, keep), + None => (n.clone(), sharp.clone()), }; - let mut ink = sharp; + let mut pm = draw(r, frame, &pn, &pt); + let (mut inked, mut ink) = (pn, pt); for r in &mut ink.runs { black(&mut r.style.fill); black(&mut r.style.stroke); } - let mut inked = n.clone(); for item in &mut inked.appearance.items { black(item.paint_mut()); match item { @@ -384,10 +394,39 @@ impl Renderer { AppearanceItem::Stroke(st) => st.opacity = 1.0, } } - let covered = draw(self, frame, &inked, &ink); + let covered = draw(r, frame, &inked, &ink); for (p, c) in pm.data_mut().iter_mut().zip(covered.data()) { *p = edge_pixel(*p, c.a, t.anti_alias); } + (pm, covered) + }; + let groups: Vec>> = match type_paints(n, &sharp, g, self.alpha >= 1.0) { + Some(groups) => groups.into_iter().map(Some).collect(), + None => vec![None], + }; + if groups.len() <= 1 { + return groups.first().map_or_else(|| Pixmap::new(w, h), |group| reshaped(self, group.as_deref()).0); + } + // Where every group covers a pixel wholly or not at all, the pixel is the type's as Sharp + // draws it. Elsewhere it is the groups composited in the order they are drawn, source over. + let mut pm = draw(self, frame, n, &sharp); + let mut sum = vec![[0.0f32; 4]; usize::from(w) * usize::from(h)]; + let mut edge = vec![false; sum.len()]; + for group in &groups { + let (layer, covered) = reshaped(self, group.as_deref()); + for (((s, e), p), c) in sum.iter_mut().zip(&mut edge).zip(layer.data()).zip(covered.data()) { + *e |= c.a != 0 && c.a != 255; + let keep = 1.0 - f32::from(p.a) / 255.0; + for (v, c) in s.iter_mut().zip([p.r, p.g, p.b, p.a]) { + *v = f32::from(c) + *v * keep; + } + } + } + for ((p, s), e) in pm.data_mut().iter_mut().zip(&sum).zip(&edge) { + if *e { + let [red, green, blue, a] = s.map(|v| v.round().clamp(0.0, 255.0) as u8); + *p = vello_cpu::color::PremulRgba8 { r: red.min(a), g: green.min(a), b: blue.min(a), a }; + } } pm } @@ -497,6 +536,72 @@ pub(crate) fn edge_grid(on: Rect, grid: Affine) -> Affine { if area(out) > area(on).max(fx::PIXEL_BUDGET) || out.width() > side || out.height() > side { Affine::IDENTITY } else { grid } } +/// One paint of type: one of the object's own fills and strokes (its index), or the fill or the +/// stroke of a run (its index). +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum TypePaint { + Item(usize), + Fill(usize), + Stroke(usize), +} + +/// Most groups of paints [`Renderer::draw_type_pixels`] draws one at a time ([`type_paints`]). +const MAX_TYPE_PAINTS: usize = 8; + +/// The paints of type `t` of node `n` (laid out in `g`) in groups that +/// [`Renderer::draw_type_pixels`] draws one at a time, in the order they are drawn: the object's +/// own fills and strokes below the characters, the runs' fills and strokes, then the object's own +/// fills and strokes above the characters. Each of the object's own fills and strokes is a group, +/// and so is each fill and stroke of a run, except that fills and strokes of runs one after +/// another in solid colors are one group when colors are drawn opaque (`opaque`). Paints that +/// draw nothing are left out. `None` for type with more than [`MAX_TYPE_PAINTS`] groups, which is +/// drawn as one. +fn type_paints(n: &Node, t: &TextObject, g: &TextGeom, opaque: bool) -> Option>> { + let shown = |i: &AppearanceItem| i.visible() && !i.paint().is_none() && !matches!(i, AppearanceItem::Stroke(st) if st.width <= 0.0); + let items = &n.appearance.items; + let k = n.appearance.contents_at(); + let mut groups: Vec> = (0..k).filter(|j| shown(&items[*j])).map(|j| vec![TypePaint::Item(j)]).collect(); + // Whether the last group holds run paints in opaque solid colors, which the next such paint joins. + let mut joins = false; + for (i, run) in t.runs.iter().enumerate() { + if g.run(i).elements().is_empty() { + continue; + } + let fill = (!run.style.fill.is_none()).then_some((TypePaint::Fill(i), &run.style.fill)); + let stroke = run.style.has_stroke().then_some((TypePaint::Stroke(i), &run.style.stroke)); + for (p, paint) in [fill, stroke].into_iter().flatten() { + let solid = opaque && matches!(paint, vectorcraft_color::Paint::Solid { .. }); + match groups.last_mut() { + Some(last) if joins && solid => last.push(p), + _ => groups.push(vec![p]), + } + joins = solid; + } + } + groups.extend((k..items.len()).filter(|j| shown(&items[*j])).map(|j| vec![TypePaint::Item(j)])); + (groups.len() <= MAX_TYPE_PAINTS).then_some(groups) +} + +/// Node `n` and type `t` with every paint but those in `keep` taken away. +fn type_paint_only(n: &Node, t: &TextObject, keep: &[TypePaint]) -> (Node, TextObject) { + let keep: std::collections::HashSet = keep.iter().copied().collect(); + let (mut n, mut t) = (n.clone(), t.clone()); + for (j, item) in n.appearance.items.iter_mut().enumerate() { + if !keep.contains(&TypePaint::Item(j)) { + *item.paint_mut() = vectorcraft_color::Paint::None; + } + } + for (i, run) in t.runs.iter_mut().enumerate() { + if !keep.contains(&TypePaint::Fill(i)) { + run.style.fill = vectorcraft_color::Paint::None; + } + if !keep.contains(&TypePaint::Stroke(i)) { + run.style.stroke = vectorcraft_color::Paint::None; + } + } + (n, t) +} + /// Pixel `p` of type the anti-aliasing method `m` draws, where the type covers `c` (out of 255) of /// the pixel: its color and opacity scaled by [`edge_coverage`] of `c` divided by `c`. A pixel the /// type covers wholly or not at all is kept. diff --git a/crates/render/src/tests/typeedges.rs b/crates/render/src/tests/typeedges.rs index b98dfe60f..946dd5d21 100644 --- a/crates/render/src/tests/typeedges.rs +++ b/crates/render/src/tests/typeedges.rs @@ -154,6 +154,69 @@ fn the_methods_keep_the_opacity_of_the_paint() { } } +/// Crisp and Strong reshape the edges of each of the type's paints on its own: where two +/// half-opaque fills of the object overlap, each edge pixel shows the first fill with the +/// method's share of the pixel under the second fill with that share. +#[test] +fn the_methods_reshape_each_paint_on_its_own() { + let half = FillLayer { opacity: 0.5, ..FillLayer::new(Paint::solid(Color::BLACK)) }; + let opts = RenderOptions::default(); + let coverage = alphas(&page(TypeAntiAlias::Sharp, None), &opts); + assert!(partial(&coverage) > 200, "{} partial pixels", partial(&coverage)); + for m in [TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + let mut d = page(m, Some(half.clone())); + let id = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); + d.node_mut(id).unwrap().appearance.items.push(AppearanceItem::Fill(half.clone())); + let drawn = alphas(&d, &opts); + // Two half-opaque layers that each show `e` of the pixel, within a step of rounding in the + // coverage and in the result. + let both = |c: u8| { + let e = f64::from(edge_coverage(m, c)) / 255.0; + 255.0 * (1.0 - (1.0 - 0.5 * e).powi(2)) + }; + let wrong: Vec<_> = coverage + .iter() + .zip(&drawn) + .enumerate() + .filter(|(_, (c, v))| { + let near = [c.saturating_sub(1), **c, c.saturating_add(1)].map(both); + let (lo, hi) = (near.iter().copied().fold(f64::MAX, f64::min), near.iter().copied().fold(0.0, f64::max)); + f64::from(**v) < lo - 1.5 || f64::from(**v) > hi + 1.5 + }) + .map(|(i, (c, v))| (i, *c, *v)) + .take(5) + .collect(); + assert!(wrong.is_empty(), "{m:?}: (pixel, coverage, drawn) {wrong:?}"); + } +} + +/// Crisp and Strong type with several faint fills of the object draws the pixels inside the +/// characters as Sharp draws them: the fills are composited there before their colors are rounded. +#[test] +fn the_methods_keep_faint_fills_inside_the_characters() { + let faint = FillLayer { opacity: 0.01, ..FillLayer::new(Paint::solid(Color::BLACK)) }; + let draw = |m: TypeAntiAlias| { + let mut d = page(m, Some(faint.clone())); + let id = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); + let n = d.node_mut(id).unwrap(); + for _ in 0..7 { + n.appearance.items.push(AppearanceItem::Fill(faint.clone())); + } + alphas(&d, &RenderOptions::default()) + }; + let sharp = draw(TypeAntiAlias::Sharp); + // The pixels the characters cover wholly. + let coverage = alphas(&page(TypeAntiAlias::Sharp, None), &RenderOptions::default()); + let inside: Vec = coverage.iter().enumerate().filter(|(_, c)| **c == 255).map(|(i, _)| i).collect(); + assert!(inside.len() > 200, "{} pixels inside the characters", inside.len()); + assert!(inside.iter().all(|i| sharp[*i] > 0), "the faint fills show inside the characters"); + for m in [TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + let drawn = draw(m); + let wrong: Vec<_> = inside.iter().filter(|i| drawn[**i] != sharp[**i]).take(5).map(|i| (*i, sharp[*i], drawn[*i])).collect(); + assert!(wrong.is_empty(), "{m:?}: (pixel, Sharp, drawn) {wrong:?}"); + } +} + /// In an export that composites with floating point, None, Crisp and Strong type composite its /// translucent fills as precisely as Sharp type: inside the characters each method draws the /// pixels Sharp draws, within the one step of rounding that keeping the type's pixels as 8-bit From 82c04ff35afa20f04cd50bf7bd4c34eeef24024f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 20:10:13 -0700 Subject: [PATCH 039/140] Docs: Batch's Override Export names every output collision it numbers The progress-doc bullet now says that folder and file names drop trailing periods and spaces, and that Batch also numbers a name that an earlier output, a source file in the destination folder or, for data sets, an existing file already has. --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6d82c456a..114909cfc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -547,7 +547,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Variables: when no object is bound to a variable that the data set has a value for, for example after Import Data Sets… defines new variables without binding them, choosing the data set in the Variables panel or stepping to it with the panel's arrows shows a status message, and `dataset.select`, `dataset.next` and `dataset.prev` add the message to the reply as `warning`. - Variables: Lock Variables (`variable.lock`, in the Variables panel's bottom bar and menu) stops variables from being defined, deleted or renamed until they are unlocked, while objects can still be bound and unbound and data sets still change and apply; the lock is saved with the document. - Batch…: Override Save and Override Export name each file with the extension of the format the step writes, however the step spells the format (`.svg` and `SVG` give `.svg`, `png8` gives `.png`), in `action.batch`. - - Batch…: Override Export writes the files of an Export for Screens or Export Assets step into a folder for each source file or data set inside the destination folder, under the names the export gives them (`action.batch`). When source files have the same name without the extension, Batch adds `-2`, `-3`… to the folder or file name of each later one. + - Batch…: Override Export writes the files of an Export for Screens or Export Assets step into a folder for each source file or data set inside the destination folder, under the names the export gives them (`action.batch`). The folder and file names drop trailing periods and spaces (`a..svg` gives `a`). When two source files would get the same name that way (compared without case), or a later file's folder or file would take the name of an output an earlier file wrote, of a source file in the destination folder or, for data sets, of a file already there, Batch adds `-2`, `-3`… to the later one's name. - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. - Actions panel: Delete Selection deletes the selected action set with its actions and keeps the other sets ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html): "Removes the selected action, command, or action set from the panel"; `ui.actionSet.delete`). VectorCraft's own rules, which the overview does not cover: deleting a set asks first, Shift-click on a set's name selects a range of sets to delete together, the panel menu's Delete does the same, and Default Actions and the last set can be deleted. Undo does not bring a deleted set back. - Batch… over data sets names each file after the document and the data set (`action.batch`). It writes `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set used. From 24b509aca15668f3ef2806a31f62fa8f205eeb2d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 21:29:23 -0700 Subject: [PATCH 040/140] i18n: add the antiAlias message in Bulgarian, Bosnian, Danish, Indonesian and Dutch --- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + 5 files changed, 5 insertions(+) diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d35434d0b..3d7f78a2b 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -3881,6 +3881,7 @@ axis Both И двете @msg Targa files are RGB: colorModel rgb Файловете Targa са RGB: colorModel rgb @msg Text Wrap: select a wrap object Обгръщане на текст: изберете обгръщащ обект @msg This shortcut file is in a form VectorCraft can't read. Този файл с преки пътища е във формат, който VectorCraft не може да прочете. +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` трябва да е "none", "sharp", "crisp" или "strong" @msg `aspect` must be a number (%) `aspect` трябва да бъде число (%) @msg `at` must be [x, y] `at` трябва да бъде [x, y] @msg `color` must be a colour `color` трябва да бъде цвят diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index e9a90846d..e6a20b18a 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -4246,6 +4246,7 @@ imageTrace Outline Obris @msg Split Into Grid: select paths large enough for the grid Podijeli u mrežu: odaberite putanje dovoljno velike za mrežu @msg Targa files are RGB: colorModel rgb Targa datoteke su RGB: colorModel rgb @msg Text Wrap: select a wrap object Omotavanje teksta: odaberite objekat omotavanja +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` mora biti "none", "sharp", "crisp" ili "strong" @msg `aspect` must be a number (%) `aspect` mora biti broj (%) @msg `at` must be [x, y] `at` mora biti [x, y] @msg `color` must be a colour `color` mora biti boja diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 13bc90584..25ac7f612 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -3993,6 +3993,7 @@ imageTrace Outline Kontur @msg Split Into Grid: select paths large enough for the grid Opdel i gitter: markér kurver, der er store nok til gitteret @msg Targa files are RGB: colorModel rgb Targa-filer er RGB: colorModel rgb @msg Text Wrap: select a wrap object Tekstombrydning: markér et ombrydningsobjekt +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` skal være "none", "sharp", "crisp" eller "strong" @msg `aspect` must be a number (%) `aspect` skal være et tal (%) @msg `at` must be [x, y] `at` skal være [x, y] @msg `color` must be a colour `color` skal være en farve diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 710fda56f..882d384c3 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3993,6 +3993,7 @@ imageTrace Outline Kontur @msg Split Into Grid: select paths large enough for the grid Bagi menjadi kisi: pilih jalur yang cukup besar untuk kisi @msg Targa files are RGB: colorModel rgb File Targa adalah RGB: colorModel rgb @msg Text Wrap: select a wrap object Pembungkusan teks: pilih objek pembungkus +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` harus "none", "sharp", "crisp" atau "strong" @msg `aspect` must be a number (%) `aspect` harus berupa angka (%) @msg `at` must be [x, y] `at` harus [x, y] @msg `color` must be a colour `color` harus berupa warna diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 9686ccde8..9ee4d9e6b 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4007,6 +4007,7 @@ imageTrace Outline Contour @msg Split Into Grid: select paths large enough for the grid In raster verdelen: selecteer paden die groot genoeg zijn voor het raster @msg Targa files are RGB: colorModel rgb Targa-bestanden zijn RGB: colorModel rgb @msg Text Wrap: select a wrap object Tekstomloop: selecteer een omloopobject +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` moet "none", "sharp", "crisp" of "strong" zijn @msg `aspect` must be a number (%) `aspect` moet een getal zijn (%) @msg `at` must be [x, y] `at` moet [x, y] zijn @msg `color` must be a colour `color` moet een kleur zijn From e725c4920cdd6ddefa2eaca0588e08ee04e44abc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 22:26:08 -0700 Subject: [PATCH 041/140] Tests: nested clip groups in isolation mode and Direct Selection drags - isolation_reaches_hidden_art_only_for_the_isolated_clip_group: with the outer clip group isolated, clicks and marquees take the art its own mask hides, while an inner clip group's mask still clips that group's art; isolating the inner group or its art reaches the inner hidden art. - isolation_lets_direct_selection_take_the_art_a_clip_group_hides now continues the press with a drag on the same tool and checks the Move step and its preview, for Direct Selection and Group Selection. --- crates/doc/src/hit/tests.rs | 50 ++++++++++++++++++++++++++++++++ crates/tools/src/direct/tests.rs | 13 +++++++-- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index 2e2cd6944..8cc3dd71f 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -533,6 +533,56 @@ fn isolation_reaches_the_art_a_clipping_mask_hides() { } } +/// #3748, nested clip groups: with the outer clip group isolated, a click or a marquee takes +/// the art its own mask hides, while the mask of a clip group inside it still limits that +/// group's art. With the inner clip group isolated, or its art, the inner mask does not. +#[test] +fn isolation_reaches_hidden_art_only_for_the_isolated_clip_group() { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + let (outer, outer_clip, outer_art, inner, inner_clip, inner_art) = + (d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id()); + let mask = |id, r| { + let mut path = Node::path(id, shapes::rectangle(r), Appearance::basic(Paint::None, Paint::None, 0.0)); + if let NodeKind::Path { clipping, .. } = &mut path.kind { + *clipping = true; + } + Arc::new(path) + }; + let art = |id, r| Arc::new(Node::path(id, shapes::rectangle(r), Appearance::default_art())); + let inner_group = Node::new( + inner, + NodeKind::Group { + children: vec![mask(inner_clip, Rect::new(60.0, 60.0, 120.0, 120.0)), art(inner_art, Rect::new(40.0, 40.0, 160.0, 160.0))], + clip: true, + }, + ); + let children = + vec![mask(outer_clip, Rect::new(20.0, 20.0, 180.0, 180.0)), art(outer_art, Rect::new(190.0, 20.0, 260.0, 180.0)), Arc::new(inner_group)]; + d.insert(Some(l), 0, Node::new(outer, NodeKind::Group { children, clip: true })).unwrap(); + // `outer_hidden` is outside the outer mask, on the outer group's own art; `inner_hidden` is + // inside the outer mask and outside the inner one, on the inner group's art. + let (outer_hidden, inner_hidden, shown) = (Point::new(220.0, 100.0), Point::new(150.0, 100.0), Point::new(90.0, 90.0)); + let leaf = |p, scope| hit_test(&d, p, HitOptions { scope, ..SELECTING }).map(|h| h.leaf); + assert_eq!((leaf(outer_hidden, None), leaf(inner_hidden, None)), (None, None), "not isolated"); + assert_eq!(leaf(outer_hidden, Some(outer)), Some(outer_art), "outer isolated: its own hidden art"); + assert_eq!(leaf(inner_hidden, Some(outer)), None, "outer isolated: the inner mask still clips"); + let h = hit_test(&d, shown, HitOptions { scope: Some(outer), ..SELECTING }); + assert_eq!(h.map(|h| (h.leaf, h.top_object(Some(outer)))), Some((inner_art, inner)), "outer isolated: shown art"); + assert_eq!(leaf(inner_hidden, Some(inner)), Some(inner_art), "inner isolated"); + assert_eq!(leaf(outer_hidden, Some(inner)), None, "inner isolated: the outer group's art"); + assert_eq!(leaf(inner_hidden, Some(inner_art)), Some(inner_art), "the inner art isolated"); + // These marquees cross the right edge of the outer art (outside the outer mask) and of the + // inner art (inside the outer mask, outside the inner one). + let (outer_edge, inner_edge) = (Rect::new(255.0, 95.0, 265.0, 105.0), Rect::new(155.0, 95.0, 165.0, 105.0)); + assert_eq!(marquee(&d, outer_edge, Some(outer), false), vec![outer_art]); + assert!(marquee(&d, inner_edge, Some(outer), false).is_empty()); + assert!(marquee(&d, inner_edge, Some(outer), true).is_empty()); + assert_eq!(marquee(&d, inner_edge, Some(inner), false), vec![inner_art]); + assert!(marquee(&d, inner_edge, None, false).is_empty()); + assert!(marquee(&d, outer_edge, None, false).is_empty()); +} + /// Art in a sublayer is an object of its own: a click or a marquee takes it, not the /// sublayer; hidden, locked and template sublayers block both. #[test] diff --git a/crates/tools/src/direct/tests.rs b/crates/tools/src/direct/tests.rs index d61a8e538..718983d96 100644 --- a/crates/tools/src/direct/tests.rs +++ b/crates/tools/src/direct/tests.rs @@ -129,16 +129,25 @@ fn a_press_on_a_clipping_path_takes_its_segment_over_the_art() { } /// #3748: in isolation mode Direct Selection and Group Selection take the art that the -/// isolated clip group hides outside its mask; outside isolation mode a press there does not. +/// isolated clip group hides outside its mask, and a drag from there moves it; outside +/// isolation mode a press there does not take it. #[test] fn isolation_lets_direct_selection_take_the_art_a_clip_group_hides() { let (d, g, _, art) = doc_with_masked_square(); let (s, p) = (Selection::default(), paint()); + let mut picked = Selection::default(); + picked.add(art); let names = |actions: &[Action]| actions.iter().any(|a| matches!(a, Action::Exec(_, v) if v["id"] == json!(art.0) || v["ids"] == json!([art.0]))); for group in [false, true] { let isolated = ToolContext { isolation: Some(g), ..cx(&d, &s, &p) }; - let a = DirectSelectionTool::new(group).pointer(&isolated, &PointerEvent::new(PointerKind::Down, 530.0, 450.0)); + let mut t = DirectSelectionTool::new(group); + let a = t.pointer(&isolated, &PointerEvent::new(PointerKind::Down, 530.0, 450.0)); assert!(names(&a), "group {group}: {a:?}"); + // The press selected the art: the drag that follows moves it. + let isolated = ToolContext { isolation: Some(g), ..cx(&d, &picked, &p) }; + let a = t.pointer(&isolated, &PointerEvent::new(PointerKind::Drag, 510.0, 470.0)); + assert!(a.iter().any(|a| matches!(a, Action::Begin(n) if n == "Move")), "group {group}: {a:?}"); + assert!(a.iter().any(|a| matches!(a, Action::Preview(..))), "group {group}: {a:?}"); let a = DirectSelectionTool::new(group).pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 530.0, 450.0)); assert!(!names(&a), "group {group}, not isolated: {a:?}"); } From db40218790bb3a69cf4242bd9c0c3466ae4eddc0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 06:08:13 -0800 Subject: [PATCH 042/140] Type: Crisp and Strong reshape the inner edge of a character stroke A run's solid fill and its stroke no longer share one edge group, so the stroke's edge inside the characters takes the method. docs/mcp.md says the native document an SVG, PDF or EPS file carries keeps each frame's method. --- crates/render/src/renderer/text.rs | 11 +++++--- crates/render/src/tests/typeedges.rs | 41 ++++++++++++++++++++++++++++ docs/mcp.md | 4 ++- 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/crates/render/src/renderer/text.rs b/crates/render/src/renderer/text.rs index 95728dbde..c5e2b666c 100644 --- a/crates/render/src/renderer/text.rs +++ b/crates/render/src/renderer/text.rs @@ -553,9 +553,10 @@ const MAX_TYPE_PAINTS: usize = 8; /// own fills and strokes below the characters, the runs' fills and strokes, then the object's own /// fills and strokes above the characters. Each of the object's own fills and strokes is a group, /// and so is each fill and stroke of a run, except that fills and strokes of runs one after -/// another in solid colors are one group when colors are drawn opaque (`opaque`). Paints that -/// draw nothing are left out. `None` for type with more than [`MAX_TYPE_PAINTS`] groups, which is -/// drawn as one. +/// another in solid colors are one group when colors are drawn opaque (`opaque`). A run's stroke +/// starts a new group rather than join its own fill's, so the stroke's edges inside the characters +/// take the method too. Paints that draw nothing are left out. `None` for type with more than +/// [`MAX_TYPE_PAINTS`] groups, which is drawn as one. fn type_paints(n: &Node, t: &TextObject, g: &TextGeom, opaque: bool) -> Option>> { let shown = |i: &AppearanceItem| i.visible() && !i.paint().is_none() && !matches!(i, AppearanceItem::Stroke(st) if st.width <= 0.0); let items = &n.appearance.items; @@ -571,8 +572,10 @@ fn type_paints(n: &Node, t: &TextObject, g: &TextGeom, opaque: bool) -> Option last.push(p), + Some(last) if joins && solid && !own_fill => last.push(p), _ => groups.push(vec![p]), } joins = solid; diff --git a/crates/render/src/tests/typeedges.rs b/crates/render/src/tests/typeedges.rs index 946dd5d21..f1fb75458 100644 --- a/crates/render/src/tests/typeedges.rs +++ b/crates/render/src/tests/typeedges.rs @@ -190,6 +190,47 @@ fn the_methods_reshape_each_paint_on_its_own() { } } +/// Crisp and Strong reshape the inner edge of a character stroke, where the stroke lies on the +/// characters' fill in another color: at a pixel the fill covers wholly, the stroke shows the +/// method's share of its own coverage. +#[test] +fn the_methods_reshape_a_character_strokes_inner_edge() { + let opts = RenderOptions::default(); + // "Hamburg" with the characters' fill `fill` and a 1.5 pt stroke `stroke`, drawn with `m`. + let draw = |m: TypeAntiAlias, fill: Paint, stroke: Paint| { + let mut d = page(m, None); + let id = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { + let s = &mut t.runs[0].style; + (s.fill, s.stroke, s.stroke_width) = (fill, stroke, 1.5); + } + Renderer::new().render(&d, W, H, Affine::IDENTITY, &opts).pixels + }; + let black = || Paint::solid(Color::BLACK); + let alpha = |p: Vec| p.chunks(4).map(|p| p[3]).collect::>(); + let fill = alpha(draw(TypeAntiAlias::Sharp, black(), Paint::None)); + let stroke = alpha(draw(TypeAntiAlias::Sharp, Paint::None, black())); + // Pixels inside the fill that the stroke covers in part: the stroke's inner edge. + let inner: Vec = (0..fill.len()).filter(|i| fill[*i] == 255 && stroke[*i] > 8 && stroke[*i] < 247).collect(); + assert!(inner.len() > 50, "{} pixels on the stroke's inner edge", inner.len()); + for m in [TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + let drawn = draw(m, Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + let wrong: Vec<_> = inner + .iter() + .filter(|i| { + let c = stroke[**i]; + let near = [c.saturating_sub(1), c, c.saturating_add(1)].map(|c| edge_coverage(m, c)); + let (lo, hi) = (near.iter().min().copied().unwrap_or(0), near.iter().max().copied().unwrap_or(255)); + let red = drawn[**i * 4]; + red < lo.saturating_sub(2) || red > hi.saturating_add(2) + }) + .take(5) + .map(|i| (*i, stroke[*i], drawn[*i * 4])) + .collect(); + assert!(wrong.is_empty(), "{m:?}: (pixel, stroke coverage, red) {wrong:?}"); + } +} + /// Crisp and Strong type with several faint fills of the object draws the pixels inside the /// characters as Sharp draws them: the fills are composited there before their colors are rounded. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 0c4e0950e..48d753c3a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5065,7 +5065,9 @@ inline graphics or color glyphs (bitmap or COLR), when its own fills or strokes stroke or a blend mode other than Normal, when it overprints while Overprint Preview is on, and on the canvas while it shows a substitution highlight. With Preferences › General › Anti-aliased Artwork off, the canvas draws all its art with hard edges, type included, and -so does an export whose own Anti-aliasing option is None. SVG, PDF and EPS export are unchanged and carry no setting. +so does an export whose own Anti-aliasing option is None. SVG, PDF and EPS export draw the type unchanged. The native +document such a file carries (an EPS VectorCraft wrote, or an SVG or PDF saved with Preserve Editing) keeps each frame's +method, and opening the file restores it. Reset Panel sets Sharp. Saved as `antiAlias` only when not `sharp`. One undo step. ```json From fd973bdf48e260fd8357c221d14b243bdcfd035c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 06:23:42 -0800 Subject: [PATCH 043/140] Tests: Outline Stroke with Round Corners, no effect, hidden effects, blur and undo --- crates/engine/src/tests/outlinestroke.rs | 101 +++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/crates/engine/src/tests/outlinestroke.rs b/crates/engine/src/tests/outlinestroke.rs index 7e14859a5..71bc4724a 100644 --- a/crates/engine/src/tests/outlinestroke.rs +++ b/crates/engine/src/tests/outlinestroke.rs @@ -372,6 +372,107 @@ fn outlines_keep_the_random_transforms_the_canvas_drew() { assert_eq!(differ, none_differ); } +/// For each command in [`outlining`] on a stroke-only square with `effects` on appearance item +/// `item`: the pixels that differ by more than 64 from the canvas before, whether a geometry +/// effect is left on the result, and (for Outline Stroke) the outline's box and area. +fn outlined_square(item: Value, effects: &[(&str, Value)], hide: bool) -> (Vec<(&'static str, usize, bool)>, Rect, f64) { + let mut results = vec![]; + let mut shape = (Rect::ZERO, 0.0); + for (command, p) in outlining() { + let mut s = session(); + square_with(&mut s, json!({"none": true}), item.clone(), effects); + if hide { + for index in 0..effects.len() { + s.execute("effect.setParams", &json!({"index": index, "visible": false, "item": item})).unwrap(); + } + } + let before = render(&s); + let n = outlined_by(&mut s, command, &p); + if command == "object.path.outlineStroke" { + shape = (n.geometric_bounds().unwrap(), area(&n)); + } + results.push((command, differing(&before, &render(&s), 64), has_geometry_effects(&n))); + } + (results, shape.0, shape.1) +} + +/// What [`outlined_square`] gives for each command when the outline draws as the canvas did and +/// keeps no geometry effect. +fn drawn_plain() -> Vec<(&'static str, usize, bool)> { + outlining().map(|(command, _)| (command, 0, false)).into_iter().collect() +} + +/// A path with no effects outlines its own stroke: 4 pt wide around the 100 pt square. +#[test] +fn a_path_without_effects_outlines_its_own_stroke() { + let (results, bounds, a) = outlined_square(Value::Null, &[], false); + assert_eq!(results, drawn_plain()); + assert!(close(bounds, Rect::new(148.0, 98.0, 252.0, 202.0), 0.01), "{bounds:?}"); + assert!(near(a, 104.0 * 104.0 - 96.0 * 96.0, 0.5), "{a}"); +} + +/// Round Corners on the object rounds the path whose stroke is outlined, with the radius in +/// points as the canvas draws it: corners of radius 12 outside and 8 inside. +#[test] +fn round_corners_on_the_object_round_the_stroke_it_outlines() { + let (results, bounds, a) = outlined_square(Value::Null, &[("stylize.roundCorners", json!({"radius": 10}))], false); + assert_eq!(results, drawn_plain()); + assert!(close(bounds, Rect::new(148.0, 98.0, 252.0, 202.0), 0.01), "{bounds:?}"); + let rounded = 104.0 * 104.0 - 96.0 * 96.0 - (4.0 - std::f64::consts::PI) * (12.0 * 12.0 - 8.0 * 8.0); + assert!(near(a, rounded, 2.0), "{a}, rounded {rounded}"); +} + +/// A hidden geometry effect, on the object or on the stroke, does not draw on the canvas and does +/// not shape the outline. +#[test] +fn a_hidden_geometry_effect_does_not_shape_the_outline() { + for item in [Value::Null, json!(1)] { + let (results, bounds, a) = outlined_square(item.clone(), &geometry_effects(), true); + let drawn: Vec<_> = results.iter().map(|(command, differ, _)| (*command, *differ)).collect(); + assert_eq!(drawn, drawn_plain().into_iter().map(|(command, differ, _)| (command, differ)).collect::>(), "item {item}"); + assert!(close(bounds, Rect::new(148.0, 98.0, 252.0, 202.0), 0.01), "item {item}: {bounds:?}"); + assert!(near(a, 104.0 * 104.0 - 96.0 * 96.0, 0.5), "item {item}: {a}"); + } +} + +/// An effect on the object that is not a geometry effect, such as a Gaussian Blur, stays on the +/// result of Outline Stroke and Expand while the geometry effect shapes the path. Flatten +/// Transparency renders raster effects into an image instead. +#[test] +fn an_effect_that_is_not_geometry_stays_on_the_result() { + for (command, p) in outlining().into_iter().filter(|(command, _)| *command != "object.flattenTransparency") { + let mut s = session(); + square_with( + &mut s, + json!({"color": "#33aa55"}), + Value::Null, + &[("distort.zigZag", json!({"size": 6, "ridges": 3})), ("blur.gaussian", json!({}))], + ); + let n = outlined_by(&mut s, command, &p); + let kept: Vec<&str> = n.appearance.effects.iter().map(|e| e.id.as_str()).collect(); + assert_eq!(kept, ["blur.gaussian"], "{command}"); + assert!(!has_geometry_effects(&n), "{command}: {n:?}"); + } +} + +/// Undo after outlining a path with geometry effects on the object and on its stroke gives back +/// the path as it was, effects included, and its selection. +#[test] +fn undo_gives_back_the_path_with_its_effects() { + for (command, p) in outlining() { + let mut s = session(); + square_with(&mut s, json!({"color": "#33aa55"}), Value::Null, &[("distort.zigZag", json!({"size": 6, "ridges": 3}))]); + s.execute("effect.apply", &json!({"effect": "path.offsetPath", "params": {"offset": 10}, "item": 1})).unwrap(); + let before = s.doc().unwrap().doc.layers.clone(); + let selected = s.doc().unwrap().selection.objects.clone(); + outlined_by(&mut s, command, &p); + assert_ne!(s.doc().unwrap().doc.layers, before, "{command} changed nothing"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.layers, before, "{command}"); + assert_eq!(s.doc().unwrap().selection.objects, selected, "{command}"); + } +} + #[test] fn brushed_strokes_outline_to_their_brush_art() { let mut s = session(); From a4436388ccf07e15b6247847d772bc2f66bb3e63 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 06:35:40 -0800 Subject: [PATCH 044/140] Batch: a folder source's file name keeps a backslash on Linux and macOS Output stems for a folder batch went through safe_file_name, which turns `\` and `:` into `-`, so `a\b.svg` on Linux was written as `a-b.svg`. The stem now only loses trailing periods and spaces and gets `-` after a Windows device name, and keeps every other character. --- crates/engine/src/cmd/fileio/action_batch.rs | 30 ++++++++++++++++---- crates/engine/src/cmd/fileio/screens.rs | 8 +++++- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index 0b85ab9df..c34bfd854 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -102,7 +102,6 @@ pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result { #[cfg(not(target_arch = "wasm32"))] fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: &Run<'_>) -> Result { - use super::screens::safe_file_name; use super::{absolute_path, may_read}; let folder = str_param(p, "folder").map(absolute_path).ok_or_else(|| bad(cmd, "missing `folder`"))?; @@ -115,9 +114,9 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: if files.is_empty() { return Err(bad(cmd, "no readable files in the folder")); } - // Each file's name without its extension, through `safe_file_name` (`a..svg` gives `a`, the - // folder Windows makes for `a.`), with `-2`, `-3`… after a name an earlier file used (`a.svg` and - // `sub/a.svg`, or `a.svg` and `a.ai`) or after a name whose files or folder an earlier file + // Each file's name without its extension, through [`source_stem`] (`a..svg` gives `a`, the + // folder Windows makes for `a.`, and `a\b.svg` keeps `a\b` where `\` is not a separator), with + // `-2`, `-3`… after a name an earlier file used (`a.svg` and `sub/a.svg`, or `a.svg` and `a.ai`) or after a name whose files or folder an earlier file // wrote ([`distinct_stems`]), as data set names are numbered. No output takes the name of a // source file in the destination folder, other than that file's own output, or of a subfolder // of the destination folder that holds source files. @@ -127,10 +126,10 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: let reserved = in_dest.iter().flatten().map(|(name, _)| name.clone()).collect(); let stems = distinct_stems( files.iter().zip(&in_dest).map(|(path, place)| { - let stem = safe_file_name(std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("")); + let stem = source_stem(std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("")); let (exts, makes_folder) = outputs(&run, &super::extension(path)); let own = place.as_ref().filter(|(_, own)| *own).map(|(name, _)| name.clone()); - (if stem.is_empty() { "file".to_string() } else { stem }, exts, makes_folder, own) + (stem, exts, makes_folder, own) }), reserved, ); @@ -183,6 +182,16 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result { Ok(json!({"processed": processed, "count": processed.len(), "failed": failed, "stopped": false})) } +/// The output stem for a source file's name without its extension: without trailing periods and +/// spaces and with `-` after a Windows device name ([`super::screens::name_without_device`]), and +/// "file" when nothing is left. Other characters stay, so `a\b` and `a:b` keep their names on Linux +/// and macOS. +#[cfg(not(target_arch = "wasm32"))] +fn source_stem(stem: &str) -> String { + let stem = super::screens::name_without_device(stem); + if stem.is_empty() { "file".to_string() } else { stem } +} + /// What one source writes into the destination folder: the extension of each file a Save or /// Export step or the Folder destination writes there as `{stem}.{ext}`, and whether an Export for /// Screens or Export Assets step makes the folder `{stem}` there. Follows [`rewrite_paths`] and @@ -748,6 +757,15 @@ mod tests { assert_eq!(file_names(&dest), ["a\\b.svg"], "{r}"); } + /// A source stem keeps `\`, `:` and the other characters Windows does not allow in a name, which + /// only reach the batch where the file system allows them, and loses trailing periods and spaces. + #[test] + fn source_stem_keeps_characters_the_file_system_allowed() { + for (stem, want) in [("a\\b", "a\\b"), ("a:b", "a:b"), ("a.", "a"), ("a. ", "a"), ("CON", "CON-"), ("...", "file"), ("", "file")] { + assert_eq!(source_stem(stem), want, "{stem:?}"); + } + } + #[test] fn output_path_accepts_only_platform_file_name_separators() { let folder = tmp("output-path"); diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index 966038b35..bb5bd0acf 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -174,7 +174,13 @@ pub(super) fn file_part(s: &str) -> String { /// every platform. Spaces between the device name and the period are dropped (`CON .png` becomes /// `CON-.png`). pub(crate) fn safe_file_name(s: &str) -> String { - let name = file_part(s); + name_without_device(&file_part(s)) +} + +/// [`safe_file_name`] without [`file_part`]: drops trailing periods and spaces and puts `-` after a +/// Windows device name, and keeps every other character, so a source file name with `\` or `:` keeps +/// it where the file system allows it. +pub(super) fn name_without_device(name: &str) -> String { let name = name.trim_end_matches(['.', ' ']); let (base, rest) = name.split_at(name.find('.').unwrap_or(name.len())); // Windows ignores spaces before the extension, so `CON .png` is the CON device too. From 92e71a9fce72e9d60468e6a411ddd33400ed9be6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 08:49:19 -0800 Subject: [PATCH 045/140] Type: test that the four anti-aliasing methods draw the same text four different ways --- crates/render/src/tests/typeedges.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/render/src/tests/typeedges.rs b/crates/render/src/tests/typeedges.rs index f1fb75458..0d2b91053 100644 --- a/crates/render/src/tests/typeedges.rs +++ b/crates/render/src/tests/typeedges.rs @@ -77,6 +77,14 @@ fn each_method_draws_the_share_of_each_pixel_it_defines() { assert!(wrong.is_empty(), "{m:?}: (pixel, Sharp, drawn) {wrong:?}"); } assert_eq!(partial(&alphas(&page(TypeAntiAlias::None, None), &opts)), 0, "None paints whole pixels"); + // The same text drawn with each of the four methods gives four different pictures. + let methods = [TypeAntiAlias::None, TypeAntiAlias::Sharp, TypeAntiAlias::Crisp, TypeAntiAlias::Strong]; + let drawn: Vec> = methods.iter().map(|m| alphas(&page(*m, None), &opts)).collect(); + for (i, a) in drawn.iter().enumerate() { + for (j, b) in drawn.iter().enumerate().skip(i + 1) { + assert_ne!(a, b, "{:?} and {:?} draw alike", methods[i], methods[j]); + } + } } /// `edge_coverage` gives each method the share its documentation defines: None shows all of a From da078bf11be08033f85e04b82a7e9c38e6f542b1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 09:54:31 -0800 Subject: [PATCH 046/140] Type: test that type past the paint group limit takes the anti-aliasing method Nine runs with opaque solid fills and strokes make ten groups of paints, past the eight the renderer draws one at a time. The test checks that the type is drawn as one group whose outer edges Crisp and Strong reshape, and that None paints whole pixels. --- crates/render/src/tests/typeedges.rs | 41 ++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/crates/render/src/tests/typeedges.rs b/crates/render/src/tests/typeedges.rs index 0d2b91053..f5aac9c7c 100644 --- a/crates/render/src/tests/typeedges.rs +++ b/crates/render/src/tests/typeedges.rs @@ -239,6 +239,47 @@ fn the_methods_reshape_a_character_strokes_inner_edge() { } } +/// Type with more groups of paints than the renderer draws one at a time is drawn as one group, +/// and each method still reshapes its outer edges. Nine runs, each with an opaque solid fill and a +/// stroke, make ten groups: each run's stroke starts a group that the next run's fill joins. Crisp +/// and Strong give each pixel the share `edge_coverage` gives the share Sharp draws, and None +/// paints whole pixels. +#[test] +fn type_with_more_paint_groups_than_the_limit_takes_the_method() { + let opts = RenderOptions::default(); + let draw = |m: TypeAntiAlias| { + let mut d = Document::new(f64::from(W), f64::from(H)); + let style = CharStyle { + size: 32.0, + fill: Paint::solid(Color::rgb(0.0, 0.0, 1.0)), + stroke: Paint::solid(Color::rgb(1.0, 0.0, 0.0)), + stroke_width: 1.0, + ..Default::default() + }; + let mut t = TextObject::point(Point::new(10.3, 60.4), "H", style.clone()); + for c in "amburger".chars() { + t.runs.push(TextRun::new(c.to_string(), style.clone())); + } + assert_eq!(t.runs.len(), 9, "nine runs"); + t.anti_alias = m; + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + alphas(&d, &opts) + }; + let sharp = draw(TypeAntiAlias::Sharp); + assert!(partial(&sharp) > 200, "Sharp draws smooth edges: {} partial pixels", partial(&sharp)); + for m in [TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + let drawn = draw(m); + assert_ne!(drawn, sharp, "{m:?} changes the edges"); + let wrong = off_the_share(m, &sharp, &drawn); + assert!(wrong.is_empty(), "{m:?}: (pixel, Sharp, drawn) {wrong:?}"); + } + let none = draw(TypeAntiAlias::None); + assert_ne!(none, sharp, "None changes the edges"); + assert_eq!(partial(&none), 0, "None paints whole pixels"); +} + /// Crisp and Strong type with several faint fills of the object draws the pixels inside the /// characters as Sharp draws them: the fills are composited there before their colors are rounded. #[test] From 4f07313ba465c0dc6bd4855dfc516f73751eba6a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 14:38:21 -0800 Subject: [PATCH 047/140] i18n: move the Catalan antiAlias message into the sorted message block --- crates/ui-egui/src/i18n/ca.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 329d95db3..dc5cbc72a 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -4751,6 +4751,7 @@ digits Malayalam Malaiàlam 3d Solid Sòlid 3d Space Espai @msg "{0}" is not a colour: write it as #rrggbb "{0}" no és un color: escriviu-lo com a #rrggbb +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` ha de ser "none", "sharp", "crisp" o "strong" @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all o un interval com ara 1, 3-5, no {v} @msg `aspect` must be a number (%) `aspect` ha de ser un nombre (%) @msg `at` must be [x, y] `at` ha de ser [x, y] @@ -5908,7 +5909,6 @@ anchor Points: Punts: CMYK Images Imatges CMYK Grayscale Images Imatges en escala de grisos @msg `kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages `kind` ha de ser spotColors, colorEffects, rgbImages, cmykImages o grayImages -@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` ha de ser "none", "sharp", "crisp" o "strong" # Color Effects add-on: Vector Halftone (Effect › Color Effects). Dot Color from Art Color dels punts de la il·lustració From c4a8c43dc511faf982b884a73ef64cb963f2d565 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 11:43:07 -0800 Subject: [PATCH 048/140] Batch: a folder source's artboard files do not replace another source file A folder source's stem is picked once the file is open, so the files a save or export writes for each artboard count, as they do for data sets. With the destination folder the same as the source folder, saving a.vectorcraft with separateArtboards wrote a-Artboard-1.vectorcraft over the source file of that name; a.vectorcraft is now saved as a-2. --- crates/engine/src/cmd/fileio/action_batch.rs | 169 +++++++++++-------- docs/mcp.md | 4 +- 2 files changed, 98 insertions(+), 75 deletions(-) diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index c34bfd854..c0dd38ea0 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -117,27 +117,26 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: // Each file's name without its extension, through [`source_stem`] (`a..svg` gives `a`, the // folder Windows makes for `a.`, and `a\b.svg` keeps `a\b` where `\` is not a separator), with // `-2`, `-3`… after a name an earlier file used (`a.svg` and `sub/a.svg`, or `a.svg` and `a.ai`) or after a name whose files or folder an earlier file - // wrote ([`distinct_stems`]), as data set names are numbered. No output takes the name of a + // wrote ([`StemPicker`]), as data set names are numbered. A file's stem is picked once it is + // open, so its artboard files count ([`dataset_output_paths`]). No output takes the name of a // source file in the destination folder, other than that file's own output, or of a subfolder // of the destination folder that holds source files. let steps = filter_steps(run.steps, override_open, false); let run = Run { steps: &steps, ..*run }; let in_dest: Vec> = files.iter().map(|path| name_in_folder(path, run.dest_folder)).collect(); - let reserved = in_dest.iter().flatten().map(|(name, _)| name.clone()).collect(); - let stems = distinct_stems( - files.iter().zip(&in_dest).map(|(path, place)| { - let stem = source_stem(std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("")); - let (exts, makes_folder) = outputs(&run, &super::extension(path)); - let own = place.as_ref().filter(|(_, own)| *own).map(|(name, _)| name.clone()); - (stem, exts, makes_folder, own) - }), - reserved, - ); + let mut picker = + StemPicker { stems: std::collections::HashSet::new(), written: in_dest.iter().flatten().map(|(name, _)| name.clone()).collect() }; let mut processed = Vec::new(); let mut failed = 0usize; - for (path, stem) in files.iter().zip(&stems) { + for (path, place) in files.iter().zip(&in_dest) { let ext = super::extension(path); - match process_one_file(s, &run, path, stem, &ext) { + let base = source_stem(std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("")); + let own = place.as_ref().filter(|(_, own)| *own).map(|(name, _)| name.clone()); + let pick = |doc: &vectorcraft_doc::Document| { + let outputs = dataset_output_extensions(&run, doc, &ext); + picker.pick(&base, |stem| output_names(run.dest_folder, stem, &outputs, doc), own.as_deref()) + }; + match process_one_file(s, &run, path, &ext, pick) { Ok(row) => processed.push(row), Err(e) => { failed += 1; @@ -192,31 +191,14 @@ fn source_stem(stem: &str) -> String { if stem.is_empty() { "file".to_string() } else { stem } } -/// What one source writes into the destination folder: the extension of each file a Save or -/// Export step or the Folder destination writes there as `{stem}.{ext}`, and whether an Export for -/// Screens or Export Assets step makes the folder `{stem}` there. Follows [`rewrite_paths`] and -/// [`finish_destination`]. +/// The names in lower case of the files and folders that a source with the stem `stem` writes +/// into the destination folder ([`dataset_output_paths`]), artboard files included. #[cfg(not(target_arch = "wasm32"))] -fn outputs(run: &Run<'_>, default_ext: &str) -> (Vec, bool) { - let mut exts = Vec::new(); - let mut folder = false; - if run.dest_folder.is_some() { - for c in run.steps { - let id = c.get("command").and_then(Value::as_str).unwrap_or(""); - if run.override_export && matches!(id, "document.exportForScreens" | "assets.export") { - folder = true; - } else if (run.override_save && matches!(id, "document.save" | "file.save" | "file.saveAs")) - || (run.override_export && matches!(id, "document.export" | "document.exportSelection")) - { - let format = c.get("params").and_then(|p| p.get("format")).and_then(Value::as_str).filter(|f| !f.is_empty()); - exts.push(format.map_or_else(|| default_ext.to_string(), format_extension)); - } - } - } - if run.destination == "folder" { - exts.push(if default_ext.is_empty() { "vectorcraft".to_string() } else { default_ext.to_string() }); - } - (exts, folder) +fn output_names(folder: Option<&str>, stem: &str, outputs: &[DatasetOutput], doc: &vectorcraft_doc::Document) -> Vec { + dataset_output_paths(folder, stem, outputs, doc) + .iter() + .filter_map(|path| path.file_name().and_then(|name| name.to_str()).map(str::to_lowercase)) + .collect() } /// When the file at `path` is inside `folder`: the name in lower case of the file, when it is @@ -230,36 +212,35 @@ fn name_in_folder(path: &str, folder: Option<&str>) -> Option<(String, bool)> { Some((first, parts.next().is_none())) } -/// The stem of each source, given as (name, extensions, folder, own file) with [`outputs`]: the -/// name, with `-2`, `-3`… after it when an earlier source used it or when one of the files or the -/// folder it writes has the name of a file or folder an earlier source wrote (`a.svg` writes the -/// file `a.svg`, so the Export for Screens folder of `a.svg.svg` is `a.svg-2`) or a name in -/// `reserved` other than its own file's. Names are compared without case. +/// Picks the stem of each folder source in turn: its name, with `-2`, `-3`… after it when an +/// earlier source used it or when one of the files or the folder it writes has the name of a file +/// or folder an earlier source wrote (`a.svg` writes the file `a.svg`, so the Export for Screens +/// folder of `a.svg.svg` is `a.svg-2`) or a name in `written` from the start (the source files and +/// subfolders in the destination folder) other than its own file's. Names are compared without case. #[cfg(not(target_arch = "wasm32"))] -fn distinct_stems( - sources: impl IntoIterator, bool, Option)>, - reserved: std::collections::HashSet, -) -> Vec { - let mut stems = std::collections::HashSet::new(); - let mut written = reserved; - sources - .into_iter() - .map(|(base, exts, folder, own)| { - let mut name = base.clone(); - let mut n = 1usize; - loop { - let lower = name.to_lowercase(); - let names: Vec = exts.iter().map(|e| format!("{lower}.{}", e.to_lowercase())).chain(folder.then(|| lower.clone())).collect(); - if !stems.contains(&lower) && names.iter().all(|f| !written.contains(f) || own.as_ref() == Some(f)) { - stems.insert(lower); - written.extend(names); - return name; - } - n += 1; - name = format!("{base}-{n}"); +struct StemPicker { + stems: std::collections::HashSet, + written: std::collections::HashSet, +} + +#[cfg(not(target_arch = "wasm32"))] +impl StemPicker { + /// `names` gives the names in lower case of what a stem writes; `own` is the source's own name. + fn pick(&mut self, base: &str, names: impl Fn(&str) -> Vec, own: Option<&str>) -> String { + let mut name = base.to_string(); + let mut n = 1usize; + loop { + let lower = name.to_lowercase(); + let outputs = names(&name); + if !self.stems.contains(&lower) && outputs.iter().all(|f| !self.written.contains(f) || own == Some(f.as_str())) { + self.stems.insert(lower); + self.written.extend(outputs); + return name; } - }) - .collect() + n += 1; + name = format!("{base}-{n}"); + } + } } /// Drop Open (and optionally Close) steps when the batch supplies the file itself. @@ -281,12 +262,19 @@ fn filter_steps(cmds: &[Value], drop_open: bool, drop_close: bool) -> Vec } #[cfg(not(target_arch = "wasm32"))] -fn process_one_file(s: &mut Session, run: &Run<'_>, path: &str, stem: &str, ext: &str) -> Result { +fn process_one_file( + s: &mut Session, + run: &Run<'_>, + path: &str, + ext: &str, + stem_for: impl FnOnce(&vectorcraft_doc::Document) -> String, +) -> Result { s.execute("document.open", &json!({"path": path}))?; - let rewritten = rewrite_paths(run.steps, run.override_save, run.override_export, run.dest_folder, stem, ext); + let stem = stem_for(&s.doc()?.doc); + let rewritten = rewrite_paths(run.steps, run.override_save, run.override_export, run.dest_folder, &stem, ext); let batch = s.execute("command.batch", &json!({"label": run.label, "commands": rewritten}))?; // Folder sources open a file per pass: close after Save and Close / Folder. - finish_destination(s, run, stem, ext, path, true)?; + finish_destination(s, run, &stem, ext, path, true)?; Ok(json!({"path": path, "result": batch})) } @@ -356,7 +344,7 @@ fn dataset_stems(names: &[String], doc_stem: &str, naming: &str, run: &Run<'_>, _ => safe_file_name(&format!("{doc_stem}_{safe}")), } }); - let outputs = dataset_output_extensions(run, doc); + let outputs = dataset_output_extensions(run, doc, "vectorcraft"); let mut taken = std::collections::HashSet::new(); let mut reserved = std::collections::HashSet::new(); let mut stems = Vec::with_capacity(bases.len()); @@ -395,12 +383,14 @@ struct DatasetOutput { /// Lists step output extensions and reserves artboard file names for saves with any artboard or /// exports with multiple artboards. Per-artboard names count for vectorcraft, template, AI and AIT /// saves and SVG, SVGZ, EPS, raster, DXF, EMF, WMF and AI exports. An Export for Screens or Export -/// Assets step reserves the folder `{stem}` ([`rewrite_paths`]). +/// Assets step reserves the folder `{stem}` ([`rewrite_paths`]). A step without a format and the +/// Folder destination write `default_ext` (`vectorcraft` when it is empty). #[cfg(not(target_arch = "wasm32"))] -fn dataset_output_extensions(run: &Run<'_>, doc: &vectorcraft_doc::Document) -> Vec { +fn dataset_output_extensions(run: &Run<'_>, doc: &vectorcraft_doc::Document, default_ext: &str) -> Vec { + let default_ext = if default_ext.is_empty() { "vectorcraft" } else { default_ext }; let mut outputs = Vec::new(); if run.destination == "folder" { - outputs.push(DatasetOutput { ext: "vectorcraft".into(), artboards: false, joiner: "-", folder: false }); + outputs.push(DatasetOutput { ext: default_ext.into(), artboards: false, joiner: "-", folder: false }); } for step in run.steps { let id = step.get("command").and_then(Value::as_str).unwrap_or(""); @@ -415,10 +405,10 @@ fn dataset_output_extensions(run: &Run<'_>, doc: &vectorcraft_doc::Document) -> let ext = match format { Some(format) if super::format(format).is_some() => format_extension(format), Some(_) => continue, - None => "vectorcraft".to_string(), + None => default_ext.to_string(), }; if safe_extension(&ext) { - let format_id = format.and_then(super::format).map(|f| f.id).unwrap_or("vectorcraft"); + let format_id = super::format(format.unwrap_or(default_ext)).map(|f| f.id).unwrap_or("vectorcraft"); let is_save = matches!(id, "document.save" | "file.save" | "file.saveAs"); let artboards = if is_save { !doc.artboards.is_empty() && matches!(format_id, "vectorcraft" | "template" | "ai" | "ait") @@ -1045,6 +1035,37 @@ mod tests { assert!(export.contains("#ff0000"), "{r}: {export}"); } + /// With the destination folder the same as the source folder, a save's artboard files do not + /// replace another source file: `a.vectorcraft` would also write `a-Artboard-1.vectorcraft`, so + /// it is saved as `a-2`. + #[test] + fn batch_does_not_save_artboard_files_over_another_source_file() { + let src = tmp("in-place-artboards"); + let mut s = Session::new(); + for (name, width) in [("a.vectorcraft", 20), ("a-Artboard-1.vectorcraft", 30)] { + s.execute("file.new", &json!({"width": width, "height": 20, "artboards": 1})).unwrap(); + s.execute("document.save", &json!({"path": src.join(name).to_string_lossy()})).unwrap(); + } + let r = s + .execute( + "action.batch", + &json!({ + "folder": src.to_string_lossy(), + "destinationFolder": src.to_string_lossy(), + "commands": [{"command": "document.save", "params": {"separateArtboards": true}}] + }), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!( + file_names(&src), + ["a-2-Artboard-1.vectorcraft", "a-2.vectorcraft", "a-Artboard-1-Artboard-1.vectorcraft", "a-Artboard-1.vectorcraft", "a.vectorcraft"], + "{r}" + ); + s.execute("document.open", &json!({"path": src.join("a-Artboard-1.vectorcraft").to_string_lossy()})).unwrap(); + assert_eq!(s.doc().unwrap().doc.artboards[0].rect.width(), 30.0, "{r}"); + } + /// Data sets whose names differ only by a trailing period (`a` and `a.`) get Export Assets /// folders of their own: the second one's folder is numbered. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 3e8bcf2c1..e57870b8e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1673,7 +1673,9 @@ already walks Image Trace. Short agent entry points: destination, `a.svg` is saved as `a.svg`, so the Export for Screens folder of `a.svg.svg` is `a.svg-2`. When `destinationFolder` holds source files, only a file's own output takes its name, and no export folder takes the name of a subfolder that holds source files: with `a..svg` and `a.svg` saved in place, `a..svg` is saved as - `a-2.svg` and `a.svg` over itself. + `a-2.svg` and `a.svg` over itself. A file's artboard files count as its files, as for data sets: with + `a.vectorcraft` and `a-Artboard-1.vectorcraft` saved in place with `separateArtboards`, `a.vectorcraft` is saved + as `a-2`. - Action sets: `ui.actionSet.delete {sets?: [index], ids?: [id]}` deletes the Actions panel's sets at these indexes (0 is the top set) with their actions, by default the current set, and returns `{deleted}`. An index past the last set is an error, and then no set is deleted. `ids` holds the panel's own set ids, which its question passes: the From 37be6768819ec05b703ec5d7c2fad028f4b5af94 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 17:02:04 -0800 Subject: [PATCH 049/140] Spiral: apply decay once per full wind --- crates/engine/src/tests/draw2.rs | 12 ++++----- crates/geom/src/shapes.rs | 26 ++++++++++++------ crates/tools/src/draw2/family.rs | 46 ++++++++++++++++---------------- docs/mcp.md | 3 ++- docs/target-app-parity.md | 7 +++-- 5 files changed, 54 insertions(+), 40 deletions(-) diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index 30b1c4a3f..ab039b9c3 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -1391,17 +1391,17 @@ fn spiral_drags_with_alt_and_ctrl_change_its_segments_and_decay() { }; s.select_tool("spiral", v).unwrap(); let undo = undo_steps(&s); - // Alt from a radius of 100 to 200: 10 segments become 14, one full wind of four quarter turns. + // Alt from a radius of 100 to 200: preserving the inner end adds three full winds. let (anchors, outer) = spiral(&mut s, Mods { alt: true, ..Mods::default() }, (300.0, 100.0)); - assert_eq!(anchors, 15); + assert_eq!(anchors, 23); assert!(near(outer, Point::new(300.0, 100.0)), "{outer:?}"); - assert_eq!(s.tool_options()["segments"], json!(14)); - // Ctrl/Cmd from 100 to 50: the decay rises from 80 % to 80 × 2^(1/14) %. + assert_eq!(s.tool_options()["segments"], json!(22)); + // Ctrl/Cmd from 100 to 50: the decay rises from 80 % to 80 × 2^(4/22) %. let (anchors, outer) = spiral(&mut s, Mods { cmd: true, ..Mods::default() }, (150.0, 100.0)); - assert_eq!(anchors, 15); + assert_eq!(anchors, 23); assert!(near(outer, Point::new(150.0, 100.0)), "{outer:?}"); let decay = s.tool_options()["decay"].as_f64().unwrap(); - assert!((decay - 80.0 * 2f64.powf(1.0 / 14.0)).abs() < 1e-9, "{decay}"); + assert!((decay - 80.0 * 2f64.powf(4.0 / 22.0)).abs() < 1e-9, "{decay}"); assert_eq!(undo_steps(&s) - undo, 2, "one undo step a spiral"); } diff --git a/crates/geom/src/shapes.rs b/crates/geom/src/shapes.rs index 2747fdf45..8ba46c780 100644 --- a/crates/geom/src/shapes.rs +++ b/crates/geom/src/shapes.rs @@ -219,17 +219,18 @@ pub fn arc(a: Point, b: Point, slope: f64, closed: bool, _base_along_x: bool) -> PathData::single(SubPath::new(anchors, closed)) } -/// Logarithmic spiral approximating the reference app's Spiral tool (decay-based, `segments` quarter turns). +/// Logarithmic spiral with decay per full wind (`segments` quarter turns). pub fn spiral(center: Point, radius: f64, decay_percent: f64, segments: u32, clockwise: bool) -> PathData { let decay = (decay_percent / 100.0).clamp(0.05, 0.9999); + let step_decay = decay.powf(0.25); let dir = if clockwise { 1.0 } else { -1.0 }; - // The radius shrinks by `decay` each quarter turn: r(φ) = radius · e^(kφ) after turning φ. - let k = decay.ln() / (PI / 2.0); + // The radius shrinks by `decay` each full wind: r(φ) = radius · e^(kφ). + let k = decay.ln() / TAU; // Handle lengths per unit of the derivative below, at a segment's outer and inner end: √r // changes evenly with the curve's parameter along each quarter turn. Both are KAPPA when the // radius does not shrink, and the curve stays within 0.8% of the spiral's radius at 20% decay. - let outer = 2.0 * KAPPA * (decay.sqrt() - 1.0) / decay.ln(); - let inner = outer / decay.sqrt(); + let outer = 2.0 * KAPPA * (step_decay.sqrt() - 1.0) / step_decay.ln(); + let inner = outer / step_decay.sqrt(); let mut anchors = Vec::new(); let mut r = radius; for i in 0..=segments { @@ -241,7 +242,7 @@ pub fn spiral(center: Point, radius: f64, decay_percent: f64, segments: u32, clo let h_in = p - d * inner; let h_out = p + d * outer; anchors.push(Anchor { p, h_in: if i == 0 { p } else { h_in }, h_out: if i == segments { p } else { h_out }, kind: AnchorKind::Smooth }); - r *= decay; + r *= step_decay; } // Illustrator spirals wind inward from the outer point; reverse so the path starts at the centre end. let mut sp = SubPath::new(anchors, false); @@ -475,8 +476,17 @@ mod tests { assert!(!p.subpaths[0].closed); } + #[test] + fn spiral_decay_is_per_full_wind() { + let p = spiral(Point::ZERO, 100.0, 80.0, 8, true); + let radii: Vec<_> = p.subpaths[0].anchors.iter().map(|a| (a.p - Point::ZERO).hypot()).collect(); + assert!((radii[8] - 100.0).abs() < 1e-9); + assert!((radii[4] - 80.0).abs() < 1e-9); + assert!((radii[0] - 64.0).abs() < 1e-9); + } + /// #3437: every segment stays on the spiral through its anchors, whose radius shrinks by - /// `decay` each quarter turn, for strong and weak decay and both directions. + /// `decay` each full wind, for strong and weak decay and both directions. #[test] fn spiral_segments_follow_the_spiral() { let center = Point::new(40.0, -25.0); @@ -484,7 +494,7 @@ mod tests { for clockwise in [true, false] { let p = spiral(center, 100.0, decay, 8, clockwise); let anchors = &p.subpaths[0].anchors; - let k = (decay / 100.0_f64).ln() / (PI / 2.0); + let k = (decay / 100.0_f64).ln() / TAU; let mut worst = 0.0_f64; for pair in anchors.windows(2) { let c = kurbo::CubicBez::new(pair[0].p, pair[0].h_out, pair[1].h_in, pair[1].p); diff --git a/crates/tools/src/draw2/family.rs b/crates/tools/src/draw2/family.rs index 0bbdcf79b..6d1082971 100644 --- a/crates/tools/src/draw2/family.rs +++ b/crates/tools/src/draw2/family.rs @@ -127,7 +127,7 @@ impl FamilyTool { /// with the tool's decay and segments (a logarithm, so that a low decay with many segments does /// not round to 0). The decay ratio is clamped to 0.05…0.9999 as `shapes::spiral` clamps it. fn spiral_inner_end_ln(&self, r: f64) -> f64 { - r.ln() + f64::from(self.segments) * (self.decay / 100.0).clamp(0.05, 0.9999).ln() + r.ln() + f64::from(self.segments) / 4.0 * (self.decay / 100.0).clamp(0.05, 0.9999).ln() } /// Alt or Ctrl/Cmd in a spiral drag from `start` to `p`: the inner end keeps the distance from @@ -150,12 +150,12 @@ impl FamilyTool { } let Some(end) = self.spiral_end.filter(|e| e.is_finite()) else { return }; if m.cmd { - let ratio = ((end - r.ln()) / f64::from(self.segments)).exp(); + let ratio = ((end - r.ln()) * 4.0 / f64::from(self.segments)).exp(); if ratio.is_finite() { self.decay = (ratio * 100.0).clamp(5.0, 99.99); } } else { - let fitted = (r.ln() - end) / -(self.decay / 100.0).clamp(0.05, 0.9999).ln(); + let fitted = (r.ln() - end) * 4.0 / -(self.decay / 100.0).clamp(0.05, 0.9999).ln(); if fitted.is_finite() { let base = f64::from(self.spiral_alt_base.unwrap_or(self.segments)); self.segments = (base + 4.0 * ((fitted - base) / 4.0).round()).clamp(2.0, 1000.0) as u32; @@ -631,44 +631,44 @@ mod tests { assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0))), (100.0, 80.0, 10)); // Alt rounds the fitted count to whole winds from the count when Alt goes down. assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(alt))), (100.0, 80.0, 10)); - assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt))), (200.0, 80.0, 14)); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt))), (200.0, 80.0, 22)); // ↑ with Alt held adds one, and the held inner end moves with it. - assert_eq!(spiral(t.key(&cx, ToolKey::Up, alt)).2, 15); - assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt))).2, 15); - assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0).with_mods(alt))), (50.0, 80.0, 7)); + assert_eq!(spiral(t.key(&cx, ToolKey::Up, alt)).2, 23); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt))).2, 23); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0).with_mods(alt))), (50.0, 80.0, 2)); assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 101.0, 100.0).with_mods(alt))).2, 2, "at least two"); - assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0).with_mods(alt))).2, 7); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0).with_mods(alt))).2, 2); // The count stays as the radius follows the pointer again. - assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0))), (100.0, 80.0, 7)); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0))), (100.0, 80.0, 2)); // Ctrl/Cmd fits the decay to the held inner end. t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(ctrl)); let (r, decay, n) = spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0).with_mods(ctrl))); - assert!((r, n) == (50.0, 7) && (decay - 80.0 * 2f64.powf(1.0 / 7.0)).abs() < 1e-9, "{r} {decay} {n}"); + assert_eq!((r, decay, n), (50.0, 99.99, 2), "{r} {decay} {n}"); let (_, decay, _) = spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 400.0, 100.0).with_mods(ctrl))); - assert!((decay - 80.0 * (1.0f64 / 3.0).powf(1.0 / 7.0)).abs() < 1e-9, "{decay}"); + assert!((decay - 80.0 / 9.0).abs() < 1e-9, "{decay}"); assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.5, 100.0).with_mods(ctrl))).1, 99.99, "at most 99.99 %"); assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 1e20, 100.0).with_mods(ctrl))).1, 5.0, "at least 5 %"); let (_, decay, _) = spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(ctrl))); - assert!((decay - 80.0 * 0.5f64.powf(1.0 / 7.0)).abs() < 1e-9, "{decay}"); + assert!((decay - 20.0).abs() < 1e-9, "{decay}"); // Let go: the decay stays, and the spiral drawn next keeps both. - assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 100.0))), (150.0, decay, 7)); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 100.0))), (150.0, decay, 2)); assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 100.0)), vec![Action::Commit]); - assert_eq!((t.options()["decay"].as_f64(), t.options()["segments"].clone()), (Some(decay), json!(7))); + assert_eq!((t.options()["decay"].as_f64(), t.options()["segments"].clone()), (Some(decay), json!(2))); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); let (_, d2, n2) = spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.0, 140.0).with_mods(alt))); - assert_eq!((d2, n2), (decay, 7), "a new drag holds the end anew"); - // 5 % and 300 segments: an inner radius of 100 × 0.05^300 is below the smallest f64. + assert_eq!((d2, n2), (decay, 2), "a new drag holds the end anew"); + // Low decay over many winds still fits using logarithms. let mut t = FamilyTool::new("spiral"); t.set_option("decay", &json!(5.0)); t.set_option("segments", &json!(300)); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(ctrl)); let (_, decay, _) = spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0).with_mods(ctrl))); - assert!((decay - 5.0 * 2f64.powf(1.0 / 300.0)).abs() < 1e-9, "{decay}"); + assert!((decay - 5.0 * 2f64.powf(4.0 / 300.0)).abs() < 1e-9, "{decay}"); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0)); // Alt: a large radius increase adds a whole wind. t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0).with_mods(alt)); - assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200100.0, 100.0).with_mods(alt))).2, 304); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200100.0, 100.0).with_mods(alt))).2, 312); } /// Switching between Alt and Ctrl/Cmd keeps the held inner end and uses the current segment @@ -687,21 +687,21 @@ mod tests { t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(alt)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt)); - assert_eq!(t.segments, 14); + assert_eq!(t.segments, 22); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(both)); - assert_eq!(t.segments, 14, "Ctrl/Cmd takes precedence"); + assert_eq!(t.segments, 22, "Ctrl/Cmd takes precedence"); let decay = t.decay; assert!(decay > 80.0); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(ctrl)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt)); - assert_eq!(t.segments, 18, "Alt resumes from the current count with the held end"); + assert_eq!(t.segments, 50, "Alt resumes from the current count with the held end"); assert_eq!(t.decay, decay); t.key(&cx, ToolKey::Escape, alt); assert_eq!(t.spiral_end, None); assert_eq!(t.spiral_alt_base, None); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(alt)); - assert_eq!(t.segments, 18, "a new drag holds a new inner end"); + assert_eq!(t.segments, 50, "a new drag holds a new inner end"); } /// Alt adjusts the segment count in whole winds while keeping it within 2 to 1000. @@ -712,7 +712,7 @@ mod tests { let start = Point::new(0.0, 0.0); t.hold_spiral_end(start, Point::new(100.0, 0.0), alt); t.hold_spiral_end(start, Point::new(200.0, 0.0), alt); - assert_eq!(t.segments, 14); + assert_eq!(t.segments, 22); t.hold_spiral_end(start, Point::new(0.01, 0.0), alt); assert_eq!(t.segments, 2); t.hold_spiral_end(start, Point::new(1e150, 0.0), alt); diff --git a/docs/mcp.md b/docs/mcp.md index 0ca4e3235..f8474e09d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -492,7 +492,8 @@ While a spiral is being dragged out with the Spiral tool, a `drag` event with `m spiral's inner end at the distance from the center it had on the first modified `drag` event, and the outer end follows the pointer. With `mods.alt` alone, dragging outward adds whole winds of four segments and dragging inward removes them. The fitted segment count rounds to the nearest whole wind from the count on the first Alt-only `drag`, within 2…1000. -With `mods.cmd`, decay changes to fit within 5…99.99 %; it takes precedence when both modifiers are held. Switching +Decay is the percentage retained after each full wind of four segments. With `mods.cmd`, decay changes to fit within +5…99.99 %; it takes precedence when both modifiers are held. Switching back to Alt uses the current count as its new base while keeping the held inner end. The spiral drawn on release uses the new values, and they stay as the Spiral tool's `segments` and `decay` options for the next spiral: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4370a3a0..1481596d5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,9 +1,11 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (new gradients follow document color mode; 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) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (spiral decay applies per full wind; 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) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence +Spiral decay now applies after each full wind of four segments. A newly drawn spiral at 80% decay keeps 80% of its radius after one wind. Existing saved paths retain their geometry; newly drawn paths with the same settings differ from earlier VectorCraft builds. Scores and estimates are unchanged. + Creating a gradient from solid or empty paint uses the document's color model for its default stops, including drawing its vector with the Gradient tool. Explicit `keepModel: true` retains RGB defaults, and existing gradients keep their own colors. Scores and estimates are unchanged. **Feature evidence last updated:** 2026-11-03. @@ -21,6 +23,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Spiral decay applies once per full wind of four segments; newly drawn spirals with the same settings wind more broadly than earlier builds. Existing paths are unchanged. Scores and estimates unchanged. | | 2026-11-03 | minor | New gradients use CMYK default stops in CMYK documents, including Gradient tool creation. Scores and estimates unchanged. | | 2026-11-03 | minor | Type Tools add-on: Text Tweaker (a VectorCraft addition, #4924): evidence added; scores unchanged. | | 2026-11-03 | minor | Transform previews valid typed values while numeric fields remain focused. Effect dialogs use the object, group, or layer targeted in Appearance. | @@ -739,7 +742,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. The Flare tool's center drag turns the rays with the pointer, Shift keeps them in their original orientation, and Space held during either of its drags moves the flare. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). While a grid is dragged out, F lowers `rowSkew` (rectangular) or `radialSkew` (polar) by 10 percentage points and V raises it by 10; X lowers `columnSkew` (rectangular) or `concentricSkew` (polar) by 10 and C raises it by 10. Plain and Shift presses work; with Alt, Ctrl or Cmd held they do not, as for the other letters a tool takes while dragging. Each press changes the skew once and updates the preview; values stay within ±500% and remain in the tool options after release. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list the four drag keys and a 10% change. The signs (positive values follow VectorCraft's existing skew convention, so F and X lower the stored value) and one change per press with key repeat ignored are our own rules. While an arc is dragged out, C switches it between open and closed, F switches its base between the X and Y axes in the same box, and Up/Down raise and lower its slope by 10 within −100…100; each press switches once, and the values stay as the Arc tool's options. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list C, F and Up/Down for an arc being dragged out. F as the base switch, the arrows changing the slope, the step of 10 and one switch per press are our own rules. - While a spiral is dragged out, Alt adds or removes whole winds of four segments and Ctrl/Cmd changes the decay. The spiral's inner end keeps the distance from the center it had on the first modified drag event, while the outer end follows the pointer. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list Alt-drag to add or subtract winds and Ctrl-drag to change the decay rate; [Draw spirals](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-spirals.html) defines four segments per full wind. Rounding the fitted count to whole winds from the count on the first Alt-only drag event, the held inner radius, the ranges (2…1000 segments, 5…99.99 % decay), Ctrl/Cmd taking precedence when both are held, and the new values staying as Spiral tool options are our own rules. Switching back to Alt uses the current count as its new base while keeping the held inner end. + While a spiral is dragged out, Alt adds or removes whole winds of four segments and Ctrl/Cmd changes the decay. The spiral's inner end keeps the distance from the center it had on the first modified drag event, while the outer end follows the pointer. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list Alt-drag to add or subtract winds and Ctrl-drag to change the decay rate; [Draw spirals](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-spirals.html) defines four segments per full wind and Decay as the amount each wind decreases. An 80% spiral has 80% of its outer radius after four segments; newly drawn spirals with the same settings wind more broadly than in earlier VectorCraft builds. Rounding the fitted count to whole winds from the count on the first Alt-only drag event, the held inner radius, the ranges (2…1000 segments, 5…99.99 % decay), Ctrl/Cmd taking precedence when both are held, and the new values staying as Spiral tool options are our own rules. Switching back to Alt uses the current count as its new base while keeping the held inner end. - **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes). While Alt is held, the Scissors tool works as the Add Anchor Point tool ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)): a click on a segment adds an anchor point without cutting (`path.insertAnchor`), and the pointer shows the Add Anchor Point cursor. When Alt is let go, a click cuts again. Alt-drag with the Knife cuts in a straight line, and Shift+Alt-drag cuts at 45° or 90°, as [Cut objects using the Knife and Scissors tools](https://web.archive.org/web/20260926095638/https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/cut-objects.html) and [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) describe. Alt counts only when held at the press; the dashed line runs from the press point to the pointer, stays straight after Alt is let go, and release sends `path.knife` with those two points. Alt pressed after the press leaves the cut freehand. Shift+Alt keeps the endpoint at 45° steps from the horizontal and vertical, whatever the Constrain Angle. From 72af76d418a43cbc3cb111297626fe8ff4e0f56b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 21:35:41 -0800 Subject: [PATCH 050/140] Artboards: a Cut or Copy with the Artboard tool replays on its artboards edit.copy and edit.cut chose the artboard route by the tool chosen, which the journal does not record. A replay in a new session acted on the objects selected there, or stopped with "nothing selected" when there were none. With the Artboard tool chosen, both now record selectedArtboards and art in the journal entry. A given selectedArtboards runs the artboard route with any tool and needs only a document. A recorded action leaves both values out, so it follows the tool chosen when it plays. Cut and Copy of objects record nothing new. --- crates/engine/src/cmd/edit.rs | 32 +++++-- crates/engine/src/cmd/panelcmds.rs | 5 +- crates/engine/src/command_sets.rs | 11 ++- crates/engine/src/session/execute.rs | 8 +- crates/engine/src/tests/journal.rs | 121 +++++++++++++++++++++++++-- docs/mcp.md | 7 ++ docs/target-app-parity.md | 1 + 7 files changed, 165 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index f4b2d21d2..cf5a31adf 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -139,7 +139,7 @@ pub fn specs() -> Vec { "Cut", ["Edit"], Some("Cmd+X"), - "{} (selected ruler guides go too; the Artboard tool chosen: artboard.cut)", + "{selectedArtboards?: [n…], art?: bool} (as edit.copy) (selected ruler guides go too; the Artboard tool chosen, or selectedArtboards given: artboard.cut)", has_selection_guides_or_artboard_tool, cut ), @@ -148,7 +148,7 @@ pub fn specs() -> Vec { "Copy", ["Edit"], Some("Cmd+C"), - "{} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy → {copied, guides}", + "{selectedArtboards?: [n…] (the artboards the Artboard tool had selected, which the journal records for a replay; it runs artboard.copy with any tool chosen), art?: bool (default: the Artboard tool's moveArt option; the journal records it beside selectedArtboards)} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy → {copied, guides}", has_selection_guides_or_artboard_tool, copy ), @@ -701,10 +701,27 @@ fn copy_cut_targets(s: &Session) -> Result<(bool, Vec)> { Ok((direct, roots)) } -fn copy(s: &mut Session, _: &Value) -> Result { +/// Copy or Cut takes artboards: the Artboard tool is chosen, or the journal recorded the artboards +/// it took then. +fn takes_artboards(s: &Session, p: &Value) -> bool { + s.tool_id() == "artboard" || p.get("selectedArtboards").is_some() +} + +/// Copy or Cut of artboards: `selectedArtboards` and `art` of `p`, or their defaults from the +/// Artboard tool, which the journal entry records. +fn copy_artboards(s: &mut Session, p: &Value, cut: bool) -> Result { + let given: serde_json::Map = + ["selectedArtboards", "art"].into_iter().filter_map(|key| p.get(key).map(|v| (key.to_string(), v.clone()))).collect(); + let given = Value::Object(given); + super::panelcmds::boards_and_art_noted(s, &given, if cut { "edit.cut" } else { "edit.copy" })?; + super::panelcmds::artboard_copy(s, &given, cut) +} + +fn copy(s: &mut Session, p: &Value) -> Result { + switch_or(p, "art", false, "edit.copy")?; // The Artboard tool copies its artboard (and the art on it). - if s.tool_id() == "artboard" { - return super::panelcmds::artboard_copy(s, &json!({}), false); + if takes_artboards(s, p) { + return copy_artboards(s, p, false); } let (direct, targets) = copy_cut_targets(s)?; let st = s.doc()?; @@ -743,8 +760,9 @@ fn copy(s: &mut Session, _: &Value) -> Result { } fn cut(s: &mut Session, p: &Value) -> Result { - if s.tool_id() == "artboard" { - return super::panelcmds::artboard_copy(s, &json!({}), true); + switch_or(p, "art", false, "edit.cut")?; + if takes_artboards(s, p) { + return copy_artboards(s, p, true); } // Ruler guides selected on their own: Copy takes them, Clear removes them. if s.doc()?.selection.is_empty() { diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 2e3ae4301..f0f978931 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -111,9 +111,8 @@ fn boards_and_art(s: &Session, p: &Value, cmd: &str) -> Result<(Vec, bool } /// [`boards_and_art`], with both values added to the running command's journal entry, so a replay -/// acts on the same artboards and art whatever the Artboard tool's state is then. `edit.cut` and -/// `edit.copy` call [`artboard_copy`] without this and take no such params. -fn boards_and_art_noted(s: &mut Session, p: &Value, cmd: &str) -> Result<(Vec, bool)> { +/// acts on the same artboards and art whatever the Artboard tool's state is then. +pub(crate) fn boards_and_art_noted(s: &mut Session, p: &Value, cmd: &str) -> Result<(Vec, bool)> { let (indices, art) = boards_and_art(s, p, cmd)?; note_artboards(s, p, &indices); s.note_journal("art", json!(art)); diff --git a/crates/engine/src/command_sets.rs b/crates/engine/src/command_sets.rs index 4752b0a96..8e83da93c 100644 --- a/crates/engine/src/command_sets.rs +++ b/crates/engine/src/command_sets.rs @@ -25,9 +25,10 @@ pub const FIT_ACTIVE_ARTBOARD: [&str; 2] = ["artboard.fitToArt", "artboard.fitTo /// `(command, param)` pairs the journal records only so a replay reproduces the original run /// (dates from the clock: `cmd::clock_date`; the artboards the app passes to the /// [`ON_ACTIVE_ARTBOARD`] and [`FIT_ACTIVE_ARTBOARD`] commands and to a batch; the artboards the -/// Artboard tool had selected for its commands; the view's center the app passes to Edit Symbol); -/// an action leaves them out ([`Session::journal_for_action`]). -const REPLAY_ONLY: [(&str, &str); 21] = [ +/// Artboard tool had selected for its commands; the artboards and art Cut and Copy took with that +/// tool chosen; the view's center the app passes to Edit Symbol); an action leaves them out +/// ([`Session::journal_for_action`]). +const REPLAY_ONLY: [(&str, &str); 25] = [ ("file.new", "created"), ("document.save", "modified"), ("file.saveAs", "modified"), @@ -45,6 +46,10 @@ const REPLAY_ONLY: [(&str, &str); 21] = [ ("artboard.cut", "selectedArtboards"), ("artboard.rename", "selectedArtboards"), ("artboard.switchOrientation", "selectedArtboards"), + ("edit.copy", "selectedArtboards"), + ("edit.copy", "art"), + ("edit.cut", "selectedArtboards"), + ("edit.cut", "art"), ("select.allOnArtboard", "artboard"), ("symbol.edit", "artboard"), ("symbol.edit", "viewCenter"), diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index f993ac585..07959cf0e 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -54,9 +54,13 @@ impl Session { self.check_given_ids(id, params)?; } // Clear's menu needs a selection; an agent supplying explicit targets only needs a - // document. The command validates those targets before making any edit. + // document. The command validates those targets before making any edit. Cut and Copy given + // the artboards the journal recorded need only a document too, whatever is selected and + // whichever tool is chosen. let given = given_ids(spec.params, params); - let enabled = if id == "edit.clear" && params.get("ids").is_some() { + let enabled = if (id == "edit.clear" && params.get("ids").is_some()) + || (matches!(id, "edit.copy" | "edit.cut") && params.get("selectedArtboards").is_some()) + { cmd::has_doc(self) } else if let Some(ids) = given.clone() { // Objects given as `ids` (or `id`) stand in for the selection: the command is enabled diff --git a/crates/engine/src/tests/journal.rs b/crates/engine/src/tests/journal.rs index 26fcc0835..016270c88 100644 --- a/crates/engine/src/tests/journal.rs +++ b/crates/engine/src/tests/journal.rs @@ -328,23 +328,134 @@ fn artboard_commands_read_the_recorded_selected_artboards() { } } -/// `edit.copy` and `edit.cut` run the Artboard tool's copy and take no artboards, so their entries -/// stay as given. +/// A Cut or Copy with the Artboard tool replays on the artboards it took in a new session, where +/// another tool is chosen: with nothing selected there, and with an object selected that a Cut or +/// Copy of objects would take. What it pastes is the same. #[test] -fn edit_copy_and_cut_with_the_artboard_tool_record_their_params_as_given() { +fn edit_copy_and_cut_with_the_artboard_tool_replay_on_its_artboards() { + for id in ["edit.copy", "edit.cut"] { + for select in ["select.all", "select.none"] { + let mut s = Session::new(); + second_artboard_selected(&mut s); + s.execute(select, &json!({})).unwrap(); + s.execute(id, &json!({})).unwrap(); + let boards = if id == "edit.cut" { 2 } else { 3 }; + assert_eq!(s.doc().unwrap().doc.artboards.len(), boards, "{id} after {select}"); + assert_eq!(documents(&replayed(&s)), documents(&s), "{id} after {select}"); + let entry = s.journal.last().unwrap().clone(); + s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.artboards.len(), boards + 1, "{id} after {select}"); + let r = replayed(&s); + assert_ne!(r.tool_id(), "artboard"); + assert_eq!(documents(&r), documents(&s), "{id} after {select}"); + assert_eq!(entry, (id.to_string(), json!({"selectedArtboards": [1], "art": true})), "{id} after {select}"); + } + } +} + +/// A Copy after a Paste takes the pasted artboards, which the Artboard tool selected, and so does its +/// replay, though `edit.paste` selects nothing in a new session's tool. +#[test] +fn edit_copy_after_a_paste_with_the_artboard_tool_replays_on_the_pasted_artboards() { + let mut s = Session::new(); + second_artboard_selected(&mut s); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + assert_eq!(s.journal.last().unwrap().1, json!({"selectedArtboards": [3], "art": true})); + s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.artboards.len(), 5); + assert_eq!(documents(&replayed(&s)), documents(&s)); +} + +/// A recorded action keeps `edit.copy` and `edit.cut` as given, so when it plays they follow the tool +/// chosen then, and a batch step records and leaves out the same values. +#[test] +fn edit_copy_and_cut_with_the_artboard_tool_record_nothing_for_an_action() { for id in ["edit.copy", "edit.cut"] { let mut s = Session::new(); - second_artboard_selected(&mut s); + let start = second_artboard_selected(&mut s); + s.execute(id, &json!({})).unwrap(); + let action = s.journal_for_action(start); + assert_eq!(action, [(id.to_string(), json!({}))], "{id}"); + let mut played = Session::new(); + played.execute("file.new", &json!({"width": 400, "height": 200, "created": null})).unwrap(); + played.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap(); + played.execute(id, &action[0].1).unwrap(); + assert_eq!(played.doc().unwrap().doc.artboards.len(), 1, "{id}"); + assert!(played.clipboard.artboards.is_empty(), "{id}"); + assert_eq!(played.clipboard.nodes.len(), 1, "{id}"); + + let mut s = Session::new(); + let start = second_artboard_selected(&mut s); + let steps = json!([{"command": id, "params": {}}, {"command": "edit.paste", "params": {}}]); + s.execute("command.batch", &json!({ "commands": steps })).unwrap(); + assert_eq!(s.journal.last().unwrap().1["commands"][0]["params"], json!({"selectedArtboards": [1], "art": true}), "{id}"); + assert_eq!(documents(&replayed(&s)), documents(&s), "{id}"); + assert_eq!(s.journal_for_action(start)[0].1["commands"][0]["params"], json!({}), "{id}"); + } +} + +/// Without the Artboard tool, Cut and Copy of objects record nothing more than they did. +#[test] +fn edit_copy_and_cut_of_objects_record_their_params_as_given() { + for id in ["edit.copy", "edit.cut"] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 200, "created": null})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap(); s.execute(id, &json!({})).unwrap(); assert_eq!(s.journal.last().unwrap(), &(id.to_string(), json!({})), "{id}"); + assert!(s.clipboard.artboards.is_empty(), "{id}"); + assert_eq!(documents(&replayed(&s)), documents(&s), "{id}"); } } +/// A recorded `selectedArtboards` needs only a document and is checked as `indices` is, as are `art` +/// and the rule that a cut leaves one artboard. A failed call changes neither the document nor the +/// clipboard. +#[test] +fn edit_copy_and_cut_read_the_recorded_artboards() { + for id in ["edit.copy", "edit.cut"] { + assert!(matches!(Session::new().execute(id, &json!({"selectedArtboards": [0]})), Err(crate::EngineError::Disabled(..)))); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 200, "created": null})).unwrap(); + s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 300, "height": 100})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 1010, "y": 10, "width": 100, "height": 50})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + assert!(matches!(s.execute(id, &json!({})), Err(crate::EngineError::Disabled(..))), "{id}"); + let before = documents(&s); + for bad in [ + json!({"selectedArtboards": [9]}), + json!({"selectedArtboards": []}), + json!({"selectedArtboards": "1"}), + json!({"selectedArtboards": null}), + json!({"selectedArtboards": [1], "art": "yes"}), + ] { + let err = s.execute(id, &bad).unwrap_err().to_string(); + assert!(err.contains(id) || err.contains("no such artboard"), "{id} {bad}: {err}"); + assert_eq!(documents(&s), before, "{id} {bad}"); + assert!(s.clipboard.artboards.is_empty(), "{id} {bad}"); + } + s.execute(id, &json!({"selectedArtboards": [1], "art": false})).unwrap(); + assert_eq!(s.clipboard.artboards.len(), 1, "{id}"); + assert!(s.clipboard.nodes.is_empty(), "{id}"); + assert_eq!(s.journal.last().unwrap().1, json!({"selectedArtboards": [1], "art": false}), "{id}"); + // Objects selected, `art` is still checked. + s.execute("select.all", &json!({})).unwrap(); + let err = s.execute(id, &json!({"art": "yes"})).unwrap_err().to_string(); + assert!(err.contains(id), "{id}: {err}"); + } + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 200, "created": null})).unwrap(); + assert!(s.execute("edit.cut", &json!({"selectedArtboards": [0]})).is_err()); + assert_eq!(s.doc().unwrap().doc.artboards.len(), 1); +} + /// `artboard.duplicate` and the cut and copy record the tool's Move/Copy Artwork option: replayed in /// a new session, where the option is on, the art stays behind as it did. #[test] fn artboard_commands_record_the_tools_move_art_option() { - for id in ["artboard.duplicate", "artboard.cut", "artboard.copy"] { + for id in ["artboard.duplicate", "artboard.cut", "artboard.copy", "edit.cut", "edit.copy"] { let mut s = Session::new(); second_artboard_selected(&mut s); s.set_tool_option("moveArt", &json!(false)); diff --git a/docs/mcp.md b/docs/mcp.md index 1adcf32b1..e948a3963 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4024,6 +4024,13 @@ When a call to `artboard.duplicate`, `artboard.copy`, `artboard.cut`, `artboard. names no artboard, its journal entry records the artboards it acted on as `selectedArtboards`. A replay of the journal acts on those artboards, and a recorded action leaves the value out and acts on the selection when it plays. +`edit.copy` and `edit.cut` with the Artboard tool chosen record the same `selectedArtboards`, and `art` beside it. +`edit.copy {selectedArtboards: [n…], art?}` and `edit.cut {selectedArtboards: [n…], art?}` run `artboard.copy` and +`artboard.cut` on those artboards whichever tool is chosen and whatever is selected, so a replay of the journal in a new +session takes the same artboards. A value of `selectedArtboards` that names no artboard fails as `indices` does, and `art` +that is not true or false fails. A recorded action leaves both values out: when it plays, `edit.copy` and `edit.cut` take +artboards when the Artboard tool is chosen then, and the selection otherwise. + `artboard.selectAll {}` chooses the Artboard tool when another tool is in use and selects every artboard. The active artboard stays active, the art's selection stays as it is, and no undo step is recorded → `{selected: [index…]}`, the active artboard first. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0af2f0670..04d7b6726 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -925,6 +925,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Object › Artboards › Fit to Selected Art resizes the active artboard (`artboard.fitToSelection {index}`). It used to resize artboard 1 whichever artboard was active. When an action plays, its Fit to Artwork Bounds and Fit to Selected Art steps that name no artboard resize the artboard that is active then. Object › Artboards › Fit to Artwork Bounds and the Artboards panel menu's Fit to Artwork Bounds fit each selected artboard (those the Artboard tool has selected, else the active one) to the art on it, as the public [Use the artboard context menu](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/use-artboard-context-menu.html) page describes (`artboard.fitToArt {indices}`). An object partly on an artboard counts for each artboard it overlaps, and an artboard with no art on it keeps its size and place. They used to fit the active artboard to all the art in the document. The Artboards panel menu's Duplicate Artboards copies each selected artboard (those the Artboard tool has selected, else the active one) with its art, in one undo step, and the copies are then selected, as the public [Use the artboard context menu](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/use-artboard-context-menu.html) page describes. With the Artboard tool, Copy and Cut take every selected artboard with its art, as the public [Cut, copy, and paste artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/cut-copy-and-paste-artboards.html) page describes, and Paste brings them all back in one undo step (`artboard.duplicate`, `artboard.copy` and `artboard.cut` with `indices`). The copies keep their places relative to each other, together right of the last artboard. All three used to act on the active artboard only. + A replay of the journal repeats a Cut or Copy made with the Artboard tool on the artboards it took (`edit.cut` and `edit.copy` record `selectedArtboards` and `art`), whichever tool is chosen in the new session and whatever is selected there. A recorded action follows the tool chosen when it plays. The replay used to act on the objects selected then, or stop with nothing selected. This rule is VectorCraft's own. With the Artboard tool, the canvas context menu lists the entries of the public [Use the artboard context menu](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/use-artboard-context-menu.html) page in its order: Cut, Copy, Paste at the pointer, Undo, Redo, New Artboard, Duplicate Artboards, Rename (`artboard.rename`), Delete Artboards, Delete Empty Artboards, Select All Artboards (`artboard.selectAll`), Lock Artboards and Unlock Artboards, Rearrange, Fit to Artwork Bounds, Switch Orientation (`artboard.switchOrientation`), Artboard Options and Export. A right-click on an artboard that is not selected selects it first. The Artboards panel menu's New Artboard adds the artboard in one undo step and makes it the active one, as the panel's button, the Control bar and Properties do; with the Artboard tool it is then the only one selected. This is VectorCraft's own rule. The menu item used to leave the active artboard where it was. With the Artboard tool, Paste and Paste in Place make the first pasted artboard the active one (the navigator's and the Artboards panel's), and Cut makes the artboard before the first cut one active (the new first artboard when artboard 1 was cut). A command that chooses another document (`document.activate`) starts the tool on that document's active artboard, as choosing its tab does, and File › Revert keeps the active artboard and starts the tool on it. Paste used to leave the active artboard where it was while the tool had the pasted artboard active, and Cut left the active artboard on the artboard after the cut one while the tool had the one before it. From 6ffcf800c85ed330b6f0d980988049ed52e3bcf6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 22:08:13 -0800 Subject: [PATCH 051/140] Export: the export commands refuse an unknown option key document.export, document.exportSelection, document.serialize, app.export and file.export took {noSuchOption: true} and wrote the file with no warning. They now refuse a key that no format lists in document.formats, and name the nearest key when one is close. document.exportPdf, document.pdfSettings and pdf.preset.save refuse a key PDF doesn't take, document.exportEps one EPS doesn't take and document.exportDxf one DXF doesn't take. Each refuses an unknown key inside an option object it takes, such as marks.useDocumentBleed, and names its path. A key that only another format reads, such as {format: pdf, quality: 80}, is still ignored: docs/mcp.md says one object can carry several formats' options. The check runs for callers (the control channel, MCP, the CLI). A journal or a recorded action isn't checked, and a saved PDF preset with a key that is no longer read still loads. document.formats now also lists PDF's thumbnails, fastWebView and viewAfterSaving. --- crates/engine/src/cmd/fileio/commands.rs | 6 +- crates/engine/src/cmd/fileio/dxf.rs | 4 +- crates/engine/src/cmd/fileio/eps.rs | 4 +- crates/engine/src/cmd/fileio/pdf.rs | 19 +++- crates/engine/src/cmd/params.rs | 102 +++++++++++++++++++++- crates/engine/src/cmd/pdfcmds.rs | 2 +- crates/engine/src/tests/params.rs | 105 ++++++++++++++++++++++- crates/engine/tests/it/param_keys.rs | 16 ++++ crates/mcp/src/headless.rs | 2 + crates/mcp/src/tests/exportas.rs | 28 ++++++ crates/ui-egui/src/control.rs | 6 ++ crates/ui-egui/src/tests/automation.rs | 22 +++++ docs/control-protocol.md | 2 +- docs/mcp.md | 15 ++++ docs/target-app-parity.md | 1 + 15 files changed, 317 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index b1a422dea..6988f943a 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "Serialize Document", [], None, - "{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]", + "{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored", has_doc, export::serialize ), @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in their layers), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in their layers), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path", has_doc, export::export ), @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Export Selection…", ["File"], None, - "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export", + "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored", has_selection, export::export_selection ), diff --git a/crates/engine/src/cmd/fileio/dxf.rs b/crates/engine/src/cmd/fileio/dxf.rs index 4320cbb9c..c1f09b18f 100644 --- a/crates/engine/src/cmd/fileio/dxf.rs +++ b/crates/engine/src/cmd/fileio/dxf.rs @@ -21,14 +21,14 @@ pub fn specs() -> Vec { "Export DXF", [], None, - "{path?, version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}", + "{path?, version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}. A key DXF doesn't take is refused", has_doc, export_dxf )] } /// The DXF options `document.formats` lists. -pub(super) const OPTIONS: &[FormatOption] = &[ +pub(crate) const OPTIONS: &[FormatOption] = &[ super::ARTBOARD, super::ARTBOARDS, super::RANGE, diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index f90dacfc7..ac3332699 100644 --- a/crates/engine/src/cmd/fileio/eps.rs +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -32,14 +32,14 @@ pub fn specs() -> Vec { "Export EPS", [], None, - "{path?, level?: 2|3 (default; PostScript language level: 3 writes smooth gradients and masks transparent image pixels out, 2 steps gradients and writes those pixels white), previewFormat?: none|tiffBw|tiffColor (default: a TIFF preview other apps show), transparentPreview?: true (else on white), overprints?: preserve (default)|discard, flattenerPreset?: \"medium\" (default; high, low or a saved preset: flattener.presets.list), flattener?: {the options of object.flattenTransparency over the preset}, embedFonts?: true (type is always written as outlines, so no fonts are needed), includeLinkedFiles?: false (the embedded document keeps linked images whole, not just their previews), thumbnails?: true (a PNG thumbnail in the file), cmykPostScript?: true (RGB documents written in CMYK), compatibleGradients?: false (stepped gradients at level 3 too), selectedOnly?: false, useArtboards?: true (one file per chosen artboard, artboards?/range?, {stem}_{artboard}.eps, the bounding box the artboard; with two or more, also the central file {stem}.eps first: the whole document, the bounding box the visible art; none when no art shows) | false (one file, the bounding box the visible art, y up from the first artboard's bottom-left corner, even when artboards are named; the default when none are named), format?: \"eps\", artboard?: i | artboards?: [i…] | range?: \"1-3, 5\" (the artboards written; naming them without useArtboards works as useArtboards: true)} → {path, bytes, warnings, files?}; no path → {dataBase64, …}. Transparency is flattened with the preset (EPS has none); the native document rides along in comments, for reopening the file as it was. Same as document.export {format: eps}", + "{path?, level?: 2|3 (default; PostScript language level: 3 writes smooth gradients and masks transparent image pixels out, 2 steps gradients and writes those pixels white), previewFormat?: none|tiffBw|tiffColor (default: a TIFF preview other apps show), transparentPreview?: true (else on white), overprints?: preserve (default)|discard, flattenerPreset?: \"medium\" (default; high, low or a saved preset: flattener.presets.list), flattener?: {the options of object.flattenTransparency over the preset}, embedFonts?: true (type is always written as outlines, so no fonts are needed), includeLinkedFiles?: false (the embedded document keeps linked images whole, not just their previews), thumbnails?: true (a PNG thumbnail in the file), cmykPostScript?: true (RGB documents written in CMYK), compatibleGradients?: false (stepped gradients at level 3 too), selectedOnly?: false, useArtboards?: true (one file per chosen artboard, artboards?/range?, {stem}_{artboard}.eps, the bounding box the artboard; with two or more, also the central file {stem}.eps first: the whole document, the bounding box the visible art; none when no art shows) | false (one file, the bounding box the visible art, y up from the first artboard's bottom-left corner, even when artboards are named; the default when none are named), format?: \"eps\", artboard?: i | artboards?: [i…] | range?: \"1-3, 5\" (the artboards written; naming them without useArtboards works as useArtboards: true)} → {path, bytes, warnings, files?}; no path → {dataBase64, …}. Transparency is flattened with the preset (EPS has none); the native document rides along in comments, for reopening the file as it was. Same as document.export {format: eps}. A key EPS doesn't take, and an unknown key inside flattener, are refused", has_doc, export_eps )] } /// The EPS options `document.formats` lists. -pub(super) const OPTIONS: &[FormatOption] = &[ +pub(crate) const OPTIONS: &[FormatOption] = &[ super::ARTBOARD, super::ARTBOARDS, super::RANGE, diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 812b8ac97..7f2d31d01 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -21,7 +21,7 @@ const C: &str = "document.exportPdf"; pub use vectorcraft_pdf::DEFAULT_PRESET; /// The PDF options `document.formats` lists; `document.exportPdf` documents every field. -pub(super) const OPTIONS: &[FormatOption] = &[ +pub(crate) const OPTIONS: &[FormatOption] = &[ super::ARTBOARD, super::ARTBOARDS, super::RANGE, @@ -50,6 +50,19 @@ pub(super) const OPTIONS: &[FormatOption] = &[ default: "true", description: "embed the native document so VectorCraft reopens the PDF editable (the default preset's choice)", }, + FormatOption { name: "thumbnails", ty: "boolean", default: "false", description: "each page drawn as a /Thumb image, 106 px on its long side" }, + FormatOption { + name: "fastWebView", + ty: "boolean", + default: "false", + description: "a linearized file: the first page shows while the rest downloads", + }, + FormatOption { + name: "viewAfterSaving", + ty: "boolean", + default: "false", + description: "in the desktop app's document.exportPdf, the written file opens in the system viewer", + }, FormatOption { name: "compression", ty: "object", default: "null", description: "{color, gray, mono, compressText} (see document.exportPdf)" }, FormatOption { name: "marks", ty: "object", default: "null", description: "printer's marks (see document.exportPdf)" }, FormatOption { @@ -109,7 +122,7 @@ pub fn specs() -> Vec { "Export PDF", [], None, - "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options", + "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", has_doc, export_pdf ), @@ -118,7 +131,7 @@ pub fn specs() -> Vec { "PDF Settings", [], None, - "{preset?, includeDocument?: false, …options of document.exportPdf} → {settings (the preset with the options applied), presets: [name…], changed: [{option: \"compression.compressText\", value}] (what differs from the preset), warnings}; includeDocument also exports the active document in memory and adds its warnings (knockout groups approximated, effects left out…)", + "{preset?, includeDocument?: false, …options of document.exportPdf} → {settings (the preset with the options applied), presets: [name…], changed: [{option: \"compression.compressText\", value}] (what differs from the preset), warnings}; includeDocument also exports the active document in memory and adds its warnings (knockout groups approximated, effects left out…). A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", always, pdf_settings ), diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index 1ca9bf233..412c620bb 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -6,7 +6,9 @@ //! by `|`; a `?` marks an optional one, and a `:`, `=` or `(` starts what the entry says about it. An entry that starts //! with `…` stands for keys listed elsewhere: those of each command it names after `as` or `of` //! (`…settings as file.new`), or, naming none, any key (the options of the format an export picks). Objects inside a value aren't -//! described key by key, so only the top level is checked. +//! described key by key, so only the top level is checked, except in an export's options: those +//! are checked against the options of the formats the command can write, in `marks`, `bleed` and +//! the other objects too ([`check_export_options`]). //! //! Callers outside the app (the control channel, MCP, the CLI) are answered with a parameter error //! for a key the description doesn't name ([`check_params`]): a misspelt `widht` no longer runs the @@ -473,9 +475,100 @@ pub const UNCHECKED: &[&str] = &[ "webExport.settings", ]; -/// [`check_keys`] for an engine command, the steps of a `command.batch` included, and -/// [`check_effect_params`] for the effect `effect.apply` adds. A command the engine doesn't have -/// passes: running it reports that. +/// The object options of a PDF export, key by key: the Save PDF settings as a preset stores them, +/// with the passwords it leaves out. +fn pdf_option_tree() -> Value { + let mut tree = serde_json::to_value(vectorcraft_pdf::PdfSettings::default()).unwrap_or_default(); + if let Some(security) = tree.get_mut("security").and_then(Value::as_object_mut) { + security.extend(["openPassword", "permissionsPassword"].map(|k| (k.to_string(), Value::Null))); + } + tree +} + +/// `given`, the object at `within` (`marks.`), has only keys of `schema`, the objects inside it too. +fn check_option_tree(id: &str, within: &str, given: &serde_json::Map, schema: &serde_json::Map) -> Result<()> { + for (key, value) in given { + match (schema.get(key), value) { + (None, _) => { + let keys: Vec<&str> = schema.keys().map(String::as_str).collect(); + let why = match nearest(key, &keys) { + Some(near) => format!("did you mean `{within}{near}`?"), + None => format!("`{}` takes: {}", within.trim_end_matches('.'), keys.join(", ")), + }; + return Err(bad(id, format!("unknown parameter `{within}{key}`; {why}"))); + } + (Some(Value::Object(inner)), Value::Object(value)) => check_option_tree(id, &format!("{within}{key}."), value, inner)?, + _ => {} + } + } + Ok(()) +} + +/// The options of an export command `id`. `document.export`, `document.exportSelection` and +/// `document.serialize` take a key that some format reads (one object can carry several formats' +/// options; `document.formats` lists them). `document.exportPdf`, `document.pdfSettings` and +/// `pdf.preset.save` take a key that PDF reads, `document.exportEps` one that EPS reads and +/// `document.exportDxf` one that DXF reads. Where a command takes `marks`, `bleed`, `flattener` or +/// another object option of PDF or EPS, only the object's own keys pass. An SVG export's `svg` +/// object is checked when it is read. Saved presets are not checked: one with a key that is no +/// longer read keeps loading. +fn check_export_options(id: &str, params: &Value) -> Result<()> { + use super::fileio::{FORMATS, dxf, eps, pdf}; + let Value::Object(given) = params else { return Ok(()) }; + let mut keys: Vec<&str> = vec!["path", "format", "acknowledgeLoss"]; + let why_not = match id { + "document.export" | "document.exportSelection" | "document.serialize" => { + // `richBlack` is Appearance of Black's setting for a raster export, and `params` an + // exporter plug-in's own. + keys.extend(["includeArtboardFill", "selectedOnly", "richBlack", "params"]); + keys.extend(FORMATS.iter().filter(|f| f.write).flat_map(|f| f.options).map(|o| o.name)); + "no format takes it" + } + "document.exportPdf" | "document.pdfSettings" | "pdf.preset.save" => { + keys.push("includeArtboardFill"); + keys.extend(pdf::OPTIONS.iter().map(|o| o.name)); + match id { + "document.pdfSettings" => keys.push("includeDocument"), + "pdf.preset.save" => keys.extend(["name", "newName", "description"]), + _ => {} + } + "PDF doesn't take it" + } + "document.exportEps" => { + keys.push("includeArtboardFill"); + keys.extend(eps::OPTIONS.iter().map(|o| o.name)); + "EPS doesn't take it" + } + "document.exportDxf" => { + keys.extend(dxf::OPTIONS.iter().map(|o| o.name)); + "DXF doesn't take it" + } + _ => return Ok(()), + }; + let mut trees = pdf_option_tree(); + if let Some(tree) = trees.as_object_mut() { + tree.insert("flattener".into(), serde_json::to_value(super::flatten::FlattenOptions::default()).unwrap_or_default()); + } + for (key, value) in given { + if !keys.contains(&key.as_str()) { + return Err(bad( + id, + match nearest(key, &keys) { + Some(near) => format!("unknown parameter `{key}`; did you mean `{near}`?"), + None => format!("unknown parameter `{key}`; {why_not} (see document.formats)"), + }, + )); + } + if let (Some(Value::Object(schema)), Value::Object(value)) = (trees.get(key), value) { + check_option_tree(id, &format!("{key}."), value, schema)?; + } + } + Ok(()) +} + +/// [`check_keys`] for an engine command, the steps of a `command.batch` included, +/// [`check_effect_params`] for the effect `effect.apply` adds and [`check_export_options`] for an +/// export. A command the engine doesn't have passes: running it reports that. pub fn check_params(id: &str, params: &Value) -> Result<()> { let Some(spec) = find_command(id) else { return Ok(()) }; if let ("effect.apply" | "appearance.addEffect", Some(effect), Some(given)) = @@ -483,6 +576,7 @@ pub fn check_params(id: &str, params: &Value) -> Result<()> { { check_effect_params(id, effect, given)?; } + check_export_options(id, params)?; if UNCHECKED.contains(&id) { return Ok(()); } diff --git a/crates/engine/src/cmd/pdfcmds.rs b/crates/engine/src/cmd/pdfcmds.rs index b5a8778fe..35478cdd1 100644 --- a/crates/engine/src/cmd/pdfcmds.rs +++ b/crates/engine/src/cmd/pdfcmds.rs @@ -46,7 +46,7 @@ pub fn specs() -> Vec { "Save PDF Preset", [], None, - "{name?: (default: a new \"PDF Preset N\"), newName?: rename it, description?, preset?: the preset to start from (default: the saved preset `name`, else VectorCraft Default), …options of document.exportPdf} create or change a saved preset (built-in presets are read-only; passwords are never stored) → {name, created, settings}", + "{name?: (default: a new \"PDF Preset N\"), newName?: rename it, description?, preset?: the preset to start from (default: the saved preset `name`, else VectorCraft Default), …options of document.exportPdf} create or change a saved preset (built-in presets are read-only; passwords are never stored) → {name, created, settings}. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", always, save ), diff --git a/crates/engine/src/tests/params.rs b/crates/engine/src/tests/params.rs index 2eba6c81a..275d75044 100644 --- a/crates/engine/src/tests/params.rs +++ b/crates/engine/src/tests/params.rs @@ -64,7 +64,7 @@ fn an_unknown_key_is_named_with_the_nearest_accepted_one() { ("file.new", json!(null)), // Keys listed by reference, and any key where the description says so. ("file.newPresets.save", json!({"name": "a", "width": 300})), - ("document.export", json!({"format": "png", "anyFormatOption": 1})), + ("document.export", json!({"format": "png", "styling": "style"})), // Not a command: running it says so. ("no.such", json!({"x": 1})), ] { @@ -74,6 +74,109 @@ fn an_unknown_key_is_named_with_the_nearest_accepted_one() { assert!(check_keys("x", "{a, …as file.new}", &json!({"b": 1})).is_err()); } +/// An export's options: a key no format reads is refused, at the top level and in the PDF objects, +/// under each command that takes them. +#[test] +fn export_options_refuse_a_key_no_format_reads() { + for id in ["document.export", "document.exportSelection", "document.serialize"] { + assert_eq!( + refusal(id, json!({"format": "pdf", "standard": "pdfX4", "noSuchOption": true})), + "unknown parameter `noSuchOption`; no format takes it (see document.formats)" + ); + assert_eq!(refusal(id, json!({"format": "pdf", "standardd": "pdfX4"})), "unknown parameter `standardd`; did you mean `standard`?"); + assert_eq!( + refusal(id, json!({"format": "pdf", "options": {"standard": "pdfX4"}})), + "unknown parameter `options`; no format takes it (see document.formats)" + ); + } + // A command that writes one format refuses another format's option, and says which format. + for (id, why) in [ + ("document.exportPdf", "PDF doesn't take it"), + ("document.pdfSettings", "PDF doesn't take it"), + ("pdf.preset.save", "PDF doesn't take it"), + ("document.exportEps", "EPS doesn't take it"), + ("document.exportDxf", "DXF doesn't take it"), + ] { + for key in ["noSuchOption", "quality"] { + assert_eq!(refusal(id, json!({key: 1})), format!("unknown parameter `{key}`; {why} (see document.formats)")); + } + } + assert_eq!( + refusal("document.exportEps", json!({"flattener": {"balanse": 50}})), + "unknown parameter `flattener.balanse`; did you mean `flattener.balance`?" + ); + assert!(refusal("document.exportDxf", json!({"flattener": {}})).contains("DXF doesn't take it")); + assert!(refusal("document.exportEps", json!({"marks": {}})).contains("EPS doesn't take it")); + for id in ["document.pdfSettings", "pdf.preset.save"] { + assert!(refusal(id, json!({"marks": {"useDocumentBleed": true}})).starts_with("unknown parameter `marks.useDocumentBleed`;"), "{id}"); + } + for (given, message) in [ + (json!({"marks": {"useDocumentBleed": true}}), "unknown parameter `marks.useDocumentBleed`; `marks` takes:"), + (json!({"bleed": {"useDocumen": true}}), "unknown parameter `bleed.useDocumen`; did you mean `bleed.useDocument`?"), + (json!({"output": {"intent": "x"}}), "unknown parameter `output.intent`;"), + (json!({"advanced": {"outlineTxt": true}}), "unknown parameter `advanced.outlineTxt`; did you mean `advanced.outlineText`?"), + (json!({"security": {"openPasswrd": "x"}}), "unknown parameter `security.openPasswrd`; did you mean `security.openPassword`?"), + (json!({"compression": {"color": {"dpi": 300}}}), "unknown parameter `compression.color.dpi`;"), + (json!({"flattener": {"balanse": 50}}), "unknown parameter `flattener.balanse`; did you mean `flattener.balance`?"), + ] { + for id in ["document.export", "document.exportPdf"] { + let got = refusal(id, given.clone()); + assert!(got.starts_with(message), "{id} {given}: {got}"); + } + } +} + +/// The check passes each key `document.formats` lists, each key a command's own description names, +/// and each field of the PDF settings and the flattener options. A key an encoder reads that none +/// of these names is outside this test. +#[test] +fn export_options_pass_every_listed_key() { + use crate::cmd::fileio::{FORMATS, dxf, eps, pdf::OPTIONS}; + for f in FORMATS.iter().filter(|f| f.write) { + for o in f.options { + let params = json!({"format": f.id, o.name: null}); + assert!(check_params("document.export", ¶ms).is_ok(), "{params}"); + } + } + for (id, options) in [("document.exportEps", eps::OPTIONS), ("document.exportDxf", dxf::OPTIONS), ("document.exportPdf", OPTIONS)] { + for o in options { + assert!(check_params(id, &json!({o.name: null})).is_ok(), "{id} {}", o.name); + } + } + for id in [ + "document.export", + "document.exportSelection", + "document.serialize", + "document.exportPdf", + "document.pdfSettings", + "pdf.preset.save", + "document.exportEps", + "document.exportDxf", + ] { + // An export reads these two when it writes over the file a lossy document came from. The + // PDF settings and preset commands take them through document.exportPdf's description. + check_params(id, &json!({"path": "a", "acknowledgeLoss": true})).unwrap_or_else(|e| panic!("{id}: {e}")); + let keys = doc_keys(find_command(id).unwrap().params).unwrap().keys; + let refused: Vec<&str> = keys.into_iter().filter(|key| check_params(id, &json!({*key: null})).is_err()).collect(); + // `document.exportPdf`'s description explains the page boxes as `BleedBox = …` between its + // keys, which reads as two more keys. + let prose = if id == "document.exportPdf" { vec!["BleedBox", "MediaBox"] } else { vec![] }; + assert_eq!(refused, prose, "{id} names and refuses these"); + } + let settings = serde_json::to_value(vectorcraft_pdf::PdfSettings::default()).unwrap(); + for key in settings.as_object().unwrap().keys() { + assert!(OPTIONS.iter().any(|o| o.name == key), "document.formats doesn't list the PDF option `{key}`"); + } + let mut every = settings; + every["flattener"] = serde_json::to_value(crate::cmd::flatten::FlattenOptions::default()).unwrap(); + every["security"]["openPassword"] = json!("a"); + every["security"]["permissionsPassword"] = json!("b"); + every["preset"] = json!("High Quality Print"); + for id in ["document.export", "document.exportPdf", "document.pdfSettings", "pdf.preset.save"] { + check_params(id, &every).unwrap_or_else(|e| panic!("{id}: {e}")); + } +} + /// `stroke.set` names every key it reads (its own, `item`, and `ids`/`id`/`target`), so a /// misnamed one is refused instead of answering `objects: 1` with nothing changed. #[test] diff --git a/crates/engine/tests/it/param_keys.rs b/crates/engine/tests/it/param_keys.rs index 3da393ca8..89a27ba81 100644 --- a/crates/engine/tests/it/param_keys.rs +++ b/crates/engine/tests/it/param_keys.rs @@ -21,6 +21,22 @@ fn a_journal_with_a_key_no_longer_read_still_replays() { assert_eq!((st.doc.artboards[0].rect.width(), st.doc.layers[0].children().unwrap().len()), (300.0, 1)); } +/// `Session::execute` still runs an export with an option that is no longer read, and a preset +/// file that holds one still loads and exports. Actions play through `execute`; this test does not +/// play one. +#[test] +fn an_export_step_or_preset_with_a_key_no_longer_read_still_runs() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let old = json!({"format": "pdf", "noSuchOption": true, "marks": {"useDocumentBleed": true}}); + assert!(check_params("document.export", &old).is_err()); + s.execute("document.export", &old).unwrap(); + let file = json!({"format": "vcpdfpresets", "presets": [{"name": "Old", "settings": {"standard": "none", "noLongerRead": 1, "marks": {"retired": true}}}]}); + s.execute("pdf.preset.import", &json!({"data": file.to_string()})).unwrap(); + check_params("document.export", &json!({"format": "pdf", "preset": "Old"})).unwrap(); + s.execute("document.export", &json!({"format": "pdf", "preset": "Old"})).unwrap(); +} + /// So do an effect's own parameters (#1592): a journal or a file with a key the effect doesn't /// take, or a value of another type, replays and opens; the effect reads what it knows, clamped. #[test] diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 0a81bb127..59b3f973f 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -315,6 +315,8 @@ impl Headless { /// `app.export {path?, format?, …}`: `document.export` (format defaults to the path's extension). pub fn export(&mut self, p: &Value) -> Result { + // The key check of `document.export` runs before the file is written. + self.session.check_params("document.export", p).map_err(|e| e.to_string())?; let roots = self.roots.clone(); file_access::confine(roots.as_ref(), || self.session.execute("document.export", p)).map_err(|e| e.to_string()) } diff --git a/crates/mcp/src/tests/exportas.rs b/crates/mcp/src/tests/exportas.rs index 0d9dc5c49..8bc958af8 100644 --- a/crates/mcp/src/tests/exportas.rs +++ b/crates/mcp/src/tests/exportas.rs @@ -16,6 +16,34 @@ fn three_boards() -> Headless { h } +/// The tool, `app.export` and `file.export` refuse a key no format takes, at the top level of +/// `options` and inside a PDF object. +#[test] +fn an_option_no_format_takes_is_an_error() { + let mut h = Headless::new(); + h.call("engine.execute", json!({"command": "file.new", "params": {"width": 50, "height": 30}})).unwrap(); + for (options, named) in [ + (json!({"standard": "pdfX4", "noSuchOption": true}), "noSuchOption"), + (json!({"marks": {"useDocumentBleed": true}}), "marks.useDocumentBleed"), + ] { + let r = call_tool(&mut h, "export", &json!({"format": "pdf", "options": options})); + assert!(r.is_error && r.content[0]["text"].as_str().unwrap().contains(named), "{r:?}"); + let mut params = options.clone(); + params["format"] = json!("pdf"); + for method in ["app.export", "file.export"] { + let e = if method == "app.export" { + h.call(method, params.clone()).unwrap_err() + } else { + h.call("engine.execute", json!({"command": method, "params": params})).unwrap_err() + }; + assert!(e.contains(named), "{method}: {e}"); + } + } + // One object can still carry several formats' options. + let r = call_tool(&mut h, "export", &json!({"format": "pdf", "options": {"quality": 80, "styling": "style"}})); + assert!(!r.is_error, "{r:?}"); +} + #[test] fn raster_export_uses_document_resolution_unless_overridden() { let mut h = Headless::new(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index fccfbedab..71b1aacdc 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -456,6 +456,12 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques o.remove("wait"); } let method = req.method.as_str(); + // The key check of `document.export` runs before the file is written. + if method == "app.export" + && let Err(e) = crate::menus::check_params(&app.session, "document.export", &p) + { + return err(e); + } wrap(if wait { crate::background::in_foreground(app, |app| save_or_export(app, method, &p)) } else { save_or_export(app, method, &p) }) } // As the menu's Quit: `{"pending": "saveChanges"}` while a modified document is asked diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 5139d4881..0682b533e 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -159,6 +159,28 @@ fn shortcut_sets_are_read_only_inside_the_read_root() { let _ = std::fs::remove_dir_all(base); } +/// `app.export` refuses a key no format takes before anything is written, with a path or without. +#[test] +fn control_export_refuses_an_option_no_format_takes() { + let (mut app, tx, base) = confined("export-options"); + let r = request(&mut app, &tx, "app.open", json!({"path": s(base.join("in/a.svg"))})); + assert_eq!(r["ok"], true, "{r}"); + let path = base.join("out/a.pdf"); + let refused = |r: &Value| r["error"].as_str().is_some_and(|e| e.contains("unknown parameter")); + for mut params in [json!({"format": "pdf", "noSuchOption": true}), json!({"format": "pdf", "marks": {"useDocumentBleed": true}})] { + let r = request(&mut app, &tx, "app.export", params.clone()); + assert!(refused(&r), "{r}"); + params["path"] = json!(s(path.clone())); + params["wait"] = json!(true); + let r = request(&mut app, &tx, "app.export", params); + assert!(refused(&r), "{r}"); + assert!(!path.exists()); + } + let r = request(&mut app, &tx, "app.export", json!({"format": "pdf", "path": s(path.clone()), "wait": true})); + assert_eq!(r["ok"], true, "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// `ui.render` (MCP's `render_preview` and `screenshot` in the app) draws the artboard pixel for /// pixel as `app.export` writes a PNG on white: two rectangles that meet at x 40.3 and a diagonal /// edge. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0a9bfd9f0..2ff2a07f8 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -107,7 +107,7 @@ the two can still win that race. | `ui.render` | `{path?, scale?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.resize` / `ui.focus` | | | -| `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | +| `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. It refuses a key that no format takes, and an unknown key inside a PDF or EPS option object, before it writes anything (`docs/mcp.md`, Parameter keys). `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | | `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `-.`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards | | `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | | `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set | diff --git a/docs/mcp.md b/docs/mcp.md index 1adcf32b1..e16b78022 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1134,6 +1134,21 @@ of the format they write (one object can carry several formats' options). A comm (`document.setup`, `text.areaOptions`) says so the same way. The app's own journal isn't checked: an action or journal recorded by an older version replays as it did, except for an effect `color` that the effect can't read. +The export commands limit those keys. `document.export`, `document.exportSelection`, `document.serialize`, `app.export` +and `file.export` refuse a key that no format takes, and ignore a key that only another format takes +(`{"format": "pdf", "quality": 80}` writes the PDF). `document.exportPdf`, `document.pdfSettings` and `pdf.preset.save` +refuse a key that PDF doesn't take, `document.exportEps` one that EPS doesn't take and `document.exportDxf` one that DXF +doesn't take. Each of them refuses an unknown key inside an option object it takes (`marks`, `bleed`, `compression`, +`output`, `advanced`, `security`, `flattener`), naming its path: + +```json +{"id": "document.export", "params": {"format": "pdf", "marks": {"useDocumentBleed": true}}} +→ invalid parameters for `document.export`: unknown parameter `marks.useDocumentBleed`; `marks` takes: … +``` + +A saved PDF preset that holds a key that is no longer read keeps loading. `document.save`, `file.saveAs`, `file.saveCopy` +and `document.exportForScreens` don't apply this check. + An unknown command id is matched the same way as an unknown parameter key: the error names the nearest command id when one is near, ignoring case. A step of `command.batch` or `action.batch` gets the same suggestion in its step error: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0af2f0670..2ef553b6e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -692,6 +692,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). - The errors of the blend spine commands (Move Spine Point, Reshape Spine Segment, Add Spine Point and Delete Spine Point) show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages and in Bulgarian (`blend_spine_param_errors_are_translated`). - The errors of the parameter checks that many commands share show in the status bar in the interface language: a value that is not a number where a number goes (`object.move` with `"dx": "60 mm"`, and `object.rotate`, `object.setBounds` and the other commands that call `check_numbers`), a missing number or index, `ids` that are not a list of object ids, an object id that is not a non-negative integer or names no object, and a date that is not Unix seconds. Each has a row in every catalog that translates all status and error messages, and in Bulgarian, Danish, Indonesian and Dutch (`shared_param_check_errors_are_translated`). + - Export commands run through MCP, the control channel or the CLI refuse a key that no format takes, and a command that writes one format (`document.exportPdf`, `document.exportEps`, `document.exportDxf`) or takes one format's options (`document.pdfSettings`, `pdf.preset.save`) refuses a key that format doesn't take. An unknown key inside `marks`, `bleed`, `compression`, `output`, `advanced`, `security` or `flattener` is refused by its path. - The errors of Enter Isolation Mode (`object.isolate`) for a type object, for hidden or locked art and for a call with no `id` while nothing is selected ("missing id", which other commands that take an `id` also give), and the error of the Layers panel's target circle (`layer.target`) for an object outside the isolated group, show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages, and in Bulgarian, Danish, Indonesian and Dutch. - An EPS or PostScript file that stops on a PostScript error gives the error's name as PostScript spells it (`typecheck`, `rangecheck` and the other names the reader raises, except `undefined`, whose message names the unknown name) followed by what it means, in the warning after opening and in the error when the file can't be opened. The status bar and the open-error dialog show the meaning in the interface language: each meaning has a row in every catalog (`postscript_error_meanings_are_translated`). - The macOS menu bar is built from the in-window menus (one model, `native_menu`) and laid out as a Mac app's: the VectorCraft menu holds About, Settings ▸ (each Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays Hide Edges), Hide Others, Show All and Quit; Window gains Minimize ⌃⌘M, Zoom and Bring All to Front, and Help the system's search field. Its key equivalents go back to egui as key presses, so user shortcuts, focused text fields and the Type tool's input method behave as in the window, and nothing fires twice; enabled, check marks and labels update in place. `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps the menus in the window; `ui.inspect` reports `nativeMenuBar`. From 73e216bd9d9af0235957e075d07cf238962847ae Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 00:01:56 -0800 Subject: [PATCH 052/140] Docs: the parameter-key description names the export commands as an exception The earlier paragraph said document.export takes any key when its description names none. The export commands refuse an unknown option key, so the paragraph now lists them as an exception and keeps document.save as the example that takes any key. --- docs/mcp.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index e16b78022..25086f268 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1090,8 +1090,8 @@ is refused before the command runs: → invalid parameters for `file.new`: unknown parameter `widht`; did you mean `width`? ``` -Only the top level is checked: the keys of an object inside a value (`artboardLayout: {…}`) are the command's to -read. The one exception is an effect's `params`: `effect.apply` (and `appearance.addEffect`) and `effect.setParams` +Only the top level is checked, except for the export commands below: the keys of an object inside a value +(`artboardLayout: {…}`) are the command's to read. Another exception is an effect's `params`: `effect.apply` (and `appearance.addEffect`) and `effect.setParams` check them against the effect's own entry in `effect.list`, its `defaults` and the keys its `params` text names. A key the effect doesn't take, or a value of another type than its default (a string for a number, a number for a switch), is refused the same way: @@ -1129,8 +1129,8 @@ A number outside the range the effect documents is still stored as given and cla plug-in effect's parameters are resolved against its manifest instead, and `effect.setParams` as a step of `command.batch` isn't checked before the batch runs (the effect it edits may only exist once the steps before it ran); the command still refuses an effect `color` it can't read. A description with a `…` entry takes more keys than it lists: those of the command it names -(`…settings as file.new`), or any key when it names none, as `document.export` and `document.save` do for the options -of the format they write (one object can carry several formats' options). A command that takes its own reply back +(`…settings as file.new`), or, except for the export commands below, any key when it names none, as `document.save` +does for the options of the format it writes (one object can carry several formats' options). A command that takes its own reply back (`document.setup`, `text.areaOptions`) says so the same way. The app's own journal isn't checked: an action or journal recorded by an older version replays as it did, except for an effect `color` that the effect can't read. From 9fe7aec49c4f6618ef6cd1cee7fc33c11a9778f3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 16:31:39 -0800 Subject: [PATCH 053/140] Graphs: Appearance commands and the Eyedropper update a series' stored paint paint.setFill, paint.setStroke, paint.none, Apply Fill to Stroke and Apply Stroke to Fill stored a series' paint on the graph. appearance.setItem, the Eyedropper, appearance.transfer, Clear Appearance, Reduce to Basic Appearance and the add, remove, move and duplicate row commands stored no paint or weight. An Eyedropper pick of a blue fill at 40% onto a red column drew red at 40% after the next redraw. These commands and Show All Hidden Attributes now store the paint and weight of the topmost visible fill and stroke of a path directly in a series group, in the same undo step. A color, weight or opacity that a command writes to that row replaces the stored value even when the mark's own value does not change. A row that has no paint before and after the command does not change the stored paint. These commands store no stroke for a series that draws Filled Lines bands, and a band no longer takes its color from an empty stored stroke. The stored opacity also comes from the topmost visible row: hiding a top row stores the row under it, and an opacity set on a hidden top row is not stored. Follows #5501. --- crates/engine/src/cmd/appearance.rs | 133 ++++- crates/engine/src/cmd/graph.rs | 2 +- crates/engine/src/cmd/graph/axis.rs | 7 +- crates/engine/src/cmd/graph/generate.rs | 8 +- crates/engine/src/cmd/graph/labels.rs | 12 +- crates/engine/src/cmd/graph/series.rs | 83 ++- crates/engine/src/cmd/graph/tests.rs | 754 ++++++++++++++++++++++++ docs/mcp.md | 14 + docs/target-app-parity.md | 2 +- 9 files changed, 971 insertions(+), 44 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 9cfedcb7a..6675c63d0 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Add New Fill", ["Window", "Appearance"], Some("Cmd+/"), - "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row", + "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, |s, p| add_item(s, p, true) ), @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Add New Stroke", ["Window", "Appearance"], Some("Cmd+Alt+/"), - "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt", + "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, |s, p| add_item(s, p, false) ), @@ -63,7 +63,7 @@ pub fn specs() -> Vec { "Clear Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode", + "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, clear_appearance ), @@ -72,7 +72,7 @@ pub fn specs() -> Vec { "Reduce to Basic Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects", + "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, reduce_basic ), @@ -81,7 +81,7 @@ pub fn specs() -> Vec { "Appearance Item", [], None, - "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), 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)} edit one fill/stroke of each target object's own appearance stack", + "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), 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)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, set_item ), @@ -90,7 +90,7 @@ pub fn specs() -> Vec { "Remove Item", [], None, - "{index | indices: [..] (paint-order item indices), ids?|id?, target?, 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)} remove those fills/strokes from each target object's own stack", + "{index | indices: [..] (paint-order item indices), ids?|id?, target?, 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)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, remove_item ), @@ -108,7 +108,7 @@ pub fn specs() -> Vec { "Duplicate Item", ["Window", "Appearance"], None, - "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, 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)} copy fills/strokes with their effects, as Alt-dragging rows places them", + "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, 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)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, duplicate_item ), @@ -117,7 +117,7 @@ pub fn specs() -> Vec { "Reorder Appearance Item", [], None, - "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above → {index: where the item landed | contents: the row's slot}", + "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {index: where the item landed | contents: the row's slot}", has_doc, move_item ), @@ -126,7 +126,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}", + "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art). On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", has_doc, copy_from ), @@ -144,7 +144,7 @@ pub fn specs() -> Vec { "Show All Hidden Attributes", ["Window", "Appearance"], None, - "{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden → {ids}", + "{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", has_doc, show_all_hidden ), @@ -162,7 +162,7 @@ pub fn specs() -> Vec { "Move Appearance", [], None, - "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds → {source, target}", + "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {source, target}", has_doc, transfer ), @@ -595,6 +595,7 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result { } } super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); + super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); Ok(()) })?; // A fill added above the active row becomes the active row; otherwise the rows above the new @@ -614,7 +615,8 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result { } } let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids)); - Ok(count_changed(d, &before, &ids) + usize::from(kept)) + let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) })?; s.active_appearance_item = None; ok() @@ -626,15 +628,31 @@ fn both_kinds(ids: &[NodeId]) -> Vec<(NodeId, bool)> { ids.iter().flat_map(|id| [(*id, true), (*id, false)]).collect() } -/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost row has another opacity in `d` than -/// in `before` (no row counts as opaque), for [`super::graph::capture_series_opacity`]. +/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost visible row has another opacity in `d` +/// than in `before` (no row counts as opaque), for [`super::graph::capture_series_opacity`]. fn top_opacity_changed(before: &vectorcraft_doc::Document, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<(NodeId, bool)> { let top = |d: &vectorcraft_doc::Document, id: NodeId, fill: bool| { - d.node(id).and_then(|n| n.appearance.items.iter().rev().find(|it| it.is_fill() == fill).map(AppearanceItem::opacity)).unwrap_or(1.0) + d.node(id) + .and_then(|n| n.appearance.items.iter().rev().find(|it| it.is_fill() == fill && it.visible()).map(AppearanceItem::opacity)) + .unwrap_or(1.0) }; ids.iter().flat_map(|id| [(*id, true), (*id, false)]).filter(|(id, fill)| top(before, *id, *fill) != top(d, *id, *fill)).collect() } +/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost visible row has another paint, or a +/// stroke another weight, in `d` than in `before` (no row counts as no paint), for +/// [`super::graph::capture_series_top_paints`]. +fn top_paint_changed(before: &vectorcraft_doc::Document, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<(NodeId, bool)> { + let top = |d: &vectorcraft_doc::Document, id: NodeId, fill: bool| d.node(id).map(|n| super::graph::shown_paint(n, fill)); + ids.iter().flat_map(|id| [(*id, true), (*id, false)]).filter(|(id, fill)| top(before, *id, *fill) != top(d, *id, *fill)).collect() +} + +/// `rows` (each object with the kind of row) whose topmost visible row of that kind has paint: a command that wrote them +/// replaces the paint another mark of the series left on the graph, even when the mark's own row comes out as it was. +fn painted_rows(d: &vectorcraft_doc::Document, rows: impl IntoIterator) -> Vec<(NodeId, bool)> { + rows.into_iter().filter(|(id, fill)| d.node(*id).is_some_and(|n| !super::graph::shown_paint(n, *fill).0.is_none())).collect() +} + /// Clear Appearance on `n`: no fill, no stroke, and the object's Opacity row back to Default. A /// group keeps no fill or stroke rows of its own. fn clear(n: &mut Node) { @@ -672,7 +690,11 @@ fn transfer(s: &mut Session, p: &Value) -> Result { } let changed = if copy { vec![target] } else { vec![source, target] }; let kept = super::graph::capture_series_opacity(d, &both_kinds(&changed)); - Ok(count_changed(d, &before, &[source, target]) + usize::from(kept)) + // The target last: on marks of one series, its paint is the one the series keeps. + let mut rows = top_paint_changed(&before, d, &changed); + rows.extend(painted_rows(d, both_kinds(&[target]))); + let painted = super::graph::capture_series_top_paints(d, &rows); + Ok(count_changed(d, &before, &[source, target]) + usize::from(kept || painted)) })?; s.active_appearance_item = None; Ok(json!({ "source": source.0, "target": target.0 })) @@ -726,7 +748,8 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result { } } let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids)); - Ok(count_changed(d, &before, &ids) + usize::from(kept)) + let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) })?; s.active_appearance_item = None; ok() @@ -752,10 +775,6 @@ fn set_item(s: &mut Session, p: &Value) -> Result { for (id, at) in ids.iter().zip(&per) { let Some(&idx) = at.first() else { continue }; let Some(n) = d.node_mut(*id) else { continue }; - let items = &n.appearance.items; - if let Some(fill) = items.get(idx).map(AppearanceItem::is_fill).filter(|f| items.iter().rposition(|it| it.is_fill() == *f) == Some(idx)) { - edited.push((*id, fill)); - } if p.get("weight").is_none() && let Some(pa) = &paint { @@ -785,10 +804,26 @@ fn set_item(s: &mut Session, p: &Value) -> Result { if let Some(v) = p.get("visible").and_then(Value::as_bool) { *vis = v; } + let items = &n.appearance.items; + if let Some(fill) = + items.get(idx).map(AppearanceItem::is_fill).filter(|f| items.iter().rposition(|it| it.is_fill() == *f && it.visible()) == Some(idx)) + { + edited.push((*id, fill)); + } } - // The graph keeps an opacity set on the topmost fill or stroke of its series art for when it is drawn again. - let kept = p.get("opacity").and_then(Value::as_f64).is_some() && super::graph::capture_series_opacity(d, &edited); - Ok(count_changed(d, &before, &ids) + usize::from(kept)) + // The graph keeps an opacity, paint or weight set on the topmost visible fill or stroke of its series art for when + // it is drawn again, and hiding or showing a row changes which row that is. + let mut opacities = top_opacity_changed(&before, d, &ids); + if p.get("opacity").and_then(Value::as_f64).is_some() { + opacities.extend(edited.iter().copied()); + } + let kept = super::graph::capture_series_opacity(d, &opacities); + // A weight goes with the paint of its stroke; a stroke without paint leaves the series' paint alone. + let written = edited.iter().copied().filter(|(_, fill)| paint.is_some() || (!*fill && p.get("weight").is_some())); + let mut rows = top_paint_changed(&before, d, &ids); + rows.extend(painted_rows(d, written)); + let painted = super::graph::capture_series_top_paints(d, &rows); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) })?; if let Some(pa) = &paint { s.remember_paint(pa); @@ -832,7 +867,8 @@ fn remove_item(s: &mut Session, p: &Value) -> Result { } } let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); - Ok(count_changed(d, &before, &ids) + usize::from(kept)) + let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) })?; s.remap_appearance_item(&ids, |a| (!idx.contains(&a)).then(|| a - idx.iter().filter(|i| **i < a).count())); ok() @@ -901,6 +937,7 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result { return Err(bad(C, format!("no item at index {}", idx[0]))); } super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); + super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); Ok(()) })?; // Rows at or above each copy move up by one (by the block, for copies placed at `to`). @@ -918,11 +955,14 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result { /// `appearance.showAllHidden`: shows every hidden fill, stroke and effect of the selected objects. fn show_all_hidden(s: &mut Session, p: &Value) -> Result { let roots = require_targets(s, p, "appearance.showAllHidden")?; + let before = s.doc()?.doc.clone(); let ids = s.edit("Show All Hidden Attributes", |d, _| { let shown: Vec = roots.iter().copied().filter(|id| d.node_mut(*id).is_some_and(|n| n.appearance.show_all())).collect(); if shown.is_empty() { return Err(EngineError::Other("Show All Hidden Attributes: nothing is hidden".into())); } + super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &shown)); + super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &shown)); Ok(shown) })?; Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) @@ -962,7 +1002,8 @@ fn move_item(s: &mut Session, p: &Value) -> Result { } } let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); - Ok(count_changed(d, &before, &ids) + usize::from(kept)) + let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) })?; // The moved row stays the active one; the rows it passed shift by one. s.remap_appearance_item(&ids, |a| { @@ -1006,7 +1047,8 @@ fn move_items(s: &mut Session, p: &Value, ids: &[NodeId], to: usize) -> Result Result { picked.apply(n, &attrs, append); } } - let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &targets)); - Ok(count_changed(d, &before, &targets) + usize::from(kept)) + // The paints the pick wrote, with a weight going with the paint of its stroke, as in `set_item`. `append` adds + // only the colors of the visible rows the source has. + let (fill, stroke) = (attrs.appearance.fill, attrs.appearance.stroke); + let source_has = |kind: bool| picked.appearance.items.iter().any(|it| it.is_fill() == kind); + let source_shows = |kind: bool| picked.appearance.items.iter().any(|it| it.is_fill() == kind && it.visible()); + let (paints, opacities) = if append { + let rows = (fill.color && source_shows(true), stroke.color && source_shows(false)); + (rows, rows) + } else { + ( + (fill.color, stroke.color || (stroke.weight && source_has(false))), + (fill.transparency && source_has(true), stroke.transparency && source_has(false)), + ) + }; + // A pick of some attributes edits the topmost row of its kind, which may be hidden; `append` and a whole pick write rows. + let rows_written = append || (fill == FillAttrs::ALL && stroke == StrokeAttrs::ALL); + let top_shows = |id: NodeId, kind: bool| { + let items = d.node(id).map_or(&[][..], |n| &n.appearance.items[..]); + items.iter().rev().find(|it| it.is_fill() == kind).is_some_and(|it| it.visible()) + }; + let written = |(fill, stroke): (bool, bool)| -> Vec<(NodeId, bool)> { + targets + .iter() + .flat_map(|id| [fill.then_some((*id, true)), stroke.then_some((*id, false))]) + .flatten() + .filter(|(id, kind)| rows_written || top_shows(*id, *kind)) + .collect() + }; + let mut opacity_rows = top_opacity_changed(&before, d, &targets); + opacity_rows.extend(painted_rows(d, written(opacities))); + let mut rows = top_paint_changed(&before, d, &targets); + rows.extend(painted_rows(d, written(paints))); + let kept = super::graph::capture_series_opacity(d, &opacity_rows); + let painted = super::graph::capture_series_top_paints(d, &rows); + Ok(count_changed(d, &before, &targets) + usize::from(kept || painted)) })?; Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::>() })) } diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index e88e2810c..7a47457c2 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -42,7 +42,7 @@ use data::{fmt_value, parse_csv, to_csv}; use generate::generate; #[cfg(test)] use series::default_series_color; -pub(crate) use series::{capture_label_styles, capture_series_opacity, capture_series_paints, paint_targets}; +pub(crate) use series::{capture_label_styles, capture_series_opacity, capture_series_paints, capture_series_top_paints, paint_targets, shown_paint}; #[cfg(test)] mod tests; diff --git a/crates/engine/src/cmd/graph/axis.rs b/crates/engine/src/cmd/graph/axis.rs index 52e49dbee..47858d395 100644 --- a/crates/engine/src/cmd/graph/axis.rs +++ b/crates/engine/src/cmd/graph/axis.rs @@ -1,4 +1,4 @@ -use super::Value; +use super::{GraphSpec, Value}; pub(super) const LABEL_SIZE: f64 = 9.0; @@ -32,6 +32,11 @@ pub(super) fn nice_axis(lo: f64, hi: f64, ticks: usize) -> (f64, f64, f64) { ((lo / step).floor() * step, (hi / step).ceil() * step, step) } +/// The value axis of a radar graph: from 0 to the largest value of any series. +pub(super) fn radar_axis(g: &GraphSpec) -> (f64, f64, f64) { + value_axis(g.ticks, g.axis_min, g.axis_max, 0.0, g.rows.iter().flatten().copied().fold(0.0, f64::max)) +} + /// A value axis: with both `min` and `max` set (Graph Type › Tick Values › Override Calculated Values), exactly that /// range split into `ticks` divisions (5 when automatic); otherwise a nice axis around the data, using whichever bound /// was given. diff --git a/crates/engine/src/cmd/graph/generate.rs b/crates/engine/src/cmd/graph/generate.rs index 6db9a0fe4..d1a5ddde0 100644 --- a/crates/engine/src/cmd/graph/generate.rs +++ b/crates/engine/src/cmd/graph/generate.rs @@ -1,8 +1,8 @@ -use super::axis::{LABEL_SIZE, MAX_CATEGORY_TICKS, MAX_TICK_MARKS, one_line, tick_values, value_axis}; +use super::axis::{LABEL_SIZE, MAX_CATEGORY_TICKS, MAX_TICK_MARKS, one_line, radar_axis, tick_values, value_axis}; use super::columns::{Allowance, ColumnArt, column_art, column_designs}; use super::data::fmt_value; use super::labels::{ - FILLED_LINE_BUDGET, Gen, Role, SHADOW_BUDGET, SHADOW_OPACITY, blank_label, label_size, label_width, polyline, recolour_text, runs, shadow_of, + Gen, Role, SHADOW_BUDGET, SHADOW_OPACITY, blank_label, filled_lines_on, label_size, label_width, polyline, recolour_text, runs, shadow_of, }; use super::markers::{MARKER_BUDGET, marker_cost, marker_designs}; use super::series::{series_marks, with_series_opacity}; @@ -264,7 +264,7 @@ impl<'a> Plot<'a> { right_edge: g.rect.x1, wedge_labels: vec![], on_top: vec![], - filled: g.filled_lines && g.rows.len().saturating_mul(nser) <= FILLED_LINE_BUDGET, + filled: filled_lines_on(g, nser), bars_reach: None, shadowed: vec![], series: vec![vec![]; nser], @@ -422,7 +422,7 @@ impl<'a> Plot<'a> { let (g, r, ncat, cs) = (self.g, self.r, self.ncat, self.cs); let centre = r.center(); let rad = (r.width().min(r.height()) / 2.0).max(1.0); - let (lo, hi, step) = value_axis(g.ticks, g.axis_min, g.axis_max, 0.0, g.rows.iter().flatten().copied().fold(0.0, f64::max)); + let (lo, hi, step) = radar_axis(g); let at = |c: usize, v: f64| { let a = -std::f64::consts::FRAC_PI_2 + std::f64::consts::TAU * c as f64 / ncat as f64; centre + vectorcraft_geom::Vec2::new(a.cos(), a.sin()) * (rad * ((v - lo) / (hi - lo)).clamp(0.0, 1.0)) diff --git a/crates/engine/src/cmd/graph/labels.rs b/crates/engine/src/cmd/graph/labels.rs index 664356499..fdd640a95 100644 --- a/crates/engine/src/cmd/graph/labels.rs +++ b/crates/engine/src/cmd/graph/labels.rs @@ -90,9 +90,10 @@ impl Gen<'_> { }; // A run the outline can't be made for (its points all in one place) stays a thin line. if !band.is_empty() { - // The band takes the series' colour, set as a fill or, on a thin line, as its stroke; a thin black outline. + // The band takes the series' color, set as a fill or, on a thin line, as a stroke with paint; a thin black outline. let slot = g.series_paints.get(s); - let fill = slot.and_then(|p| p.fill.clone().or_else(|| p.stroke.clone())).unwrap_or_else(|| default_series_paint(s)); + let fill = + slot.and_then(|p| p.fill.clone().or_else(|| p.stroke.clone().filter(|st| !st.is_none()))).unwrap_or_else(|| default_series_paint(s)); return self.path(band, fill, Paint::solid(Color::BLACK), 0.25); } let (fill, stroke, w) = series_marks(g, s, false, default_series_paint(s), 1.0); @@ -140,7 +141,12 @@ pub(super) const SHADOW_OPACITY: f32 = 0.35; /// Draw Filled Lines: how wide a filled line is. const FILLED_LINE_WIDTH: f64 = 3.0; -pub(super) const FILLED_LINE_BUDGET: usize = 20_000; +const FILLED_LINE_BUDGET: usize = 20_000; + +/// Does graph `g`, with `series` series, draw Filled Lines (up to FILLED_LINE_BUDGET data points)? +pub(super) fn filled_lines_on(g: &GraphSpec, series: usize) -> bool { + g.filled_lines && g.rows.len().saturating_mul(series) <= FILLED_LINE_BUDGET +} /// Most marks a graph draws shadows for (each is a copy). pub(super) const SHADOW_BUDGET: usize = 200_000; diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index 1b864ba92..8ab2573ff 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -1,4 +1,5 @@ -use super::labels::{Role, label_style}; +use super::axis::radar_axis; +use super::labels::{Role, filled_lines_on, label_style}; use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Result, Session, Value, bad}; // ---------- generation ---------- @@ -121,11 +122,82 @@ pub(crate) fn capture_series_paints(doc: &mut Document, ids: &[NodeId], fill: bo let node = doc.node(*id)?; let paint = node.appearance.paint_at(None, fill)?.clone(); let width = (!fill).then(|| node.appearance.stroke_width()); - Some((r.graph, r.index, paint, width)) + Some((r.graph, r.index, fill, paint, width)) }) .collect(); + store_series_paints(doc, captures) +} + +/// The paint of the topmost visible fill (`true`) or stroke (`false`) of `node`, and a stroke's weight (0 without paint). +/// With no such row: no paint. +pub(crate) fn shown_paint(node: &Node, fill: bool) -> (Paint, f64) { + match node.appearance.items.iter().rev().find(|it| it.is_fill() == fill && it.visible()) { + Some(vectorcraft_doc::AppearanceItem::Stroke(st)) if !st.paint.is_none() => (st.paint.clone(), st.width), + Some(it) => (it.paint().clone(), 0.0), + None => (Paint::None, 0.0), + } +} + +/// Copy the paint of the topmost visible fill (`true`) or stroke (`false`), and a stroke's weight, of generated series +/// art just edited (`edited`: each object with the kind of row, later entries replacing earlier ones) into that series' +/// fill or stroke; art left without such a row keeps no paint. A series that draws Filled Lines bands stores no stroke: its +/// bands keep their own outline. Called inside the same document edit, so both undo together. Returns whether a series' +/// captured fill or stroke changed. +pub(crate) fn capture_series_top_paints(doc: &mut Document, edited: &[(NodeId, bool)]) -> bool { + if edited.is_empty() { + return false; + } + let members = series_members(doc); + if members.is_empty() { + return false; + } + // Each series is checked once: the check reads the graph's whole grid. + let mut bands: HashMap<(NodeId, usize), bool> = HashMap::new(); + let mut captures = Vec::new(); + for (id, fill) in edited { + // Only the paths the graph draws again, not the copy of a graph design. + let Some(r) = members.get(id).filter(|r| doc.parent_of(*id) == Some(r.group)) else { continue }; + let Some(node) = doc.node(*id).filter(|n| matches!(n.kind, NodeKind::Path { .. })) else { continue }; + let drawn = || doc.node(r.graph).and_then(|n| n.extras().graph.as_deref()).is_some_and(|g| series_draws_bands(g, r.index)); + if !*fill && *bands.entry((r.graph, r.index)).or_insert_with(drawn) { + continue; + } + let (paint, width) = shown_paint(node, *fill); + captures.push((r.graph, r.index, *fill, paint, (!*fill).then_some(width))); + } + store_series_paints(doc, captures) +} + +/// Does series `i` draw any Filled Lines band? A line needs two neighboring values, a scatter series two neighboring points +/// in different places (its index counts pairs of columns), and a radar series two categories with a value plotted away +/// from the center. +fn series_draws_bands(g: &GraphSpec, i: usize) -> bool { + let series = g.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES); + if !g.connect_points || !filled_lines_on(g, series) { + return false; + } + let cells = g.cells(); + let cell = |row: &[Option], s: usize| row.get(s).copied().flatten(); + match g.series_kind(i) { + GraphKind::Line => cells.windows(2).any(|w| w.iter().all(|row| cell(row, i).is_some())), + GraphKind::Radar => { + let (lo, hi, _) = radar_axis(g); + cells.len() > 1 && cells.iter().any(|row| (cell(row, i).unwrap_or(0.0) - lo) / (hi - lo) > 0.0) + } + GraphKind::Scatter => { + let s = i.saturating_mul(2); + let point = |row: &[Option]| Some((cell(row, s)?, cell(row, s.saturating_add(1))?)); + cells.windows(2).any(|w| matches!(w, [a, b] if point(a).is_some() && point(b).is_some() && point(a) != point(b))) + } + _ => false, + } +} + +/// Store each capture (graph, series index, fill or stroke, paint, stroke weight) in its series. Returns whether a +/// series' captured fill or stroke changed. +fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, Paint, Option)>) -> bool { let mut changed = false; - for (graph, index, paint, width) in captures { + for (graph, index, fill, paint, width) in captures { let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; let was = spec.series_paints.clone(); // `index` is below the graph's series count (`series_members`). @@ -144,7 +216,7 @@ pub(crate) fn capture_series_paints(doc: &mut Document, ids: &[NodeId], fill: bo changed } -/// Copy the opacity of the topmost fill (`true`) or stroke (`false`) of generated series art just edited (`edited`: +/// Copy the opacity of the topmost visible fill (`true`) or stroke (`false`) of generated series art just edited (`edited`: /// each object with the kind of row) into that series' fill or stroke opacity; art left without a row of that kind /// keeps none. Called inside the same document edit, so both undo together. Returns whether a series' kept opacity /// changed. @@ -162,7 +234,8 @@ pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool // Only the paths the graph draws again, not the copy of a graph design. let r = members.get(id).filter(|r| doc.parent_of(*id) == Some(r.group))?; let node = doc.node(*id).filter(|n| matches!(n.kind, NodeKind::Path { .. }))?; - let opacity = node.appearance.items.iter().rev().find(|it| it.is_fill() == *fill).map_or(1.0, vectorcraft_doc::AppearanceItem::opacity); + let row = node.appearance.items.iter().rev().find(|it| it.is_fill() == *fill && it.visible()); + let opacity = row.map_or(1.0, vectorcraft_doc::AppearanceItem::opacity); Some((r.graph, r.index, *fill, (opacity < 1.0).then_some(opacity))) }) .collect(); diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 2be4bbf48..89bb7a4c0 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -503,6 +503,760 @@ fn a_stroke_opacity_on_a_series_line_survives_regeneration() { assert!(!after.is_empty() && after.iter().all(|m| m.appearance.stroke().unwrap().opacity == 0.6)); } +/// The colors of series `index`'s marks: each mark's top fill and stroke paint and its stroke weight. +fn marks_of(s: &Session, id: NodeId, index: u32) -> Vec<(Paint, Paint, f64)> { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let marks = series(&n, index).children().unwrap().to_vec(); + marks.iter().map(|m| (m.appearance.fill_paint(), m.appearance.stroke_paint(), m.appearance.stroke_width())).collect() +} + +/// Set the top fill of series 0's first mark to the color `hex` with `appearance.setItem`, and return that paint. +fn hex_paint(s: &mut Session, id: NodeId, hex: &str) -> Paint { + let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id; + let row = s.doc().unwrap().doc.node(bar).unwrap().appearance.items.iter().rposition(vectorcraft_doc::AppearanceItem::is_fill).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": row, "color": hex})).unwrap(); + s.doc().unwrap().doc.node(bar).unwrap().appearance.fill_paint() +} + +/// A color or weight set with `appearance.setItem` on the topmost fill or stroke of one series mark is kept by the +/// graph: every mark of that series has it after the graph is drawn again, and undo takes it back with its edit. A row +/// under the topmost one is not kept, and a change to one kind of row keeps nothing for the other. +#[test] +fn an_appearance_item_paint_on_a_series_mark_survives_regeneration() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let blue = hex_paint(&mut s, id, "#0000ff"); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.stroke, kept.stroke_width), (Some(blue.clone()), None, None)); + let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id; + let row = s.doc().unwrap().doc.node(bar).unwrap().appearance.items.iter().rposition(|it| !it.is_fill()).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": row, "color": "#ff0000", "weight": 3})).unwrap(); + let red = s.doc().unwrap().doc.node(bar).unwrap().appearance.stroke_paint(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.stroke, kept.stroke_width), (Some(red.clone()), Some(3.0))); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| *m == (blue.clone(), red.clone(), 3.0)), "{:?}", marks_of(&s, id, 0)); + assert!(marks_of(&s, id, 1).iter().all(|m| m.0 != blue)); + // Undo goes back through the data edit, then takes the stroke paint away with its edit. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.stroke, kept.stroke_width), (Some(blue.clone()), None, None)); + // The graph does not keep a color set on a fill under the topmost one. + let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id; + s.execute("appearance.addFill", &json!({"ids": [bar.0]})).unwrap(); + let (lower, _) = fill_rows(&s, bar); + s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": lower, "color": "#00ff00"})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(blue)); +} + +/// A weight set on a stroke row, with no color, is kept by the graph with the row's paint. +#[test] +fn an_appearance_item_weight_on_a_series_mark_survives_regeneration() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone(); + let row = bar.appearance.items.iter().rposition(|it| !it.is_fill()).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [bar.id.0], "index": row, "color": "#ff0000"})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [bar.id.0], "index": row, "weight": 4})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(4.0)); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| m.2 == 4.0), "{:?}", marks_of(&s, id, 0)); +} + +/// A column graph with series 0 kept blue at 40% opacity: the graph, the paint, the first marks of series 0 and 1. +fn blue_source() -> (Session, NodeId, Paint, NodeId, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let blue = hex_paint(&mut s, id, "#0000ff"); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let (from, to) = (series(&n, 0).children().unwrap()[0].id, series(&n, 1).children().unwrap()[0].id); + s.execute("transparency.set", &json!({"ids": [from.0], "item": 0, "opacity": 40})).unwrap(); + (s, id, blue, from, to) +} + +/// After the graph is drawn again, every mark of series 1 is `blue` at 40%; undo goes back through the data edit and the +/// command before it, which restores the paints the graph kept in `was`. +fn series_1_is_blue_at_40_then_undo(s: &mut Session, id: NodeId, blue: &Paint, was: &[vectorcraft_doc::SeriesPaint]) { + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + for m in series(&n, 1).children().unwrap() { + let f = m.appearance.fill().unwrap(); + assert_eq!((f.paint.clone(), f.opacity), (blue.clone(), 0.4)); + } + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(s, id).series_paints, was); +} + +/// The topmost fill (`true`) or stroke (`false`) row of `mark`. +fn top_row(s: &Session, mark: NodeId, fill: bool) -> usize { + s.doc().unwrap().doc.node(mark).unwrap().appearance.items.iter().rposition(|it| it.is_fill() == fill).unwrap() +} + +/// A new column graph, the fill of series 0's marks and those marks' ids. +fn column_marks() -> (Session, NodeId, Paint, Vec) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let marks = series(&n, 0).children().unwrap().to_vec(); + (s, id, marks[0].appearance.fill_paint(), marks.iter().map(|m| m.id).collect()) +} + +/// An appearance moved onto a mark of another series makes the graph keep its paint with its opacity for that series, +/// and leaves the source series without paint. +#[test] +fn moving_an_appearance_onto_a_series_mark_keeps_its_paint() { + let (mut s, id, blue, from, to) = blue_source(); + let was = spec(&s, id).series_paints; + s.execute("appearance.transfer", &json!({"source": from.0, "target": to.0})).unwrap(); + let paints = spec(&s, id).series_paints; + assert_eq!((paints[0].fill.clone(), paints[1].fill.clone()), (Some(Paint::None), Some(blue.clone()))); + series_1_is_blue_at_40_then_undo(&mut s, id, &blue, &was); +} + +/// An appearance copied onto a mark of another series makes the graph keep its paint with its opacity for that series. +#[test] +fn copying_an_appearance_onto_a_series_mark_keeps_its_paint() { + let (mut s, id, blue, from, to) = blue_source(); + let was = spec(&s, id).series_paints; + s.execute("appearance.transfer", &json!({"source": from.0, "target": to.0, "copy": true})).unwrap(); + assert_eq!(spec(&s, id).series_paints[1].fill, Some(blue.clone())); + series_1_is_blue_at_40_then_undo(&mut s, id, &blue, &was); +} + +/// An Eyedropper pick onto a mark of another series makes the graph keep its paint with its opacity for that series. +#[test] +fn an_eyedropper_pick_onto_a_series_mark_keeps_its_paint() { + let (mut s, id, blue, from, to) = blue_source(); + let was = spec(&s, id).series_paints; + s.execute("appearance.copyFrom", &json!({"source": from.0, "ids": [to.0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints[1].fill, Some(blue.clone())); + series_1_is_blue_at_40_then_undo(&mut s, id, &blue, &was); +} + +/// A reversed Eyedropper (Alt-click) copies onto the clicked mark, and the graph keeps the paint for the clicked series. +#[test] +fn a_reversed_eyedropper_pick_keeps_its_paint_on_the_clicked_series() { + let (mut s, id, blue, from, to) = blue_source(); + let was = spec(&s, id).series_paints; + s.execute("appearance.copyFrom", &json!({"source": to.0, "ids": [from.0], "reverse": true})).unwrap(); + assert_eq!(spec(&s, id).series_paints[1].fill, Some(blue.clone())); + series_1_is_blue_at_40_then_undo(&mut s, id, &blue, &was); +} + +/// A reversed Eyedropper with nothing selected copies what it picked up last onto the clicked mark. +#[test] +fn a_reversed_eyedropper_pick_of_the_last_sample_keeps_its_paint() { + let (mut s, id, blue, from, to) = blue_source(); + s.execute("appearance.copyFrom", &json!({"source": from.0, "ids": []})).unwrap(); + s.execute("select.set", &json!({"ids": []})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": to.0, "reverse": true})).unwrap(); + assert_eq!(spec(&s, id).series_paints[1].fill, Some(blue)); +} + +/// Clear Appearance on a series mark leaves it with no fill and no stroke, and the series stays that way when the graph +/// is drawn again. Undo brings the kept paint back. +#[test] +fn clearing_a_series_marks_appearance_updates_its_kept_paint() { + let (mut s, id, _, marks) = column_marks(); + let row = top_row(&s, marks[0], false); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": row, "color": "#ff0000", "weight": 2})).unwrap(); + let was = spec(&s, id).series_paints; + s.execute("appearance.clear", &json!({"ids": [marks[0].0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.stroke, kept.stroke_width), (Some(Paint::None), Some(Paint::None), Some(0.0))); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| *m == (Paint::None, Paint::None, 0.0)), "{:?}", marks_of(&s, id, 0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was); +} + +/// A mark with `blue` under a second fill: the graph, the paint, the mark and its (lower, upper) fill rows, with the +/// upper row green and kept. +fn two_fills() -> (Session, NodeId, Paint, NodeId, usize, usize) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let blue = hex_paint(&mut s, id, "#0000ff"); + let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id; + s.execute("appearance.addFill", &json!({"ids": [bar.0]})).unwrap(); + let (lower, upper) = fill_rows(&s, bar); + s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": upper, "color": "#00ff00"})).unwrap(); + assert_ne!(spec(&s, id).series_paints[0].fill, Some(blue.clone())); + (s, id, blue, bar, lower, upper) +} + +/// `cmd` makes the graph keep `blue`, and undo brings back the paints it kept before. +fn top_row_command_keeps_blue(s: &mut Session, id: NodeId, blue: &Paint, cmd: &str, p: &serde_json::Value) { + let was = spec(s, id).series_paints; + s.execute(cmd, p).unwrap(); + assert_eq!(spec(s, id).series_paints[0].fill.as_ref(), Some(blue), "{cmd}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(s, id).series_paints, was, "{cmd}"); +} + +/// Reduce to Basic Appearance with the lower fill as the active row leaves that fill, and the graph keeps its paint. +#[test] +fn reducing_a_series_marks_appearance_keeps_the_row_it_leaves() { + let (mut s, id, blue, bar, lower, _) = two_fills(); + s.execute("select.set", &json!({"ids": [bar.0]})).unwrap(); + s.execute("appearance.setActiveItem", &json!({"index": lower})).unwrap(); + top_row_command_keeps_blue(&mut s, id, &blue, "appearance.reduceToBasic", &json!({})); +} + +/// Removing a mark's top row makes the graph keep the paint now on top. +#[test] +fn removing_a_series_marks_top_row_keeps_the_paint_now_on_top() { + let (mut s, id, blue, bar, _, upper) = two_fills(); + top_row_command_keeps_blue(&mut s, id, &blue, "appearance.removeItem", &json!({"ids": [bar.0], "index": upper})); +} + +/// Moving a lower row above the others makes the graph keep its paint. +#[test] +fn moving_a_row_to_a_series_marks_top_keeps_its_paint() { + let (mut s, id, blue, bar, lower, upper) = two_fills(); + top_row_command_keeps_blue(&mut s, id, &blue, "appearance.moveItem", &json!({"ids": [bar.0], "from": lower, "to": upper})); +} + +/// Moving a block of rows above the others makes the graph keep the paint now on top. +#[test] +fn moving_a_block_of_rows_to_a_series_marks_top_keeps_its_paint() { + let (mut s, id, blue, bar, lower, upper) = two_fills(); + top_row_command_keeps_blue(&mut s, id, &blue, "appearance.moveItem", &json!({"ids": [bar.0], "indices": [lower], "to": upper})); +} + +/// A copy of a lower row placed on top makes the graph keep its paint. +#[test] +fn a_copy_placed_on_a_series_marks_top_keeps_its_paint() { + let (mut s, id, blue, bar, lower, upper) = two_fills(); + top_row_command_keeps_blue(&mut s, id, &blue, "appearance.duplicateItem", &json!({"ids": [bar.0], "index": lower, "to": upper + 1})); +} + +/// The mark's only stroke row removed: the series draws without a stroke; a new row comes back as its paint. +#[test] +fn removing_and_adding_a_series_marks_only_stroke_updates_its_kept_paint() { + let (mut s, id, _, marks) = column_marks(); + let bar = marks[0]; + let row = top_row(&s, bar, false); + s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": row, "color": "#ff0000", "weight": 2})).unwrap(); + s.execute("appearance.removeItem", &json!({"ids": [bar.0], "index": row})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.stroke, kept.stroke_width), (None, Some(Paint::None), Some(0.0))); + let was = spec(&s, id).series_paints; + s.execute("appearance.addStroke", &json!({"ids": [bar.0]})).unwrap(); + let stroke = s.doc().unwrap().doc.node(bar).unwrap().appearance.stroke_paint(); + assert!(!stroke.is_none()); + assert_eq!(spec(&s, id).series_paints[0].stroke, Some(stroke)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was); +} + +/// Hiding the top row makes the graph keep the visible row under it, and its opacity; hiding the only one keeps no paint; +/// Show All Hidden Attributes brings the top row back. A hidden row on top of a moved appearance is not kept. +#[test] +fn a_hidden_top_row_is_not_what_the_graph_keeps() { + let (mut s, id, blue, bar, lower, upper) = two_fills(); + let green = s.doc().unwrap().doc.node(bar).unwrap().appearance.fill_paint(); + s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": lower, "opacity": 50})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None, "the row under the top one"); + s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": upper, "visible": false})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.fill_opacity), (Some(blue.clone()), Some(0.5))); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == blue), "{:?}", marks_of(&s, id, 0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("appearance.showAllHidden", &json!({"ids": [bar.0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.fill_opacity), (Some(green), None)); + + // A transfer of [blue, hidden green] onto another series keeps the blue the source shows. + let (mut s, id, blue, bar, _, upper) = two_fills(); + s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": upper, "visible": false})).unwrap(); + let to = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + s.execute("appearance.transfer", &json!({"source": bar.0, "target": to.0, "copy": true})).unwrap(); + assert_eq!(spec(&s, id).series_paints[1].fill, Some(blue)); + + let (mut s, id, _, marks) = column_marks(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "visible": false})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(Paint::None)); +} + +/// Hiding a top row and Show All Hidden Attributes each undo in one step, with what the graph stores. +#[test] +fn hiding_and_showing_a_series_marks_top_row_undo_with_the_stored_paint() { + let (mut s, id, _, bar, _, upper) = two_fills(); + let was = spec(&s, id).series_paints; + s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": upper, "visible": false})).unwrap(); + let hidden = spec(&s, id).series_paints; + assert_ne!(hidden, was); + s.execute("appearance.showAllHidden", &json!({"ids": [bar.0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, hidden); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was); +} + +/// An opaque Eyedropper pick onto a mark replaces the opacity another mark of the series stored, even though the mark +/// itself stays opaque: the series is drawn with the picked color at 100%. +#[test] +fn an_opaque_pick_replaces_the_opacity_the_series_stores() { + let (mut s, id, _, marks) = column_marks(); + let fill = top_row(&s, marks[0], true); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "opacity": 40})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill_opacity, Some(0.4)); + let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + s.execute("appearance.setItem", &json!({"ids": [source.0], "index": top_row(&s, source, true), "color": "#0000ff"})).unwrap(); + let was = spec(&s, id).series_paints; + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + assert!(series(&n, 0).children().unwrap().iter().all(|m| m.appearance.fill().unwrap().opacity == 1.0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was); + + // A pick of the opacity alone replaces it too, and a pick that leaves opacity out keeps it. + let (mut s, id, _, marks) = column_marks(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "opacity": 40})).unwrap(); + let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + let colors_only = json!({"appearance": {"fill": {"color": true, "transparency": false}, "stroke": {"color": false}}}); + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "pickUp": colors_only})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill_opacity, Some(0.4)); + let opacity_only = json!({"appearance": {"fill": {"color": false, "transparency": true}, "stroke": {"color": false}}}); + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "pickUp": opacity_only})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None); +} + +/// An appearance moved between two marks of one series leaves the series with the target's paint, not the source's +/// empty one, and the series still shows when the graph is drawn again. +#[test] +fn moving_an_appearance_within_a_series_keeps_the_targets_paint() { + let (mut s, id, shown, marks) = column_marks(); + s.execute("appearance.transfer", &json!({"source": marks[0].0, "target": marks[1].0})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(shown.clone())); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == shown), "{:?}", marks_of(&s, id, 0)); +} + +/// A row shown again with the color the mark already shows replaces the paint another mark of the series stored. +#[test] +fn showing_a_row_with_the_color_a_mark_shows_replaces_the_kept_paint() { + let (mut s, id, shown, marks) = column_marks(); + s.execute("appearance.addFill", &json!({"ids": [marks[0].0]})).unwrap(); + let (_, upper) = fill_rows(&s, marks[0]); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": upper, "visible": false})).unwrap(); + let other = top_row(&s, marks[1], true); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": other, "color": "#ff0000"})).unwrap(); + assert_ne!(spec(&s, id).series_paints[0].fill, Some(shown.clone())); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": upper, "visible": true, "color": "#000000"})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint())); +} + +/// A color or weight set again to what a mark already shows and an Eyedropper pick that leaves the mark as it was replace +/// the paint another mark of the series stored, in an undo step of their own. Clear Appearance on a clear mark leaves the +/// stored paint and the undo history as they are. +#[test] +fn writing_the_paint_a_series_mark_already_shows_replaces_the_kept_paint() { + let (mut s, id, shown, marks) = column_marks(); + let fill = top_row(&s, marks[0], true); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap(); + assert_ne!(spec(&s, id).series_paints[0].fill, Some(shown.clone())); + let was = spec(&s, id).series_paints; + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": fill, "color": "#000000"})).unwrap(); + let again = s.doc().unwrap().doc.node(marks[1]).unwrap().appearance.fill_paint(); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(again)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was); + + let (mut s, id, _, marks) = column_marks(); + let stroke = top_row(&s, marks[0], false); + for (mark, weight) in [(marks[1], 2), (marks[0], 5), (marks[1], 2)] { + s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": stroke, "color": "#ff0000", "weight": weight})).unwrap(); + } + assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(2.0)); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "weight": 5})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "weight": 2})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(2.0)); + + let (mut s, id, shown, marks) = column_marks(); + let fill = top_row(&s, marks[0], true); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": marks[2].0, "ids": [marks[1].0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(shown)); + + let (mut s, id, _, marks) = column_marks(); + let fill = top_row(&s, marks[0], true); + s.execute("appearance.clear", &json!({"ids": [marks[1].0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap(); + let was = spec(&s, id).series_paints; + let shown = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint(); + s.execute("appearance.clear", &json!({"ids": [marks[1].0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was); + s.execute("edit.undo", &json!({})).unwrap(); + assert_ne!(s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint(), shown, "the undo took back the color, not a clear"); +} + +/// A line graph with a marker: the graph, the first thin line, a marker. +fn line_and_marker() -> (Session, NodeId, NodeId, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "line"); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let marks = series(&n, 0).children().unwrap().to_vec(); + let line = marks.iter().find(|m| m.appearance.stroke_width() > 0.0).unwrap().id; + let marker = marks.iter().find(|m| m.appearance.stroke_width() == 0.0 && m.appearance.stroke().is_some()).unwrap().id; + (s, id, line, marker) +} + +/// The first thin line of series 0, and every other path of the series (its markers and legend swatch). +fn line_parts(s: &Session, id: NodeId) -> (vectorcraft_doc::Node, Vec) { + let parts = series_parts(s, id, 0); + let (lines, others): (Vec<_>, Vec<_>) = + parts.into_iter().partition(|m| m.appearance.fill_paint().is_none() && m.appearance.stroke_width() == 1.0); + (lines[0].clone(), others) +} + +/// On a line graph, a stroke set on the thin line goes on the line after a redraw; a fill set on the thin line goes on +/// the markers and the legend swatch, and the line stays without a fill. +#[test] +fn a_line_series_draws_its_kept_stroke_on_the_line_and_its_kept_fill_on_the_markers() { + let (mut s, id, line, _) = line_and_marker(); + s.execute("appearance.setItem", &json!({"ids": [line.0], "index": top_row(&s, line, false), "color": "#ff0000"})).unwrap(); + let red = s.doc().unwrap().doc.node(line).unwrap().appearance.stroke_paint(); + s.execute("appearance.setItem", &json!({"ids": [line.0], "index": top_row(&s, line, true), "color": "#0000ff"})).unwrap(); + let blue = s.doc().unwrap().doc.node(line).unwrap().appearance.fill_paint(); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let (drawn, others) = line_parts(&s, id); + assert_eq!((drawn.appearance.fill_paint(), drawn.appearance.stroke_paint()), (Paint::None, red)); + assert!(others.iter().all(|m| m.appearance.fill_paint() == blue), "{others:?}"); +} + +/// Clear Appearance on a thin line leaves the fill the graph stores for the markers; on a marker that shows no stroke, it +/// leaves the stroke the graph stores for the line. An Eyedropper pick of a stroke-only source onto the line and a copy +/// of one line onto another leave the stored fill too. +#[test] +fn a_row_left_without_paint_leaves_the_paint_the_series_stores() { + let (mut s, id, line, marker) = line_and_marker(); + s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": top_row(&s, marker, true), "color": "#0000ff"})).unwrap(); + let blue = spec(&s, id).series_paints[0].fill.clone().unwrap(); + s.execute("appearance.clear", &json!({"ids": [line.0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill.as_ref(), kept.stroke.as_ref()), (Some(&blue), Some(&Paint::None))); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let parts = series_parts(&s, id, 0); + assert!(parts.iter().filter(|m| !m.appearance.fill_paint().is_none()).all(|m| m.appearance.fill_paint() == blue)); + assert!(parts.iter().any(|m| m.appearance.fill_paint() == blue), "the markers keep their fill"); + + let (mut s, id, line, marker) = line_and_marker(); + s.execute("appearance.setItem", &json!({"ids": [line.0], "index": top_row(&s, line, false), "color": "#ff0000"})).unwrap(); + let red = s.doc().unwrap().doc.node(line).unwrap().appearance.stroke_paint(); + s.execute("appearance.clear", &json!({"ids": [marker.0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke, Some(red.clone()), "the marker showed no stroke"); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert_eq!(line_parts(&s, id).0.appearance.stroke_paint(), red); + + let (mut s, id, line, marker) = line_and_marker(); + s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": top_row(&s, marker, true), "color": "#0000ff"})).unwrap(); + let blue = spec(&s, id).series_paints[0].fill.clone().unwrap(); + let other = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + s.execute("appearance.copyFrom", &json!({"source": other.0, "ids": [line.0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(blue.clone()), "the source's line has no fill"); + s.execute("appearance.transfer", &json!({"source": other.0, "target": line.0, "copy": true})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(blue)); +} + +/// A line graph with Filled Lines on: the session, the graph and series 0's band. +fn band_graph() -> (Session, NodeId, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "line", ",a\nQ1,2\nQ2,3\nQ3,2"); + s.execute("graph.setType", &json!({"id": id.0, "filledLines": true})).unwrap(); + let band = series_parts(&s, id, 0)[0].id; + (s, id, band) +} + +/// The band's fill, outline paint and outline weight after the graph is drawn again. +fn redrawn_band(s: &mut Session, id: NodeId) -> (Paint, Paint, f64) { + s.execute("graph.setData", &json!({"id": id.0, "rows": [[2], [3], [4]]})).unwrap(); + let band = series_parts(s, id, 0)[0].clone(); + (band.appearance.fill_paint(), band.appearance.stroke_paint(), band.appearance.stroke_width()) +} + +/// A fill set on a Filled Lines band is kept, and the band keeps its thin black outline. +#[test] +fn a_filled_line_band_keeps_a_fill_set_on_it() { + let (mut s, id, band) = band_graph(); + s.execute("appearance.setItem", &json!({"ids": [band.0], "index": top_row(&s, band, true), "color": "#0000ff"})).unwrap(); + let blue = s.doc().unwrap().doc.node(band).unwrap().appearance.fill_paint(); + assert_eq!(redrawn_band(&mut s, id), (blue, Paint::solid(vectorcraft_color::Color::BLACK), 0.25)); +} + +/// A stroke set on a Filled Lines band is not kept: the band keeps its color and its thin black outline. +#[test] +fn a_stroke_set_on_a_filled_line_band_does_not_repaint_it() { + let (mut s, id, band) = band_graph(); + let was = series_parts(&s, id, 0)[0].appearance.fill_paint(); + let row = top_row(&s, band, false); + s.execute("appearance.setItem", &json!({"ids": [band.0], "index": row, "color": "#ff0000", "weight": 3})).unwrap(); + assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.stroke.is_none())); + assert_eq!(redrawn_band(&mut s, id), (was, Paint::solid(vectorcraft_color::Color::BLACK), 0.25)); +} + +/// A Filled Lines band's stroke row removed with the Appearance panel or set to None with `paint.setStroke` leaves the +/// band its color: an empty stored stroke is not a color for the band. +#[test] +fn removing_a_filled_line_bands_stroke_leaves_its_color() { + let (mut s, id, band) = band_graph(); + let was = series_parts(&s, id, 0)[0].appearance.fill_paint(); + s.execute("appearance.removeItem", &json!({"ids": [band.0], "index": top_row(&s, band, false)})).unwrap(); + assert_eq!(redrawn_band(&mut s, id).0, was); + + let (mut s, id, band) = band_graph(); + let was = series_parts(&s, id, 0)[0].appearance.fill_paint(); + s.execute("paint.setStroke", &json!({"ids": [band.0], "none": true})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke, Some(Paint::None)); + assert_eq!(redrawn_band(&mut s, id).0, was); +} + +/// With Connect Data Points off a Filled Lines graph draws no bands, so a stroke set on a marker is kept. +#[test] +fn a_stroke_on_a_marker_is_kept_when_filled_lines_draws_no_bands() { + let (mut s, id, _) = band_graph(); + s.execute("graph.setType", &json!({"id": id.0, "connectPoints": false})).unwrap(); + let marker = series_parts(&s, id, 0)[0].id; + s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": top_row(&s, marker, false), "color": "#ff0000"})).unwrap(); + assert!(spec(&s, id).series_paints[0].stroke.as_ref().is_some_and(|p| !p.is_none())); +} + +/// A Filled Lines graph of `ty` and `csv` draws no bands, so a stroke set on its first mark is stored and stays on it after +/// the graph is drawn again. +fn stroke_is_kept_without_bands(ty: &str, csv: &str) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, ty, csv); + s.execute("graph.setType", &json!({"id": id.0, "filledLines": true})).unwrap(); + let mark = series_parts(&s, id, 0)[0].id; + s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, false), "color": "#ff0000", "weight": 2})).unwrap(); + let red = s.doc().unwrap().doc.node(mark).unwrap().appearance.stroke_paint(); + assert_eq!(spec(&s, id).series_paints[0].stroke, Some(red.clone())); + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + let drawn = &series_parts(&s, id, 0)[0]; + assert_eq!((drawn.appearance.stroke_paint(), drawn.appearance.stroke_width()), (red, 2.0)); +} + +#[test] +fn a_stroke_is_kept_on_a_one_row_line_series() { + stroke_is_kept_without_bands("line", ",a\nQ1,2"); +} + +#[test] +fn a_stroke_is_kept_on_a_radar_series_of_zeros() { + stroke_is_kept_without_bands("radar", ",a\nQ1,0\nQ2,0\nQ3,0"); +} + +#[test] +fn a_stroke_is_kept_on_a_scatter_series_of_one_repeated_point() { + stroke_is_kept_without_bands("scatter", "a,b\n1,2\n1,2"); +} + +/// A radar series whose values all fall below `axisMin` is plotted at the center and draws no band, so a stroke set on its +/// marks is kept. A radar series with a value above `axisMin` draws a band and stores no stroke. +#[test] +fn a_radar_series_draws_a_band_only_for_values_away_from_the_center() { + for (csv, kept) in [(",a\nQ1,1\nQ2,2\nQ3,3", true), (",a\nQ1,1\nQ2,15\nQ3,3", false)] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "radar", csv); + s.execute("graph.setType", &json!({"id": id.0, "filledLines": true, "axisMin": 10, "axisMax": 20})).unwrap(); + let mark = series_parts(&s, id, 0)[0].id; + s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, false), "color": "#ff0000", "weight": 2})).unwrap(); + assert_eq!(spec(&s, id).series_paints.first().is_some_and(|p| p.stroke.is_some()), kept, "{csv}"); + } +} + +/// Past the Filled Lines budget the graph draws thin lines, and a stroke set on one is kept. +#[test] +fn a_stroke_on_a_thin_line_past_the_filled_lines_budget_is_kept() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let rows: Vec> = (0..8_000).map(|i| vec![(i % 7) as f64, 1.0, 2.0]).collect(); + let id = NodeId( + s.execute("graph.create", &json!({"type": "line", "x": 0, "y": 0, "width": 300, "height": 200, "rows": rows})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + s.execute("graph.setType", &json!({"id": id.0, "filledLines": true, "markPoints": false})).unwrap(); + let line = series_parts(&s, id, 0)[0].id; + s.execute("appearance.setItem", &json!({"ids": [line.0], "index": top_row(&s, line, false), "color": "#ff0000"})).unwrap(); + assert!(spec(&s, id).series_paints[0].stroke.as_ref().is_some_and(|p| !p.is_none())); +} + +/// A weight of 0 set again on a painted stroke replaces the weight another mark of the series left on the graph. +#[test] +fn a_zero_weight_on_a_painted_stroke_replaces_the_kept_weight() { + let (mut s, id, _, marks) = column_marks(); + let stroke = top_row(&s, marks[0], false); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 0})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#ff0000", "weight": 5})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(5.0)); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "weight": 0})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(0.0)); +} + +/// An Eyedropper `append` of a source with no stroke leaves the stroke the series keeps. +#[test] +fn appending_a_fill_only_appearance_leaves_the_kept_stroke() { + let (mut s, id, _, marks) = column_marks(); + let stroke = top_row(&s, marks[0], false); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#ff0000", "weight": 5})).unwrap(); + let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + s.execute("appearance.removeItem", &json!({"ids": [source.0], "index": top_row(&s, source, false)})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "append": true})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(5.0)); + // A pick of the weight alone from that source leaves the mark and what the series keeps as they were. + let pick = json!({"appearance": {"fill": {"color": false}, "stroke": {"color": false, "weight": true}}}); + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "pickUp": pick})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(5.0)); +} + +/// An Eyedropper `append` of a source with only a hidden fill leaves the fill the series keeps. +#[test] +fn appending_a_hidden_fill_leaves_the_kept_fill() { + let (mut s, id, _, marks) = column_marks(); + let fill = top_row(&s, marks[0], true); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap(); + let kept = spec(&s, id).series_paints[0].fill.clone(); + let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + s.execute("appearance.setItem", &json!({"ids": [source.0], "index": top_row(&s, source, true), "visible": false})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "append": true})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, kept); +} + +/// An Eyedropper pick of the weight alone reads a hidden stroke too, and that weight replaces the one the series keeps. +#[test] +fn picking_the_weight_of_a_hidden_stroke_replaces_the_kept_weight() { + let (mut s, id, _, marks) = column_marks(); + let stroke = top_row(&s, marks[0], false); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#ff0000", "weight": 5})).unwrap(); + let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + let row = top_row(&s, source, false); + s.execute("appearance.setItem", &json!({"ids": [source.0], "index": row, "color": "#00ff00", "weight": 2, "visible": false})).unwrap(); + let pick = json!({"appearance": {"fill": {"color": false}, "stroke": {"color": false, "weight": true}}}); + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "pickUp": pick})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(2.0)); +} + +/// An Eyedropper pick of some attributes edits the mark's topmost stroke. When that row is hidden, the series keeps what +/// it stores instead of taking the visible row under it. +#[test] +fn a_partial_pick_onto_a_hidden_top_row_leaves_the_kept_stroke() { + let (mut s, id, _, marks) = column_marks(); + let stroke = top_row(&s, marks[0], false); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap(); + s.execute("appearance.addStroke", &json!({"ids": [marks[1].0]})).unwrap(); + let hidden = top_row(&s, marks[1], false); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": hidden, "visible": false})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#0000ff", "weight": 5})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + let row = top_row(&s, source, false); + s.execute("appearance.setItem", &json!({"ids": [source.0], "index": row, "color": "#00ff00", "weight": 3})).unwrap(); + let pick = json!({"appearance": {"fill": {"color": false}, "stroke": {"color": true, "weight": true}}}); + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "pickUp": pick})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0], kept); +} + +/// An Eyedropper `append` writes a visible row under the source's hidden top row, and the series stores that row's paint +/// even when the mark already shows the same paint. +#[test] +fn appending_a_visible_row_under_a_hidden_one_replaces_the_kept_stroke() { + let (mut s, id, _, marks) = column_marks(); + let stroke = top_row(&s, marks[0], false); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#0000ff", "weight": 5})).unwrap(); + let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + let row = top_row(&s, source, false); + s.execute("appearance.setItem", &json!({"ids": [source.0], "index": row, "color": "#ff0000", "weight": 2})).unwrap(); + s.execute("appearance.addStroke", &json!({"ids": [source.0]})).unwrap(); + let hidden = top_row(&s, source, false); + s.execute("appearance.setItem", &json!({"ids": [source.0], "index": hidden, "visible": false})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "append": true})).unwrap(); + let kept = &spec(&s, id).series_paints[0]; + assert_eq!(kept.stroke, Some(Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)))); + assert_eq!(kept.stroke_width, Some(2.0)); +} + +/// A weight set on a stroke without paint (a line graph's marker) leaves the paint the series keeps for its line. +#[test] +fn a_weight_on_a_stroke_without_paint_leaves_the_kept_stroke() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "line"); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let marks = series(&n, 0).children().unwrap().to_vec(); + let line = marks.iter().find(|m| m.appearance.stroke_width() > 0.0).unwrap().id; + let marker = marks.iter().find(|m| m.appearance.stroke_width() == 0.0 && m.appearance.stroke().is_some()).unwrap().id; + s.execute("appearance.setItem", &json!({"ids": [line.0], "index": top_row(&s, line, false), "color": "#ff0000"})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert!(kept.stroke.as_ref().is_some_and(|p| !p.is_none())); + s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": top_row(&s, marker, false), "weight": 4})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0], kept); +} + +/// The graph does not keep a paint set on a row of a graph design's copy inside a series. A stroke edit on a line keeps +/// no fill, and a blend mode alone leaves what the graph keeps as it was. +#[test] +fn a_paint_on_a_design_copy_or_the_other_kind_of_row_is_not_kept() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + save_design(&mut s, "Square"); + s.execute("graph.column", &json!({"id": id.0, "design": "Square"})).unwrap(); + let copy = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone(); + assert!(matches!(copy.kind, NodeKind::Group { .. })); + s.execute("appearance.addFill", &json!({"ids": [copy.id.0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [copy.id.0], "index": 0, "color": "#0000ff"})).unwrap(); + assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.fill.is_none())); + // A path inside the design's copy is a member of the series too, and its paint is not kept either. + let copy = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone(); + let mut paths = vec![]; + copy.walk(&mut |n| { + if matches!(n.kind, NodeKind::Path { .. }) { + paths.push(n.id); + } + }); + let art = paths[0]; + s.execute("appearance.setItem", &json!({"ids": [art.0], "index": top_row(&s, art, true), "color": "#00ff00"})).unwrap(); + assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.fill.is_none())); + + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "line"); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let line = series(&n, 0).children().unwrap().iter().find(|m| m.appearance.stroke().is_some_and(|st| st.width > 0.0)).unwrap().clone(); + let row = line.appearance.items.iter().rposition(|it| !it.is_fill()).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [line.id.0], "index": row, "color": "#0000ff"})).unwrap(); + assert!(spec(&s, id).series_paints[0].fill.is_none(), "a stroke edit on a line keeps no fill"); + + // A blend mode on another mark of the series leaves the stored paint as that mark's own paint differs from it. + let (mut s, id, _, marks) = column_marks(); + let fill = top_row(&s, marks[0], true); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap(); + let kept = spec(&s, id); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": fill, "blend": "multiply"})).unwrap(); + assert_eq!(spec(&s, id), kept); +} + /// A kept opacity read from a file outside 0..1 is held to that range, and one that is not a number is left out. #[test] fn a_kept_series_opacity_from_a_file_is_held_to_its_range() { diff --git a/docs/mcp.md b/docs/mcp.md index 1adcf32b1..ee9b6a9dc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -534,6 +534,20 @@ stroke instead of the whole object: `run_command effect.apply {"effect": "path.o `effect.remove`, `effect.setParams` (`visible` toggles one) and `effect.duplicate` address that item's effects; `apply_effect` uses the active item. `effect.list` reports each object's item effects under `applied[].items`. +On a path directly inside a graph series, `appearance.setItem`, `appearance.addFill`, `appearance.addStroke`, +`appearance.removeItem`, `appearance.moveItem`, `appearance.duplicateItem`, `appearance.clear`, +`appearance.reduceToBasic`, `appearance.showAllHidden`, `appearance.transfer` and `appearance.copyFrom` store the paint, +weight and opacity of the topmost visible fill and stroke on the graph. When the graph is drawn again, columns, bars, +wedges, areas, markers and legend swatches take the stored fill and stroke. A thin line takes the stored stroke and has +no fill. A Filled Lines band takes the stored fill, or the stored stroke when no fill is stored and that stroke has +paint, and has a 0.25 pt black outline, so these commands do not store a stroke for a series that draws a band. Graph +designs keep their own paint. A row that had no paint before the command and has none after it does not change what the +graph stores. `paint.setFill`, `paint.setStroke`, `paint.none` and the commands that apply one paint kind to the other +store the topmost row of the kind even when it is hidden, and store a stroke set on a band. +`stroke.set`, `paint.editGradient`, `paint.swap`, `paint.default`, graphic styles and the recolor commands do not store a +series' paint. An `appearance.*` command given a series group's id edits the group's own rows, and the graph does not +store them. + `effect.apply {"effect": "path.outlineObject"}` (Effect › Path › Outline Object) takes no parameters. Type that carries it is drawn, exported and expanded as its glyph outlines and stays editable type, and the effects after it reshape those outlines. On paths, compound paths and images it does not change what is drawn. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0af2f0670..aac729730 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -840,7 +840,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur View › Hide Edges hides the Live Corners widgets, the bounding box and the type widget, and the selection tools then do not answer a press on them or change the cursor over them. - **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html). - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. - - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, and the opacity set on a mark's top fill or stroke row, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). + - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, and the paint, weight and opacity of a mark's top visible fill or stroke row after an Appearance command or the Eyedropper, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete. Type in a column design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits: the first digit is the least number of places before the decimal point (zeros are added in front; 0 gives as many as the value needs), and the second digit sets the places after it, rounded. Unless it is inside a group that clips or has its own opacity or appearance, or inside a blend, compound shape, envelope, repeat or intertwined objects, the type keeps its size and is drawn once per column in a repeating design. The legend swatch leaves it out. - **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View. From 434cf3e692dadbdcd482d249ed9b902948fd45d7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 02:38:40 -0800 Subject: [PATCH 054/140] Graphs: graphic styles and Default and Swap Fill and Stroke update a series' stored paint Applying a graphic style, Redefine Graphic Style, Default Fill and Stroke and Swap Fill and Stroke on a path in a graph series changed the path but not the paint the graph stores for the series. When the graph was drawn again, the series went back to its earlier paint. These commands now store the mark's topmost visible fill and stroke on the graph, in the same undo step as the command: - graphicStyle.apply stores the paint, weight and opacity of both kinds of row. With `add` it stores only the kinds of row that are visible in the style. - graphicStyle.redefine stores the paint, weight and opacity of the marks still linked to the style. - paint.default stores the white fill and the 1 pt black stroke, both opaque. - paint.swap stores the swapped paints and the stroke weight and keeps the stored opacity. It swaps the topmost rows even when they are hidden, and a hidden row it swaps is not stored. The rule for each kind of mark is the one docs/mcp.md gives for the Appearance commands. docs/mcp.md also says what the graph does not store from a graphic style and that a series group given to a graphic style command is not stored. The params text, docs/mcp.md and the Graphs line in docs/target-app-parity.md list the commands. --- crates/engine/src/cmd/appearance.rs | 29 +++++ crates/engine/src/cmd/graph/tests.rs | 163 +++++++++++++++++++++++++++ crates/engine/src/cmd/paint.rs | 11 +- crates/engine/src/cmd/style.rs | 16 ++- docs/mcp.md | 27 +++-- docs/target-app-parity.md | 2 +- 6 files changed, 228 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 6675c63d0..9ddd21279 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -653,6 +653,35 @@ fn painted_rows(d: &vectorcraft_doc::Document, rows: impl IntoIterator bool { + let top_shows = |d: &vectorcraft_doc::Document, id: NodeId, kind: bool| { + let items = d.node(id).map_or(&[][..], |n| &n.appearance.items[..]); + items.iter().rev().find(|it| it.is_fill() == kind).is_some_and(AppearanceItem::visible) + }; + let written = |d: &vectorcraft_doc::Document, (fill, stroke): (bool, bool)| -> Vec<(NodeId, bool)> { + let rows = ids.iter().flat_map(|id| [fill.then_some((*id, true)), stroke.then_some((*id, false))]).flatten(); + painted_rows(d, rows.filter(|(id, kind)| !top_only || top_shows(d, *id, *kind))) + }; + let mut opacity_rows = top_opacity_changed(before, d, ids); + opacity_rows.extend(written(d, opacities)); + let mut rows = top_paint_changed(before, d, ids); + rows.extend(written(d, paints)); + let kept = super::graph::capture_series_opacity(d, &opacity_rows); + let painted = super::graph::capture_series_top_paints(d, &rows); + kept || painted +} + /// Clear Appearance on `n`: no fill, no stroke, and the object's Opacity row back to Default. A /// group keeps no fill or stroke rows of its own. fn clear(n: &mut Node) { diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 89bb7a4c0..90e9c2833 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -1257,6 +1257,169 @@ fn a_paint_on_a_design_copy_or_the_other_kind_of_row_is_not_kept() { assert_eq!(spec(&s, id), kept); } +/// A graphic style `name` made from a new rectangle with a blue fill at 50% opacity, and without a stroke row unless +/// `stroke`: the rectangle and the blue paint. +fn blue_style(s: &mut Session, name: &str, stroke: bool) -> (NodeId, Paint) { + let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap()); + s.execute("appearance.setItem", &json!({"ids": [r.0], "index": top_row(s, r, true), "color": "#0000ff", "opacity": 50})).unwrap(); + if !stroke { + s.execute("appearance.removeItem", &json!({"ids": [r.0], "index": top_row(s, r, false)})).unwrap(); + } + s.execute("graphicStyle.new", &json!({"id": r.0, "name": name})).unwrap(); + let blue = s.doc().unwrap().doc.node(r).unwrap().appearance.fill_paint(); + (r, blue) +} + +/// Series 0's marks with a red 2 pt stroke stored from `marks[1]` and a 40% fill opacity stored from `marks[2]`. +fn stored_red_stroke() -> (Session, NodeId, Vec) { + let (mut s, id, _, marks) = column_marks(); + let stroke = top_row(&s, marks[1], false); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[2].0], "index": top_row(&s, marks[2], true), "opacity": 40})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.stroke_width, kept.fill_opacity), (Some(2.0), Some(0.4))); + (s, id, marks) +} + +/// After the graph is drawn again, every mark of series 0 has `fill` at `opacity` and `stroke` at `weight`; two undos (the +/// data edit, then the command) bring back the paints the graph stored in `was`. +fn series_0_is(s: &mut Session, id: NodeId, (fill, opacity, stroke, weight): (&Paint, f32, &Paint, f64), was: &[vectorcraft_doc::SeriesPaint]) { + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + for m in series(&n, 0).children().unwrap() { + let f = m.appearance.fill().unwrap(); + assert_eq!((&f.paint, f.opacity), (fill, opacity)); + assert_eq!((&m.appearance.stroke_paint(), m.appearance.stroke_width()), (stroke, weight)); + } + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(s, id).series_paints, was); +} + +/// A graphic style applied to a series mark is stored on the graph, its fill opacity included. A style without a stroke, +/// applied to a mark that shows one, leaves the series without a stroke. +#[test] +fn a_graphic_style_applied_to_a_series_mark_is_kept() { + let (mut s, id, marks) = stored_red_stroke(); + let (_, blue) = blue_style(&mut s, "Blue", false); + let was = spec(&s, id).series_paints; + s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[1].0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.fill_opacity, kept.stroke), (Some(blue.clone()), Some(0.5), Some(Paint::None))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was, "one undo takes back the style and what the graph stored"); + s.execute("edit.redo", &json!({})).unwrap(); + series_0_is(&mut s, id, (&blue, 0.5, &Paint::None, 0.0), &was); +} + +/// A graphic style added on top of a series mark's appearance (Alt-click) stores the kinds of row the style shows: a style +/// with only a fill leaves the stroke the series stores. +#[test] +fn a_graphic_style_added_to_a_series_mark_keeps_only_its_own_kinds() { + let (mut s, id, marks) = stored_red_stroke(); + let stroke = top_row(&s, marks[0], false); + let red = spec(&s, id).series_paints[0].stroke.clone().unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap(); + let (_, blue) = blue_style(&mut s, "Blue", false); + let was = spec(&s, id).series_paints; + s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[0].0], "add": true})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.fill_opacity), (Some(blue.clone()), Some(0.5))); + assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(red.clone()), Some(2.0))); + series_0_is(&mut s, id, (&blue, 0.5, &red, 2.0), &was); +} + +/// A graphic style whose stroke row is hidden, added on top of a series mark (Alt-click), stores its fill and leaves the +/// stroke the series stores, although the mark shows a stroke of its own. +#[test] +fn a_graphic_style_with_a_hidden_stroke_added_to_a_series_mark_keeps_the_stored_stroke() { + let (mut s, id, marks) = stored_red_stroke(); + let stroke = top_row(&s, marks[0], false); + let red = spec(&s, id).series_paints[0].stroke.clone().unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap(); + let (rect, blue) = blue_style(&mut s, "Blue", true); + s.execute("appearance.setItem", &json!({"ids": [rect.0], "index": top_row(&s, rect, false), "visible": false})).unwrap(); + s.execute("graphicStyle.new", &json!({"id": rect.0, "name": "Hidden stroke"})).unwrap(); + let was = spec(&s, id).series_paints; + s.execute("graphicStyle.apply", &json!({"name": "Hidden stroke", "ids": [marks[0].0], "add": true})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.fill_opacity), (Some(blue.clone()), Some(0.5))); + assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(red.clone()), Some(2.0))); + series_0_is(&mut s, id, (&blue, 0.5, &red, 2.0), &was); +} + +/// Redefine Graphic Style restyles the series marks still linked to the style, and the graph stores the new look. +#[test] +fn redefining_a_graphic_style_updates_a_linked_series_mark() { + let (mut s, id, _, marks) = column_marks(); + let (rect, _) = blue_style(&mut s, "Blue", true); + s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[0].0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [rect.0], "index": top_row(&s, rect, true), "color": "#00ff00", "opacity": 100})).unwrap(); + let green = s.doc().unwrap().doc.node(rect).unwrap().appearance.fill_paint(); + let was = spec(&s, id).series_paints; + s.execute("graphicStyle.redefine", &json!({"name": "Blue", "id": rect.0})).unwrap(); + assert_eq!(s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint(), green, "the mark follows the style"); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.fill_opacity), (Some(green.clone()), None)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was, "one undo takes back the redefinition and what the graph stored"); + s.execute("edit.redo", &json!({})).unwrap(); + let (stroke, weight) = { + let r = s.doc().unwrap().doc.node(rect).unwrap().clone(); + (r.appearance.stroke_paint(), r.appearance.stroke_width()) + }; + series_0_is(&mut s, id, (&green, 1.0, &stroke, weight), &was); +} + +/// Default Fill and Stroke on a series mark stores a white fill and a 1 pt black stroke, both opaque, on the graph. +#[test] +fn default_fill_and_stroke_on_a_series_mark_is_kept() { + let (mut s, id, marks) = stored_red_stroke(); + let was = spec(&s, id).series_paints; + s.execute("paint.default", &json!({"ids": [marks[0].0]})).unwrap(); + let (white, black) = (Paint::solid(vectorcraft_color::Color::WHITE), Paint::solid(vectorcraft_color::Color::BLACK)); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill.clone(), kept.fill_opacity), (Some(white.clone()), None)); + assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(black.clone()), Some(1.0))); + series_0_is(&mut s, id, (&white, 1.0, &black, 1.0), &was); +} + +/// Swap Fill and Stroke on a series mark stores the swapped paints and the stroke weight, and leaves the opacity the series +/// stores. +#[test] +fn swapping_a_series_marks_fill_and_stroke_is_kept() { + let (mut s, id, marks) = stored_red_stroke(); + let stroke = top_row(&s, marks[0], false); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3})).unwrap(); + let mark = s.doc().unwrap().doc.node(marks[0]).unwrap().clone(); + let (gray, green) = (mark.appearance.fill_paint(), mark.appearance.stroke_paint()); + let was = spec(&s, id).series_paints; + s.execute("paint.swap", &json!({"ids": [marks[0].0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill.clone(), kept.fill_opacity), (Some(green.clone()), Some(0.4))); + assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(gray.clone()), Some(3.0))); + series_0_is(&mut s, id, (&green, 0.4, &gray, 3.0), &was); +} + +/// Swap Fill and Stroke writes the topmost fill even when it is hidden: the visible fill under it does not replace the +/// fill the series stores. +#[test] +fn swapping_onto_a_hidden_top_fill_leaves_the_stored_fill() { + let (mut s, id, _, marks) = column_marks(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "color": "#ff0000"})).unwrap(); + let red = spec(&s, id).series_paints[0].fill.clone(); + s.execute("appearance.addFill", &json!({"ids": [marks[0].0]})).unwrap(); + let (_, upper) = fill_rows(&s, marks[0]); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": upper, "visible": false})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, red); + let stroke = top_row(&s, marks[0], false); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3})).unwrap(); + s.execute("paint.swap", &json!({"ids": [marks[0].0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, red); +} + /// A kept opacity read from a file outside 0..1 is held to that range, and one that is not a number is left out. #[test] fn a_kept_series_opacity_from_a_file_is_held_to_its_range() { diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 390f6ef49..b3c0d0f90 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -37,7 +37,7 @@ pub fn specs() -> Vec { "Swap Fill and Stroke", [], Some("Shift+X"), - "{ids?|id?} swap the fill and stroke of the selection (type too) and of the defaults", + "{ids?|id?} swap the fill and stroke of the selection (type too) and of the defaults. On a path directly in a graph series, the graph also stores the swapped paints and the stroke weight (exceptions: docs/mcp.md)", has_doc, swap ), @@ -46,7 +46,7 @@ pub fn specs() -> Vec { "Default Fill and Stroke", [], Some("D"), - "{ids?|id?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke", + "{ids?|id?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, default_paint ), @@ -783,7 +783,9 @@ fn swap(s: &mut Session, p: &Value) -> Result { restore_unpainted_stroke(&mut n.appearance, None, &f); n.appearance.set_stroke(f); } - Ok(super::appearance::count_changed(d, &before, &ids)) + // The swap writes the paint of the topmost fill and stroke, not their opacity. + let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (false, false), true); + Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept)) })?; } if s.paint.stroke.is_none() && !s.paint.fill.is_none() && s.paint.stroke_width == 0.0 { @@ -811,7 +813,8 @@ fn default_paint(s: &mut Session, p: &Value) -> Result { _ => n.appearance = Appearance::default_art().into(), } } - Ok(super::appearance::count_changed(d, &before, &ids)) + let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false); + Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept)) })?; } super::newart::reset(s); diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index a15ea9ff4..6379aeac5 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Apply Graphic Style", ["Window", "Graphic Styles"], None, - "{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}", + "{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows; exceptions: docs/mcp.md). With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}", has_doc, style_apply ), @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Redefine Graphic Style", ["Window", "Appearance"], None, - "{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object; the objects still linked to it update, and that object becomes linked → {name}", + "{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object; the objects still linked to it update, and that object becomes linked. On a linked path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {name}", has_doc, style_redefine ), @@ -333,6 +333,7 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { s.new_art_style(&g, add); return Ok(json!({ "newArt": true })); } + let before = s.doc()?.doc.clone(); s.edit("Apply Graphic Style", |d, _| { if add { let g = d.graphic_styles[i].clone(); @@ -344,12 +345,17 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { n.extras_mut().graphic_style = None; } } + // The graph stores the rows of the kinds the style shows, now on top of each stack. + let shows = |kind: bool| g.appearance.items.iter().any(|it| it.is_fill() == kind && it.visible()); + let kinds = (shows(true), shows(false)); + super::appearance::capture_series_writes(&before, d, &ids, kinds, kinds, false); } else { d.graphic_style_id(i); let g = d.graphic_styles[i].clone(); for id in &ids { apply_style(d, *id, &g, chars); } + super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false); } Ok(()) })?; @@ -456,6 +462,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { let look = GraphicStyle::of(String::new(), captured(n), n); let name = d.graphic_styles[i].name.clone(); let chars = s.prefs.override_char_color; + let before = s.doc()?.doc.clone(); s.edit_named("Redefine Graphic Style “{name}”", &name, false, |d, _| { let id = d.graphic_style_id(i); let old = std::mem::replace(&mut d.graphic_styles[i], GraphicStyle { name: name.clone(), id, ..look }); @@ -465,9 +472,10 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { let (follow, edited): (Vec, Vec) = linked.into_iter().partition(|nid| d.node(*nid).is_some_and(|n| in_sync(n, &old))); set_link(d, &edited, None); let g = d.graphic_styles[i].clone(); - for nid in follow { - apply_style(d, nid, &g, chars); + for nid in &follow { + apply_style(d, *nid, &g, chars); } + super::appearance::capture_series_writes(&before, d, &follow, (true, true), (true, true), false); set_link(d, &[src], Some(id)); Ok(()) })?; diff --git a/docs/mcp.md b/docs/mcp.md index ee9b6a9dc..f23fec388 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -536,17 +536,22 @@ stroke instead of the whole object: `run_command effect.apply {"effect": "path.o On a path directly inside a graph series, `appearance.setItem`, `appearance.addFill`, `appearance.addStroke`, `appearance.removeItem`, `appearance.moveItem`, `appearance.duplicateItem`, `appearance.clear`, -`appearance.reduceToBasic`, `appearance.showAllHidden`, `appearance.transfer` and `appearance.copyFrom` store the paint, -weight and opacity of the topmost visible fill and stroke on the graph. When the graph is drawn again, columns, bars, -wedges, areas, markers and legend swatches take the stored fill and stroke. A thin line takes the stored stroke and has -no fill. A Filled Lines band takes the stored fill, or the stored stroke when no fill is stored and that stroke has -paint, and has a 0.25 pt black outline, so these commands do not store a stroke for a series that draws a band. Graph -designs keep their own paint. A row that had no paint before the command and has none after it does not change what the -graph stores. `paint.setFill`, `paint.setStroke`, `paint.none` and the commands that apply one paint kind to the other -store the topmost row of the kind even when it is hidden, and store a stroke set on a band. -`stroke.set`, `paint.editGradient`, `paint.swap`, `paint.default`, graphic styles and the recolor commands do not store a -series' paint. An `appearance.*` command given a series group's id edits the group's own rows, and the graph does not -store them. +`appearance.reduceToBasic`, `appearance.showAllHidden`, `appearance.transfer`, `appearance.copyFrom`, `paint.default`, +`graphicStyle.apply` and `graphicStyle.redefine` (on the marks still linked to the style) store the paint, weight and +opacity of the topmost visible fill and stroke on the graph. `graphicStyle.apply` with `add` stores only the kinds of row +that are visible in the style. `paint.swap` stores the swapped paints and the stroke weight, keeps the stored opacity, +and does not store a hidden topmost row that it swaps. The graph does not store the rest of a graphic style's look (more +fill and stroke rows, effects, the object's opacity and blend mode, and the link to the style), and a mark loses it when +the graph is drawn again. When the graph is drawn again, columns, bars, wedges, areas, markers and legend swatches take +the stored fill and stroke. A thin line takes the stored stroke and has no fill. A Filled Lines band takes the stored +fill, or the stored stroke when no fill is stored and that stroke has paint, and has a 0.25 pt black outline, so these +commands do not store a stroke for a series that draws a band. Graph designs keep their own paint. A row that had no +paint before the command and has none after it does not change what the graph stores. `paint.setFill`, +`paint.setStroke`, `paint.none` and the commands that apply one paint kind to the other store the topmost row of the kind +even when it is hidden, and store a stroke set on a band. `stroke.set`, `paint.editGradient` and the recolor commands do +not store a series' paint. An `appearance.*` command or `graphicStyle.apply` given a series group's id, and +`graphicStyle.redefine` on a series group linked to the style, edit the group's own rows, and the graph does not store +them. `effect.apply {"effect": "path.outlineObject"}` (Effect › Path › Outline Object) takes no parameters. Type that carries it is drawn, exported and expanded as its glyph outlines and stays editable type, and the effects after it reshape diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index aac729730..6b57dc5ec 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -840,7 +840,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur View › Hide Edges hides the Live Corners widgets, the bounding box and the type widget, and the selection tools then do not answer a press on them or change the cursor over them. - **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html). - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. - - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, and the paint, weight and opacity of a mark's top visible fill or stroke row after an Appearance command or the Eyedropper, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). + - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, and the paint, weight and opacity of a mark's top visible fill or stroke row after an Appearance command, the Eyedropper, a graphic style, Default Fill and Stroke or Swap Fill and Stroke, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete. Type in a column design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits: the first digit is the least number of places before the decimal point (zeros are added in front; 0 gives as many as the value needs), and the second digit sets the places after it, rounded. Unless it is inside a group that clips or has its own opacity or appearance, or inside a blend, compound shape, envelope, repeat or intertwined objects, the type keeps its size and is drawn once per column in a repeating design. The legend swatch leaves it out. - **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View. From 5a9a08fcb14d63783d7c65da428b1cc9424797bb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 04:01:31 -0800 Subject: [PATCH 055/140] Graphs: a series keeps the object opacity and blend modes set on one of its marks An object opacity or blend mode set on a path directly in a graph series, and the blend mode of its topmost visible fill or stroke, were lost when the graph was drawn again. The graph now stores them per series next to the row opacity it already stores, and puts them on every path directly in the series when it draws the series again. - transparency.set and object.setProps store the object's opacity and blend mode. A value the mark already has still replaces the value another mark of the series stored. - appearance.clear, appearance.transfer, appearance.copyFrom with Transparency picked up, graphicStyle.apply without add and graphicStyle.redefine store the object's opacity and blend mode they leave on the mark. - The other Appearance and style commands store them when they change them on a mark. - Every command that stores the opacity of the topmost visible fill or stroke also stores its blend mode. A Filled Lines band's outline takes the stored stroke opacity and blend mode and keeps its black 0.25 pt stroke. - An opacity and blend mode are stored as a pair from the edited mark: setting only the opacity of one mark also stores that mark's blend mode. - A graph design's copy in a series keeps its own values. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/doc/src/graph.rs | 14 ++ crates/engine/src/cmd/appearance.rs | 52 ++++-- crates/engine/src/cmd/graph.rs | 5 +- crates/engine/src/cmd/graph/series.rs | 118 ++++++++++-- crates/engine/src/cmd/graph/tests.rs | 222 ++++++++++++++++++++++- crates/engine/src/cmd/object/commands.rs | 2 +- crates/engine/src/cmd/object/props.rs | 9 +- crates/engine/src/cmd/paint.rs | 6 +- crates/engine/src/cmd/style.rs | 10 +- docs/mcp.md | 25 ++- docs/target-app-parity.md | 2 +- 11 files changed, 400 insertions(+), 65 deletions(-) diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index b19adc8b0..15f04ce15 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -321,6 +321,20 @@ pub struct SeriesPaint { /// Opacity (0..1) of the topmost stroke of each path directly in the series group, as [`Self::fill_opacity`]. #[serde(skip_serializing_if = "Option::is_none")] pub stroke_opacity: Option, + /// Blend mode of the topmost fill of each path directly in the series group, as [`Self::fill_opacity`]. Absent: Normal. + #[serde(skip_serializing_if = "Option::is_none")] + pub fill_blend: Option, + /// Blend mode of the topmost stroke of each path directly in the series group, as [`Self::fill_opacity`]. Absent: + /// Normal. + #[serde(skip_serializing_if = "Option::is_none")] + pub stroke_blend: Option, + /// Object opacity (0..1) of each path directly in the series group, taken from the object opacity set on one of them. + /// Absent: fully opaque. + #[serde(skip_serializing_if = "Option::is_none")] + pub opacity: Option, + /// Object blend mode of each path directly in the series group, as [`Self::opacity`]. Absent: Normal. + #[serde(skip_serializing_if = "Option::is_none")] + pub blend: Option, } /// Graph data and options. `rows` are categories (one per row of the Graph Data window), each diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 9ddd21279..4c4873447 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -63,7 +63,7 @@ pub fn specs() -> Vec { "Clear Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md)", has_doc, clear_appearance ), @@ -81,7 +81,7 @@ pub fn specs() -> Vec { "Appearance Item", [], None, - "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), 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)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), 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)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, set_item ), @@ -126,7 +126,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art). On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", + "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art). On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {ids}", has_doc, copy_from ), @@ -162,7 +162,7 @@ pub fn specs() -> Vec { "Move Appearance", [], None, - "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {source, target}", + "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {source, target}", has_doc, transfer ), @@ -616,25 +616,33 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result { } let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids)); let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); - Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) + let object = super::graph::capture_series_object_opacity(d, &ids); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || object)) })?; s.active_appearance_item = None; ok() } +/// Each of `ids` whose object opacity or blend mode is another in `d` than in `before`, for +/// [`super::graph::capture_series_object_opacity`]. +fn object_opacity_changed(before: &vectorcraft_doc::Document, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec { + let of = |d: &vectorcraft_doc::Document, id: NodeId| d.node(id).map(|n| (n.opacity, n.blend)); + ids.iter().copied().filter(|id| of(before, *id) != of(d, *id)).collect() +} + /// Each of `ids` with both kinds of row (fill, then stroke), for [`super::graph::capture_series_opacity`] after an /// edit that replaces whole stacks. fn both_kinds(ids: &[NodeId]) -> Vec<(NodeId, bool)> { ids.iter().flat_map(|id| [(*id, true), (*id, false)]).collect() } -/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost visible row has another opacity in `d` -/// than in `before` (no row counts as opaque), for [`super::graph::capture_series_opacity`]. +/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost visible row has another opacity or blend +/// mode in `d` than in `before` (no row counts as opaque and Normal), for [`super::graph::capture_series_opacity`]. fn top_opacity_changed(before: &vectorcraft_doc::Document, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<(NodeId, bool)> { let top = |d: &vectorcraft_doc::Document, id: NodeId, fill: bool| { d.node(id) - .and_then(|n| n.appearance.items.iter().rev().find(|it| it.is_fill() == fill && it.visible()).map(AppearanceItem::opacity)) - .unwrap_or(1.0) + .and_then(|n| n.appearance.items.iter().rev().find(|it| it.is_fill() == fill && it.visible()).map(|it| (it.opacity(), it.blend()))) + .unwrap_or((1.0, BlendMode::Normal)) }; ids.iter().flat_map(|id| [(*id, true), (*id, false)]).filter(|(id, fill)| top(before, *id, *fill) != top(d, *id, *fill)).collect() } @@ -654,9 +662,10 @@ fn painted_rows(d: &vectorcraft_doc::Document, rows: impl IntoIterator bool { let top_shows = |d: &vectorcraft_doc::Document, id: NodeId, kind: bool| { let items = d.node(id).map_or(&[][..], |n| &n.appearance.items[..]); @@ -679,7 +689,9 @@ pub(super) fn capture_series_writes( rows.extend(written(d, paints)); let kept = super::graph::capture_series_opacity(d, &opacity_rows); let painted = super::graph::capture_series_top_paints(d, &rows); - kept || painted + let objects = if object { ids.to_vec() } else { object_opacity_changed(before, d, ids) }; + let stored = super::graph::capture_series_object_opacity(d, &objects); + kept || painted || stored } /// Clear Appearance on `n`: no fill, no stroke, and the object's Opacity row back to Default. A @@ -723,7 +735,8 @@ fn transfer(s: &mut Session, p: &Value) -> Result { let mut rows = top_paint_changed(&before, d, &changed); rows.extend(painted_rows(d, both_kinds(&[target]))); let painted = super::graph::capture_series_top_paints(d, &rows); - Ok(count_changed(d, &before, &[source, target]) + usize::from(kept || painted)) + let object = super::graph::capture_series_object_opacity(d, &changed); + Ok(count_changed(d, &before, &[source, target]) + usize::from(kept || painted || object)) })?; s.active_appearance_item = None; Ok(json!({ "source": source.0, "target": target.0 })) @@ -840,10 +853,10 @@ fn set_item(s: &mut Session, p: &Value) -> Result { edited.push((*id, fill)); } } - // The graph keeps an opacity, paint or weight set on the topmost visible fill or stroke of its series art for when - // it is drawn again, and hiding or showing a row changes which row that is. + // The graph keeps an opacity, blend mode, paint or weight set on the topmost visible fill or stroke of its series art + // for when it is drawn again, and hiding or showing a row changes which row that is. let mut opacities = top_opacity_changed(&before, d, &ids); - if p.get("opacity").and_then(Value::as_f64).is_some() { + if p.get("opacity").and_then(Value::as_f64).is_some() || blend.is_some() { opacities.extend(edited.iter().copied()); } let kept = super::graph::capture_series_opacity(d, &opacities); @@ -1265,7 +1278,10 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { rows.extend(painted_rows(d, written(paints))); let kept = super::graph::capture_series_opacity(d, &opacity_rows); let painted = super::graph::capture_series_top_paints(d, &rows); - Ok(count_changed(d, &before, &targets) + usize::from(kept || painted)) + // A pick of the object's transparency writes it on every target. + let objects = if attrs.appearance.transparency { targets.clone() } else { object_opacity_changed(&before, d, &targets) }; + let object = super::graph::capture_series_object_opacity(d, &objects); + Ok(count_changed(d, &before, &targets) + usize::from(kept || painted || object)) })?; Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::>() })) } diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 7a47457c2..8c838d421 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -42,7 +42,10 @@ use data::{fmt_value, parse_csv, to_csv}; use generate::generate; #[cfg(test)] use series::default_series_color; -pub(crate) use series::{capture_label_styles, capture_series_opacity, capture_series_paints, capture_series_top_paints, paint_targets, shown_paint}; +pub(crate) use series::{ + capture_label_styles, capture_series_object_opacity, capture_series_opacity, capture_series_paints, capture_series_top_paints, paint_targets, + series_object_opacity_differs, shown_paint, +}; #[cfg(test)] mod tests; diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index 8ab2573ff..7d7d75a16 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -1,3 +1,5 @@ +use vectorcraft_color::BlendMode; + use super::axis::radar_axis; use super::labels::{Role, filled_lines_on, label_style}; use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Result, Session, Value, bad}; @@ -26,26 +28,43 @@ pub(super) fn series_marks(g: &GraphSpec, i: usize, series_fill: bool, stroke: P (fill, stroke, width) } -/// The paths directly inside series `i`'s group (`items`) with the series' kept fill and stroke opacity on their -/// topmost fill and stroke. Other art, such as a graph design, keeps its own opacity. A value read from a file is -/// held to 0..1, and one that is not a number is left out. +/// The paths directly inside series `i`'s group (`items`) with the series' kept fill and stroke opacity and blend mode on +/// their topmost fill and stroke, and its kept object opacity and blend mode. Other art, such as a graph design, keeps its +/// own. An opacity read from a file is held to 0..1, and one that is not a number is left out. pub(super) fn with_series_opacity(g: &GraphSpec, i: usize, mut items: Vec>) -> Vec> { let Some(slot) = g.series_paints.get(i) else { return items }; let kept = |o: Option| o.filter(|o| o.is_finite()).map(|o| o.clamp(0.0, 1.0)); - let (fill, stroke) = (kept(slot.fill_opacity), kept(slot.stroke_opacity)); - if fill.is_none() && stroke.is_none() { + let (fill, stroke, object) = (kept(slot.fill_opacity), kept(slot.stroke_opacity), kept(slot.opacity)); + if fill.is_none() && stroke.is_none() && object.is_none() && slot.fill_blend.is_none() && slot.stroke_blend.is_none() && slot.blend.is_none() { return items; } for item in &mut items { if !matches!(item.kind, NodeKind::Path { .. }) { continue; } - let ap = &mut Arc::make_mut(item).appearance; - if let (Some(o), Some(f)) = (fill, ap.fill_mut()) { - f.opacity = o; + let n = Arc::make_mut(item); + if let Some(o) = object { + n.opacity = o; } - if let (Some(o), Some(st)) = (stroke, ap.stroke_mut()) { - st.opacity = o; + if let Some(b) = slot.blend { + n.blend = b; + } + let ap = &mut n.appearance; + if let Some(f) = ap.fill_mut() { + if let Some(o) = fill { + f.opacity = o; + } + if let Some(b) = slot.fill_blend { + f.blend = b; + } + } + if let Some(st) = ap.stroke_mut() { + if let Some(o) = stroke { + st.opacity = o; + } + if let Some(b) = slot.stroke_blend { + st.blend = b; + } } } items @@ -216,10 +235,10 @@ fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, P changed } -/// Copy the opacity of the topmost visible fill (`true`) or stroke (`false`) of generated series art just edited (`edited`: -/// each object with the kind of row) into that series' fill or stroke opacity; art left without a row of that kind -/// keeps none. Called inside the same document edit, so both undo together. Returns whether a series' kept opacity -/// changed. +/// Copy the opacity and blend mode of the topmost visible fill (`true`) or stroke (`false`) of generated series art just +/// edited (`edited`: each object with the kind of row) into that series' fill or stroke opacity and blend mode; art left +/// without a row of that kind keeps neither. Called inside the same document edit, so both undo together. Returns +/// whether a series' kept opacity or blend mode changed. pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool)]) -> bool { if edited.is_empty() { return false; @@ -236,14 +255,15 @@ pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool let node = doc.node(*id).filter(|n| matches!(n.kind, NodeKind::Path { .. }))?; let row = node.appearance.items.iter().rev().find(|it| it.is_fill() == *fill && it.visible()); let opacity = row.map_or(1.0, vectorcraft_doc::AppearanceItem::opacity); - Some((r.graph, r.index, *fill, (opacity < 1.0).then_some(opacity))) + let blend = row.map_or(BlendMode::Normal, vectorcraft_doc::AppearanceItem::blend); + Some((r.graph, r.index, *fill, (opacity < 1.0).then_some(opacity), (blend != BlendMode::Normal).then_some(blend))) }) .collect(); let mut changed = false; - for (graph, index, fill, kept) in captures { + for (graph, index, fill, kept, blend) in captures { let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; - // A fully opaque row on a series the graph keeps nothing for leaves the graph as it is. - if kept.is_none() && spec.series_paints.len() <= index { + // A fully opaque Normal row on a series the graph keeps nothing for leaves the graph as it is. + if kept.is_none() && blend.is_none() && spec.series_paints.len() <= index { continue; } let was = spec.series_paints.clone(); @@ -253,15 +273,73 @@ pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool } let Some(slot) = spec.series_paints.get_mut(index) else { continue }; if fill { - slot.fill_opacity = kept; + (slot.fill_opacity, slot.fill_blend) = (kept, blend); } else { - slot.stroke_opacity = kept; + (slot.stroke_opacity, slot.stroke_blend) = (kept, blend); } changed |= spec.series_paints != was; } changed } +/// Copy the object opacity and blend mode of generated series art just edited (`edited`) into that series' object opacity +/// and blend mode. Called inside the same document edit, so both undo together. Returns whether a series' kept object +/// opacity or blend mode changed. +pub(crate) fn capture_series_object_opacity(doc: &mut Document, edited: &[NodeId]) -> bool { + let mut changed = false; + for (graph, index, opacity, blend) in object_opacity_captures(doc, edited) { + let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; + // A fully opaque Normal object on a series the graph keeps nothing for leaves the graph as it is. + if opacity.is_none() && blend.is_none() && spec.series_paints.len() <= index { + continue; + } + let was = spec.series_paints.clone(); + // `index` is below the graph's series count (`series_members`). + if spec.series_paints.len() <= index { + spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default()); + } + let Some(slot) = spec.series_paints.get_mut(index) else { continue }; + (slot.opacity, slot.blend) = (opacity, blend); + changed |= spec.series_paints != was; + } + changed +} + +/// Would [`capture_series_object_opacity`] change what the graph stores for `edited`? Reads the document without copying +/// it. +pub(crate) fn series_object_opacity_differs(doc: &Document, edited: &[NodeId]) -> bool { + let mut after: HashMap<(NodeId, usize), (Option, Option)> = HashMap::new(); + let stored = |graph: NodeId, index: usize| { + let spec = doc.node(graph).and_then(|n| n.extras().graph.as_deref()); + spec.and_then(|g| g.series_paints.get(index)).map_or((None, None), |slot| (slot.opacity, slot.blend)) + }; + for (graph, index, opacity, blend) in object_opacity_captures(doc, edited) { + after.insert((graph, index), (opacity, blend)); + } + after.into_iter().any(|((graph, index), pair)| stored(graph, index) != pair) +} + +/// The series and object opacity and blend mode (None for 100% and Normal) of each of `edited` that is a path directly in a +/// series group, in order. +fn object_opacity_captures(doc: &Document, edited: &[NodeId]) -> Vec<(NodeId, usize, Option, Option)> { + if edited.is_empty() { + return Vec::new(); + } + let members = series_members(doc); + if members.is_empty() { + return Vec::new(); + } + edited + .iter() + .filter_map(|id| { + // Only the paths the graph draws again, not the copy of a graph design. + let r = members.get(id).filter(|r| doc.parent_of(*id) == Some(r.group))?; + let node = doc.node(*id).filter(|n| matches!(n.kind, NodeKind::Path { .. }))?; + Some((r.graph, r.index, (node.opacity < 1.0).then_some(node.opacity), (node.blend != BlendMode::Normal).then_some(node.blend))) + }) + .collect() +} + /// Graph Data › Transpose row/column: categories become series and series categories. pub(super) fn transpose(g: &mut GraphSpec) { let cells = g.cells(); diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 90e9c2833..ff85533ca 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -1,5 +1,5 @@ use serde_json::json; -use vectorcraft_color::Paint; +use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_doc::{CharStyle, ColumnDesign, ColumnType, Fractions, GraphKind, GraphSpec, NodeKind}; use crate::{NodeId, Session}; @@ -347,14 +347,14 @@ fn clearing_a_series_marks_appearance_forgets_its_kept_opacity() { } /// Removing the fill whose opacity the graph keeps takes the kept opacity away; an edit that leaves a mark opaque -/// (a blend mode alone) keeps nothing for its series. +/// and Normal (an opacity of 100% alone) keeps nothing for its series. #[test] fn removing_a_series_marks_fill_forgets_its_kept_opacity() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let id = graph(&mut s, "column"); let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id; - s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "blend": "multiply"})).unwrap(); + s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "opacity": 100})).unwrap(); assert!(spec(&s, id).series_paints.is_empty()); s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "opacity": 40})).unwrap(); assert_eq!(spec(&s, id).series_paints[0].fill_opacity, Some(0.4)); @@ -503,6 +503,198 @@ fn a_stroke_opacity_on_a_series_line_survives_regeneration() { assert!(!after.is_empty() && after.iter().all(|m| m.appearance.stroke().unwrap().opacity == 0.6)); } +/// Each mark of series `index`: its object opacity and blend mode, and the blend modes of its top fill and stroke. +fn transparency_of(s: &Session, id: NodeId, index: u32) -> Vec<(f32, BlendMode, BlendMode, BlendMode)> { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let marks = series(&n, index).children().unwrap().to_vec(); + marks.iter().map(|m| (m.opacity, m.blend, m.appearance.fill().unwrap().blend, m.appearance.stroke().unwrap().blend)).collect() +} + +/// An object opacity and blend mode set on one series mark with no fill or stroke row picked are kept by the graph: +/// every mark of that series has them after the graph is drawn again, through save and open, the other series stay +/// opaque and Normal, 100% and Normal forget them, and undo takes them back. +#[test] +fn an_object_opacity_and_blend_mode_on_a_series_mark_survive_regeneration() { + let (mut s, id, _, marks) = column_marks(); + s.execute("transparency.set", &json!({"ids": [marks[0].0], "opacity": 40, "blend": "multiply"})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (Some(0.4), Some(BlendMode::Multiply))); + let restored: GraphSpec = serde_json::from_value(serde_json::to_value(spec(&s, id)).unwrap()).unwrap(); + assert_eq!(restored, spec(&s, id)); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let normal = BlendMode::Normal; + assert!(transparency_of(&s, id, 0).iter().all(|t| *t == (0.4, BlendMode::Multiply, normal, normal)), "{:?}", transparency_of(&s, id, 0)); + assert!(transparency_of(&s, id, 1).iter().all(|t| *t == (1.0, normal, normal, normal)), "{:?}", transparency_of(&s, id, 1)); + let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id; + s.execute("transparency.set", &json!({"ids": [bar.0], "opacity": 100, "blend": "normal"})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (None, None)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(spec(&s, id).series_paints.is_empty(), "undo takes back the edit and what the graph stored"); +} + +/// `object.setProps` with the opacity a mark already shows replaces the opacity another mark of the series left on the +/// graph, in one undo step, and an opacity on a graph design's copy in a series is not kept. +#[test] +fn an_object_opacity_a_series_mark_already_shows_replaces_the_kept_opacity() { + let (mut s, id, _, marks) = column_marks(); + s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 50})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.5)); + s.execute("object.setProps", &json!({"id": marks[1].0, "opacity": 100})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, None); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(transparency_of(&s, id, 0).iter().all(|t| t.0 == 1.0), "{:?}", transparency_of(&s, id, 0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.5)); + // Setting it again where the graph already stores it leaves the document and its history as they are. + let undo = s.doc().unwrap().history.undo.len(); + s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 50})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + save_design(&mut s, "Square"); + s.execute("graph.column", &json!({"id": id.0, "design": "Square"})).unwrap(); + let copy = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone(); + assert!(matches!(copy.kind, NodeKind::Group { .. })); + s.execute("object.setProps", &json!({"id": copy.id.0, "opacity": 50})).unwrap(); + assert!(spec(&s, id).series_paints.iter().all(|p| p.opacity.is_none())); +} + +/// A blend mode set on the top fill or stroke row of one series mark is kept by the graph and drawn on every mark of +/// the series again; Normal forgets it. +#[test] +fn a_row_blend_mode_on_a_series_mark_survives_regeneration() { + let (mut s, id, _, marks) = column_marks(); + let (fill, stroke) = (top_row(&s, marks[0], true), top_row(&s, marks[0], false)); + s.execute("transparency.set", &json!({"ids": [marks[0].0], "item": fill, "blend": "screen"})).unwrap(); + s.execute("transparency.set", &json!({"ids": [marks[1].0], "item": stroke, "blend": "darken"})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill_blend, kept.stroke_blend), (Some(BlendMode::Screen), Some(BlendMode::Darken))); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let normal = BlendMode::Normal; + assert!(transparency_of(&s, id, 0).iter().all(|t| *t == (1.0, normal, BlendMode::Screen, BlendMode::Darken)), "{:?}", transparency_of(&s, id, 0)); + assert!(transparency_of(&s, id, 1).iter().all(|t| *t == (1.0, normal, normal, normal)), "{:?}", transparency_of(&s, id, 1)); + let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id; + s.execute("transparency.set", &json!({"ids": [bar.0], "item": top_row(&s, bar, true), "blend": "normal"})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill_blend, None); +} + +/// The graph stores an object's or a row's opacity and blend mode as one pair from the mark just edited: setting only +/// one half on another mark replaces both, either way round, and the redraw and undo follow the stored pair. +#[test] +fn setting_half_of_a_series_marks_transparency_stores_that_marks_whole_pair() { + let normal = BlendMode::Normal; + let (mut s, id, _, marks) = column_marks(); + s.execute("transparency.set", &json!({"ids": [marks[0].0], "blend": "multiply"})).unwrap(); + s.execute("object.setProps", &json!({"id": marks[1].0, "opacity": 50})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (Some(0.5), None)); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(transparency_of(&s, id, 0).iter().all(|t| (t.0, t.1) == (0.5, normal)), "{:?}", transparency_of(&s, id, 0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (None, Some(BlendMode::Multiply))); + + let (mut s, id, _, marks) = column_marks(); + s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 30})).unwrap(); + s.execute("transparency.set", &json!({"ids": [marks[1].0], "blend": "screen"})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (None, Some(BlendMode::Screen))); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(transparency_of(&s, id, 0).iter().all(|t| (t.0, t.1) == (1.0, BlendMode::Screen)), "{:?}", transparency_of(&s, id, 0)); + + // A fill row: a blend alone on one mark replaces the opacity another mark's row left, and the other way round. + let (mut s, id, _, marks) = column_marks(); + let fill = top_row(&s, marks[0], true); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "opacity": 40})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": fill, "blend": "darken"})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill_opacity, kept.fill_blend), (None, Some(BlendMode::Darken))); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "opacity": 60})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill_opacity, kept.fill_blend), (Some(0.6), None)); + s.execute("edit.undo", &json!({})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill_opacity, kept.fill_blend), (None, Some(BlendMode::Darken))); +} + +/// Clear Appearance on a series mark sets its object opacity back to 100% and Normal, and the graph keeps neither. +#[test] +fn clearing_a_series_marks_appearance_forgets_its_kept_object_opacity() { + let (mut s, id, _, marks) = column_marks(); + s.execute("transparency.set", &json!({"ids": [marks[0].0], "opacity": 40, "blend": "multiply"})).unwrap(); + s.execute("appearance.clear", &json!({"ids": [marks[1].0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (None, None)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.4)); +} + +/// An appearance moved or copied onto a mark of another series takes the source's object opacity and blend mode, and +/// the graph keeps them for the target's series; a move leaves the source's series opaque and Normal. +#[test] +fn moving_an_appearance_onto_a_series_mark_keeps_its_object_opacity() { + for copy in [false, true] { + let (mut s, id, _, marks) = column_marks(); + let to = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + s.execute("transparency.set", &json!({"ids": [marks[0].0], "opacity": 40, "blend": "multiply"})).unwrap(); + s.execute("appearance.transfer", &json!({"source": marks[0].0, "target": to.0, "copy": copy})).unwrap(); + let paints = spec(&s, id).series_paints; + assert_eq!((paints[1].opacity, paints[1].blend), (Some(0.4), Some(BlendMode::Multiply)), "copy {copy}"); + let source = if copy { (Some(0.4), Some(BlendMode::Multiply)) } else { (None, None) }; + assert_eq!((paints[0].opacity, paints[0].blend), source, "copy {copy}"); + } +} + +/// An Eyedropper pick of the object's transparency onto a series mark makes the graph keep the picked opacity and blend +/// mode for that series, even an opaque Normal pick onto a mark that already shows it. +#[test] +fn an_eyedropper_pick_of_object_transparency_onto_a_series_mark_is_kept() { + let (mut s, id, _, marks) = column_marks(); + let to = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + s.execute("transparency.set", &json!({"ids": [marks[0].0], "opacity": 40, "blend": "multiply"})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": marks[0].0, "ids": [to.0]})).unwrap(); + let kept = spec(&s, id).series_paints[1].clone(); + assert_eq!((kept.opacity, kept.blend), (Some(0.4), Some(BlendMode::Multiply))); + // An opaque Normal source picked onto another opaque mark of series 0 replaces what series 0 stores. + let opaque = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[1].id; + s.execute("appearance.copyFrom", &json!({"source": opaque.0, "ids": [marks[1].0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (None, None)); +} + +/// A graphic style made from an object at 50% Multiply, applied to a series mark, makes the graph keep the style's object +/// opacity and blend mode; redefining the style updates them for a mark still linked to it. +#[test] +fn a_graphic_styles_object_opacity_on_a_series_mark_is_kept() { + let (mut s, id, _, marks) = column_marks(); + let (rect, _) = blue_style(&mut s, "Blue", true); + s.execute("object.setProps", &json!({"id": rect.0, "opacity": 50, "blend": "multiply"})).unwrap(); + s.execute("graphicStyle.new", &json!({"id": rect.0, "name": "Faded"})).unwrap(); + s.execute("graphicStyle.apply", &json!({"name": "Faded", "ids": [marks[0].0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (Some(0.5), Some(BlendMode::Multiply))); + s.execute("object.setProps", &json!({"id": rect.0, "opacity": 100, "blend": "normal"})).unwrap(); + s.execute("graphicStyle.redefine", &json!({"name": "Faded", "id": rect.0})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (None, None)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.5), "one undo takes back the redefinition and what the graph stored"); + + // An opaque Normal style applied to an opaque mark replaces the opacity another mark of the series left. + let (mut s, id, _, marks) = column_marks(); + s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 40})).unwrap(); + blue_style(&mut s, "Blue", true); + s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[1].0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, None); +} + /// The colors of series `index`'s marks: each mark's top fill and stroke paint and its stroke weight. fn marks_of(s: &Session, id: NodeId, index: u32) -> Vec<(Paint, Paint, f64)> { let n = s.doc().unwrap().doc.node(id).unwrap().clone(); @@ -1010,6 +1202,22 @@ fn a_stroke_set_on_a_filled_line_band_does_not_repaint_it() { assert_eq!(redrawn_band(&mut s, id), (was, Paint::solid(vectorcraft_color::Color::BLACK), 0.25)); } +/// An opacity and blend mode set on a Filled Lines band's stroke row are kept and drawn on its outline, which keeps its +/// black paint and 0.25 pt weight. +#[test] +fn a_filled_line_bands_outline_keeps_a_stroke_opacity_and_blend_mode() { + let (mut s, id, band) = band_graph(); + let row = top_row(&s, band, false); + s.execute("appearance.setItem", &json!({"ids": [band.0], "index": row, "opacity": 50, "blend": "multiply"})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.stroke, kept.stroke_opacity, kept.stroke_blend), (None, Some(0.5), Some(BlendMode::Multiply))); + let (_, outline, width) = redrawn_band(&mut s, id); + assert_eq!((outline, width), (Paint::solid(vectorcraft_color::Color::BLACK), 0.25)); + let band = series_parts(&s, id, 0)[0].clone(); + let st = band.appearance.stroke().unwrap(); + assert_eq!((st.opacity, st.blend), (0.5, BlendMode::Multiply)); +} + /// A Filled Lines band's stroke row removed with the Appearance panel or set to None with `paint.setStroke` leaves the /// band its color: an empty stored stroke is not a color for the band. #[test] @@ -1248,13 +1456,15 @@ fn a_paint_on_a_design_copy_or_the_other_kind_of_row_is_not_kept() { s.execute("appearance.setItem", &json!({"ids": [line.id.0], "index": row, "color": "#0000ff"})).unwrap(); assert!(spec(&s, id).series_paints[0].fill.is_none(), "a stroke edit on a line keeps no fill"); - // A blend mode on another mark of the series leaves the stored paint as that mark's own paint differs from it. + // A blend mode on another mark of the series leaves the stored paint as that mark's own paint differs from it, and + // stores only the blend mode. let (mut s, id, _, marks) = column_marks(); let fill = top_row(&s, marks[0], true); s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap(); - let kept = spec(&s, id); + let mut kept = spec(&s, id).series_paints; s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": fill, "blend": "multiply"})).unwrap(); - assert_eq!(spec(&s, id), kept); + kept[0].fill_blend = Some(vectorcraft_color::BlendMode::Multiply); + assert_eq!(spec(&s, id).series_paints, kept); } /// A graphic style `name` made from a new rectangle with a blue fill at 50% opacity, and without a stroke row unless diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index 69f464edb..fa5a36be5 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -250,7 +250,7 @@ pub fn specs() -> Vec { "Object Properties", [], None, - "{ids?|id?, name?, visible?, locked? (visible: false or locked: true deselects every selected object hidden or locked after the call, the key object included), opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}", + "{ids?|id?, name?, visible?, locked? (visible: false or locked: true deselects every selected object hidden or locked after the call, the key object included), opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}. On a path directly in a graph series, the graph also stores the object's opacity and blend mode (exceptions: docs/mcp.md)", has_doc, set_props ), diff --git a/crates/engine/src/cmd/object/props.rs b/crates/engine/src/cmd/object/props.rs index 716181b1e..1fb2faf76 100644 --- a/crates/engine/src/cmd/object/props.rs +++ b/crates/engine/src/cmd/object/props.rs @@ -65,7 +65,11 @@ pub(super) fn set_props(s: &mut Session, p: &Value) -> Result { }) }; let d = &s.doc()?.doc; - if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) { + // A graph keeps the object opacity and blend mode set on a mark of a series for the whole series, so a value a mark + // already has still replaces the one another mark left on the graph. + let transparency = opacity.is_some() || blend.is_some(); + let stores = || transparency && crate::cmd::graph::series_object_opacity_differs(d, &ids); + if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) && !stores() { // Hidden or locked objects can't stay selected. if hides { s.select(|d, sel| sel.deselect_uneditable(d))?; @@ -118,6 +122,9 @@ pub(super) fn set_props(s: &mut Session, p: &Value) -> Result { }); } } + if transparency { + crate::cmd::graph::capture_series_object_opacity(d, &ids); + } // Hidden or locked objects can't stay selected. if hides { sel.deselect_uneditable(d); diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index b3c0d0f90..b600abed4 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -71,7 +71,7 @@ pub fn specs() -> Vec { "Transparency", ["Window", "Transparency"], None, - "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, 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)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself)", + "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, 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)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). On a path directly in a graph series, the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, transparency ), @@ -784,7 +784,7 @@ fn swap(s: &mut Session, p: &Value) -> Result { n.appearance.set_stroke(f); } // The swap writes the paint of the topmost fill and stroke, not their opacity. - let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (false, false), true); + let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (false, false), true, false); Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept)) })?; } @@ -813,7 +813,7 @@ fn default_paint(s: &mut Session, p: &Value) -> Result { _ => n.appearance = Appearance::default_art().into(), } } - let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false); + let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false, false); Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept)) })?; } diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 6379aeac5..e975c067c 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Apply Graphic Style", ["Window", "Graphic Styles"], None, - "{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows; exceptions: docs/mcp.md). With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}", + "{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows) and, without `add`, the object's opacity and blend mode (exceptions: docs/mcp.md). With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}", has_doc, style_apply ), @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Redefine Graphic Style", ["Window", "Appearance"], None, - "{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object; the objects still linked to it update, and that object becomes linked. On a linked path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {name}", + "{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object; the objects still linked to it update, and that object becomes linked. On a linked path directly in a graph series, the graph also stores the paint and weight of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {name}", has_doc, style_redefine ), @@ -348,14 +348,14 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { // The graph stores the rows of the kinds the style shows, now on top of each stack. let shows = |kind: bool| g.appearance.items.iter().any(|it| it.is_fill() == kind && it.visible()); let kinds = (shows(true), shows(false)); - super::appearance::capture_series_writes(&before, d, &ids, kinds, kinds, false); + super::appearance::capture_series_writes(&before, d, &ids, kinds, kinds, false, false); } else { d.graphic_style_id(i); let g = d.graphic_styles[i].clone(); for id in &ids { apply_style(d, *id, &g, chars); } - super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false); + super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false, true); } Ok(()) })?; @@ -475,7 +475,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { for nid in &follow { apply_style(d, *nid, &g, chars); } - super::appearance::capture_series_writes(&before, d, &follow, (true, true), (true, true), false); + super::appearance::capture_series_writes(&before, d, &follow, (true, true), (true, true), false, true); set_link(d, &[src], Some(id)); Ok(()) })?; diff --git a/docs/mcp.md b/docs/mcp.md index f23fec388..cffdd613b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -537,15 +537,22 @@ stroke instead of the whole object: `run_command effect.apply {"effect": "path.o On a path directly inside a graph series, `appearance.setItem`, `appearance.addFill`, `appearance.addStroke`, `appearance.removeItem`, `appearance.moveItem`, `appearance.duplicateItem`, `appearance.clear`, `appearance.reduceToBasic`, `appearance.showAllHidden`, `appearance.transfer`, `appearance.copyFrom`, `paint.default`, -`graphicStyle.apply` and `graphicStyle.redefine` (on the marks still linked to the style) store the paint, weight and -opacity of the topmost visible fill and stroke on the graph. `graphicStyle.apply` with `add` stores only the kinds of row -that are visible in the style. `paint.swap` stores the swapped paints and the stroke weight, keeps the stored opacity, -and does not store a hidden topmost row that it swaps. The graph does not store the rest of a graphic style's look (more -fill and stroke rows, effects, the object's opacity and blend mode, and the link to the style), and a mark loses it when -the graph is drawn again. When the graph is drawn again, columns, bars, wedges, areas, markers and legend swatches take -the stored fill and stroke. A thin line takes the stored stroke and has no fill. A Filled Lines band takes the stored -fill, or the stored stroke when no fill is stored and that stroke has paint, and has a 0.25 pt black outline, so these -commands do not store a stroke for a series that draws a band. Graph designs keep their own paint. A row that had no +`graphicStyle.apply` and `graphicStyle.redefine` (on the marks still linked to the style) store the paint, weight, +opacity and blend mode of the topmost visible fill and stroke on the graph. `graphicStyle.apply` with `add` stores only +the kinds of row that are visible in the style. `paint.swap` stores the swapped paints and the stroke weight, keeps the +stored opacity and blend mode, and does not store a hidden topmost row that it swaps. `transparency.set` and +`object.setProps` with an opacity or blend mode for the object, `appearance.clear`, `appearance.transfer`, +`appearance.copyFrom` with Transparency picked up, `graphicStyle.apply` without `add` and `graphicStyle.redefine` store +the object's opacity and blend mode on the graph, and the other commands above store them when they change them on a +mark. The graph stores an opacity and blend mode as a pair, taken from the mark the command edited: a command that sets +only the opacity of one mark's object or row also stores that mark's blend mode in place of the one another mark +stored. Every path directly in the series takes the stored values when the graph is drawn again. The graph does not +store the rest of a graphic style's look (more fill and stroke rows, effects, and the link to the style), and a mark +loses it when the graph is drawn again. When the graph is drawn again, columns, bars, wedges, areas, markers and legend +swatches take the stored fill and stroke. A thin line takes the stored stroke and has no fill. A Filled Lines band takes +the stored fill, or the stored stroke when no fill is stored and that stroke has paint, and has a 0.25 pt black +outline, so these commands do not store a stroke paint or weight for a series that draws a band. The outline takes the +stored stroke opacity and blend mode. Graph designs keep their own paint. A row that had no paint before the command and has none after it does not change what the graph stores. `paint.setFill`, `paint.setStroke`, `paint.none` and the commands that apply one paint kind to the other store the topmost row of the kind even when it is hidden, and store a stroke set on a band. `stroke.set`, `paint.editGradient` and the recolor commands do diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6b57dc5ec..c695f2d5e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -840,7 +840,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur View › Hide Edges hides the Live Corners widgets, the bounding box and the type widget, and the selection tools then do not answer a press on them or change the cursor over them. - **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html). - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. - - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, and the paint, weight and opacity of a mark's top visible fill or stroke row after an Appearance command, the Eyedropper, a graphic style, Default Fill and Stroke or Swap Fill and Stroke, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). + - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command, the Eyedropper, a graphic style, Default Fill and Stroke or Swap Fill and Stroke, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete. Type in a column design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits: the first digit is the least number of places before the decimal point (zeros are added in front; 0 gives as many as the value needs), and the second digit sets the places after it, rounded. Unless it is inside a group that clips or has its own opacity or appearance, or inside a blend, compound shape, envelope, repeat or intertwined objects, the type keeps its size and is drawn once per column in a repeating design. The legend swatch leaves it out. - **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View. From 2a32d1bc6594b220e64277ef4943fc21409cfe5a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 05:01:15 -0800 Subject: [PATCH 056/140] .ai import: symbol libraries open with their symbols A .ai or EPS whose editing data defines symbols but has no layers came in as its PDF part or page without symbols, and Other Library in the Symbols panel listed none. Such a file, and a file read as its PDF part or page because its layers can't be used, now gets the symbols its editing data defines before its first layer, as a file read from its layers does: File > Open shows them in the Symbols panel in the order of the symbol list, and Other Library lists them in a library panel. - Each symbol keeps its registration point. A symbol that holds an instance of a symbol the file doesn't define is left out, and an object linked to a listed graphic style keeps the link. - The symbols' art takes ids of the document, the art of opacity masks in it included, and the images it shows come with it. Instances drawn too deep are removed with the usual note. - The global and spot colors the art is linked to come in as swatches. A color whose name another swatch has takes that swatch's place when it is a default swatch of a file without a palette; otherwise it comes in under a free variant of the name ("Ink 2"), which the art then names. - Symbol art keeps the editing data's units, as in a file read from its layers, so in a file in larger units its linked graphic styles are drawn at the scaled size and the symbol art is not. - The art of the patterns a palette or brush brings into a file read as its PDF part or page now also takes new ids for its opacity masks. Part of #3349 --- .../engine/src/cmd/fileio/tests_aiimport.rs | 33 +++ crates/engine/tests/import_fuzz.rs | 30 +++ crates/eps/src/import/ai.rs | 4 +- crates/eps/src/import/ai/read.rs | 4 +- crates/eps/src/import/ai/read/document.rs | 67 +++++ crates/eps/src/import/ai/tests.rs | 44 ++++ crates/eps/src/import/native/build.rs | 2 +- crates/eps/src/import/native/layers.rs | 101 +++++-- crates/eps/src/import/tests_native.rs | 248 ++++++++++++++++++ docs/file-format-parity.md | 8 +- docs/mcp.md | 4 +- docs/target-app-parity.md | 1 + 12 files changed, 519 insertions(+), 27 deletions(-) diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index e9e24e5a1..58584e955 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -398,6 +398,39 @@ fn a_graphic_style_library_in_a_ai_file_without_layers_loads_its_styles() { assert_eq!(styles, [("Red", 1), ("Green", 2), ("Blue", 3)], "{r}"); } +/// A `.ai` symbol library (symbols and their list, no layers) opens with its symbols, and Other +/// Library… in the Symbols panel lists them in the list's order. +#[test] +fn a_symbol_library_in_a_ai_file_without_layers_loads_its_symbols() { + let symbol = |name: &str, x: f64| { + format!( + "%AI14_BeginSymbol\n({name})\n0 A\n0 Xw\n0 Ae\nu\n0 1 1 0 1 0 0 Xa\n{x} 0 m\n{x} 4 L\n{} 4 L\n{} 0 L\nf\nU\n\ + %_/ArtDictionary :\n%_5 /Int (AI13PatternRegistrationType) ,\n%_;\n%_\n9 () XW\n%AI10_EndSymbol\n", + x + 4.0, + x + 4.0 + ) + }; + let symbols = format!( + "%AI5_Begin_NonPrinting\n{}{}{}%AI5_End_NonPrinting--\n%AI24_BeginSymbolList\n(Star)\n(Leaf)\n(Arrow)\n%AI24_EndSymbolList\n", + symbol("Arrow", 0.0), + symbol("Leaf", 10.0), + symbol("Star", 20.0) + ); + let data = ai::editing_data(200.0, 100.0, &symbols); + let file = ai::ai(&compressed(data.as_bytes()), "1 0 0 rg 10 50 30 40 re f"); + let mut s = Session::new(); + let p = json!({"dataBase64": vectorcraft_format::base64_encode(&file), "name": "shapes.ai"}); + let r = s.execute("symbol.loadLibrary", &p).unwrap(); + assert_eq!((&r["name"], &r["count"]), (&json!("shapes"), &json!(3)), "{r}"); + let lib = s.execute("symbol.library", &json!({"library": r["library"]})).unwrap(); + let names: Vec<&str> = lib["symbols"].as_array().unwrap().iter().map(|g| g["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Star", "Leaf", "Arrow"], "{lib}"); + let r = open(&mut s, "shapes.ai", &file, json!({})).unwrap(); + let doc = &s.doc().unwrap().doc; + let names: Vec<&str> = doc.symbols.iter().map(|g| g.name.as_str()).collect(); + assert_eq!(names, ["Star", "Leaf", "Arrow"], "{r}"); +} + /// A 1-bit RGB image opens from the editing data in the colors its PDF part shows. #[test] fn a_one_bit_rgb_image_opens_from_the_editing_data_in_its_colors() { diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index f1a1f62c0..f40092961 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3690,9 +3690,36 @@ proptest! { let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat(); survive("mutated .ai symbols", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; survive("mutated .ai symbols, trusted", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?; + // A symbol library: the same symbols and list, no layers. + let library = mutate_text(&symbol_library_text(), cut, &edits); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&library)].concat(); + survive("mutated .ai symbol library", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; + let eps = vectorcraft_testkit::ai::eps(library.as_bytes(), vectorcraft_testkit::ai::page_ps()); + survive("mutated symbol library in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?; } } +/// [`symbols_text`] without its layer: a symbol library. +fn symbol_library_text() -> String { + let full = symbols_text(); + match (full.find("%AI5_BeginLayer"), full.find("%AI5_EndLayer--\n")) { + (Some(a), Some(b)) if a < b => format!("{}{}", &full[..a], &full[b + "%AI5_EndLayer--\n".len()..]), + _ => full, + } +} + +/// The library [`ai_symbols_never_panic`] damages, as it is, opens with its symbols. +#[test] +fn the_symbol_library_fuzz_data_opens_with_its_symbols() { + let text = symbol_library_text(); + assert!(!text.contains("%AI5_BeginLayer")); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat(); + let (doc, warnings, layered) = vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0); + assert!(!layered, "{warnings:?}"); + let names: Vec<&str> = doc.symbols.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names, ["S2", "S1"], "{warnings:?}"); +} + /// Editing data with symbols (S2's art holds an instance of S1, F's holds non-native art), the /// symbol list and a layer of instances, as the format writes them. fn symbols_text() -> String { @@ -3783,6 +3810,9 @@ fn ai_symbol_limits_never_panic() { ai::layer("L", &inst("(S1)", "1 0 0 -1 20 50")) ), format!("{}{}", def("S1", &inst("(S2)", "1 0 0 -1 0 0")), def("S2", &inst("(S1)", "1 0 0 -1 0 0"))), + // Symbol libraries without layers: definitions nested too deep and a long chain. + nested, + chain, ]; for body in cases { let data = ai::editing_data(200.0, 100.0, &body).replacen("%%EndComments", "%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n%%EndComments", 1); diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index 21c6a62aa..2c8d23d42 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -38,8 +38,8 @@ pub(crate) use envelope::DRAWN as ENVELOPE_DRAWN; pub(crate) use frames::text_frames; pub(crate) use paint::MAX_STOPS as MAX_GRADIENT_STOPS; pub use read::{ - BindingRef, Brushes, IMAGE_TOO_LARGE, PaletteSwatches, Structure, artboards, bindings_note, brushes, graphic_styles, palette, read, read_beside, - variables, + BindingRef, Brushes, IMAGE_TOO_LARGE, PaletteSwatches, Structure, Symbols, artboards, bindings_note, brushes, graphic_styles, palette, read, + read_beside, symbols, variables, }; pub(crate) use read::{join_and, slot_frame, slot_of}; diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 025ccc480..47fde0fce 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -134,7 +134,9 @@ mod types; pub(crate) use art::join_and; use art::preview_png; -pub use document::{BindingRef, Brushes, PaletteSwatches, VariableRead, artboards, bindings_note, brushes, graphic_styles, palette, variables}; +pub use document::{ + BindingRef, Brushes, PaletteSwatches, Symbols, VariableRead, artboards, bindings_note, brushes, graphic_styles, palette, symbols, variables, +}; pub(crate) use document::{slot_frame, slot_of}; pub use types::IMAGE_TOO_LARGE; use types::{BLEND_BUDGET, FLIP, Frame, GState, Graph, Kind, PatternHead, PendingEnvelope, Place, Raster, SymbolHead}; diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index 65d949f0f..22a60991e 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -390,6 +390,73 @@ pub fn graphic_styles(data: &[u8], large_canvas: bool) -> (Vec, f6 (r.graphic_styles().0, r.canvas_scale) } +/// The symbols of a `.ai` file's editing data, as [`symbols`] reads them. +pub struct Symbols { + /// The symbols in the Symbols panel's order, each with its Symbol Options › Registration. + pub symbols: Vec<(Symbol, vectorcraft_doc::SymbolRegistration)>, + /// The image blobs the symbols' art shows, by key. + pub images: std::collections::BTreeMap, + /// The global and spot colors the symbols' art is linked to, as swatches, as [`read`] adds + /// them to a document read from the layers. + pub swatches: Vec, +} + +/// The symbols of the editing data `data`, for a file whose layers aren't read (a symbol library +/// has none), as [`read`] makes them: in the symbol list's order, without those that hold an +/// instance of a symbol the data doesn't define, and with each object of their art that is linked +/// to a listed graphic style linked to it by the id [`graphic_styles`] gives that style. The +/// symbols are those defined before reading stops and before the first layer; `None` when there +/// are none. +pub fn symbols(data: &[u8]) -> Option { + crate::import::ai::find(data, b"BeginSymbol")?; + let mut r = Reader::with_names(data); + // The symbols come before the art, so an error in the art leaves them read. + let _ = r.run(); + r.check_symbols(); + r.order_symbols(); + if r.doc.symbols.is_empty() { + return None; + } + let (styles, ids) = r.graphic_styles(); + let links: HashMap = r.style_links.iter().filter_map(|(node, name)| Some((*node, *ids.get(name)?))).collect(); + if !links.is_empty() { + for symbol in &mut r.doc.symbols { + link_styles(std::slice::from_mut(&mut symbol.art), &links, &styles, 0); + } + } + let mut shown = Document::new(1.0, 1.0); + shown.symbols = std::mem::take(&mut r.doc.symbols); + let mut images = std::collections::BTreeMap::new(); + shown.visit_images(|_, im| { + if let Some(blob) = r.doc.images.get(&im.key) { + images.insert(im.key.clone(), blob.clone()); + } + }); + // The colors the art is linked to that the file defines, once each. + let mut linked = std::collections::HashSet::new(); + for s in &mut shown.symbols { + vectorcraft_doc::swatches::map_node_solid_paints(Arc::make_mut(&mut s.art), &mut |_, link, _| { + linked.extend(link.clone()); + false + }); + } + let mut swatches: Vec = vec![]; + let colors = r.spots.iter().chain(&r.stop_spots).map(|n| (n, n.spot)).chain(r.custom_colors.iter().map(|n| (n, true))); + for (n, spot) in colors { + if linked.remove(&n.name) { + swatches.push(Swatch { name: n.name.clone(), paint: Paint::solid(n.color), global: true, spot }); + } + } + let symbols = std::mem::take(&mut shown.symbols) + .into_iter() + .map(|s| { + let reg = r.doc.symbol_registration(&s.name); + (s, reg) + }) + .collect(); + Some(Symbols { symbols, images, swatches }) +} + /// [`artboards`] as far as reading `data` from its start gets: those read before an error, and /// those of a `/Document` still open when it stopped (its art can be what stopped it). fn artboards_read(data: &[u8]) -> (Vec<(String, Rect)>, Option) { diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 36a93a690..e54522a18 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -5463,6 +5463,50 @@ fn an_object_in_a_symbols_art_with_a_listed_style_is_linked() { assert_eq!(art.extras().graphic_style, Some(1), "{art:#?}"); } +/// Editing data with symbols and no layers (a symbol library) gives its symbols: in the symbol +/// list's order with the unlisted one after them, with their registration points and the images +/// their art shows, without the one holding an instance of a symbol the data doesn't define, and +/// with the object linked to a listed graphic style linked to it by its id. +#[test] +fn a_symbol_library_without_layers_gives_its_symbols() { + let styles = styles_and_list(&[fill_style("Blue", "0 0 0 0 0 0 1 Xa\n")], &["Blue"]); + // The template box's center is (100.5, 50.5): art space's origin is at canvas (8091, 8242). + let nested = instance("Dot", [1.0, 0.0, 0.0, -1.0, -8081.0, 8237.0]); + let lost = instance("Ghost", [1.0, 0.0, 0.0, -1.0, -8081.0, 8237.0]); + let m = "[ 5 0 0 5 -8096 8244.5 ]"; + let raster = format!( + "%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceRGB XN\n{m} 2 1 0 Xh\n{m} 0 0 2 1 2 1 8 3 0 0 1 0 4 4 0 0\n\ + %%BeginData: 6\rXI\nABCDEF%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN\n" + ); + let defs = symbols( + &(symbol("Dot", &dot(), 1) + + &symbol("Pic", &raster, 5) + + &symbol("Pair", &nested, 5) + + &symbol("Tile", &styled_square("Blue"), 5) + + &symbol("Lost", &lost, 5)), + ); + let list = "%AI24_BeginSymbolList\n(Tile)\n(Pair)\n(Lost)\n(Dot)\n%AI24_EndSymbolList\n"; + let body = format!("%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n{styles}{defs}{list}"); + let read = super::symbols(stream(&body).as_bytes()).expect("the library's symbols"); + let names: Vec<&str> = read.symbols.iter().map(|(s, _)| s.name.as_str()).collect(); + assert_eq!(names, ["Tile", "Pair", "Dot", "Pic"]); + let registrations: Vec = read.symbols.iter().map(|(_, r)| r.0).collect(); + let center = vectorcraft_doc::SymbolRegistration::CENTER.0; + assert_eq!(registrations, [center, center, 0, center]); + let Some(((tile, _), (pair, _))) = read.symbols.first().zip(read.symbols.get(1)) else { panic!("two symbols") }; + assert_eq!(tile.art.extras().graphic_style, Some(1), "{:#?}", tile.art); + // Dot sits at (10, 5) in Pair's art. + let mut doc = Document::new(1.0, 1.0); + doc.symbols = read.symbols.iter().map(|(s, _)| s.clone()).collect(); + assert_eq!(pair.art.visual_bounds_with(&doc.symbol_boxes()), Some(Rect::new(8.0, 3.0, 12.0, 7.0))); + let keys: Vec = + doc.symbols.iter().filter_map(|s| if let NodeKind::Image(im) = &s.art.kind { Some(im.key.clone()) } else { None }).collect(); + assert_eq!(keys.len(), 1); + assert_eq!(read.images.keys().cloned().collect::>(), keys); + // Without symbols there are none to give. + assert!(super::symbols(stream(&styles).as_bytes()).is_none()); +} + #[test] fn without_a_style_list_the_default_graphic_styles_stay() { let defaults = style_names(&Document::new(1.0, 1.0)); diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index 698201be5..6e437acd2 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -1094,7 +1094,7 @@ fn keep_differing( } /// Copy page drawing IDs, including mask art outside the ordinary child tree. -fn reid_page_art(doc: &mut Document, source: &Node) -> Node { +pub(super) fn reid_page_art(doc: &mut Document, source: &Node) -> Node { let mut copy = doc.reid(source); if let Some(mask) = &mut copy.extras_mut().mask { mask.art = Arc::new(reid_page_art(doc, &mask.art)); diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs index 22c3cf7c7..d493034e9 100644 --- a/crates/eps/src/import/native/layers.rs +++ b/crates/eps/src/import/native/layers.rs @@ -1,4 +1,4 @@ -use super::build::{PAGE_MOVE, Page, build, build_from, build_on_page, build_without_page}; +use super::build::{PAGE_MOVE, Page, build, build_from, build_on_page, build_without_page, reid_page_art}; use super::slots::LAYERS_UNREAD; use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect, Vec2, ai}; @@ -7,11 +7,11 @@ use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect /// hidden ones too), groups, compound paths, clipping groups and artboards, with the text and the /// strokes around it of the page. When the file has no editing copy, `visible` as it was; when it /// has one that can't be used, `visible` with a warning that says why, with the swatches of the -/// editing copy's palette ([`use_palette`]), its graphic styles ([`use_styles`]) and its variable -/// library ([`use_variables`]) when it could be read, and with its layer tree, empty, -/// below the page's layer when that could be read (#2033). A program in the legacy format without -/// layers (`illustrator`, see [`program_alone`]) is read with its art on one layer; when that -/// can't be used, the file is `visible` as it was. +/// editing copy's palette ([`use_palette`]), its graphic styles ([`use_styles`]), its symbols +/// ([`use_symbols`]) and its variable library ([`use_variables`]) when it could be read, and with +/// its layer tree, empty, below the page's layer when that could be read (#2033). A program in the +/// legacy format without layers (`illustrator`, see [`program_alone`]) is read with its art on one +/// layer; when that can't be used, the file is `visible` as it was. pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illustrator: bool) -> Imported { let Some(data) = editing_copy(ps) else { let read = illustrator.then(|| one_layer(ps)).flatten().and_then(|p| build(&p, Some(&visible.document), Page::Art, false, trust, 1.0).ok()); @@ -38,9 +38,10 @@ pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illus let mut v = visible; if let Ok(data) = &data { let have = v.document.patterns.len(); - use_palette(data, &mut v.document); + let own_palette = use_palette(data, &mut v.document); use_brushes(data, &mut v.document, have, &mut v.warnings); use_styles(data, &mut v.document, true, 1.0, None); + use_symbols(data, &mut v.document, own_palette, &mut v.warnings); use_variables(data, &mut v.document, &mut v.warnings); } if tree.is_empty() { @@ -100,10 +101,11 @@ fn layer_tree(nodes: &[Arc]) -> Vec> { /// notes, or a note saying why the layers weren't read when the editing data can't be decompressed /// or its layers can't be read. The flag returned is `true` when the document is the one read from /// the layers; else the document is `visible`, its pages moved by `place_pages` when the editing -/// data was decompressed but its layers can't be read, and its swatches, brushes, graphic styles and -/// variables those of the editing data's palette ([`use_palette`]), brushes ([`use_brushes`]), styles -/// ([`use_styles`]) and variable library ([`use_variables`]) when it was decompressed: a graphic -/// style library, which has no layers, opens with its styles, and editing data with no layers opens +/// data was decompressed but its layers can't be read, and its swatches, brushes, graphic styles, +/// symbols and variables those of the editing data's palette ([`use_palette`]), brushes +/// ([`use_brushes`]), styles ([`use_styles`]), symbols ([`use_symbols`]) and variable library +/// ([`use_variables`]) when it was decompressed: a graphic style library or a symbol library, which +/// has no layers, opens with its styles or its symbols, and editing data with no layers opens /// on the page of its active artboard under the checks of `place_pages`. `outlined`: the PDF part's type was /// read as outlines (a file whose type shows then comes in as its PDF part). /// `trust`: layers that draw differently from the PDF part are used all the same, with a warning. @@ -140,9 +142,10 @@ pub fn layered_ai_from( visible.opened_artboard = active_page(active, &visible); } let have = visible.patterns.len(); - use_palette(&data, &mut visible); + let own_palette = use_palette(&data, &mut visible); use_brushes(&data, &mut visible, have, &mut warnings); use_styles(&data, &mut visible, false, unit, Some(&boards)); + use_symbols(&data, &mut visible, own_palette, &mut warnings); use_variables(&data, &mut visible, &mut warnings); return (visible, warnings, false); } @@ -152,10 +155,11 @@ pub fn layered_ai_from( let (boards, active) = ai::artboards(&data); place_pages(&boards, active, &mut visible, unit); let have = visible.patterns.len(); - use_palette(&data, &mut visible); + let own_palette = use_palette(&data, &mut visible); use_brushes(&data, &mut visible, have, &mut warnings); use_styles(&data, &mut visible, false, unit, Some(&boards)); let mut all = unread(warnings, why); + use_symbols(&data, &mut visible, own_palette, &mut all); use_variables(&data, &mut visible, &mut all); (visible, all, false) }; @@ -243,14 +247,14 @@ fn use_variables(data: &[u8], doc: &mut Document, warnings: &mut Vec) { /// palette doesn't have follow the palette's swatches. The patterns that the editing data defines come /// in for the palette's pattern swatches, with the images they show; one whose name a pattern of /// the PDF part already has takes a free variant of it ([`ai::palette`]). Left as -/// it is when the palette is empty. -fn use_palette(data: &[u8], doc: &mut Document) { - let Some(ai::PaletteSwatches { swatches, groups, patterns, images }) = ai::palette(data, &doc.patterns) else { return }; +/// it is when the palette is empty. Whether the palette's swatches came in. +fn use_palette(data: &[u8], doc: &mut Document) -> bool { + let Some(ai::PaletteSwatches { swatches, groups, patterns, images }) = ai::palette(data, &doc.patterns) else { return false }; for (key, blob) in images { doc.images.entry(key).or_insert(blob); } for mut p in patterns { - p.art = p.art.iter().map(|n| Arc::new(doc.reid(n))).collect(); + p.art = p.art.iter().map(|n| Arc::new(reid_page_art(doc, n))).collect(); doc.patterns.push(p); } let named = |name: &str| swatches.iter().chain(groups.iter().flat_map(|g| &g.swatches)).any(|s| s.name == name); @@ -259,6 +263,7 @@ fn use_palette(data: &[u8], doc: &mut Document) { let mut swatches = swatches; swatches.extend(art); (doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into()); + true } /// `doc` (a file read as its page or PDF part, in place of its layers) with the brushes of its @@ -277,7 +282,7 @@ fn use_brushes(data: &[u8], doc: &mut Document, have: usize, warnings: &mut Vec< if doc.pattern(&p.name).is_some() { continue; } - p.art = p.art.iter().map(|n| Arc::new(doc.reid(n))).collect(); + p.art = p.art.iter().map(|n| Arc::new(reid_page_art(doc, n))).collect(); doc.patterns.push(p); } vectorcraft_brush::store(doc, &brushes); @@ -317,6 +322,66 @@ fn use_styles(data: &[u8], doc: &mut Document, large_canvas: bool, unit: f64, bo doc.graphic_styles = styles.into(); } +/// `doc` (a file read as its page or PDF part, in place of its layers) with the symbols of its +/// editing data `data` ([`ai::symbols`]), their registration points, the images their art shows and +/// the global and spot colors it is linked to that `doc` has no such swatch for (a color whose name +/// another swatch has takes that swatch's place when it is a default swatch and not `own_palette`, +/// the swatches being those of the editing data's palette, and else comes in under a free variant +/// of the name, which the art then names), as [`build`] gives a document read from the layers, and +/// the note about instances that can't be drawn added to `warnings`. An object of their art linked +/// to a graphic style keeps the link when [`use_styles`] gave `doc` the editing data's styles. The +/// symbols' art keeps the editing data's units, as in a document read from the layers: when +/// [`use_styles`] draws the styles' strokes and effects larger (a file in larger units), linked art +/// isn't scaled with them. Left as it is when `doc` has symbols already or the editing data has +/// none this reads. +fn use_symbols(data: &[u8], doc: &mut Document, own_palette: bool, warnings: &mut Vec) { + if !doc.symbols.is_empty() { + return; + } + let Some(ai::Symbols { symbols, images, swatches }) = ai::symbols(data) else { return }; + for (key, blob) in images { + doc.images.entry(key).or_insert(blob); + } + // A color whose name a swatch of another color has takes that swatch's place when it is a + // default swatch, and else a free variant of the name. + let (defaults, groups) = vectorcraft_color::default_swatches(doc.color_mode.model()); + let is_default = + |have: &vectorcraft_color::Swatch| !own_palette && defaults.iter().chain(groups.iter().flat_map(|g| &g.swatches)).any(|d| d == have); + let mut renamed = std::collections::HashMap::new(); + for s in swatches { + match doc.swatch(&s.name) { + None => doc.swatches.push(s), + Some(have) if have == &s || (s.name.starts_with('[') && s.name.ends_with(']')) => {} + Some(have) if is_default(have) => { + if let Some(default) = doc.swatch_mut(&s.name) { + *default = s; + } + } + Some(_) => { + let name = doc.free_swatch_name(&s.name); + renamed.insert(s.name.clone(), name.clone()); + doc.swatches.push(vectorcraft_color::Swatch { name, ..s }); + } + } + } + let mut follow = |_: &mut vectorcraft_color::Color, link: &mut Option, _: &mut f32| { + let Some(new) = link.as_ref().and_then(|l| renamed.get(l)) else { return false }; + *link = Some(new.clone()); + true + }; + for (mut symbol, registration) in symbols { + let mut art = reid_page_art(doc, &symbol.art); + if !renamed.is_empty() { + vectorcraft_doc::swatches::map_node_solid_paints(&mut art, &mut follow); + } + symbol.art = Arc::new(art); + doc.set_symbol_registration(&symbol.name, registration); + doc.symbols.push(symbol); + } + // Symbols that hold instances of themselves can't be drawn: those instances go. + doc.limit_depth_noting(warnings); +} + /// How many points a unit of `boards` (the artboards of a file's editing data, in art space) is in /// `doc` (the file read as its PDF part: a page per artboard): 1 when there are as many artboards as /// pages, each the size of its page in points, or `unit` when `unit` is above 1 and each is the size diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index dde26bc8d..81a0a2280 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -1356,6 +1356,37 @@ fn an_ai_file_without_layers_keeps_the_patterns_of_its_palette() { assert!(keys.iter().all(|k| d.images.contains_key(k)), "{keys:?}"); } +/// The art of a pattern with an opacity mask that a palette swatch or a brush's art paints with, +/// in a file without layers, takes ids of the document, its mask's art included. +#[test] +fn a_pattern_of_a_file_without_layers_takes_ids_for_its_mask_art() { + let mask_art: String = symbol_square(0.0, 0.0, 4.0, 4.0).replace("0 1 1 0 1 0 0 Xa", "1 g").lines().map(|l| format!("%_{l}\n")).collect(); + let mask = format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{mask_art}%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_; (AI9_Mask) ,\n%_;\n"); + let hidden = |art: String| -> String { art.lines().map(|l| format!("%_{l}\n")).collect() }; + let tile = format!("{}{mask}{}", hidden(symbol_square(0.0, 0.0, 4.0, 4.0)), hidden(symbol_square(1.0, 1.0, 3.0, 3.0))); + let dots = format!("%AI3_BeginPattern: (Dots)\n(Dots) 0 0 4 4\n{tile}E\n%AI3_EndPattern\n"); + let palette = "%AI5_BeginPalette\n0 0 Pb\n(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n(Dots)\nPc\nPB\n%AI5_EndPalette\n"; + let order = "%AI8_BeginPluginObject\n(Adobe Brush Manager Order)\n(Adobe Brush Manager Order)\n( Adobe Scatter Brush Tool/ Spray/) .\n%AI8_EndPluginObject\n"; + let spray = "%AI8_BeginPluginObject\n(Adobe Scatter Brush Tool)\n(Spray)\n(2 /Dot art/ 5 1 0 0 / 0 0.5 1.5 1 ) -\n\ + (1.2 2 0 -0.25 0.25 2 -45 30 1 1 0) .\n%AI8_EndPluginObject\n"; + let dot = "%AI8_BeginBrushPattern\n(Dot art)\n(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n0 0 m\n4 0 L\n4 4 L\nf\n%AI8_EndBrushPattern\n"; + for setup in [format!("{dots}{palette}"), format!("{dots}{dot}{order}{spray}")] { + let library = format!("%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%BeginSetup\n{setup}%%EndSetup\n%%PageTrailer\n%%Trailer\n"); + let (d, warnings, read) = crate::layered_ai(&private(&library), pdf_part(10.0, 86.0), vec![], false, true, 1.0); + assert!(!read, "{warnings:?}"); + let art = d.pattern("Dots").map(|p| p.art.clone()).unwrap_or_default(); + let mut masked = art.iter().flat_map(|n| std::iter::once(n).chain(n.children().into_iter().flatten())); + assert!(masked.any(|n| n.extras().mask.is_some()), "the mask is read: {art:#?} {warnings:?}"); + let mut ids = vec![]; + for n in d.layers.iter().chain(d.patterns.iter().flat_map(|p| &p.art)) { + all_ids(n, &mut ids); + } + let unique: std::collections::HashSet<_> = ids.iter().copied().collect(); + assert_eq!(unique.len(), ids.len(), "{ids:?}"); + assert!(ids.iter().all(|id| id.0 < d.peek_next_id()), "{ids:?}"); + } +} + /// A brush library (brush objects and their art, no layers) comes in as its PDF part with the /// library's brushes in place of the default ones: Other Library lists them. #[test] @@ -1486,6 +1517,223 @@ fn a_file_whose_layers_cant_be_used_keeps_its_graphic_styles() { assert_eq!(names(&r.document), ["Thin", "Medium", "Thick"], "{:?}", r.warnings); } +/// A symbol named `name` whose art is `art` (in the symbol's space, y down), with the registration +/// point `registration` (1 to 9 from the top left, row by row), as the editing data defines it. +fn symbol_def(name: &str, art: &str, registration: u32) -> String { + format!( + "%AI14_BeginSymbol\n({name})\n0 A\n0 Xw\n0 Ae\nu\n{art}U\n%_/ArtDictionary :\n%_{registration} /Int (AI13PatternRegistrationType) ,\n%_;\n%_\n9 () XW\n%AI10_EndSymbol\n" + ) +} + +/// A red square from (`x0`, `y0`) to (`x1`, `y1`), as a symbol's art. +fn symbol_square(x0: f64, y0: f64, x1: f64, y1: f64) -> String { + format!("0 1 1 0 1 0 0 Xa\n{x0} {y0} m\n{x0} {y1} L\n{x1} {y1} L\n{x1} {y0} L\n{x0} {y0} L\nf\n") +} + +/// The symbols Dot (a square of 4 about the origin, registered at its top left), Bar (12 × 2 about +/// the origin) and Masked (a square with an opacity mask), listed as Bar, Masked, Dot. +fn symbol_defs() -> String { + let mask_art: String = symbol_square(-2.0, -2.0, 2.0, 2.0).replace("0 1 1 0 1 0 0 Xa", "1 g").lines().map(|l| format!("%_{l}\n")).collect(); + let mask = format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{mask_art}%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_; (AI9_Mask) ,\n%_;\n"); + let masked = format!("{}{mask}{}", symbol_square(-2.0, -2.0, 2.0, 2.0), symbol_square(-1.0, -1.0, 1.0, 1.0)); + let defs = symbol_def("Dot", &symbol_square(-2.0, -2.0, 2.0, 2.0), 1) + + &symbol_def("Bar", &symbol_square(-6.0, -1.0, 6.0, 1.0), 5) + + &symbol_def("Masked", &masked, 5); + format!("%AI5_Begin_NonPrinting\n{defs}%AI5_End_NonPrinting--\n%AI24_BeginSymbolList\n(Bar)\n(Masked)\n(Dot)\n%AI24_EndSymbolList\n") +} + +/// A symbol library (the symbols of [`symbol_defs`], and no layers). +fn symbol_library() -> String { + format!("%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%BeginSetup\n{}%%EndSetup\n%%PageTrailer\n%%Trailer\n", symbol_defs()) +} + +/// The ids of `n`, what it holds and the art of its opacity masks. +fn all_ids(n: &Node, out: &mut Vec) { + out.push(n.id); + if let Some(m) = n.extras().mask.as_ref() { + all_ids(&m.art, out); + } + for c in n.children().into_iter().flatten() { + all_ids(c, out); + } +} + +/// The names of the symbols of `d`, in the Symbols panel's order. +fn symbol_names(d: &Document) -> Vec<&str> { + d.symbols.iter().map(|s| s.name.as_str()).collect() +} + +/// A symbol library comes in as its PDF part with the library's symbols, in the list's order, with +/// their registration points: Other Library lists them. Their art takes ids of the document, the +/// art of an opacity mask in it included. +#[test] +fn an_ai_symbol_library_without_layers_keeps_its_symbols() { + let (d, warnings, read) = crate::layered_ai(&private(&symbol_library()), pdf_part(10.0, 86.0), vec![], false, true, 1.0); + assert!(!read, "{warnings:?}"); + assert_eq!(symbol_names(&d), ["Bar", "Masked", "Dot"], "{warnings:?}"); + assert_eq!(d.symbol_registration("Dot"), vectorcraft_doc::SymbolRegistration(0)); + assert!(d.symbol_registration("Bar").is_center()); + let boxes = d.symbol_boxes(); + let bar = d.symbols.first().and_then(|s| s.art.visual_bounds_with(&boxes)); + assert_eq!(bar, Some(vectorcraft_geom::Rect::new(-6.0, -1.0, 6.0, 1.0))); + drop(boxes); + let mut ids = vec![]; + for n in d.layers.iter().chain(d.symbols.iter().map(|s| &s.art)) { + all_ids(n, &mut ids); + } + let masked = d.symbols.get(1).map(|s| &s.art); + assert!(masked.is_some_and(|a| a.children().into_iter().flatten().any(|c| c.extras().mask.is_some())), "the mask is read"); + let unique: std::collections::HashSet<_> = ids.iter().copied().collect(); + assert_eq!(unique.len(), ids.len(), "{ids:?}"); + assert!(warnings.is_empty(), "{warnings:?}"); + // Without symbols, the PDF part's (none) stay. + let (d, warnings, _) = crate::layered_ai(&private(&native("")), pdf_part(10.0, 86.0), vec![], false, true, 1.0); + assert!(d.symbols.is_empty(), "{warnings:?}"); +} + +/// An EPS whose editing copy has symbols and no layers comes in as its page with those symbols. +#[test] +fn an_eps_without_layers_keeps_its_symbols() { + let r = import(&eps(&page_square(50, 50), &symbol_library())).unwrap(); + assert_eq!(symbol_names(&r.document), ["Bar", "Masked", "Dot"], "{:?}", r.warnings); +} + +/// A `.ai` that comes in as its PDF part, and an EPS that comes in as its page, because their +/// layers can't be used (their editing data is for another page) get the symbols defined before +/// their layers. +#[test] +fn a_file_whose_layers_cant_be_used_keeps_its_symbols() { + let art = native(&format!("{}{}", symbol_defs(), layer("Art", true, &square(10, 10)))).replace(" 0 0 100 100", " 0 0 600 400"); + let (d, warnings, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false, 1.0); + assert!(!read && warnings.iter().any(|w| w.contains("600 × 400")), "{warnings:?}"); + assert_eq!(symbol_names(&d), ["Bar", "Masked", "Dot"], "{warnings:?}"); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("600 × 400")), "{:?}", r.warnings); + assert_eq!(symbol_names(&r.document), ["Bar", "Masked", "Dot"], "{:?}", r.warnings); +} + +/// A symbol library's symbols bring the spot colors their art is linked to. A color whose name a +/// swatch of the library's palette has for another color comes in under a free variant of the +/// name, which the art then names, as it does for an ink of the PDF part; without a palette, it +/// takes the place of the default swatch of that name. +#[test] +fn an_ai_symbol_librarys_symbols_keep_their_spot_colors() { + let library = |setup: &str, ink: &str| { + let art = format!("0 0 1 0 ({ink}) 1 x\n-2 -2 m\n-2 2 L\n2 2 L\n2 -2 L\n-2 -2 L\nf\n"); + let defs = format!("{setup}%AI5_Begin_NonPrinting\n{}%AI5_End_NonPrinting--\n", symbol_def("Inked", &art, 5)); + format!("%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%BeginSetup\n{defs}%%EndSetup\n%%PageTrailer\n%%Trailer\n") + }; + let open = |text: &str| crate::layered_ai(&private(text), pdf_part(10.0, 86.0), vec![], false, true, 1.0); + let link = |d: &Document| match d.symbols.first().and_then(|s| s.art.appearance.fill()).map(|f| &f.paint) { + Some(vectorcraft_color::Paint::Solid { swatch, .. }) => swatch.clone(), + _ => None, + }; + let ink = |d: &Document, name: &str| d.swatch(name).map(|s| (s.paint.color(), s.spot)); + let yellow = Some(vectorcraft_color::Color::cmyk(0.0, 0.0, 1.0, 0.0)); + let (d, warnings, _) = open(&library("", "Brand Ink")); + assert_eq!(link(&d).as_deref(), Some("Brand Ink"), "{warnings:?}"); + assert_eq!(ink(&d, "Brand Ink"), Some((yellow, true))); + let palette = "%AI5_BeginPalette\n0 0 Pb\n0 0 0 0 0 1 0 Xa\n(Clash)\nPc\nPB\n%AI5_EndPalette\n"; + let (d, warnings, _) = open(&library(palette, "Clash")); + assert_eq!(link(&d).as_deref(), Some("Clash 2"), "{warnings:?}"); + assert_eq!(ink(&d, "Clash 2"), Some((yellow, true))); + assert_eq!(ink(&d, "Clash"), Some((Some(vectorcraft_color::Color::rgb(0.0, 1.0, 0.0)), false))); + let (d, warnings, _) = open(&library("", "Red")); + assert_eq!(link(&d).as_deref(), Some("Red"), "{warnings:?}"); + assert_eq!(ink(&d, "Red"), Some((yellow, true))); + assert_eq!(d.swatches.iter().filter(|s| s.name == "Red").count(), 1); + // An ink of the PDF part keeps its swatch: the library's color of that name is renamed. + let mut part = pdf_part(10.0, 86.0); + let green = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(1.0, 0.0, 1.0, 0.0)); + part.swatches.push(vectorcraft_color::Swatch { name: "Ink".into(), paint: green.clone(), global: true, spot: true }); + let (d, warnings, _) = crate::layered_ai(&private(&library("", "Ink")), part, vec![], false, true, 1.0); + assert_eq!(link(&d).as_deref(), Some("Ink 2"), "{warnings:?}"); + assert_eq!(ink(&d, "Ink 2"), Some((yellow, true))); + assert_eq!(d.swatch("Ink").map(|s| &s.paint), Some(&green)); +} + +/// A symbol library's symbols bring a global process color their art is filled with as a global +/// swatch that isn't an ink, and a spot color a stop of their gradient names as an ink. +#[test] +fn an_ai_symbol_librarys_symbols_keep_their_global_and_gradient_stop_colors() { + let gradient = "%AI5_BeginGradient: (Fade)\n(Fade) 0 2 Bd\n[\n\ + %_0 1 1 0 (Brand) 0 0 5 1 6 50 0 Bs\n%_0 0 0 0 1 0 0 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n"; + let global = "0 0 1 0 (Process Yellow) 0 0 Xk\n-2 -2 m\n-2 2 L\n2 2 L\n2 -2 L\n-2 -2 L\nf\n"; + let graded = "Bb\n1 (Fade) 0 0 0 4 1 0 0 1 0 0 Bg\n-2 -2 m\n-2 2 L\n2 2 L\n2 -2 L\n-2 -2 L\nf\n0 BB\n"; + let defs = symbol_def("Global", global, 5) + &symbol_def("Graded", graded, 5); + let text = format!( + "%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%BeginSetup\n{gradient}%AI5_Begin_NonPrinting\n{defs}%AI5_End_NonPrinting--\n\ + %%EndSetup\n%%PageTrailer\n%%Trailer\n" + ); + let (d, warnings, _) = crate::layered_ai(&private(&text), pdf_part(10.0, 86.0), vec![], false, true, 1.0); + assert_eq!(symbol_names(&d), ["Global", "Graded"], "{warnings:?}"); + let swatch = |name: &str| d.swatch(name).map(|s| (s.paint.color(), s.global, s.spot)); + assert_eq!(swatch("Process Yellow"), Some((Some(vectorcraft_color::Color::cmyk(0.0, 0.0, 1.0, 0.0)), true, false)), "{warnings:?}"); + assert_eq!(swatch("Brand"), Some((Some(vectorcraft_color::Color::cmyk(0.0, 1.0, 1.0, 0.0)), true, true)), "{warnings:?}"); +} + +/// A symbol library's symbol shows its image from the document's images, and an object of a +/// symbol's art linked to a style of the library's style list links to that style as the document +/// has it. +#[test] +fn an_ai_symbol_librarys_symbols_bring_their_images_and_style_links() { + // The template box's center is (100.5, 50.5): art space's origin is at canvas (8091, 8242). + let m = "[ 5 0 0 5 -8096 8244.5 ]"; + let raster = format!( + "%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceRGB XN\n{m} 2 1 0 Xh\n{m} 0 0 2 1 2 1 8 3 0 0 1 0 4 4 0 0\n\ + %%BeginData: 6\rXI\nABCDEF%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN\n" + ); + let path = "-2 -2 m\n-2 2 L\n2 2 L\n2 -2 L\n-2 -2 L\n"; + let hidden: String = path.lines().map(|l| format!("%_{l}\n")).collect(); + let styled = format!("0 Ae\nu\n0 0 0 0 0 0 1 XA\n0 J 0 j 3 w 10 M []0 d\n{path}S\nU\n{hidden}%_S\n1 (Thick) XW\n"); + let defs = format!("%AI5_Begin_NonPrinting\n{}{}%AI5_End_NonPrinting--\n", symbol_def("Pic", &raster, 5), symbol_def("Line", &styled, 5)); + let text = style_library(&defs).replacen("%%BeginSetup", "%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n%%BeginSetup", 1); + let (d, warnings, read) = crate::layered_ai(&private(&text), pdf_part(10.0, 86.0), vec![], false, true, 1.0); + assert!(!read, "{warnings:?}"); + assert_eq!(symbol_names(&d), ["Pic", "Line"], "{warnings:?}"); + let key = d.symbols.first().and_then(|s| if let NodeKind::Image(im) = &s.art.kind { Some(im.key.clone()) } else { None }); + let key = key.unwrap_or_else(|| panic!("an image: {:#?}", d.symbols.first())); + assert!(d.images.contains_key(&key), "{key}"); + let mut linked = vec![]; + let mut stack: Vec<&Node> = d.symbols.get(1).map(|s| &*s.art).into_iter().collect(); + while let Some(n) = stack.pop() { + linked.extend(n.extras().graphic_style); + stack.extend(n.children().into_iter().flatten().map(|c| &**c)); + } + let named: Vec<&str> = linked.iter().filter_map(|id| d.graphic_styles.iter().find(|g| g.id == *id)).map(|g| g.name.as_str()).collect(); + assert_eq!(named, ["Thick"], "{linked:?}"); +} + +/// The instances of a symbol library's symbols that would draw more than +/// [`vectorcraft_doc::depth::MAX_DRAWN_DEPTH`] objects deep go, with the note saying so, and a +/// document that has symbols already keeps them. +#[test] +fn an_ai_symbol_librarys_instances_drawn_too_deep_go() { + // C0 is a square, and each later symbol holds an instance of the one before it. The template + // box's center is (100.5, 50.5): art space's origin is at canvas (8091, 8242). + let instance = |name: &str| format!("/SymbolInstance :\n({name}) /SymbolRef ,\n1 0 0 -1 -8091 8242 /RTransformMatrix\n;\n9 () XW\n"); + let count = vectorcraft_doc::depth::MAX_DRAWN_DEPTH + 10; + let mut defs = symbol_def("C0", &symbol_square(-2.0, -2.0, 2.0, 2.0), 5); + for i in 1..count { + defs.push_str(&symbol_def(&format!("C{i}"), &instance(&format!("C{}", i - 1)), 5)); + } + let text = format!( + "%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n%%BeginSetup\n\ + %AI5_Begin_NonPrinting\n{defs}%AI5_End_NonPrinting--\n%%EndSetup\n%%PageTrailer\n%%Trailer\n" + ); + let (d, warnings, _) = crate::layered_ai(&private(&text), pdf_part(10.0, 86.0), vec![], false, true, 1.0); + assert_eq!(d.symbols.len(), count, "{warnings:?}"); + let instances = d.symbols.iter().filter(|s| matches!(s.art.kind, NodeKind::SymbolInstance { .. })).count(); + assert!(instances < count - 1, "{instances}"); + assert!(warnings.contains(&vectorcraft_doc::depth::symbols_note()), "{warnings:?}"); + // A document with symbols keeps its own. + let mut part = pdf_part(10.0, 86.0); + let mine = Node::group(part.alloc_id(), vec![]); + part.symbols.push(vectorcraft_doc::Symbol { name: "Mine".into(), art: std::sync::Arc::new(mine), dynamic: false }); + let (d, warnings, _) = crate::layered_ai(&private(&text), part, vec![], false, true, 1.0); + assert_eq!(symbol_names(&d), ["Mine"], "{warnings:?}"); +} + /// A PDF part whose tiling has the name of a pattern its editing data defines (the PDF part names /// its tilings `Pattern 1`, `Pattern 2` and so on): the editing data's pattern comes in under a free /// variant of the name, and the palette's swatch and the brush's art paint with it, not with the diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f60d46f62..a3b3aaafc 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -120,8 +120,8 @@ private editing data. VectorCraft: 14; #868: round trip with Illustrator 2018 loses layers and groups), some files 4× slower to open since the editing data is read whole (#758), live effects that are not read or have settings that are not read, and brushes come in as their drawn look (#637; pattern fills are read since #1025, and symbols and their instances - open live); legacy `.ai` without the prolog opens since #1027, its legacy type (`To` … `TO`) since #868; guides - open as ruler guides and guide paths since #5057 (#779). + open live, as do the symbols of a symbol library without layers); legacy `.ai` without the prolog opens since + #1027, its legacy type (`To` … `TO`) since #868; guides open as ruler guides and guide paths since #5057 (#779). Legacy `.ai` type keeps its paragraphs' letter and word spacing (`TC`, `TW`), hanging punctuation (`Tq`) and hyphenation (`Th`); its tab definitions (`Xb` … `XB`) set its paragraphs' tab stops. Paragraphs in a row with the same attributes share one copy of them while the text object is read, and those copies hold at most @@ -163,8 +163,8 @@ private editing data. VectorCraft: ### Illustrator's private editing data -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. ## Format by format diff --git a/docs/mcp.md b/docs/mcp.md index 1adcf32b1..e4153a9f6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2378,7 +2378,9 @@ what dragging a symbol out of the library panel onto the canvas does. `symbol.saveLibrary {path?, names?, name?, user?}` writes the document's symbols as a symbol library: a `.vectorcraft` document holding only them and what they use; without `path` it returns `{dataBase64}`, and `user: true` saves into the user library folder (category `user`, User Defined). `symbol.loadLibrary {path? | data? | dataBase64?, name?}` -loads the symbols of any document VectorCraft opens as a library to add from; a file it can't read is an error. +loads the symbols of any document VectorCraft opens as a library to add from (a `.ai` or EPS whose editing data holds +symbols and no layers gives the symbols it defines, in the order of its symbol list); a file it can't read is an +error. ## Brush libraries diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0af2f0670..7e26dfdee 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1222,6 +1222,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur In a `.ai` or EPS opened from its editing data, type has its text object's opacity, blend mode and knockout shape flag, whether it is made from the file's text document or taken from the page, and type made from the text document takes its text object's lock. Where such type has transparency and comes in with other art (the rest of its look, or page type and strokes that go with it), a group of them has it. Where the page draws its type, or the strokes around it, with transparency that is neither its text object's nor its text object's combined with that of the objects around it, the page's type and strokes keep their own instead (`crates/eps/src/import/tests_native.rs`). Where the page draws type of no text object over text objects made from the text document, with their words in any case, that type is theirs as the page draws it and is left out; the page's other type of no text object goes only to text objects whose type the text document can't make, or else is left out with a warning. `.ai` editing-data import restores the document's listed graphic styles in panel order and links objects that use a listed style; unsupported appearances keep their drawn look (`crates/eps/src/import/ai/tests.rs`). A `.ai` or EPS graphic style library, whose editing data holds the styles and their list but no layers, opens with those styles in list order. Other Library… loads another document's graphic styles ([Apply graphic styles](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/apply-graphic-styles.html)) and lists such a library's styles in a library panel. A `.ai` or EPS read as its PDF part or page because its layers can't be used also gets the styles of its list in place of the default ones (`crates/eps/src/import/tests_native.rs`, `cmd::fileio::tests_aiimport::a_graphic_style_library_in_a_ai_file_without_layers_loads_its_styles`). + A `.ai` or EPS symbol library, whose editing data holds symbol definitions and their list but no layers, opens with those symbols in list order, each with its registration point. Other Library… in the Symbols panel's Symbol Libraries Menu imports the symbols of the file you pick ([Create or import symbol libraries](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html)) and lists such a library's symbols in a library panel. A `.ai` or EPS read as its PDF part or page because its layers can't be used also gets the symbols defined before its layers (`crates/eps/src/import/tests_native.rs`, `crates/eps/src/import/ai/tests.rs`, `cmd::fileio::tests_aiimport::a_symbol_library_in_a_ai_file_without_layers_loads_its_symbols`). A `.ai` or EPS opened from its editing data whose text document makes the type of every text object that shows takes all its type from the text document: each text object stays in its own group, frame and layer, the page's type isn't matched with them, a file of more than 5,000 text objects still opens with its layers, and type the page draws differently (in another font) keeps the layers, with a warning. A line break inside a paragraph stays a line break in it (`crates/eps/src/import/tests_native.rs`, `crates/eps/src/import/ate.rs`). In SVG export without Fewer `` Elements, each character of live type that justification or Mojikumi moves (the letter gaps of a Justify All line without word spaces, the gaps between the characters of a justified Japanese line, closed-up punctuation, the space between Japanese and Latin letters) starts its own positioned ``, and type scaled horizontally on a justified line is stretched to its own width (`crates/svg/tests/it/text_layout.rs`). To find those characters, SVG export lays a type object out a second time only when the second layout can differ from the first: point type with Line-end Punctuation Half Width and no Japanese punctuation or letters, the setting new type gets, is laid out once. Exporting 5,000 such objects takes a median of 109.4 ms instead of 171.5 ms on Windows 11, with the same SVG (`crates/svg/src/export/text/tests.rs`). From f8e0639d14d2b40663ef6fdcb154a6482fc6d8be Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 5 Nov 2026 08:25:31 -0800 Subject: [PATCH 057/140] .ai save: the editing copy keeps symbols and their instances The editing copy of a PDF-compatible .ai wrote every symbol instance as its art. Symbols are now written as symbols, in the form the .ai reader reads them: - Every symbol is defined in the setup, in a non-printing block after the patterns: %AI14_BeginSymbol, its name, its art in the symbol's own space, its registration point as AI13PatternRegistrationType and %AI10_EndSymbol, each after the symbols its art uses. The nine points are numbered 1 to 9 row by row from the top left, in the order the scripting reference lists them. A definition is written the same wherever its first instance lies. The symbol list after the palette keeps the Symbols panel's order. A hidden root of a symbol's art is written shown, as the canvas draws it. - Each instance is a /SymbolInstance with its symbol's name and the matrix that places the art (to ten decimals), followed by its name, opacity mask, opacity and blending mode as a group's. In a symbol's art, the matrices of instances, gradients and images are written in the canvas's space about the canvas point of the ruler origin. - Pattern fills in a symbol's art are written as their tiles cut to the shape. A symbol whose definition holds such tiles, from its own objects, a brushed stroke's brush art, a live object, an opacity mask, a nested symbol or an inline graphic, has its instances written as their art. - Instances with a fill, appearance changes, isolation, knockout or knockout shape of their own, instances of symbols with 9-slice guides or pattern paint, instances in pattern tiles or in art cut to a clipping object, instances scaled almost flat, instances that turn, mirror or scale symbols holding type, live objects, instances written as their art, shapes with rounded live corners or strokes written as outlines or brush art (the canvas works these out again where the instance puts them), instances that scale their symbol more than 100 times, nested instances included (numbers are written to four decimals), and instances on the layers of a large canvas are written as their art (the symbols are still defined). Symbols past the 4,096 the reader keeps are left out and their instances written as art; an instance of a symbol the document doesn't have is an empty group. 9-slice guides, export types and the dynamic type are left out, and linked images and type in a symbol's art are embedded and written as outlines. Each comes with a warning. The writer takes the reader's limit of 4,096 definitions (MAX_SYMBOLS) and its symbol_art_member check, which now are pub(crate). The editingData option's description lists what the copy now holds, type written as text included. Symbols and their instances: https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html The nine registration points, in the order the scripting reference lists them: https://web.archive.org/web/20180219005134/https://wwwimages.adobe.com/content/dam/acom/en/devnet/illustrator/pdf/Illustrator-Scripting-Reference-JavaScript-cs6.pdf#page=252 Tests: the round trip of symbols, nested symbols and instances in every state, symbols whose art is a group with a state of its own, images and gradients in a symbol's art, every registration point, symbol names with special characters, duplicate and missing symbols, a symbol first used deep in opacity masks, symbols past the reader's count (one found only while its holder's art is written included), a large canvas, symbols with pattern fills (directly, nested, in an opacity mask, through an inline graphic and through a brushed stroke), scaled instances of symbols with an outside stroke, rounded live corners, type, a blend, a tiny or thin object, an ellipse or an instance of a symbol with 9-slice guides, a symbol whose root is hidden, instance matrices from a fixed seed, a second write of what is read back giving the same bytes, the forms written, the instances written as art and the options reported. Docs: docs/file-format-parity.md, README.md, ROADMAP.md, docs/gaps.md, docs/roadmap.md and docs/target-app-parity.md describe the editing copy a saved .ai holds. --- README.md | 2 +- ROADMAP.md | 2 +- crates/engine/src/cmd/fileio/native.rs | 2 +- crates/eps/src/ai_editing.rs | 47 +- crates/eps/src/ai_editing/tests.rs | 1035 ++++++++++++++++++- crates/eps/src/ai_editing/writer.rs | 106 +- crates/eps/src/ai_editing/writer/images.rs | 15 +- crates/eps/src/ai_editing/writer/paints.rs | 5 +- crates/eps/src/ai_editing/writer/symbols.rs | 339 ++++++ crates/eps/src/import.rs | 2 +- crates/eps/src/import/ai.rs | 2 +- crates/eps/src/import/ai/read.rs | 4 + crates/eps/src/import/ai/read/types.rs | 2 +- docs/file-format-parity.md | 19 +- docs/gaps.md | 2 +- docs/roadmap.md | 2 +- docs/target-app-parity.md | 7 +- 17 files changed, 1539 insertions(+), 54 deletions(-) create mode 100644 crates/eps/src/ai_editing/writer/symbols.rs diff --git a/README.md b/README.md index 79d88841e..8f1113c4f 100644 --- a/README.md +++ b/README.md @@ -253,7 +253,7 @@ The scores are self-assessed from our code against public documentation; [target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them. **What's missing:** -- reliable exchange of .ai files (layer structure in real files; the editing copy a save writes leaves out live effects and writes symbols and live objects as their art); +- reliable exchange of .ai files (layer structure in real files; the editing copy a save writes leaves out live effects and writes live objects as their art); - an interaction-fidelity pass covering every tool's modifiers and small behaviours; - 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials); - filter thumbnails and a preview area inside the Effect Gallery (the gallery itself and all 57 Photoshop-style raster filters, approximated, are in); diff --git a/ROADMAP.md b/ROADMAP.md index 68b0b2d2a..ae8417530 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -24,7 +24,7 @@ and the work list in [`docs/gaps.md`](docs/gaps.md). **Why alpha:** the core workflows exist end to end (drawing and path editing, Pathfinder, paint and appearance, type, SVG/PDF/EPS, print, export) and feature depth is ~75%, but a professional can't yet switch: Illustrator's own `.ai` files open with a wrong layer structure in real cases and are saved back with an editing copy that -leaves out live effects and writes symbols and live objects as their art, the interaction details have never been checked side by side, and some machines fail to start the +leaves out live effects and writes live objects as their art, the interaction details have never been checked side by side, and some machines fail to start the app. Beta needs those closed (the beta list in [gaps.md](docs/gaps.md)). It passes the core-workflow gate: all six core workflows (path drawing and editing, paint and style, type, layers/artboards/export, print production, file exchange) work end to end on macOS with save and reopen, and only the last is partial, without diff --git a/crates/engine/src/cmd/fileio/native.rs b/crates/engine/src/cmd/fileio/native.rs index 770c11644..74cfced2b 100644 --- a/crates/engine/src/cmd/fileio/native.rs +++ b/crates/engine/src/cmd/fileio/native.rs @@ -109,7 +109,7 @@ pub const AI_OPTIONS: &[FormatOption] = &[ name: "editingData", ty: "boolean", default: "true", - description: "also carry an editing copy of the artwork in the form other apps that open .ai files read: layers and sublayers, groups, compound paths, paths, fills and strokes, colors, linear and radial gradients, patterns on a grid of tiles, swatches, artboards and which one is active, the transparency of objects and layers, objects' opacity masks, clipping groups and layers clipped by a path, embedded images with their pixels, and linked images as placed files with a preview, with type, other patterns, symbols, other clipping objects and live objects as plain filled and stroked paths, freeform gradients, gradients along or across a stroke and gradients of more than 256 stops as their average color, gradients that dither or mix perceptually as classic ones, and gradient stops linked to global process colors as their colors (written only with every artboard, as Preserve Editing is)", + description: "also carry an editing copy of the artwork in the form other apps that open .ai files read: layers and sublayers, groups, compound paths, paths, fills and strokes, colors, linear and radial gradients, patterns on a grid of tiles, swatches, artboards and which one is active, the transparency of objects and layers, objects' opacity masks, clipping groups and layers clipped by a path, embedded images with their pixels, linked images as placed files with a preview, point type, area type and type on a path as text, and symbols and their instances, with other type, other patterns, other clipping objects, other symbol instances and live objects as plain filled and stroked paths, freeform gradients, gradients along or across a stroke and gradients of more than 256 stops as their average color, gradients that dither or mix perceptually as classic ones, and gradient stops linked to global process colors as their colors (written only with every artboard, as Preserve Editing is)", }, FormatOption { name: "aiVersion", diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 2ad1c719c..88b9f0fe2 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -77,7 +77,8 @@ //! a preview of at most 256 pixels a side between `%AI26_BeginPlacedObjectPreview` and its end //! (`w h rowBytes 32 17 `, then rows of premultiplied ARGB), each in versioned content, `~`, //! `%AI5_EndPlace`. The box is the file's pixels at art space's origin on the canvas; the matrix -//! takes it into art space. In opacity masks and pattern tiles, linked images are embedded. +//! takes it into art space. In opacity masks, pattern tiles and symbols, linked images are +//! embedded. //! - Type: a text object `/AI11Text :` naming its story and frame (`/StoryIndex`, `/FrameIndex`), //! for area type and type on a path with its frame's path (`/Art :` … `; /ConfiningPath ,`), and //! the text document after the document data (`%AI11_BeginTextDocument`, see @@ -86,26 +87,49 @@ //! the alignment, indents, spacing, hyphenation and direction of each paragraph, and the frame: //! point type's point and matrix, area type's frame, rows, columns, gutters and first baseline, //! type on a path's path and span, vertical type, and the frames a threaded story flows through. +//! - Symbols, as the reader reads them ([`crate::import`]'s `.ai` reader, `read.rs`): every symbol +//! of the document defined in the setup after the patterns, in a non-printing block +//! (`%AI14_BeginSymbol`, `(name)`, `0 A`, `0 Xw`, `0 Ae`, the art in the symbol's space with y +//! down, the registration point as `AI13PatternRegistrationType` 1 to 9 from the top left, row by +//! row (VectorCraft's numbering, in the order the scripting reference lists the points), in the +//! art's dictionary, `%AI10_EndSymbol`), each after those its art uses; the symbol +//! list after the palette (`%AI24_BeginSymbolList`, the names in the Symbols panel's order, +//! `%AI24_EndSymbolList`); each instance as `/SymbolInstance :`, `(name) /SymbolRef ,`, the +//! matrix taking the art to art space as `a b c d tx ty /RTransformMatrix`, `;`, then its name +//! and opacity mask and a state block as a group's with its opacity and blending mode (an +//! instance with isolation, knockout or knockout shape of its own is written as its art). The +//! definitions are at most the reader's 4,096. In a symbol's art, an instance's matrix and +//! a gradient's take the art to the canvas's space turned y up about art space's origin there. //! //! Lines end in CR LF. Strings are written in UTF-8. Art space is ruler space: y up, its origin at //! the first artboard's top-left corner. On the canvas ([`CANVAS_SIZE`] points square, y down) the //! artboards together are centered; each artboard's `RulerOrigin` is its top-left corner there, and //! `%AI3_TemplateBox` holds the canvas's center in art space. What the form above can't hold is written as plain filled and stroked paths, each such -//! loss a warning: type the text document can't hold (in opacity masks, pattern tiles and art cut to -//! a clipping object, mirrored, turned or unevenly scaled area type and type on a path, inline graphics, ruby, +//! loss a warning: type the text document can't hold (in opacity masks, pattern tiles, symbols and +//! art cut to a clipping object, mirrored, turned or unevenly scaled area type and type on a path, inline graphics, ruby, //! gradient or pattern paint, an appearance of its own) as its outlines and its inline graphics' //! art, the character and paragraph attributes the text document doesn't hold, freeform gradients, gradients //! along or across a stroke and gradients without stops, with more than 256 stops or without extent //! as their average color //! (gradients that dither or mix perceptually are written as classic ones, and gradient stops linked //! to global process colors as their colors), pattern fills whose tiles aren't a grid, whose art reaches past -//! the tile or is painted with patterns as their tiles cut to the shape, clipping groups and clipping layers whose clipping object isn't a path, +//! the tile or is painted with patterns, and pattern fills in symbols, as their tiles cut +//! to the shape, clipping groups and clipping layers whose clipping object isn't a path, //! and the clipping groups inside them, as their art cut to that object (its fill cut too, its //! stroke not; an image in a clipping group of the region it is cut to), images that can't be //! decoded or have more than 33,554,432 pixels as their outlines filled mid gray, images of 16 bits -//! a channel at 8 bits, linked images in opacity masks and pattern tiles as embedded images, -//! symbols and live objects (placed documents included) as their art (symbol art past -//! [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects in all left out), strokes a plain line can't draw +//! a channel at 8 bits, linked images in opacity masks, pattern tiles and symbols as embedded images, +//! symbol instances with a fill, appearance changes, isolation, knockout or knockout shape of their +//! own, instances of symbols with 9-slice guides or pattern paint, instances in pattern tiles or in art cut to a +//! clipping object, instances scaled almost flat, instances that turn, mirror or scale symbols holding +//! type, live objects, instances written as their art, shapes with rounded live corners or strokes +//! written as outlines or brush art, +//! instances that scale their symbol more than 100 times, instances on the layers of a large canvas +//! and instances of +//! symbols past 4,096 as their art (symbol art past [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects +//! in all left out), instances of symbols the document doesn't have as empty groups, symbols' 9-slice +//! guides, export types and dynamic type left out, live objects (placed documents included) as their art, +//! strokes a plain line can't draw //! as their filled outlines, several fills or strokes as a group of a path for each, freeform //! gradient swatches, gradient swatches of more than 256 stops, swatches of patterns written as //! tiles and the placement of pattern swatches that move, turn or scale their pattern, the opacity and blending modes of single fills and strokes, opacity masks on @@ -206,6 +230,8 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result Result Result], depth: usize, out: &mut Vec) { } out.push(s); } + // The matrix is written to ten decimals. + NodeKind::SymbolInstance { symbol, xf } => { + let xf: Vec = xf.as_coeffs().iter().map(|v| format!("{:.6}", v + 0.0)).collect(); + out.push(format!("{head} instance {symbol:?} [{}] {} {:?}", xf.join(" "), r3(f64::from(n.opacity)), n.blend)); + } other => out.push(format!("{head} {other:?}")), } } @@ -602,8 +607,8 @@ fn hidden_layers_open_hidden() { assert!(text.contains("%AI5_NumLayers: 3\n%AI5_OpenViewLayers: 776\n"), "{text}"); } -/// A 200 × 100 document of a gradient fill, a compound path and an image, and what the data holds -/// as plain paths: type and a symbol instance. +/// A 200 × 100 document of a gradient fill, a compound path, an image and a symbol instance, and +/// what the data holds as plain paths: type. fn unsupported() -> Document { let mut doc = Document::new(200.0, 100.0); let layer = doc.layers[0].id; @@ -671,9 +676,7 @@ fn paths(nodes: &[Arc], out: &mut Vec) { fn art_the_slice_does_not_cover_is_written_as_plain_paths() { let doc = unsupported(); let out = export_ai_editing(&doc, &opts()).unwrap(); - for w in [writer::TYPE, writer::LIVE] { - assert!(out.warnings.iter().any(|x| x == w), "{w}: {:?}", out.warnings); - } + assert_eq!(out.warnings, [writer::TYPE]); let back = read_back(&doc); let layer = back.layers[0].children().unwrap(); let kinds: Vec<&str> = layer @@ -683,12 +686,13 @@ fn art_the_slice_does_not_cover_is_written_as_plain_paths() { NodeKind::Group { .. } => "group", NodeKind::Compound { .. } => "compound", NodeKind::Image(_) => "image", + NodeKind::SymbolInstance { .. } => "instance", _ => "other", }) .collect(); - assert_eq!(kinds, ["path", "compound", "image", "group", "group"]); + assert_eq!(kinds, ["path", "compound", "image", "group", "instance"]); let mut all = vec![]; - paths(&layer[3..], &mut all); + paths(&layer[3..4], &mut all); assert!(all.iter().all(|n| matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }))); // The gradient as a gradient, drawing as it did. let bounds = Rect::new(10.0, 10.0, 30.0, 30.0); @@ -802,11 +806,11 @@ fn a_scaled_nine_slice_symbol_instance_keeps_its_corner_size() { assert!(sizes.contains(&(200, 150)), "the frame is scaled: {sizes:?}"); } -/// Nested symbols whose instances multiply stop at [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects of -/// symbol art in all, with a warning. +/// Nested symbols whose instances multiply, written as their art (on a large canvas), stop at +/// [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects of symbol art in all, with a warning. #[test] fn nested_symbol_instances_stop_at_the_symbol_art_limit() { - let mut doc = Document::new(200.0, 100.0); + let mut doc = Document::new(20000.0, 100.0); let layer = doc.layers[0].id; // Empty paths: counted, but written as nothing, so the test stays small; and one filled square. let empty = Arc::new(Node::path(doc.alloc_id(), PathData::new(vec![]), Appearance::default())); @@ -824,7 +828,9 @@ fn nested_symbol_instances_stop_at_the_symbol_art_limit() { assert!(out.warnings.iter().any(|w| w == writer::SYMBOL_LIMIT), "{:?}", out.warnings); // 5,001 objects for the tree and each leaf: 3,198 leaves fit in 16,000,000. let text = String::from_utf8(out.program).unwrap(); - assert_eq!(text.matches("\r\nf\r\n").count(), 3198, "the squares of the leaves written"); + // On the layers; the symbols' definitions come before them, in the setup. + let layers = text.split_once("%%EndSetup").unwrap().1; + assert_eq!(layers.matches("\r\nf\r\n").count(), 3198, "the squares of the leaves written"); } #[test] @@ -900,7 +906,7 @@ fn geometry_effects_in_a_symbol_are_written_as_their_result() { let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((50.0, 20.0)) }); doc.insert(Some(layer), usize::MAX, instance).unwrap(); let back = read_back(&doc); - let b = back.layers[0].children().unwrap()[0].visual_bounds().unwrap(); + let b = back.bounds_of_with(&[back.layers[0].children().unwrap()[0].id], true, &back.symbol_boxes()).unwrap(); // The 10 pt square offset by 5 pt, at (50, 20). assert!((b.x0 - 45.0).abs() < 0.1 && (b.x1 - 65.0).abs() < 0.1 && (b.y0 - 15.0).abs() < 0.1 && (b.y1 - 35.0).abs() < 0.1, "{b:?}"); } @@ -925,7 +931,8 @@ fn an_adjusted_image_in_a_symbol_is_written_in_its_adjusted_color() { doc.insert(Some(layer), usize::MAX, instance).unwrap(); let back = read_back(&doc); let mut found = vec![]; - pictures(&back, &back.layers, "", &mut found); + let art: Vec> = back.symbols.iter().map(|s| s.art.clone()).collect(); + pictures(&back, &art, "", &mut found); assert_eq!(found.len(), 1, "the adjusted image is found"); assert!(!found[0].ends_with("rgba [0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255]"), "the image is adjusted: {found:?}"); } @@ -1206,6 +1213,7 @@ fn looks(nodes: &[Arc], depth: usize, out: &mut Vec) { NodeKind::Group { clip, .. } => format!("group clip={clip}"), NodeKind::Path { clipping, .. } => format!("path clipping={clipping}"), NodeKind::Compound { .. } => "compound".into(), + NodeKind::SymbolInstance { symbol, .. } => format!("instance {symbol:?}"), _ => "other".into(), }; let mut s = format!( @@ -3322,6 +3330,126 @@ fn type_lines(doc: &Document) -> Vec { out } +// ---------- symbols ---------- + +const LEAF: Color = Color::Cmyk { c: 0.6, m: 0.0, y: 0.9, k: 0.1 }; + +fn instance(doc: &mut Document, symbol: &str, xf: Affine) -> Node { + Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: symbol.into(), xf }) +} + +/// A `w` × `h` document whose swatches are one CMYK color (the default swatches' CMYK equivalents +/// change at the fourth decimal when read back, so a second write would differ there). +fn one_swatch_document(w: f64, h: f64) -> Document { + let mut doc = Document::new(w, h); + doc.swatches = vec![Swatch { name: "Leaf".into(), paint: solid(LEAF), global: false, spot: false }].into(); + doc.swatch_groups = vec![].into(); + doc +} + +/// A 300 × 200 document of symbols. Its symbols, in the Symbols panel's order: `Spare`, a path no +/// instance uses; `Pair`, a group of a red square and an instance of `Leaf` at half its size; `Leaf`, +/// a group of a square in `LEAF` and an ellipse, its registration point the top left; `Shade`, a +/// group of a rectangle filled with a gradient, a clipping group and an image, its registration +/// point the bottom right; `Solo`, a group of one square, which no instance uses. Layer `Art` +/// holds, bottom first: `Leaf` moved; `Leaf` turned 30° and scaled 1.5 by 0.5, named `Turned`; a +/// hidden one; a locked one; one at 50% in Multiply; `Pair`; a group holding `Leaf`; a clipping +/// group holding `Leaf`; `Leaf` with an opacity mask whose art holds an instance of `Leaf`; `Shade`; +/// and sublayer `Inner` (60%, Screen), which holds `Pair` mirrored. +fn symbols_document() -> Document { + use vectorcraft_color::gradient::GradientGeom; + let mut doc = Document::new(300.0, 200.0); + doc.swatches = vec![Swatch { name: "Leaf".into(), paint: solid(LEAF), global: false, spot: false }].into(); + doc.swatch_groups = vec![].into(); + doc.layers.clear(); + let art_layer = doc.add_layer(Some("Art")); + // The symbols. + let spare = square(&mut doc, Rect::new(-5.0, -5.0, 5.0, 5.0), Color::gray(0.4)); + let leaf_square = square(&mut doc, Rect::new(0.0, 0.0, 20.0, 20.0), LEAF); + let ellipse = + Node::path(doc.alloc_id(), shapes::ellipse(Rect::new(5.0, 10.0, 25.0, 30.0)), Appearance::basic(Paint::None, solid(Color::BLACK), 1.5)); + let leaf = group(&mut doc, vec![leaf_square, ellipse], false); + let red = square(&mut doc, Rect::new(-10.0, -10.0, 10.0, 10.0), Color::rgb(1.0, 0.0, 0.0)); + let small = instance(&mut doc, "Leaf", Affine::translate((15.0, -5.0)) * Affine::scale(0.5)); + let pair = group(&mut doc, vec![red, small], false); + let geom = GradientGeom { start: (0.0, 0.0).into(), end: (40.0, 10.0).into(), aspect: 1.0, focal: None }; + let shaded = painted( + &mut doc, + shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0)), + vec![AppearanceItem::Fill(FillLayer::new(gradient_paint(sunset(), Some(geom), None)))], + ); + let edge = clipping(&mut doc, shapes::ellipse(Rect::new(0.0, 20.0, 20.0, 40.0))); + let inside = square(&mut doc, Rect::new(0.0, 25.0, 40.0, 35.0), Color::rgb(0.0, 0.0, 1.0)); + let clipped = group(&mut doc, vec![edge, inside], true); + let pixels = png_of(image::DynamicImage::ImageRgba8(image::RgbaImage::from_raw(2, 1, vec![255, 0, 0, 255, 0, 128, 255, 255]).unwrap())); + let picture = image_node(&mut doc, ImageBlob::new("image/png", pixels), (2, 1), Affine::new([10.0, 0.0, 0.0, 10.0, 25.0, 25.0]), None); + let shade = group(&mut doc, vec![shaded, clipped, picture], false); + let lone = square(&mut doc, Rect::new(0.0, 0.0, 8.0, 8.0), Color::gray(0.2)); + let solo = group(&mut doc, vec![lone], false); + for (name, art) in [("Spare", spare), ("Pair", pair), ("Leaf", leaf), ("Shade", shade), ("Solo", solo)] { + doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) }); + } + doc.set_symbol_registration("Leaf", vectorcraft_doc::SymbolRegistration(0)); + doc.set_symbol_registration("Shade", vectorcraft_doc::SymbolRegistration(8)); + // The instances. + let mut art = vec![instance(&mut doc, "Leaf", Affine::translate((10.0, 10.0)))]; + let turned = Affine::translate((80.0, 20.0)) * Affine::rotate(30f64.to_radians()) * Affine::scale_non_uniform(1.5, 0.5); + art.push(named(instance(&mut doc, "Leaf", turned), "Turned")); + let mut hidden = instance(&mut doc, "Leaf", Affine::translate((130.0, 10.0))); + hidden.visible = false; + art.push(hidden); + let mut locked = instance(&mut doc, "Leaf", Affine::translate((160.0, 10.0))); + locked.locked = true; + art.push(locked); + art.push(see_through(instance(&mut doc, "Leaf", Affine::translate((190.0, 10.0))), 0.5, BlendMode::Multiply)); + art.push(instance(&mut doc, "Pair", Affine::translate((240.0, 30.0)))); + let grouped = instance(&mut doc, "Leaf", Affine::translate((10.0, 60.0))); + art.push(group(&mut doc, vec![grouped], false)); + let window = clipping(&mut doc, shapes::rectangle(Rect::new(50.0, 60.0, 65.0, 75.0))); + let clipped = instance(&mut doc, "Leaf", Affine::translate((50.0, 60.0))); + art.push(group(&mut doc, vec![window, clipped], true)); + let mask_leaf = instance(&mut doc, "Leaf", Affine::translate((90.0, 60.0))); + let mask_art = group(&mut doc, vec![mask_leaf], false); + let mut masked = instance(&mut doc, "Leaf", Affine::translate((90.0, 60.0))); + masked.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(mask_art), clip: true, invert: false, disabled: false, linked: true })); + art.push(masked); + art.push(instance(&mut doc, "Shade", Affine::translate((130.0, 60.0)))); + for n in art { + doc.insert(Some(art_layer), usize::MAX, n).unwrap(); + } + let inner = doc.add_layer(Some("Inner")); + let inner_node = doc.layers.iter().position(|l| l.id == inner).map(|i| doc.layers.remove(i)).unwrap(); + doc.insert(Some(art_layer), usize::MAX, (*inner_node).clone()).unwrap(); + let mirrored = instance(&mut doc, "Pair", Affine::new([-1.0, 0.0, 0.0, 1.0, 260.0, 120.0])); + doc.insert(Some(inner), usize::MAX, mirrored).unwrap(); + // The sublayer's own transparency, after its last object, an instance. + let sub = doc.node_mut(inner).unwrap(); + (sub.opacity, sub.blend) = (0.6, BlendMode::Screen); + doc +} + +/// Each symbol of `doc` in its order: its name and registration point, then what its art draws, its +/// objects as [`view`] shows them (points, handles, paint, strokes and instances), its images, and +/// its objects' names, transparency and clipping. +fn symbol_looks(doc: &Document) -> Vec { + let mut out = vec![]; + for s in doc.symbols.iter() { + out.push(format!("symbol {} {:?}", s.name, doc.symbol_registration(&s.name))); + let art = std::slice::from_ref(&s.art); + paints_of(doc, art, 1, &mut out); + let mut objects = vec![]; + view(art, 1, &mut objects); + // Images are compared by `pictures`, gradients by `paints_of`. + out.extend(objects.into_iter().filter(|l| !l.contains(" Image(")).map(|l| match l.find(" fill(Gradient(") { + Some(at) => format!("{} fill(gradient)", &l[..at]), + None => l, + })); + pictures(doc, art, &s.name, &mut out); + looks(art, 1, &mut out); + } + out +} + #[test] fn type_comes_back_as_type() { let doc = typed(); @@ -4132,3 +4260,884 @@ fn type_with_an_opacity_mask_is_written_as_its_outlines() { assert!(!matches!(top.kind, NodeKind::Text(_))); assert!(top.extras().mask.is_some()); } + +#[test] +fn symbols_and_their_instances_are_read_back() { + let doc = symbols_document(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let back = read_back(&doc); + same_lines(&lines(&back), &lines(&doc)); + same_lines(&all_looks(&back), &all_looks(&doc)); + same_lines(&symbol_looks(&back), &symbol_looks(&doc)); + // Each instance draws where it did: the symbols' art and the instances' places agree. + let boxes = |d: &Document| -> Vec { + let b = d.symbol_boxes(); + let mut ids = vec![]; + d.walk(|n| { + if matches!(n.kind, NodeKind::SymbolInstance { .. }) { + ids.push(n.id); + } + }); + ids.iter().map(|id| format!("{:?}", d.bounds_of_with(&[*id], true, &b).map(|r| [r.x0, r.y0, r.x1, r.y1].map(r3)))).collect() + }; + same_lines(&boxes(&back), &boxes(&doc)); +} + +#[test] +fn symbols_write_the_same_bytes_again() { + let doc = symbols_document(); + let first = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(first.program, export_ai_editing(&doc, &opts()).unwrap().program); + assert_eq!(private_data(&doc, &opts()).unwrap().0, private_data(&doc, &opts()).unwrap().0); + let again = export_ai_editing(&read_back(&doc), &opts()).unwrap().program; + same_lines(&shown(&again).lines().map(String::from).collect::>(), &shown(&first.program).lines().map(String::from).collect::>()); + assert_eq!(again, first.program); +} + +#[test] +fn symbols_are_written_in_their_forms() { + let p = program_shown(&symbols_document()); + // The definitions in a non-printing block after the patterns, in the order the art first uses + // them (each after those its art uses), the others after them; and the list after the palette + // in the panel's order. + let defs = ["(Leaf)", "(Pair)", "(Shade)", "(Spare)", "(Solo)"].map(|n| p.find(&format!("%AI14_BeginSymbol\n{n}\n0 A\n0 Xw\n0 Ae\n")).unwrap()); + assert!(defs.windows(2).all(|w| w[0] < w[1]), "{p}"); + assert!(p.contains("%AI5_Begin_NonPrinting\nNp\n%AI14_BeginSymbol\n(Leaf)\n"), "{p}"); + let list = + "%AI5_Begin_NonPrinting\nNp\n%AI24_BeginSymbolList\n(Spare)\n(Pair)\n(Leaf)\n(Shade)\n(Solo)\n%AI24_EndSymbolList\n%AI5_End_NonPrinting--\n"; + let at = p.find(list).unwrap(); + assert!(p.find("%AI5_EndPalette").unwrap() < at && at < p.find("%AI9_BeginDocumentData").unwrap(), "{p}"); + // A group's registration point in its dictionary, before its state block; a path is written in + // a group of its own. + assert!(p.contains("U\n%_/ArtDictionary :\n%_1 /Int (AI13PatternRegistrationType) ,\n%_;\n%_\n9 () XW\n%AI10_EndSymbol\n"), "{p}"); + assert!(p.contains("%_/ArtDictionary :\n%_9 /Int (AI13PatternRegistrationType) ,\n"), "{p}"); + assert!(p.contains("%AI14_BeginSymbol\n(Spare)\n0 A\n0 Xw\n0 Ae\nu\n"), "{p}"); + // A plain group of one object in a group of its own. + assert!(p.contains("%AI14_BeginSymbol\n(Solo)\n0 A\n0 Xw\n0 Ae\nu\nu\n"), "{p}"); + assert!(p.contains("f\nU\n%_/ArtDictionary :\n%_5 /Int (AI13PatternRegistrationType) ,\n%_;\n%_\n9 () XW\n%AI10_EndSymbol\n"), "{p}"); + // An instance on the page: its matrix takes the art to art space (y up from the artboard's top + // left), then its state block. + assert!(p.contains("/SymbolInstance :\n(Leaf) /SymbolRef ,\n1 0 0 -1 10 -10 /RTransformMatrix\n;\n9 () XW\n"), "{p}"); + // Its name between the two, and its transparency as a group's. + assert!(p.contains("/RTransformMatrix\n;\n%_/ArtDictionary :\n%_/XMLUID : (Turned) ; (AI10_ArtUID) ,\n%_;\n%_\n9 () XW\n"), "{p}"); + assert!(p.contains("1 0 0 -1 190 -10 /RTransformMatrix\n;\n1 0.5 0 2 0 Xy\n0 0 Xd\n6 () XW\n"), "{p}"); + // In a symbol's art, about the canvas point of art space's origin (the template box's center + // is the canvas's). + let t: Vec = p.lines().find_map(|l| l.strip_prefix("%AI3_TemplateBox: ")).unwrap().split(' ').map(|v| v.parse().unwrap()).collect(); + let (ox, oy) = (f64::from(CANVAS_SIZE) / 2.0 - t[0], f64::from(CANVAS_SIZE) / 2.0 + t[1]); + let nested = format!("(Leaf) /SymbolRef ,\n0.5 0 0 -0.5 {} {} /RTransformMatrix\n", ps::num(15.0 - ox), ps::num(oy + 5.0)); + assert!(p.contains(&nested), "{nested}\n{p}"); +} + +/// Instances that the data can't hold as instances are written as their art, with a warning: a +/// stained one, one of a symbol with 9-slice guides, one scaled almost flat, every one on the +/// layers of a large canvas (whose symbols are still defined), one in a pattern's tile, one with a +/// dynamic symbol's appearance changes and one in art cut to a clipping object that isn't a path. +/// A symbol whose art holds an instance of itself is defined once, that instance as art. +#[test] +fn instances_the_data_cant_hold_are_written_as_their_art() { + let leaf = |doc: &mut Document| { + let art = square(doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Leaf".into(), art: Arc::new(art) }); + }; + let kinds = |d: &Document| -> Vec<&'static str> { + d.layers[0].children().unwrap().iter().map(|n| if matches!(n.kind, NodeKind::SymbolInstance { .. }) { "instance" } else { "art" }).collect() + }; + // Stained. + let mut doc = Document::new(200.0, 100.0); + leaf(&mut doc); + let mut stained = instance(&mut doc, "Leaf", Affine::translate((50.0, 10.0))); + stained.appearance = Appearance::basic(solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into(); + let plain = instance(&mut doc, "Leaf", Affine::translate((80.0, 10.0))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, stained).unwrap(); + doc.insert(Some(layer), usize::MAX, plain).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_ART]); + assert_eq!(kinds(&read_back(&doc)), ["art", "instance"]); + // 9-slice guides. + doc.set_symbol_nine_slice("Leaf", Some(vectorcraft_doc::SymbolNineSlice::default())); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(out.warnings, [writer::SYMBOL_ART, writer::SYMBOL_OPTIONS]); + let back = read_back(&doc); + assert_eq!((kinds(&back), back.symbols.len()), (vec!["art", "art"], 1)); + // Scaled so flat that its matrix to ten decimals has no area. + let mut doc = Document::new(200.0, 100.0); + leaf(&mut doc); + let flat = instance(&mut doc, "Leaf", Affine::new([1e-11, 0.0, 0.0, 1.0, 50.0, 10.0])); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, flat).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_ART]); + assert_eq!(kinds(&read_back(&doc)), ["art"]); + // A large canvas. + let mut doc = Document::new(20000.0, 100.0); + leaf(&mut doc); + let one = instance(&mut doc, "Leaf", Affine::translate((50.0, 10.0))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, one).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_ART]); + // The symbol is still defined, at its own size: the canvas's scale doesn't touch its art. + let back = read_back(&doc); + assert_eq!(kinds(&back), ["art"]); + assert_eq!(symbol_looks(&back), symbol_looks(&doc)); + // A pattern a symbol's art and the layers both paint with: the layers' fill keeps it, the + // symbol's art has its tiles. + let dot = Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 4.0, 4.0), Color::BLACK)); + let mut dots = vectorcraft_doc::PatternDef::new("Dots", vec![dot]); + dots.tile = Rect::new(0.0, 0.0, 8.0, 8.0); + doc.patterns.push(dots); + let fill = || Appearance::basic(Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY }, Paint::None, 0.0); + let patch = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 16.0, 16.0)), fill()); + doc.symbols.push(Symbol { dynamic: false, name: "Patch".into(), art: Arc::new(patch) }); + let filled = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(100.0, 10.0, 132.0, 42.0)), fill()); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, filled).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(out.warnings, [writer::SYMBOL_ART, writer::PATTERNS]); + let back = read_back(&doc); + let on_layer = back.layers[0].children().unwrap()[1].appearance.items[0].paint().clone(); + assert!(matches!(on_layer, Paint::Pattern { .. }), "{on_layer:?}"); + let patch = &back.symbols.iter().find(|s| s.name == "Patch").unwrap().art; + assert!(matches!(patch.kind, NodeKind::Group { .. }), "the symbol's art is its tiles: {:?}", patch.kind); + // A pattern's tile: the tile holds the symbol's art. + let mut doc = Document::new(200.0, 100.0); + leaf(&mut doc); + let tiled = instance(&mut doc, "Leaf", Affine::IDENTITY); + doc.patterns.push(vectorcraft_doc::PatternDef::new("Tiles", vec![Arc::new(tiled)])); + let fill = Appearance::basic(Paint::Pattern { pattern: "Tiles".into(), xf: Affine::IDENTITY }, Paint::None, 0.0); + let filled = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), fill); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, filled).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_ART]); + let back = read_back(&doc); + let tile = &back.patterns.iter().find(|p| p.name == "Tiles").unwrap().art; + assert_eq!(tile.len(), 1); + assert_eq!(instances(&tile[0]), 0, "{:?}", tile[0].kind); + let NodeKind::Group { children, .. } = &tile[0].kind else { panic!("{:?}", tile[0].kind) }; + assert_eq!(paints_of_nodes(&back, children), paints_of_nodes(&back, &[Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF))])); + // A symbol holding an instance of itself: defined once, that instance as its art. + let mut doc = Document::new(200.0, 100.0); + let looped_art = { + let me = instance(&mut doc, "Loop", Affine::translate((20.0, 0.0))); + let dot = square(&mut doc, Rect::new(0.0, 0.0, 5.0, 5.0), LEAF); + group(&mut doc, vec![dot, me], false) + }; + doc.symbols.push(Symbol { dynamic: false, name: "Loop".into(), art: Arc::new(looped_art) }); + let looped = instance(&mut doc, "Loop", Affine::translate((100.0, 10.0))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, looped).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_ART]); + let p = program(&doc); + assert_eq!(p.matches("%AI14_BeginSymbol\n(Loop)\n").count(), 1, "{p}"); + assert_eq!(kinds(&read_back(&doc)), ["instance"]); + // Appearance changes of a dynamic symbol's instance. + let mut doc = Document::new(200.0, 100.0); + leaf(&mut doc); + doc.symbols[0].dynamic = true; + let mut changed = instance(&mut doc, "Leaf", Affine::translate((50.0, 10.0))); + changed.extras_mut().symbol_overrides = + vec![vectorcraft_doc::SymbolOverride { fill: Some(solid(Color::rgb(0.0, 1.0, 0.0))), ..Default::default() }]; + let plain = instance(&mut doc, "Leaf", Affine::translate((80.0, 10.0))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, changed).unwrap(); + doc.insert(Some(layer), usize::MAX, plain).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_ART, writer::SYMBOL_OPTIONS]); + let back = read_back(&doc); + assert_eq!(kinds(&back), ["art", "instance"]); + let green = back.layers[0].children().unwrap()[0].clone(); + let drawn = paints_of_nodes(&back, &[green]); + assert!(drawn.iter().any(|l| l.contains("rgb 0.000 1.000 0.000")), "the change is drawn: {drawn:?}"); + // Art cut to a clipping object that isn't a path. + let mut doc = Document::new(200.0, 100.0); + leaf(&mut doc); + let pixels = png_of(image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(2, 2, image::Rgba([0, 0, 255, 255])))); + let frame = image_node(&mut doc, ImageBlob::new("image/png", pixels), (2, 2), Affine::new([20.0, 0.0, 0.0, 20.0, 40.0, 0.0]), None); + let cut = instance(&mut doc, "Leaf", Affine::translate((45.0, 5.0))); + let clipped = group(&mut doc, vec![frame, cut], true); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, clipped).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::SYMBOL_ART), "{:?}", out.warnings); + let back = read_back(&doc); + assert_eq!(back.layers.iter().map(|l| instances(l)).sum::(), 0); + assert_eq!(back.symbols.len(), 1); +} + +/// Symbols whose art paints with a pattern, directly, through a nested symbol, in an opacity mask +/// and through an inline graphic: their definitions hold the pattern fills as tiles cut to the shape (the reader keeps no +/// symbol whose art paints with a pattern), and their instances are written as their art, whose +/// pattern fills keep the document's placement as the canvas draws them, each with a warning. An +/// instance of a symbol without pattern paint stays an instance. +#[test] +fn symbols_with_pattern_fills_are_defined_with_tiles_and_placed_as_art() { + let mut doc = Document::new(200.0, 100.0); + // Tiles that cover the shape, so its tiles cut to it draw its box. + let dot = Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 8.0, 8.0), Color::BLACK)); + let mut dots = vectorcraft_doc::PatternDef::new("Dots", vec![dot]); + dots.tile = Rect::new(0.0, 0.0, 8.0, 8.0); + doc.patterns.push(dots); + let fill = || Appearance::basic(Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY }, Paint::None, 0.0); + let patch = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 16.0, 16.0)), fill()); + doc.symbols.push(Symbol { dynamic: false, name: "Patch".into(), art: Arc::new(patch) }); + let inner = instance(&mut doc, "Patch", Affine::translate((20.0, 0.0))); + let edge = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + let outer = group(&mut doc, vec![edge, inner], false); + doc.symbols.push(Symbol { dynamic: false, name: "Outer".into(), art: Arc::new(outer) }); + let mask_art = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 12.0, 12.0)), fill()); + let mut masked = square(&mut doc, Rect::new(0.0, 0.0, 12.0, 12.0), LEAF); + masked.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(mask_art), clip: true, invert: false, disabled: false, linked: true })); + doc.symbols.push(Symbol { dynamic: false, name: "Masked".into(), art: Arc::new(masked) }); + let plain = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Plain".into(), art: Arc::new(plain) }); + // Type holding `Patch` as an inline graphic. + let style = vectorcraft_doc::CharStyle { size: 18.0, fill: solid(Color::BLACK), ..vectorcraft_doc::CharStyle::default() }; + let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(0.0, 20.0), "A", style.clone()); + t.runs.push(vectorcraft_doc::TextRun::inline(vectorcraft_doc::InlineArt::new("Patch"), style)); + let typed = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + doc.symbols.push(Symbol { dynamic: false, name: "Typed".into(), art: Arc::new(typed) }); + let layer = doc.layers[0].id; + // Two instances of `Patch` a part of a tile apart. + for (name, x) in [("Patch", 10.0), ("Patch", 33.0), ("Outer", 60.0), ("Masked", 120.0), ("Plain", 140.0), ("Typed", 10.0)] { + let one = instance(&mut doc, name, Affine::translate((x, 10.0))); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + } + let filled = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(150.0, 10.0, 182.0, 42.0)), fill()); + doc.insert(Some(layer), usize::MAX, filled).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::SYMBOL_ART) && out.warnings.iter().any(|w| w == writer::PATTERNS), "{:?}", out.warnings); + let back = read_back(&doc); + let names: Vec<&str> = back.symbols.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names, ["Patch", "Outer", "Masked", "Plain", "Typed"]); + let patch = &back.symbols[0].art; + assert!(matches!(patch.kind, NodeKind::Group { .. }), "the symbol's art is its tiles: {:?}", patch.kind); + assert_eq!(instances(&back.symbols[1].art), 0, "the nested instance is its art"); + let kids = back.layers[0].children().unwrap(); + let kinds: Vec = kids.iter().map(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })).collect(); + assert_eq!(kinds, [false, false, false, false, true, false, false]); + // The pattern fills of both `Patch` instances and of the layers' path keep one placement. + let placements = |n: &Node| { + let mut out = vec![]; + vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| { + for it in m.appearance.items.iter() { + if let Paint::Pattern { xf, .. } = it.paint() { + out.push(xf.as_coeffs().map(r3)); + } + } + }); + out + }; + let layer_fill = placements(&kids[6]); + assert_eq!(layer_fill.len(), 1); + assert_eq!(placements(&kids[0]), layer_fill); + assert_eq!(placements(&kids[1]), layer_fill); + // Each instance draws where it did. + let boxes = |d: &Document| -> Vec { + let b = d.symbol_boxes(); + let ids: Vec<_> = d.layers[0].children().unwrap().iter().take(5).map(|n| n.id).collect(); + ids.iter().map(|id| format!("{:?}", d.bounds_of_with(&[*id], true, &b).map(|r| [r.x0, r.y0, r.x1, r.y1].map(r3)))).collect() + }; + same_lines(&boxes(&back), &boxes(&doc)); +} + +/// A symbol whose art paints with a pattern only through a brushed stroke, whose brush's art is +/// filled with a pattern: the definition holds the tiles, and its instance is written as its art. +#[test] +fn symbols_painting_patterns_through_brush_art_are_placed_as_art() { + let mut doc = Document::new(200.0, 100.0); + let dot = Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 8.0, 8.0), Color::BLACK)); + let mut dots = vectorcraft_doc::PatternDef::new("Dots", vec![dot]); + dots.tile = Rect::new(0.0, 0.0, 8.0, 8.0); + doc.patterns.push(dots); + let fill = Appearance::basic(Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY }, Paint::None, 0.0); + let art = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 8.0)), fill); + let brush = vectorcraft_brush::Brush { + name: "Dotted".into(), + kind: vectorcraft_brush::BrushKind::Art(vectorcraft_brush::ArtBrush { + art, + colorization: vectorcraft_brush::Colorization::None, + ..Default::default() + }), + }; + vectorcraft_brush::store(&mut doc, &[brush]); + let mut line = + Node::path(doc.alloc_id(), shapes::line((0.0, 0.0).into(), (60.0, 0.0).into()), Appearance::basic(Paint::None, solid(Color::BLACK), 4.0)); + line.appearance.stroke_mut().unwrap().brush = Some("Dotted".into()); + doc.symbols.push(Symbol { dynamic: false, name: "Brushed".into(), art: Arc::new(line) }); + let layer = doc.layers[0].id; + let one = instance(&mut doc, "Brushed", Affine::translate((10.0, 40.0))); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::SYMBOL_ART), "{:?}", out.warnings); + let back = read_back(&doc); + assert_eq!(back.symbols.iter().map(|s| s.name.as_str()).collect::>(), ["Brushed"]); + assert_eq!(instances(&back.layers[0]), 0, "the instance is its art"); +} + +/// A symbol whose stroke is written as its outline (an outside stroke): a scaled instance, a scaled +/// instance of a symbol holding it and a turned and mirrored instance are written as their art, +/// which keeps the stroke the canvas draws; a moved instance stays an instance. +#[test] +fn scaled_instances_of_symbols_with_outlined_strokes_are_written_as_their_art() { + let mut doc = Document::new(200.0, 100.0); + let mut look = Appearance::basic(Paint::None, solid(Color::BLACK), 4.0); + look.stroke_mut().unwrap().align = vectorcraft_doc::StrokeAlign::Outside; + let framed = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), look); + doc.symbols.push(Symbol { dynamic: false, name: "Framed".into(), art: Arc::new(framed) }); + let inner = instance(&mut doc, "Framed", Affine::IDENTITY); + let edge = square(&mut doc, Rect::new(20.0, 0.0, 30.0, 10.0), LEAF); + let outer = group(&mut doc, vec![inner, edge], false); + doc.symbols.push(Symbol { dynamic: false, name: "Outer".into(), art: Arc::new(outer) }); + let layer = doc.layers[0].id; + let turned = Affine::translate((150.0, 20.0)) * Affine::rotate(0.5) * Affine::new([-1.0, 0.0, 0.0, 1.0, 0.0, 0.0]); + for (name, xf) in [ + ("Framed", Affine::translate((40.0, 40.0)) * Affine::scale(2.0)), + ("Outer", Affine::translate((80.0, 10.0)) * Affine::scale(2.0)), + ("Framed", turned), + ("Framed", Affine::translate((170.0, 60.0))), + ] { + let one = instance(&mut doc, name, xf); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + } + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::SYMBOL_ART), "{:?}", out.warnings); + let back = read_back(&doc); + assert_eq!(back.symbols.iter().map(|s| s.name.as_str()).collect::>(), ["Framed", "Outer"]); + let kids = back.layers[0].children().unwrap(); + let kinds: Vec = kids.iter().map(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })).collect(); + assert_eq!(kinds, [false, false, false, true]); + // Each draws where it did, with the stroke the canvas draws: 4 pt wide whatever the scale. + let boxes = |d: &Document| -> Vec { + let b = d.symbol_boxes(); + let ids: Vec<_> = d.layers[0].children().unwrap().iter().map(|n| n.id).collect(); + ids.iter().map(|id| format!("{:?}", d.bounds_of_with(&[*id], true, &b).map(|r| [r.x0, r.y0, r.x1, r.y1].map(r3)))).collect() + }; + let (got, want) = (boxes(&back), boxes(&doc)); + same_lines(&[got[0].clone(), got[2].clone(), got[3].clone()], &[want[0].clone(), want[2].clone(), want[3].clone()]); + // `Outer` at twice its size from (80, 10): the frame's square is 20 pt from 80 with its 4 pt + // stroke outside, and the plain square is 40 to 60 pt in from it. + assert_eq!(got[1], format!("{:?}", Some([76.0, 6.0, 140.0, 34.0].map(r3)))); +} + +/// Scaled instances of a symbol holding a rounded rectangle with live corners, of one clipped by +/// it, of one holding type, of one holding a blend and of one holding an instance written as its +/// art (of a symbol with 9-slice guides), and instances that scale their symbol more than 100 +/// times (tiny, thin and stroked tiny objects, and a 10 pt square), are written as their art; +/// instances that scale a 10 pt square a thousandth, scale an ellipse unevenly or only move stay +/// instances. +#[test] +fn scaled_instances_of_symbols_drawn_only_at_their_own_size_are_written_as_their_art() { + let mut doc = Document::new(200.0, 100.0); + let live = vectorcraft_doc::LiveShape::Rectangle { + w: 100.0, + h: 100.0, + radii: [10.0; 4], + kinds: Default::default(), + radii_relative: false, + xf: Affine::IDENTITY, + }; + let mut rounded = Node::new( + doc.alloc_id(), + NodeKind::Path { path: live.to_path(), rule: FillRule::NonZero, live: Some(Box::new(live)), clipping: false, guide: false }, + ); + rounded.appearance = Appearance::basic(solid(LEAF), Paint::None, 0.0).into(); + // The same rounded rectangle clipping a square. + let mut frame = rounded.clone(); + frame.id = doc.alloc_id(); + let inside = square(&mut doc, Rect::new(0.0, 0.0, 100.0, 100.0), LEAF); + let clipped = group(&mut doc, vec![frame, inside], true); + doc.symbols.push(Symbol { dynamic: false, name: "Rounded".into(), art: Arc::new(rounded) }); + doc.symbols.push(Symbol { dynamic: false, name: "Clipped".into(), art: Arc::new(clipped) }); + // A tiny square with a 1 pt stroke. + let mut dot = square(&mut doc, Rect::new(0.0, 0.0, 0.00001, 0.00001), LEAF); + dot.appearance = Appearance::basic(solid(LEAF), solid(Color::BLACK), 1.0).into(); + doc.symbols.push(Symbol { dynamic: false, name: "Dot".into(), art: Arc::new(dot) }); + // A blend of two squares 20 pt apart with a step every 10 pt. + let keys = + vec![Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 2.0, 2.0), LEAF)), Arc::new(square(&mut doc, Rect::new(20.0, 0.0, 22.0, 2.0), LEAF))]; + let spec = vectorcraft_doc::BlendSpec { spacing: vectorcraft_doc::BlendSpacing::Distance(10.0), ..Default::default() }; + let blend = Node::new(doc.alloc_id(), NodeKind::Blend { children: keys, spec: Box::new(spec) }); + doc.symbols.push(Symbol { dynamic: false, name: "Steps".into(), art: Arc::new(blend) }); + // A symbol holding an instance of one with 9-slice guides. + let frame = Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 100.0, 100.0), LEAF)); + let corner = Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK)); + let sliced = Node::group(doc.alloc_id(), vec![frame, corner]); + doc.symbols.push(Symbol { dynamic: false, name: "Sliced".into(), art: Arc::new(sliced) }); + doc.set_symbol_nine_slice("Sliced", Some(vectorcraft_doc::SymbolNineSlice::default())); + let inner = instance(&mut doc, "Sliced", Affine::IDENTITY); + let holder = group(&mut doc, vec![inner], false); + doc.symbols.push(Symbol { dynamic: false, name: "Holder".into(), art: Arc::new(holder) }); + let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(0.0, 10.0), "Hi", style(10.0, Color::BLACK)); + let typed = text_node(&mut doc, t); + doc.symbols.push(Symbol { dynamic: false, name: "Typed".into(), art: Arc::new(typed) }); + let tiny = square(&mut doc, Rect::new(0.0, 0.0, 0.00001, 0.00001), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Tiny".into(), art: Arc::new(tiny) }); + let thin = square(&mut doc, Rect::new(0.0, 0.0, 0.00001, 1.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Thin".into(), art: Arc::new(thin) }); + let plain = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Plain".into(), art: Arc::new(plain) }); + let oval = vectorcraft_doc::LiveShape::Ellipse { w: 10.0, h: 6.0, pie: (0.0, 360.0), xf: Affine::IDENTITY }; + let mut ellipse = Node::new( + doc.alloc_id(), + NodeKind::Path { path: oval.to_path(), rule: FillRule::NonZero, live: Some(Box::new(oval)), clipping: false, guide: false }, + ); + ellipse.appearance = Appearance::basic(solid(LEAF), Paint::None, 0.0).into(); + doc.symbols.push(Symbol { dynamic: false, name: "Oval".into(), art: Arc::new(ellipse) }); + let layer = doc.layers[0].id; + for (name, xf) in [ + ("Rounded", Affine::new([0.3, 0.0, 0.0, 0.1, 10.0, 10.0])), + ("Typed", Affine::translate((50.0, 50.0)) * Affine::scale(3.0)), + ("Tiny", Affine::translate((100.0, 50.0)) * Affine::scale(1e6)), + ("Plain", Affine::translate((150.0, 50.0)) * Affine::scale(1e-3)), + ("Plain", Affine::translate((150.0, 60.0)) * Affine::scale(1e3)), + ("Oval", Affine::translate((170.0, 20.0)) * Affine::new([3.0, 0.0, 0.0, 1.0, 0.0, 0.0])), + ("Thin", Affine::translate((100.0, 70.0)) * Affine::new([1e6, 0.0, 0.0, 10.0, 0.0, 0.0])), + ("Clipped", Affine::new([0.3, 0.0, 0.0, 0.1, 10.0, 30.0])), + ("Dot", Affine::translate((120.0, 70.0)) * Affine::scale(1e6)), + ("Steps", Affine::translate((10.0, 80.0)) * Affine::scale(5.0)), + ("Holder", Affine::scale(2.0)), + ("Holder", Affine::translate((5.0, 5.0))), + ] { + let one = instance(&mut doc, name, xf); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + } + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::SYMBOL_ART), "{:?}", out.warnings); + let back = read_back(&doc); + let kids = back.layers[0].children().unwrap(); + let kinds: Vec = kids.iter().map(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })).collect(); + assert_eq!(kinds, [false, false, false, true, false, true, false, false, false, false, false, true]); + let b = kids[6].visual_bounds().map(|r| [r.x0, r.y0, r.x1, r.y1].map(r3)); + assert_eq!(b, Some([100.0, 70.0, 110.0, 80.0].map(r3))); + // The tiny square comes back 10 pt across, where the instance put it. + let b = kids[2].visual_bounds().map(|r| [r.x0, r.y0, r.x1, r.y1].map(r3)); + assert_eq!(b, Some([100.0, 50.0, 110.0, 60.0].map(r3))); +} + +/// A symbol whose art is a hidden path with two fills: the canvas draws a symbol's art even when +/// its root is hidden, so the definition's root is written shown and the instance still draws. +#[test] +fn a_symbol_whose_root_is_hidden_still_draws() { + let mut doc = Document::new(200.0, 100.0); + let mut look = Appearance::basic(solid(LEAF), Paint::None, 0.0); + look.items.push(AppearanceItem::Fill(FillLayer::new(solid(Color::BLACK)))); + let mut art = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), look); + art.visible = false; + doc.symbols.push(Symbol { dynamic: false, name: "Hidden".into(), art: Arc::new(art) }); + let layer = doc.layers[0].id; + let one = instance(&mut doc, "Hidden", Affine::translate((20.0, 20.0))); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + let back = read_back(&doc); + let kids = back.layers[0].children().unwrap(); + assert!(matches!(kids[0].kind, NodeKind::SymbolInstance { .. })); + let mut shown = 0; + vectorcraft_doc::swatches::walk_with_masks(&back.symbols[0].art, &mut |m| { + shown += usize::from(m.visible && matches!(m.kind, NodeKind::Path { .. })) + }); + assert!(back.symbols[0].art.visible && shown > 0, "{:?}", back.symbols[0].art); +} + +/// The number of symbol instances in `n` and its subtree. +fn instances(n: &Node) -> usize { + let mut count = 0; + vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| count += usize::from(matches!(m.kind, NodeKind::SymbolInstance { .. }))); + count +} + +/// What `nodes` draw ([`paints_of`]). +fn paints_of_nodes(doc: &Document, nodes: &[Arc]) -> Vec { + let mut out = vec![]; + paints_of(doc, nodes, 1, &mut out); + out +} + +/// An instance with isolation, knockout or knockout shape of its own is written as its art, which +/// keeps them as a group's; a plain one beside it stays an instance. +#[test] +fn instances_with_a_groups_transparency_are_written_as_their_art() { + let mut doc = Document::new(200.0, 100.0); + let art = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Leaf".into(), art: Arc::new(art) }); + let mut shape = instance(&mut doc, "Leaf", Affine::translate((10.0, 10.0))); + shape.knockout_shape = true; + let mut isolated = instance(&mut doc, "Leaf", Affine::translate((30.0, 10.0))); + isolated.isolate = true; + let mut knocks = instance(&mut doc, "Leaf", Affine::translate((50.0, 10.0))); + knocks.knockout = Knockout::On; + let plain = instance(&mut doc, "Leaf", Affine::translate((70.0, 10.0))); + let mut outer = group(&mut doc, vec![shape, isolated, knocks, plain], false); + outer.knockout = Knockout::On; + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, outer).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_ART]); + let back = read_back(&doc); + let kids = back.layers[0].children().unwrap()[0].children().unwrap().clone(); + let states: Vec<_> = kids.iter().map(|n| (matches!(n.kind, NodeKind::SymbolInstance { .. }), n.knockout_shape, n.isolate, n.knockout)).collect(); + assert_eq!( + states, + [ + (false, true, false, Knockout::Neutral), + (false, false, true, Knockout::Neutral), + (false, false, false, Knockout::On), + (true, false, false, Knockout::Neutral) + ] + ); + // Each group holds the symbol's art where the instance placed it. + for (i, k) in kids[..3].iter().enumerate() { + let b = k.geometric_bounds().unwrap(); + assert_eq!([b.x0, b.y0, b.x1, b.y1].map(r3), [10.0 + 20.0 * i as f64, 10.0, 20.0 + 20.0 * i as f64, 20.0].map(r3)); + } +} + +/// A symbol's definition is the same wherever its first instance lies: one first met deep in +/// opacity masks keeps its own masks and instances. +#[test] +fn a_symbol_first_used_deep_in_masks_is_defined_whole() { + let mut doc = Document::new(200.0, 100.0); + let leaf = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Leaf".into(), art: Arc::new(leaf) }); + // `Deep`: a square with an opacity mask, and an instance of `Leaf`. + let mask_art = square(&mut doc, Rect::new(0.0, 0.0, 5.0, 5.0), Color::WHITE); + let mut veiled = square(&mut doc, Rect::new(0.0, 0.0, 20.0, 20.0), Color::BLACK); + veiled.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(mask_art), clip: true, invert: false, disabled: false, linked: true })); + let inner = instance(&mut doc, "Leaf", Affine::translate((5.0, 5.0))); + let deep = group(&mut doc, vec![veiled, inner], false); + doc.symbols.push(Symbol { dynamic: false, name: "Deep".into(), art: Arc::new(deep) }); + // Its only instance, in the art of an opacity mask seven masks deep. + let mut art = instance(&mut doc, "Deep", Affine::translate((50.0, 20.0))); + for _ in 0..7 { + let mut holder = square(&mut doc, Rect::new(0.0, 0.0, 100.0, 100.0), Color::gray(0.5)); + holder.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(art), clip: false, invert: false, disabled: false, linked: true })); + art = holder; + } + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, art).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::DEEP_MASKS || w == writer::SYMBOL_ART), "{:?}", out.warnings); + let back = read_back(&doc); + same_lines(&symbol_looks(&back), &symbol_looks(&doc)); + let deep = &back.symbols.iter().find(|s| s.name == "Deep").unwrap().art; + assert_eq!(instances(deep), 1); + let mut masks = 0; + deep.walk(&mut |n| masks += usize::from(n.extras().mask.is_some())); + assert_eq!(masks, 1); +} + +/// Symbols past the 4,096 the reader keeps are left out, their instances written as their art. +#[test] +fn symbols_past_the_readers_count_are_left_out() { + let mut doc = Document::new(200.0, 100.0); + for i in 0..=crate::import::MAX_SYMBOLS { + let art = square(&mut doc, Rect::new(0.0, 0.0, 4.0, 4.0), Color::gray(0.5)); + doc.symbols.push(Symbol { dynamic: false, name: format!("S{i}"), art: Arc::new(art) }); + } + // An instance of each, in the panel's order: the last one's symbol is past the count. + let layer = doc.layers[0].id; + for i in 0..=crate::import::MAX_SYMBOLS { + let one = instance(&mut doc, &format!("S{i}"), Affine::translate((10.0, 10.0))); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + } + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(out.warnings, [writer::SYMBOL_COUNT, writer::SYMBOL_ART]); + let back = read_back(&doc); + assert_eq!(back.symbols.len(), crate::import::MAX_SYMBOLS); + assert!(back.symbols.iter().all(|s| s.name != format!("S{}", crate::import::MAX_SYMBOLS))); + let live = back.layers[0].children().unwrap().iter().map(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })).collect::>(); + assert_eq!(live.len(), crate::import::MAX_SYMBOLS + 1); + assert!(live[..crate::import::MAX_SYMBOLS].iter().all(|l| *l) && !live[crate::import::MAX_SYMBOLS]); +} + +/// With one place left under the reader's count, a symbol whose type holds an inline graphic with +/// an instance of another symbol (found only as its art is written) takes the last place, and that +/// instance is written as its art: no more definitions are written than the reader keeps. +#[test] +fn a_symbol_found_while_its_holder_is_written_keeps_within_the_readers_count() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + for i in 0..crate::import::MAX_SYMBOLS - 1 { + let art = square(&mut doc, Rect::new(0.0, 0.0, 4.0, 4.0), Color::gray(0.5)); + doc.symbols.push(Symbol { dynamic: false, name: format!("S{i}"), art: Arc::new(art) }); + let one = instance(&mut doc, &format!("S{i}"), Affine::translate((10.0, 10.0))); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + } + let style = vectorcraft_doc::CharStyle { size: 18.0, fill: solid(Color::BLACK), ..vectorcraft_doc::CharStyle::default() }; + let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(0.0, 20.0), "A", style.clone()); + t.runs.push(vectorcraft_doc::TextRun::inline(vectorcraft_doc::InlineArt::new("Inner"), style)); + let typed = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + doc.symbols.push(Symbol { dynamic: false, name: "Typed".into(), art: Arc::new(typed) }); + // The inline graphic's art holds an instance of a symbol of its own. + let deeper = square(&mut doc, Rect::new(0.0, 0.0, 4.0, 4.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Deeper".into(), art: Arc::new(deeper) }); + let nested = instance(&mut doc, "Deeper", Affine::translate((1.0, 1.0))); + let edge = square(&mut doc, Rect::new(0.0, 0.0, 2.0, 2.0), LEAF); + let inner = group(&mut doc, vec![edge, nested], false); + doc.symbols.push(Symbol { dynamic: false, name: "Inner".into(), art: Arc::new(inner) }); + let one = instance(&mut doc, "Typed", Affine::translate((50.0, 50.0))); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + let program = String::from_utf8_lossy(&out.program); + assert_eq!(program.matches("%AI14_BeginSymbol").count(), crate::import::MAX_SYMBOLS); + let back = read_back(&doc); + assert_eq!(back.symbols.len(), crate::import::MAX_SYMBOLS); + assert!(back.symbols.iter().any(|s| s.name == "Typed")); + assert!(matches!(back.layers[0].children().unwrap().last().unwrap().kind, NodeKind::SymbolInstance { .. })); +} + +/// A linked image in a symbol's art is embedded; a dynamic symbol and a symbol's export type are +/// reported left out. +#[test] +fn symbol_options_and_linked_images_in_symbols_are_reported() { + let mut doc = Document::new(200.0, 100.0); + let pixels = png_of(image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(2, 2, image::Rgba([0, 0, 255, 255])))); + let link = vectorcraft_doc::LinkInfo { path: "/tmp/blue.png".into(), ..Default::default() }; + let picture = image_node(&mut doc, ImageBlob::new("image/png", pixels), (2, 2), Affine::scale(5.0), Some(link)); + doc.symbols.push(Symbol { dynamic: true, name: "Picture".into(), art: Arc::new(picture) }); + let one = instance(&mut doc, "Picture", Affine::translate((50.0, 10.0))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, one).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(out.warnings, [writer::SYMBOL_OPTIONS, writer::LINKS_EMBEDDED]); + let p = program_shown(&doc); + assert!(p.contains("%AI5_BeginRaster") && !p.contains("%AI5_BeginPlace"), "{p}"); + let back = read_back(&doc); + let NodeKind::Image(im) = &back.symbols[0].art.kind else { panic!("the symbol's image") }; + assert!(im.link.is_none()); + doc.symbols[0].dynamic = false; + doc.set_symbol_export_type("Picture", vectorcraft_doc::SymbolExportType::Graphic); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_OPTIONS, writer::LINKS_EMBEDDED]); +} + +/// Each of the nine registration points is written 1 to 9 by its place on the grid, row by row +/// from the top left, and read back. +#[test] +fn every_registration_point_is_written_by_its_place() { + for (point, written, read) in (0..9).map(|r| (r, r + 1, r)) { + let mut doc = Document::new(200.0, 100.0); + let art = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Mark".into(), art: Arc::new(art) }); + doc.set_symbol_registration("Mark", vectorcraft_doc::SymbolRegistration(point)); + let p = program_shown(&doc); + assert!(p.contains(&format!("%_{written} /Int (AI13PatternRegistrationType) ,\n")), "{point}: {p}"); + assert_eq!(read_back(&doc).symbol_registration("Mark"), vectorcraft_doc::SymbolRegistration(read), "{point}"); + } +} + +/// Symbols whose art is a group with a state of its own (a name, transparency, an opacity mask, a +/// clipping group) come back with it; the registration point shares the group's dictionary. +#[test] +fn symbols_whose_art_is_a_group_with_a_state_come_back() { + let mut doc = one_swatch_document(200.0, 100.0); + let pair = |doc: &mut Document| { + let a = square(doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + let b = square(doc, Rect::new(5.0, 5.0, 15.0, 15.0), Color::rgb(1.0, 0.0, 0.0)); + vec![a, b] + }; + let kids = pair(&mut doc); + let labeled = named(group(&mut doc, kids, false), "Label"); + let kids = pair(&mut doc); + let faded = see_through(group(&mut doc, kids, false), 0.5, BlendMode::Multiply); + let kids = pair(&mut doc); + let mut masked = group(&mut doc, kids, false); + let mask_art = square(&mut doc, Rect::new(0.0, 0.0, 8.0, 8.0), Color::WHITE); + masked.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(mask_art), clip: true, invert: true, disabled: false, linked: true })); + let edge = clipping(&mut doc, shapes::ellipse(Rect::new(0.0, 0.0, 12.0, 12.0))); + let inside = square(&mut doc, Rect::new(0.0, 0.0, 20.0, 20.0), Color::rgb(0.0, 0.0, 1.0)); + let window = group(&mut doc, vec![edge, inside], true); + let lone = square(&mut doc, Rect::new(0.0, 0.0, 6.0, 6.0), Color::gray(0.3)); + let lone_named = named(group(&mut doc, vec![lone], false), "Lone"); + for (name, art) in [("Labeled", labeled), ("Faded", faded), ("Masked", masked), ("Window", window), ("Lone", lone_named)] { + doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) }); + let one = instance(&mut doc, name, Affine::translate((20.0, 20.0))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, one).unwrap(); + } + doc.set_symbol_registration("Labeled", vectorcraft_doc::SymbolRegistration(2)); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let back = read_back(&doc); + same_lines(&symbol_looks(&back), &symbol_looks(&doc)); + same_lines(&all_looks(&back), &all_looks(&doc)); + assert_eq!(export_ai_editing(&back, &opts()).unwrap().program, out.program); + let p = program_shown(&doc); + // The name and the registration point in one dictionary, the group not wrapped again. + assert!(p.contains("%AI14_BeginSymbol\n(Labeled)\n0 A\n0 Xw\n0 Ae\nu\n0.6 0 0.9 0.1 k\n"), "{p}"); + assert!(p.contains("%_/ArtDictionary :\n%_3 /Int (AI13PatternRegistrationType) ,\n%_/XMLUID : (Label) ; (AI10_ArtUID) ,\n%_;\n"), "{p}"); +} + +/// The names of symbols in every form a string takes: parentheses, a backslash, a slash, text +/// outside ASCII, an empty name (which the reader names `Symbol`); two symbols of one name keep the +/// first; an instance of a symbol the document doesn't have is an empty group, with a warning that +/// says so. +#[test] +fn symbol_names_and_missing_symbols() { + let mut doc = Document::new(200.0, 100.0); + let names = ["Paren (a)", "Back\\slash", "Path/To", "Hoja ñ 葉", "", "Twice"]; + for (i, name) in names.iter().chain(["Twice"].iter()).enumerate() { + let art = square(&mut doc, Rect::new(0.0, 0.0, 4.0 + i as f64, 4.0), Color::gray(0.5)); + doc.symbols.push(Symbol { dynamic: false, name: (*name).into(), art: Arc::new(art) }); + } + let layer = doc.layers[0].id; + for name in names { + let one = instance(&mut doc, name, Affine::translate((10.0, 10.0))); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + } + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let back = read_back(&doc); + let names = names.map(|n| if n.is_empty() { "Symbol" } else { n }); + assert_eq!(back.symbols.iter().map(|s| s.name.as_str()).collect::>(), names); + let refs: Vec = back.layers[0] + .children() + .unwrap() + .iter() + .map(|n| match &n.kind { + NodeKind::SymbolInstance { symbol, .. } => symbol.clone(), + other => panic!("{other:?}"), + }) + .collect(); + assert_eq!(refs, names); + // The first symbol of a name is the one defined. + let twice = &back.symbols.iter().find(|s| s.name == "Twice").unwrap().art; + assert_eq!(paints_of_nodes(&back, std::slice::from_ref(twice)), paints_of_nodes(&doc, std::slice::from_ref(&doc.symbols[5].art))); + // A missing symbol. + let lost = instance(&mut doc, "Gone", Affine::translate((40.0, 10.0))); + doc.insert(Some(layer), usize::MAX, lost).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::MISSING_SYMBOL]); + let back = read_back(&doc); + let last = back.layers[0].children().unwrap().last().unwrap().clone(); + assert!(matches!(&last.kind, NodeKind::Group { children, clip: false } if children.is_empty()), "{:?}", last.kind); +} + +/// On a large canvas, a symbol's art holding a gradient and a nested instance is defined at its own +/// size and comes back as it was. +#[test] +fn a_symbol_with_a_gradient_and_an_instance_on_a_large_canvas_comes_back() { + use vectorcraft_color::gradient::GradientGeom; + let mut doc = Document::new(20000.0, 100.0); + let leaf = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Leaf".into(), art: Arc::new(leaf) }); + let geom = GradientGeom { start: (0.0, 0.0).into(), end: (40.0, 10.0).into(), aspect: 1.0, focal: None }; + let shaded = painted( + &mut doc, + shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0)), + vec![AppearanceItem::Fill(FillLayer::new(gradient_paint(sunset(), Some(geom), None)))], + ); + let nested = instance(&mut doc, "Leaf", Affine::translate((25.0, 5.0)) * Affine::rotate(0.4)); + let both = group(&mut doc, vec![shaded, nested], false); + doc.symbols.push(Symbol { dynamic: false, name: "Both".into(), art: Arc::new(both) }); + let one = instance(&mut doc, "Both", Affine::translate((100.0, 10.0))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, one).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_ART]); + let back = read_back(&doc); + same_lines(&symbol_looks(&back), &symbol_looks(&doc)); +} + +/// Instance matrices of every kind (turned, sheared, mirrored, scaled near the smallest area the +/// data keeps), nested in one another, are read back as written, and written again as the same +/// bytes. The matrices come from a fixed seed. +#[test] +fn generated_instance_matrices_come_back() { + let mut seed = 0x2545_f491_4f6c_dd1du64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed >> 11) as f64 / (1u64 << 53) as f64 + }; + for round in 0..40 { + let mut doc = one_swatch_document(300.0, 300.0); + let base = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "S0".into(), art: Arc::new(base) }); + let matrix = |next: &mut dyn FnMut() -> f64| { + // Four nested matrices together scale by at most 64, within the 100 an instance may. + let scale = 10f64.powf(next() * 3.0 - 3.0); + Affine::new([ + (next() * 4.0 - 2.0) * scale, + (next() * 4.0 - 2.0) * scale, + (next() * 4.0 - 2.0) * scale, + (next() * 4.0 - 2.0) * scale, + next() * 300.0, + next() * 300.0, + ]) + }; + // A chain of symbols, each holding an instance of the one before. + for level in 1..4 { + let xf = matrix(&mut next); + let inner = instance(&mut doc, &format!("S{}", level - 1), xf); + let dot = square(&mut doc, Rect::new(0.0, 0.0, 3.0, 3.0), Color::gray(0.4)); + let art = group(&mut doc, vec![dot, inner], false); + doc.symbols.push(Symbol { dynamic: false, name: format!("S{level}"), art: Arc::new(art) }); + } + let layer = doc.layers[0].id; + for _ in 0..6 { + let pick = format!("S{}", (next() * 4.0) as usize % 4); + let xf = matrix(&mut next); + let one = instance(&mut doc, &pick, xf); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + } + let out = export_ai_editing(&doc, &opts()).unwrap(); + let back = read_back(&doc); + // Each instance written as one comes back with its matrix to ten decimals. + let written = |d: &Document| { + let mut all = vec![]; + for n in d.layers[0].children().unwrap().iter().chain(d.symbols.iter().map(|s| &s.art)) { + n.walk(&mut |m| { + if let NodeKind::SymbolInstance { symbol, xf } = &m.kind { + all.push((symbol.clone(), *xf)); + } + }); + } + all + }; + assert!(out.warnings.is_empty(), "round {round}: {:?}", out.warnings); + let (got, want) = (written(&back), written(&doc)); + assert_eq!(got.len(), want.len(), "round {round}"); + for (g, w) in got.iter().zip(&want) { + assert_eq!(g.0, w.0, "round {round}"); + for (a, b) in g.1.as_coeffs().iter().zip(w.1.as_coeffs()) { + assert!((a - b).abs() <= 1e-9 * b.abs().max(1.0), "round {round}: {:?} {:?}", g.1, w.1); + } + } + let again = export_ai_editing(&back, &opts()).unwrap().program; + same_lines(&shown(&again).lines().map(String::from).collect::>(), &shown(&out.program).lines().map(String::from).collect::>()); + assert_eq!(again, out.program, "round {round}"); + } + // About the smallest area the data keeps: an instance whose matrix keeps it stays one, one + // whose matrix (to ten decimals) loses it is written as its art. + for (xf, live) in [ + (Affine::new([1e-6, 0.0, 0.0, 1.1e-6, 50.0, 10.0]), true), + (Affine::new([1e-6, 0.0, 0.0, 0.9e-6, 50.0, 10.0]), false), + (Affine::new([1.0, 0.4, 2.5, 1.0000000001, 50.0, 10.0]), true), + (Affine::new([4e-11, 0.0, 0.0, 1e6, 50.0, 10.0]), false), + ] { + let mut doc = Document::new(200.0, 100.0); + let base = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "S0".into(), art: Arc::new(base) }); + let one = instance(&mut doc, "S0", xf); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, one).unwrap(); + let back = read_back(&doc); + assert_eq!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::SymbolInstance { .. }), live, "{xf:?}"); + } +} + +/// Type in a symbol's art is written as its outlines (the reader reads no type there), so the +/// symbol and its instance come back; type on the layers stays type. +#[test] +fn type_in_a_symbols_art_is_written_as_its_outlines() { + let mut doc = Document::new(200.0, 100.0); + let label = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(0.0, 20.0), "Tag", style(18.0, Color::BLACK)); + let label = text_node(&mut doc, label); + doc.symbols.push(Symbol { dynamic: false, name: "Tag".into(), art: Arc::new(label) }); + let one = instance(&mut doc, "Tag", Affine::translate((20.0, 20.0))); + let plain = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 90.0), "Plain", style(18.0, Color::BLACK)); + let plain = text_node(&mut doc, plain); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, one).unwrap(); + doc.insert(Some(layer), usize::MAX, plain).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::TYPE]); + assert_eq!(program(&doc).matches("/AI11Text :").count(), 1); + let back = read_back(&doc); + assert_eq!(back.symbols.len(), 1); + assert!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::SymbolInstance { .. })); +} diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 37b8868ec..14fa89fe6 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -17,6 +17,7 @@ use crate::ps; mod images; mod paints; +mod symbols; mod texts; pub(super) use images::PAYLOAD; @@ -25,6 +26,8 @@ pub(super) use images::{IMAGE_DEPTH, IMAGES, LINKS_EMBEDDED}; #[cfg(test)] pub(super) use paints::{GRADIENT_MIX, GRADIENT_SHAPES, GRADIENTS, STOP_LINKS, SWATCH_PLACEMENT, free_gradient_name}; #[cfg(test)] +pub(super) use symbols::{MISSING_SYMBOL, SYMBOL_ART, SYMBOL_COUNT, SYMBOL_OPTIONS}; +#[cfg(test)] pub(super) use texts::TYPE_ATTRIBUTES; /// How deep symbols, live objects and pattern tiles may nest in one another (deeper art is left @@ -32,14 +35,14 @@ pub(super) use texts::TYPE_ATTRIBUTES; /// [`vectorcraft_doc::MAX_DEPTH`]. const MAX_NEST: u32 = 8; -pub(super) const TYPE: &str = "type in opacity masks, pattern tiles and art cut to a clipping object, mirrored type, area type and type on a path that is turned or unevenly scaled or whose frame or path has several parts, area type in an open frame, type on an open path whose ends meet, area type that flows around a text wrap, type of only white space, threads of type on a path, threads whose frames differ in orientation or hold a frame written as outlines, type of sizes past what the reader reads, stories past what the reader reads (1,048,576 bytes a story, 16,777,216 bytes in all, and the size of text document it reads), and type with inline graphics, ruby, gradient or pattern paint, or fills, strokes, effects or an opacity mask of the object's own, is written as its outlines in the editing data for other apps"; -pub(super) const PATTERNS: &str = "pattern fills whose tiles aren't a grid or whose art reaches past the tile, pattern fills in another pattern's tile and pattern fills nested more than 8 deep are written as their tiles cut to the shape in the editing data for other apps"; +pub(super) const TYPE: &str = "type in opacity masks, pattern tiles, symbols and art cut to a clipping object, mirrored type, area type and type on a path that is turned or unevenly scaled or whose frame or path has several parts, area type in an open frame, type on an open path whose ends meet, area type that flows around a text wrap, type of only white space, threads of type on a path, threads whose frames differ in orientation or hold a frame written as outlines, type of sizes past what the reader reads, stories past what the reader reads (1,048,576 bytes a story, 16,777,216 bytes in all, and the size of text document it reads), and type with inline graphics, ruby, gradient or pattern paint, or fills, strokes, effects or an opacity mask of the object's own, is written as its outlines in the editing data for other apps"; +pub(super) const PATTERNS: &str = "pattern fills whose tiles aren't a grid or whose art reaches past the tile, pattern fills in another pattern's tile or in a symbol, and pattern fills nested more than 8 deep are written as their tiles cut to the shape in the editing data for other apps"; pub(super) const PATTERN_TILES: &str = "pattern fills with more tiles than a fill places are written filled gray in the editing data for other apps"; pub(super) const CLIPPING: &str = "clipping groups and clipping layers whose clipping object isn't a path, and the clipping groups inside them, are written as their art cut to that object in the editing data for other apps"; pub(super) const CLIP_PAINT: &str = "the fills and strokes of clipping paths that a plain fill and line can't draw are written as paths of their own inside the clipping (fills under the clipped art, strokes over it) in the editing data for other apps"; pub(super) const CLIP_PAINT_MASKS: &str = "the opacity masks of clipping paths whose fills and strokes are written as paths of their own stay on the clipping path, not on those paths, in the editing data for other apps"; pub(super) const LIVE: &str = - "symbols, blends, envelopes, meshes, repeats, placed documents and intertwines are written as their art in the editing data for other apps"; + "blends, envelopes, meshes, repeats, placed documents and intertwines are written as their art in the editing data for other apps"; const STROKES: &str = "strokes a plain line can't draw (inside or outside the path, variable width, dots, dashes fitted to corners, gradients, patterns, arrowheads) are written as filled outlines, and brushed strokes as their brush's art, in the editing data for other apps"; pub(super) const ITEM_TRANSPARENCY: &str = "the opacity and blending modes of single fills and strokes are left out of the editing data for other apps"; @@ -162,6 +165,49 @@ pub(super) struct Writer<'a> { hidden: u32, /// The text document: the stories of the type written as text. pub(super) texts: texts::TextDoc, + /// Symbol instances on the layers may be written as instances ([`Self::live_instance`]); false + /// on a large canvas, where they are written as their art. + pub(super) symbols_live: bool, + /// The document symbols are defined from: on a large canvas, the document at its own size + /// (symbols' art is in its own space, which the canvas's scale doesn't change). + pub(super) symbol_doc: &'a Document, + /// A symbol's art is being written ([`Self::define_symbol`]). + in_symbol: bool, + /// The document → the space an instance's `RTransformMatrix` takes its symbol's art to: art + /// space, and in a symbol's art the canvas's turned y up about art space's origin there. + instance_space: Affine, + /// The symbol definitions written (`%AI14_BeginSymbol` … `%AI10_EndSymbol`), each after those + /// its art uses, and their names. + symbol_defs: Vec, + defined_symbols: HashSet, + /// The symbols whose definitions are being written. + defining_symbols: Vec, + /// Whether each symbol's own art paints with a pattern, and the symbols its instances use + /// ([`Self::symbol_own_patterns`]). + pattern_symbols: HashMap)>, + /// Whether each symbol's art paints with a pattern, nested symbols included + /// ([`Self::symbol_paints_pattern`]). + patterned_symbols: HashMap, + /// Pattern tiles were written in the art being written ([`Self::define_symbol`] starts it over + /// for each symbol's art). + wrote_pattern: bool, + /// The symbols whose definitions hold pattern tiles: a brushed stroke's brush art or a live + /// object's art can paint with a pattern that the symbol's own objects don't name. + pattern_written: HashSet, + /// Art that draws as it should only where it is was written in the art being written + /// ([`Self::define_symbol`] starts it over for each symbol's art): a stroke as its outline or its + /// brush's art, type as its outlines, a live object or a symbol instance as its art, or a shape + /// with rounded live corners. + wrote_fixed: bool, + /// The symbols whose definitions hold such art, nested symbols' included + /// ([`Self::keeps_scale`]). + fixed_symbols: HashSet, + /// The largest scale a live instance in the art being written applies, nested symbols' scales + /// included, and that of each symbol's definition. + inner_scale: f64, + symbol_scales: HashMap, + /// The entries a symbol's dictionary adds to its art's dictionary, and the art's id. + root_entries: Option<(vectorcraft_doc::NodeId, String)>, } impl<'a> Writer<'a> { @@ -195,6 +241,22 @@ impl<'a> Writer<'a> { next: HashMap::new(), brushes: None, symbol_nodes: 0, + symbols_live: true, + symbol_doc: doc, + in_symbol: false, + instance_space: to_art, + symbol_defs: vec![], + defined_symbols: HashSet::new(), + defining_symbols: vec![], + pattern_symbols: HashMap::new(), + patterned_symbols: HashMap::new(), + wrote_pattern: false, + pattern_written: HashSet::new(), + wrote_fixed: false, + fixed_symbols: HashSet::new(), + inner_scale: 1.0, + symbol_scales: HashMap::new(), + root_entries: None, } } @@ -394,6 +456,9 @@ impl<'a> Writer<'a> { if let NodeKind::Layer { .. } = n.kind { return self.write_layer(n, &[]); } + if self.in_symbol { + self.wrote_fixed |= symbols::redrawn(n); + } self.effects(n); self.state(n.locked, !n.visible); let xy = Xy::of(n); @@ -422,10 +487,18 @@ impl<'a> Writer<'a> { NodeKind::Compound { rule, .. } => self.shape(n, &compound_path(n), *rule, xy), NodeKind::Text(_) => self.text(n, xy), NodeKind::Image(im) => self.image(im, xy), + NodeKind::SymbolInstance { symbol, xf } if self.live_instance(n, symbol, *xf) => self.symbol_instance(symbol, *xf, xy), NodeKind::SymbolInstance { symbol, xf } => { - self.warn(LIVE); - self.line("u"); + // The canvas places an instance inside a symbol's art where the outer instance puts + // it (9-slice guides, stains and patterns included), and this art doesn't move with it. + self.wrote_fixed |= self.in_symbol; let doc = self.doc; + if doc.symbols.iter().any(|s| &s.name == symbol) { + self.warn(symbols::SYMBOL_ART); + } else { + self.warn(symbols::MISSING_SYMBOL); + } + self.line("u"); if let Some(sym) = doc.symbols.iter().find(|s| &s.name == symbol) && self.nest < MAX_NEST { @@ -492,7 +565,8 @@ impl<'a> Writer<'a> { } /// `n`'s `/ArtDictionary`, after it, when it has a name (`AI10_ArtUID`, see [`Self::take_id`]) - /// or an opacity mask (`AI9_Mask`, see [`Self::mask`]). + /// or an opacity mask (`AI9_Mask`, see [`Self::mask`]), or is a symbol's art (its symbol's + /// entries first, see [`Self::define_symbol`]). fn dictionary(&mut self, n: &Node) { let name = n.name.as_deref().filter(|s| !s.is_empty()); let mut mask = n.extras().mask.as_deref(); @@ -501,10 +575,17 @@ impl<'a> Writer<'a> { self.warn(DEEP_MASKS); mask = None; } - if name.is_none() && mask.is_none() { + let entries = match &self.root_entries { + Some((id, _)) if *id == n.id => self.root_entries.take().map(|(_, e)| e), + _ => None, + }; + if name.is_none() && mask.is_none() && entries.is_none() { return; } self.body.push_str("%_/ArtDictionary :\n"); + if let Some(e) = entries { + self.body.push_str(&e); + } if let Some(name) = name { let id = self.take_id(name); let _ = writeln!(self.body, "%_/XMLUID : {} ; (AI10_ArtUID) ,", ps::bytes(id.as_bytes())); @@ -751,6 +832,7 @@ impl<'a> Writer<'a> { AppearanceItem::Stroke(s) if self.direct_stroke(s) => self.path_object(path, rule, None, Some(s), xy), AppearanceItem::Stroke(s) => { self.warn(STROKES); + self.wrote_fixed = true; if self.brush_art(path, s, xy) { return; } @@ -812,6 +894,7 @@ impl<'a> Writer<'a> { let flat = FillLayer { paint: Paint::solid(Color::gray(0.5)), ..f.clone() }; return self.path_object(path, rule, Some(&flat), None, xy); } + self.wrote_pattern = true; let outer = self.clip.clone(); let region = self.cut(path, rule); self.clip = Some(region); @@ -850,6 +933,7 @@ impl<'a> Writer<'a> { self.plain_object(®ion, FillRule::NonZero, fill, None, own); } if let Some(s) = stroke { + self.wrote_fixed = true; let outline = stroke::outline_region(path, rule, s); let region = self.cut(&outline, FillRule::NonZero); let fill = FillLayer { overprint: s.overprint, ..FillLayer::new(s.paint.clone()) }; @@ -913,13 +997,13 @@ impl<'a> Writer<'a> { // ---------- type ---------- /// Type `n`: a text object stub naming its story in the text document ([`texts`]) when the - /// text document can hold it, outside opacity masks, pattern tiles and art cut to a clipping - /// object; else its outlines ([`Self::outlined_text`]). A frame of threaded area type names the - /// story of its thread, which its first frame holds. + /// text document can hold it, outside opacity masks, pattern tiles, symbols' art and art cut to + /// a clipping object; else its outlines ([`Self::outlined_text`]). A frame of threaded area type + /// names the story of its thread, which its first frame holds. fn text(&mut self, n: &Node, xy: Xy) { let NodeKind::Text(t) = &n.kind else { return }; let doc = self.doc; - let placed = if self.hidden > 0 || self.clip.is_some() { + let placed = if self.hidden > 0 || self.clip.is_some() || self.in_symbol { None } else if let Some(thread) = doc.text_threads.iter().find(|th| th.contains(&n.id)) { // A thread is checked for what it loses, in every frame, when its story is written. diff --git a/crates/eps/src/ai_editing/writer/images.rs b/crates/eps/src/ai_editing/writer/images.rs index d8bb4b16d..31acb1dd8 100644 --- a/crates/eps/src/ai_editing/writer/images.rs +++ b/crates/eps/src/ai_editing/writer/images.rs @@ -13,7 +13,7 @@ use crate::ps; pub(in crate::ai_editing) const IMAGES: &str = "images that can't be decoded or have more than 33,554,432 pixels are written as their outlines filled mid gray in the editing data for other apps"; pub(in crate::ai_editing) const IMAGE_DEPTH: &str = "images of 16 bits a channel are written at 8 bits a channel in the editing data for other apps"; pub(in crate::ai_editing) const LINKS_EMBEDDED: &str = - "linked images in opacity masks and pattern tiles are written as embedded images in the editing data for other apps"; + "linked images in opacity masks, pattern tiles and symbols are written as embedded images in the editing data for other apps"; /// The most pixels an image is written with: as many as the reader reads. pub(super) const MAX_PIXELS: u64 = 1 << 25; @@ -94,7 +94,7 @@ impl Samples { impl Writer<'_> { /// Image object `im` of transparency `xy`: embedded, a raster ([`Self::raster`]); linked, a /// placed file with a preview ([`Self::placed_file`]), except in a part of the data written on - /// `%_` lines. One whose pixels can't be read is its outline filled mid gray. Where the art is + /// `%_` lines and in a symbol's art. One whose pixels can't be read is its outline filled mid gray. Where the art is /// cut to a clipping path, the image is in a clipping group of that path. pub(super) fn image(&mut self, im: &ImageObject, xy: Xy) { let doc = self.doc; @@ -120,7 +120,7 @@ impl Writer<'_> { let fit = Affine::scale_non_uniform(f64::from(im.width.max(1)) / f64::from(samples.width), f64::from(im.height.max(1)) / f64::from(samples.height)); match &im.link { - Some(link) if self.hidden == 0 => self.placed_file(link, &samples, (im.width.max(1), im.height.max(1)), im.xf), + Some(link) if self.hidden == 0 && !self.in_symbol => self.placed_file(link, &samples, (im.width.max(1), im.height.max(1)), im.xf), other => { if other.is_some() { self.warn(LINKS_EMBEDDED); @@ -154,9 +154,12 @@ impl Writer<'_> { /// `[matrix] 0 0 w h w h 8 type alpha reserved 1 0 4 4 0 0`, `%%BeginData:` counting `XI`, its /// line end and the color samples, `XI`, the color samples then the alpha channel in binary, /// `%%EndData`, `XH`, `%AI5_EndRaster`, `N`. The matrix is an art-space matrix applied to the - /// pixels with their rows running downwards, `(u, -v)`, as §8.1's image matrix. + /// pixels with their rows running downwards, `(u, -v)`, as §8.1's image matrix; in a symbol's + /// art (outside a pattern's tile in it), a matrix in the canvas's space turned y up about art + /// space's origin there, as an instance's matrix in it is. fn raster(&mut self, s: &Samples, xf: Affine) { - let m = matrix(self.to_art * xf * Affine::FLIP_Y); + let space = if self.in_symbol && self.defining.is_empty() { self.instance_space } else { self.to_art }; + let m = matrix(space * xf * Affine::FLIP_Y); let (w, h) = (s.width, s.height); let alpha = u8::from(s.alpha.is_some()); let mut payload = Vec::with_capacity(2 + s.color.len() + s.alpha.as_ref().map_or(0, Vec::len)); @@ -233,7 +236,7 @@ fn matrix(m: Affine) -> String { } /// `v` rounded to ten decimals, without trailing zeros (0 when not finite). -fn precise(v: f64) -> String { +pub(super) fn precise(v: f64) -> String { let r = (v * 1e10).round() / 1e10; if !r.is_finite() { return if v.is_finite() { format!("{}", v + 0.0) } else { "0".into() }; diff --git a/crates/eps/src/ai_editing/writer/paints.rs b/crates/eps/src/ai_editing/writer/paints.rs index d805d9d0e..a29ab3df9 100644 --- a/crates/eps/src/ai_editing/writer/paints.rs +++ b/crates/eps/src/ai_editing/writer/paints.rs @@ -341,9 +341,10 @@ impl Writer<'_> { /// Can pattern `name` be written as a definition and painted with from here: the document has /// it, its tiles make a grid, its art is within its tile and holds something, and it isn't used - /// in another pattern's tile or nested more than [`MAX_NEST`] deep? + /// in another pattern's tile or a symbol's art (the reader keeps no symbol whose art paints with + /// a pattern) or nested more than [`MAX_NEST`] deep? pub(super) fn pattern_writable(&self, name: &str) -> bool { - if !self.defining.is_empty() || self.nest >= MAX_NEST { + if !self.defining.is_empty() || self.nest >= MAX_NEST || self.in_symbol { return false; } if self.patterns.iter().any(|p| p.name == name) { diff --git a/crates/eps/src/ai_editing/writer/symbols.rs b/crates/eps/src/ai_editing/writer/symbols.rs new file mode 100644 index 000000000..ccec95d42 --- /dev/null +++ b/crates/eps/src/ai_editing/writer/symbols.rs @@ -0,0 +1,339 @@ +//! Symbols: their definitions, the symbol list and their instances (see [`crate::ai_editing`] for +//! the form). + +use std::collections::HashSet; +use std::fmt::Write; + +use vectorcraft_color::Paint; +use vectorcraft_doc::{Knockout, Node, NodeKind, SymbolExportType}; +use vectorcraft_geom::Affine; + +use super::{MAX_NEST, Writer, Xy}; +use crate::import::MAX_SYMBOLS; +use crate::ps; + +pub(in crate::ai_editing) const SYMBOL_ART: &str = "symbol instances with a fill, appearance changes, isolation, knockout or knockout shape of their own, instances of symbols with 9-slice guides or pattern paint, instances in pattern tiles or in art cut to a clipping object, instances scaled almost flat, instances that turn, mirror or scale symbols holding type, live objects, instances written as their art, shapes with rounded live corners or strokes written as outlines or brush art, instances that scale their symbol more than 100 times, instances on the layers of a large canvas and instances of symbols past 4,096 are written as their art in the editing data for other apps"; +pub(in crate::ai_editing) const SYMBOL_OPTIONS: &str = + "the 9-slice guides, export types and dynamic type of symbols are left out of the editing data for other apps"; +pub(in crate::ai_editing) const MISSING_SYMBOL: &str = + "symbol instances of symbols the document doesn't have are written as empty groups in the editing data for other apps"; +/// The most an instance may scale its symbol's art, nested instances' scales included: the art's +/// numbers are written to four decimals, so a scale of 100 places them within 0.005 pt. +const MAX_SCALE: f64 = 100.0; +pub(in crate::ai_editing) const SYMBOL_COUNT: &str = "symbols past 4,096 are left out of the editing data for other apps"; + +/// Whether the canvas works `n` itself (not its children) out again after placing an instance: +/// type, live objects and shapes with rounded live corners, whose strokes, steps and corners depend +/// on where the instance puts them. +pub(super) fn redrawn(n: &Node) -> bool { + let rounded = |l: &vectorcraft_doc::LiveShape| match l { + vectorcraft_doc::LiveShape::Rectangle { radii, .. } => radii.iter().any(|r| *r > 0.0), + vectorcraft_doc::LiveShape::Polygon { radii, .. } | vectorcraft_doc::LiveShape::Path { radii, .. } => radii.iter().any(|r| *r > 0.0), + vectorcraft_doc::LiveShape::Ellipse { .. } | vectorcraft_doc::LiveShape::Line { .. } => false, + }; + match &n.kind { + NodeKind::Path { live: Some(l), .. } => rounded(l), + NodeKind::Text(_) + | NodeKind::Blend { .. } + | NodeKind::Envelope { .. } + | NodeKind::Mesh(_) + | NodeKind::Repeat(_) + | NodeKind::PlacedDocument(_) + | NodeKind::Intertwine { .. } => true, + _ => false, + } +} + +impl Writer<'_> { + /// Whether instance `n` of symbol `symbol`, placed by `xf`, is written as an instance: not on the + /// layers of a large canvas, in a pattern's tile or in art cut to a clipping object, without a + /// fill or appearance changes of its own (which stain or change the art it draws) and without + /// isolation, knockout or knockout shape of its own (which an instance's transparency doesn't + /// hold), of a symbol without 9-slice guides and without pattern paint in its art (the canvas + /// keeps an instance's pattern tiles where the document puts them, and a definition's art moves + /// with each instance) whose definition can be written ([`Self::define_symbol`]), by a matrix + /// that keeps the art's area as it is written and scales it as the definition allows + /// ([`Self::keeps_scale`]). + pub(super) fn live_instance(&mut self, n: &Node, symbol: &str, xf: Affine) -> bool { + let own = vectorcraft_brush::stain(n).is_some() || !n.extras().symbol_overrides.is_empty(); + let group_state = n.isolate || n.knockout != Knockout::Neutral || n.knockout_shape; + // The matrix as written (to ten decimals) must still keep the art's area. + let m = Affine::new((self.instance_space * xf).as_coeffs().map(|v| (v * 1e10).round() / 1e10)); + (self.symbols_live || self.in_symbol) + && self.defining.is_empty() + && self.clip.is_none() + && !own + && !group_state + && self.doc.symbol_nine_slice(symbol).is_none() + && m.as_coeffs().iter().all(|v| v.is_finite()) + && m.determinant().abs() >= 1e-12 + && self.define_symbol(symbol) + && !self.symbol_paints_pattern(symbol) + && self.keeps_scale(symbol, xf) + } + + /// Whether instance matrix `xf` places symbol `symbol`'s definition as the canvas draws the + /// instance. A definition holding art the canvas works out again after placing an instance + /// ([`redrawn`], strokes written as outlines or brush art, and instances written as their art) + /// takes only matrices that move it; + /// any other takes a scale up to [`MAX_SCALE`], nested instances' scales included. A live + /// instance in a symbol's art passes both to that symbol. + fn keeps_scale(&mut self, symbol: &str, xf: Affine) -> bool { + let [a, b, c, d, ..] = xf.as_coeffs(); + let fixed = self.fixed_symbols.contains(symbol); + let scale = a.hypot(b).max(c.hypot(d)) * self.symbol_scales.get(symbol).copied().unwrap_or(1.0); + let keeps = if fixed { (a - 1.0).abs() < 1e-9 && b.abs() < 1e-9 && c.abs() < 1e-9 && (d - 1.0).abs() < 1e-9 } else { scale <= MAX_SCALE }; + if keeps && self.in_symbol { + self.wrote_fixed |= fixed; + self.inner_scale = self.inner_scale.max(scale); + } + keeps + } + + /// Whether the art of symbol `name`, an opacity mask in it or the art of a symbol it holds an + /// instance of, at any depth, fills or strokes something (type included) with a pattern, or its + /// definition holds pattern tiles. Worked out once a symbol, after it is defined. + fn symbol_paints_pattern(&mut self, name: &str) -> bool { + if self.pattern_written.contains(name) { + return true; + } + if let Some(known) = self.patterned_symbols.get(name) { + return *known; + } + let mut seen = HashSet::from([name.to_string()]); + let mut todo = vec![name.to_string()]; + let mut found = false; + while let Some(next) = todo.pop() { + let (own, used) = self.symbol_own_patterns(&next); + if own { + found = true; + break; + } + for u in used { + if seen.insert(u.clone()) { + todo.push(u); + } + } + } + self.patterned_symbols.insert(name.to_string(), found); + found + } + + /// Whether the art of symbol `name` or an opacity mask in it fills or strokes something (type + /// included) with a pattern, and the symbols its instances and inline graphics there use; looked + /// at once a symbol. + fn symbol_own_patterns(&mut self, name: &str) -> (bool, Vec) { + if let Some(known) = self.pattern_symbols.get(name) { + return known.clone(); + } + let doc = self.symbol_doc; + let mut own = false; + let mut used = vec![]; + if let Some(sym) = doc.symbols.iter().find(|s| s.name == name) { + let pattern = |p: &Paint| matches!(p, Paint::Pattern { .. }); + vectorcraft_doc::swatches::walk_with_masks(&sym.art, &mut |m| { + own |= vectorcraft_doc::pattern::uses_pattern(m, None); + match &m.kind { + NodeKind::Text(t) => { + own |= t.runs.iter().any(|r| pattern(&r.style.fill) || pattern(&r.style.stroke)); + // Inline graphics are written as their symbols' art. + used.extend(t.runs.iter().filter_map(|r| r.inline.as_ref()).map(|art| art.symbol.clone())); + } + NodeKind::SymbolInstance { symbol, .. } => used.push(symbol.clone()), + _ => {} + } + }); + } + self.pattern_symbols.insert(name.to_string(), (own, used.clone())); + (own, used) + } + + /// An instance of `symbol` placed by `xf`, of transparency `xy`: `/SymbolInstance :`, `(name) + /// /SymbolRef ,`, the matrix taking the symbol's art to art space (in a symbol's art, to the + /// canvas's space turned y up about art space's origin there) as `a b c d tx ty + /// /RTransformMatrix` (to ten decimals), `;`, then its state block as a group's: `Xy`, `0 0 Xd` + /// and `6 () XW` when it has transparency, else `9 () XW`. Its dictionary goes between the two. + pub(super) fn symbol_instance(&mut self, symbol: &str, xf: Affine, xy: Xy) { + let m = self.instance_space * xf; + let _ = write!(self.body, "/SymbolInstance :\n{} /SymbolRef ,\n", ps::bytes(symbol.as_bytes())); + for v in m.as_coeffs() { + self.body.push_str(&super::images::precise(v)); + self.body.push(' '); + } + self.body.push_str("/RTransformMatrix\n;\n"); + // An instance with isolation, knockout or knockout shape of its own is written as its art + // ([`Self::live_instance`]); these are the values of the others. + let xy = Xy { isolate: false, knockout: Knockout::Neutral, shape: false, ..xy }; + self.block_start = self.body.len(); + if xy == Xy::PLAIN { + self.line("9 () XW"); + } else { + self.write_xy(xy); + self.line("0 0 Xd\n6 () XW"); + } + self.block_end = self.body.len(); + } + + /// Write symbol `name`'s definition, once: `%AI14_BeginSymbol`, `(name)`, `0 A`, `0 Xw`, `0 Ae`, + /// the art in the symbol's space (y down: its numbers are the symbol's own) as a group (`u` … + /// `U`; art that isn't a group, or is a plain group of one object, in a group of its own), the + /// symbol's registration point (`AI13PatternRegistrationType`, 1 to 9 from the top left, row by + /// row) in that group's dictionary, `9 () XW`, `%AI10_EndSymbol`. Linked images in the + /// art are embedded. The symbols its art's instances use are defined first, and the art is + /// written from the top of the nesting whatever the instance that asks for it, so a definition + /// is the same wherever its first instance lies. False when it can't be: the document doesn't + /// have the symbol, it is being written (its art holds an instance of itself), it is reached + /// through more than [`MAX_NEST`] definitions being written, or the definitions written and + /// being written leave no place under [`MAX_SYMBOLS`]. + pub(super) fn define_symbol(&mut self, name: &str) -> bool { + if self.defined_symbols.contains(name) { + return true; + } + let doc = self.symbol_doc; + let Some(sym) = doc.symbols.iter().find(|s| s.name == name) else { return false }; + if self.defining_symbols.iter().any(|d| d == name) || self.defining_symbols.len() >= MAX_NEST as usize { + return false; + } + // The reader keeps no more definitions than this, and each definition being written takes + // one when it ends. + if self.symbol_defs.len() + self.defining_symbols.len() >= MAX_SYMBOLS { + self.warn(SYMBOL_COUNT); + return false; + } + if sym.dynamic || doc.symbol_export_type(name) != SymbolExportType::default() || doc.symbol_nine_slice(name).is_some() { + self.warn(SYMBOL_OPTIONS); + } + self.defining_symbols.push(name.to_string()); + let mut used = vec![]; + vectorcraft_doc::swatches::walk_with_masks(&sym.art, &mut |m| { + if let NodeKind::SymbolInstance { symbol, .. } = &m.kind { + used.push(symbol.as_str()); + } + }); + for u in used { + self.define_symbol(u); + } + if self.symbol_defs.len() + self.defining_symbols.len() > MAX_SYMBOLS { + self.defining_symbols.pop(); + self.warn(SYMBOL_COUNT); + return false; + } + // The art is written in the symbol's space, from the state `0 A` and `0 Xw` set, from the + // document at its own size. + let origin = self.canvas_origin; + let about_origin = Affine::new([1.0, 0.0, 0.0, -1.0, -origin.0, origin.1]); + let page_doc = std::mem::replace(&mut self.doc, doc); + let saved = ( + std::mem::take(&mut self.body), + (self.to_art, self.grad_space, self.canvas, self.instance_space), + self.state.replace((false, false)), + self.gs.replace(Xy::OPAQUE), + self.clip.take(), + (self.block_start, self.block_end), + (std::mem::replace(&mut self.in_symbol, true), std::mem::take(&mut self.hidden)), + self.root_entries.take(), + std::mem::take(&mut self.wrote_pattern), + (std::mem::take(&mut self.wrote_fixed), std::mem::replace(&mut self.inner_scale, 1.0)), + ); + // Gradients' matrices and instances' matrices are written about the canvas point of art + // space's origin, y up; `Bg` places a gradient on the art as it lies in the symbol's space. + (self.to_art, self.grad_space, self.canvas, self.instance_space) = (Affine::IDENTITY, about_origin, Affine::IDENTITY, about_origin); + self.block_end = usize::MAX; + let registration = u16::from(doc.symbol_registration(name).0.min(8)) + 1; + let entries = format!("%_{registration} /Int (AI13PatternRegistrationType) ,\n"); + // The canvas draws a symbol's art even when its root is hidden, so the root is written shown. + let shown; + let root: &Node = if sym.art.visible { + &sym.art + } else { + let mut a = (*sym.art).clone(); + a.visible = true; + shown = a; + &shown + }; + // A definition holds a group: art of another kind, and a plain group of one object (which + // the reader takes for that group), are written in one. + let wrap = !matches!(root.kind, NodeKind::Group { clip: false, .. }) || crate::import::symbol_art_member(root).is_some(); + let nest = std::mem::replace(&mut self.nest, 1); + if wrap { + self.line("u"); + self.node(root); + let _ = write!(self.body, "U\n%_/ArtDictionary :\n{entries}%_;\n%_\n9 () XW\n"); + } else { + // A group always writes its dictionary ([`Self::dictionary`]), which takes the entries. + self.root_entries = Some((root.id, entries)); + self.node(root); + } + self.nest = nest; + // Objects the writing doesn't pass through one by one (clipping paths, a clipping group's + // own path) count as well. + vectorcraft_doc::swatches::walk_with_masks(&sym.art, &mut |m| self.wrote_fixed |= redrawn(m)); + if self.wrote_pattern { + self.pattern_written.insert(name.to_string()); + } + if self.wrote_fixed { + self.fixed_symbols.insert(name.to_string()); + } + if self.inner_scale > 1.0 { + self.symbol_scales.insert(name.to_string(), self.inner_scale); + } + let art; + ( + art, + (self.to_art, self.grad_space, self.canvas, self.instance_space), + self.state, + self.gs, + self.clip, + (self.block_start, self.block_end), + (self.in_symbol, self.hidden), + self.root_entries, + self.wrote_pattern, + (self.wrote_fixed, self.inner_scale), + ) = saved; + self.doc = page_doc; + let art = std::mem::replace(&mut self.body, art); + self.defining_symbols.pop(); + let text = format!("%AI14_BeginSymbol\n{}\n0 A\n0 Xw\n0 Ae\n{art}%AI10_EndSymbol\n", ps::bytes(name.as_bytes())); + self.defined_symbols.insert(name.to_string()); + self.symbol_defs.push(text); + true + } + + /// Every symbol of the document defined, in its order (those its art uses before it): the + /// Symbols panel keeps the ones no instance uses too, and on a large canvas all of them. + pub(in crate::ai_editing) fn symbols(&mut self) { + let doc = self.symbol_doc; + for s in &doc.symbols { + self.define_symbol(&s.name); + } + } + + /// The symbol definitions, in the setup after the patterns: `%AI5_Begin_NonPrinting`, `Np`, each + /// definition, `%AI5_End_NonPrinting--`. + pub(in crate::ai_editing) fn symbol_definitions(&self, s: &mut String) { + if self.symbol_defs.is_empty() { + return; + } + s.push_str("%AI5_Begin_NonPrinting\nNp\n"); + for d in &self.symbol_defs { + s.push_str(d); + } + s.push_str("%AI5_End_NonPrinting--\n"); + } + + /// The symbol list, after the palette: the names of the symbols defined in the document's order + /// (the Symbols panel's), between `%AI24_BeginSymbolList` and `%AI24_EndSymbolList` in a + /// non-printing block. + pub(in crate::ai_editing) fn symbol_list(&self, s: &mut String) { + if self.symbol_defs.is_empty() { + return; + } + s.push_str("%AI5_Begin_NonPrinting\nNp\n%AI24_BeginSymbolList\n"); + for sym in &self.symbol_doc.symbols { + if self.defined_symbols.contains(&sym.name) { + let _ = writeln!(s, "{}", ps::bytes(sym.name.as_bytes())); + } + } + s.push_str("%AI24_EndSymbolList\n%AI5_End_NonPrinting--\n"); + } +} diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index e0110f3de..87f1505d6 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -70,7 +70,7 @@ use graphics::{GState, Out}; use interp::{Fault, Interp}; use obj::PsError; -pub(crate) use ai::MAX_GRADIENT_STOPS; +pub(crate) use ai::{MAX_GRADIENT_STOPS, MAX_SYMBOLS, symbol_art_member}; pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES}; pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, is_loss, layered_ai, layered_ai_from}; pub(crate) use native::{MAX_TEXT, scale_document}; diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index 21c6a62aa..c1fd84db3 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -41,7 +41,7 @@ pub use read::{ BindingRef, Brushes, IMAGE_TOO_LARGE, PaletteSwatches, Structure, artboards, bindings_note, brushes, graphic_styles, palette, read, read_beside, variables, }; -pub(crate) use read::{join_and, slot_frame, slot_of}; +pub(crate) use read::{MAX_SYMBOLS, join_and, slot_frame, slot_of, symbol_art_member}; pub(crate) use read::is_look_slot; diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 025ccc480..41479444a 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -145,6 +145,10 @@ pub(super) use types::{blend_mode, blend_name}; #[path = "legacy_text.rs"] mod legacy_text; +// The `.ai` writer defines symbols as this reader keeps them. +pub(crate) use directives::symbol_art_member; +pub(crate) use types::MAX_SYMBOLS; + /// What the editing data holds: the document of its art, and where its art space is. #[derive(Debug)] pub struct Structure { diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index 17f843d9a..aac869fc7 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -41,7 +41,7 @@ pub(crate) const FLIP: Affine = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]); pub(super) const MAX_PATTERNS: usize = 4096; /// Most symbol definitions read (instances of others come in as the page draws them). -pub(super) const MAX_SYMBOLS: usize = 4096; +pub(crate) const MAX_SYMBOLS: usize = 4096; /// Most names a symbol list orders. pub(super) const MAX_LISTED: usize = 4 * MAX_SYMBOLS; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f60d46f62..aeecaa140 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -18,7 +18,7 @@ from the public specifications, and users' own `.ai` files are used locally only | | Illustrator | VectorCraft | Notes | |---|---:|---:|---| | Formats opened or placed | ~30 | 29 | missing: FreeHand, CorelDRAW, DWG, HEIC/HEIF, PICT, PS | -| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, gradients, patterns, images, type, artboards, transparency, opacity masks, clipping) or as legacy version 7 (layers and groups, one artboard per file) | +| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, gradients, patterns, images, type, symbols, artboards, transparency, opacity masks, clipping) or as legacy version 7 (layers and groups, one artboard per file) | | Formats exported | ~16 | 17 | missing: DWG, 3D (OBJ/USDA/glTF); extra: GIF/PNG-8 palette export, DXF up to 2018, layered PSD | | **Overall (weighted by use)** | | **~75%** | `.ai` and PDF dominate the weighting | @@ -75,15 +75,26 @@ private editing data. VectorCraft: RGB or CMYK ink amounts, and their alpha channel) and linked images as placed files with their path, their folder relative to the document's and a preview; images that can't be decoded or have more than 33,554,432 pixels are written as their outlines filled mid gray, images of 16 bits a channel at 8 bits, and linked images in opacity - masks and pattern tiles as embedded images, each with a warning. Point type, area type (with its rows, columns, + masks, pattern tiles and symbols as embedded images, each with a warning. Every symbol is written as a symbol + definition (its art, its registration point and its place in the Symbols panel's order, nested symbols included) + and its instances as symbol instances with their matrix, name, hide, lock, opacity, blending mode and opacity + mask; instances with a fill, appearance changes, isolation, knockout or knockout shape of their own, instances of + symbols with 9-slice guides or pattern paint, instances in pattern tiles or in art cut to a clipping object, + instances scaled almost flat, instances that turn, mirror or scale symbols holding type, live objects, + instances written as their art, shapes with rounded live corners or strokes written as outlines or brush art, + instances that scale their symbol more than 100 times and instances on the layers of a large canvas are written as their art, pattern fills in a + symbol's art are written as their tiles cut to the shape, symbols past 4,096 are written only as their + instances' art, and symbols' 9-slice guides, export types and dynamic type are left out, each with a + warning. + Point type, area type (with its rows, columns, gutters and first baseline, threaded frames included) and type on a path are written as text: a text object for each, naming its story in the text document, with the characters, fonts, sizes, leading, tracking, baseline shift, scales, solid fills and strokes, capitals and paragraph alignment, indents, spacing and direction; other character - and paragraph attributes are left out with a warning. Type in opacity masks, pattern tiles and art cut to a + and paragraph attributes are left out with a warning. Type in opacity masks, pattern tiles, symbols and art cut to a clipping object, mirrored type, turned or unevenly scaled area type and type on a path, area type in an open frame, type on an open path whose ends meet, area type that flows around a text wrap, type of only white space, threads of type on a path, threads whose frames differ in orientation, type and stories past the sizes VectorCraft's reader reads, and - type with inline graphics, ruby, gradient or pattern paint or an appearance or opacity mask of its own, other patterns, symbols, other clipping objects and live + type with inline graphics, ruby, gradient or pattern paint or an appearance or opacity mask of its own, other patterns, other clipping objects and live objects (placed documents included) are written as plain filled and stroked paths, and freeform gradients, gradients along or across a stroke and gradients without stops, with more than 256 stops or without extent as their average color, each with a warning. Gradients that dither or mix their colors perceptually are written as classic diff --git a/docs/gaps.md b/docs/gaps.md index 4dac9431e..fd7927481 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -203,7 +203,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall of 14), open speed on large files (#758), live effects, Scatter, Art, Pattern and Bristle brushes on paths, a path's own Calligraphic brush settings, and symbols as live objects (#637; plain art styles read Drop Shadow, Outer Glow and Feather as live effects; pattern fills and global colours are read since #1025, placed (linked) files since #1264). A PDF-compatible `.ai` carries an editing copy of the art in the form VectorCraft's reader reads (layers, groups, paths, colors, gradients, patterns, images, transparency, opacity masks, - clipping and type as text), with live effects left out and symbols and live objects written as their art + clipping, type as text and symbols), with live effects left out and live objects written as their art ([file-format-parity.md](file-format-parity.md)). Save As `.ai` › Version: Legacy (version 7) writes the PostScript form from the published version 7 specification, keeping layers (names, order, visibility, lock), groups, compound paths, clipping groups, gradients and diff --git a/docs/roadmap.md b/docs/roadmap.md index c4246c711..002b3a525 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -89,7 +89,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V | Set type: point and area type, Character and Paragraph styles, threading, outlines | yes | type core 80%; styles, threading, Create Outlines and font matching tested ([type-parity.md](type-parity.md)); justification options missing (not blocking) | 0 | | Organise layers and artboards and export assets (Export for Screens, SVG, PNG, PDF per artboard) | yes | Layers panel rework, Artboards panel and tool, Export for Screens and Asset Export done; Art Optimized supersample closes ghost borders (#983 / #1037) | 0 | | Print production: CMYK and spot colour, bleed and marks, PDF/X out, separations preview | yes | PDF/X-1a/3/4, presets, marks and bleed, ICC output intent, Separations Preview, Print with PostScript output | 0 | -| Exchange files with Illustrator users: open their `.ai`/EPS/PDF, hand back a file they can open | partial (doesn't block alpha) | `.ai`, EPS and PDF open as editable art, but real files can get the wrong layer structure (#951); Save As `.ai` is PDF-compatible with an editing copy of the art (layers, groups, paths, colors, gradients, patterns, images, transparency, opacity masks, clipping and type as text, with live effects left out and symbols and live objects written as their art; #868), or legacy version 7, which keeps layers, groups and type as text, one artboard per file, and waits for users' testing. EPS legacy mesh corners, vector opacity masks and raster print-page comparison now preserve editable art under synthetic regressions. This is the beta main-format gap ([gaps.md](gaps.md) G9) | 10–20 (for beta) | +| Exchange files with Illustrator users: open their `.ai`/EPS/PDF, hand back a file they can open | partial (doesn't block alpha) | `.ai`, EPS and PDF open as editable art, but real files can get the wrong layer structure (#951); Save As `.ai` is PDF-compatible with an editing copy of the art (layers, groups, paths, colors, gradients, patterns, images, transparency, opacity masks, clipping, type as text and symbols, with live effects left out and live objects written as their art; #868), or legacy version 7, which keeps layers, groups and type as text, one artboard per file, and waits for users' testing. EPS legacy mesh corners, vector opacity masks and raster print-page comparison now preserve editable art under synthetic regressions. This is the beta main-format gap ([gaps.md](gaps.md) G9) | 10–20 (for beta) | Hindi interface follow-up (2026-11-06): the catalog and zero/one plural rule are implemented; native wording review, layout checks and a web Devanagari font remain. [Notes](localization-hi.md). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0af2f0670..766dcfd5e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -201,7 +201,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), a saved `.ai`'s editing copy without live effects and with symbols and 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); 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 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); 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) | | 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) | | Guides, grids, smart guides, snapping, rulers | 3 | 80% | "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented; video rulers (View › Rulers › Show Video Rulers, #2849) done, score unchanged | @@ -282,8 +282,8 @@ The discounts, with the evidence: ones are about structure, not missing art: layers flattened or multiplied (#951: 109 layers instead of 14; #868), guides lost (#779), slower opening (#758), live effects, brushes and symbols as their drawn look (#637). VectorCraft's PDF-compatible `.ai` carries an editing copy of the art (layers, groups, paths, colors, - gradients, patterns, images, transparency, opacity masks, clipping and type as text), with live effects left out - and symbols and live objects written as their art; the legacy `.ai` of version 7 it also writes keeps layers, groups and type as text (one + gradients, patterns, images, transparency, opacity masks, clipping, type as text and symbols), with live effects left out + and live objects written as their art; the legacy `.ai` of version 7 it also writes keeps layers, groups and type as text (one artboard per file, no transparency) but hasn't been tested by users yet (#868). That blocks beta (the main-format rule) for people who hand files back to Illustrator users, but it doesn't stop someone opening and reworking their own archive. - **Interaction fidelity (×0.9).** Pen, Direct Selection, smart guides and snapping are deep @@ -1218,6 +1218,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur At a precision of 9 decimals or fewer, SVG export writes each number from an integer when the integer rounds it as the decimal formatter does; the formatter still writes values halfway between two written numbers and values too large for the integer. Exporting one path of 200,000 anchors takes a median of 43.4 ms instead of 167.0 ms on Windows 11, and 20,000 rectangles 81.4 ms instead of 141.4 ms, with the same SVG (`crates/svg/src/lib.rs`). SVG import reads each `` and `` as the ellipse VectorCraft draws: four smooth anchors on the axes, starting at the right, with handles of the standard length, so an ellipse exported as `` comes back with the anchors it was written from and draws the same (`circles_and_ellipses_import_as_the_ellipse_drawn` in `crates/svg/tests/it/basic_shapes.rs`, `filled_ellipse_on_the_artboard_edge_survives_visually` in `prop_svg.rs`). SVG import keeps attributes in other applications' XML namespaces (`plotter:pen="2"`) on the objects and layers they belong to, as it keeps their `data-*` attributes: copies, duplicates and `.vectorcraft` files keep them, and SVG export writes them back with each namespace declared once on the root (the rest of #522; `crates/svg/tests/it/foreign_attributes.rs`, `tests/attributes.rs`). + A PDF-compatible `.ai` saved with Include Editing Data for Other Apps defines every symbol in its editing copy with its art, registration point and place in the Symbols panel's order, and writes its instances as symbol instances, which VectorCraft's `.ai` reader opens as symbols and instances again (`symbols_and_their_instances_are_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the instances written as their art). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes point type, area type and type on a path as type in its editing copy, and objects next to it keep their own transparency (`crates/eps/src/ai_editing/tests.rs`, `cmd::fileio::tests_pdfedit::type_comes_back_from_the_editing_copy`; [file-format-parity.md](file-format-parity.md) lists what is written as outlines). In a `.ai` or EPS opened from its editing data, type has its text object's opacity, blend mode and knockout shape flag, whether it is made from the file's text document or taken from the page, and type made from the text document takes its text object's lock. Where such type has transparency and comes in with other art (the rest of its look, or page type and strokes that go with it), a group of them has it. Where the page draws its type, or the strokes around it, with transparency that is neither its text object's nor its text object's combined with that of the objects around it, the page's type and strokes keep their own instead (`crates/eps/src/import/tests_native.rs`). Where the page draws type of no text object over text objects made from the text document, with their words in any case, that type is theirs as the page draws it and is left out; the page's other type of no text object goes only to text objects whose type the text document can't make, or else is left out with a warning. `.ai` editing-data import restores the document's listed graphic styles in panel order and links objects that use a listed style; unsupported appearances keep their drawn look (`crates/eps/src/import/ai/tests.rs`). From 6c285d30429588852b341e1843a3bbede8f5c04f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 15:02:56 -0800 Subject: [PATCH 058/140] .ai save: the editing copy keeps live effects The editing copy of a PDF-compatible .ai wrote an object's live effects as their result (geometry effects) or left them out (the others). Objects with effects the .ai reader reads are now written as art styles: - An object is written as its drawn look (a group of its vector art as its geometry effects leave it, without shadows, glows or feathers, with the object's transparency after it), then the object itself on %_ lines (its points painted with nothing, or with its gradient fill placed on the outline it draws; a group with its members), its name and opacity mask, and 1 (style) XW naming its style. - Styles are written in a non-printing block after the symbols, in %AI9_BeginArtStyles: a chain of the stack of fills and strokes (with the contents where the appearance puts them), the effects and the object's transparency. Objects of the same appearance share a style. - The effects written are the ones the reader reads on objects, with their settings in the units it reads them in: Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Convert to Shape, Offset Path, Outline Object, Outline Stroke and Pathfinder > Add. - Each style is read back before it is written, by a new reader function, read_art_style, which runs the reader's style parser and its reading of a graphic style with the object's own gradient. An object whose style reads back differently (several fills or strokes, fills or strokes with transparency or effects of their own, pattern fills, other effects), a filled object that fills by the even-odd rule (a style's fill fills by the non-zero rule), a group whose effects change its members' outlines while a member is written on another outline (strokes as filled outlines, pattern fills as tiles, gradients that fit their object), or one in a container whose own effects change its members, is written as its drawn look with the warning, and a style that is not written leaves no gradient definition behind. Graphic styles and links to them are left out with a warning. No art style is written on a large canvas. Appearance attributes: https://helpx.adobe.com/illustrator/using/appearance-attributes.html Tests: the round trip of objects with each effect, a gradient fill, a group with transparency and a mask, Pathfinder > Add and a hidden effect; the same bytes on a second write; the forms written; the objects the reader would not read; a large canvas; the warning for graphic styles; filled even-odd objects; a gradient placed on the outline Offset Path draws; a style that is not written; effects in a pattern a drawn look uses; the reader's check of a style; fuzz cases for damaged written styles. Docs: docs/file-format-parity.md. --- crates/engine/src/cmd/fileio/tests_pdfedit.rs | 2 +- crates/engine/tests/import_fuzz.rs | 85 ++ crates/eps/src/ai_editing.rs | 32 +- crates/eps/src/ai_editing/tests.rs | 1166 +++++++++++++++++ crates/eps/src/ai_editing/writer.rs | 30 + crates/eps/src/ai_editing/writer/paints.rs | 56 +- crates/eps/src/ai_editing/writer/styles.rs | 685 ++++++++++ crates/eps/src/import.rs | 2 +- crates/eps/src/import/ai.rs | 2 +- crates/eps/src/import/ai/read.rs | 1 + crates/eps/src/import/ai/read/ops.rs | 31 + crates/eps/src/import/ai/style/tests.rs | 29 + docs/file-format-parity.md | 13 +- 13 files changed, 2112 insertions(+), 22 deletions(-) create mode 100644 crates/eps/src/ai_editing/writer/styles.rs diff --git a/crates/engine/src/cmd/fileio/tests_pdfedit.rs b/crates/engine/src/cmd/fileio/tests_pdfedit.rs index 605f82967..a38e35e82 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfedit.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfedit.rs @@ -488,7 +488,7 @@ fn an_ai_file_opens_from_its_vectorcraft_document_when_the_editing_copy_differs( let mut s = Session::new(); s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap(); - s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id]})).unwrap(); + s.execute("effect.apply", &json!({"effect": "blur.gaussian", "ids": [id]})).unwrap(); let r = s.execute("document.export", &json!({"format": "ai"})).unwrap(); assert!(r["warnings"].to_string().contains("live effects"), "{r}"); let bytes = b64(&r); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index f1a1f62c0..684cf3bfa 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3691,6 +3691,91 @@ proptest! { survive("mutated .ai symbols", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; survive("mutated .ai symbols, trusted", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?; } + + /// A `.ai` whose art styles, as the writer writes them, are damaged: the styles, the style + /// list, a styled object's drawn look, the object on `%_` lines and its `1 (style) XW`, cut + /// short or edited. + #[test] + fn ai_written_styles_never_panic(cut in 0usize..STYLES_FUZZ_CHARS, edits in prop::collection::vec((0usize..STYLES_FUZZ_CHARS, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', ',', '%', '_', 'u', 'U', 'X', 'W'])), 0..12)) { + let text = mutate_text(styles_text(), cut, &edits); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat(); + survive("mutated .ai art styles", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; + survive("mutated .ai art styles, trusted", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?; + } +} + +/// The range of [`ai_written_styles_never_panic`]'s cuts and edits: past the end of [`styles_text`]. +const STYLES_FUZZ_CHARS: usize = 64_000; + +/// The editing data VectorCraft writes for a document with a drop shadow on a path, a gradient fill +/// and a width profile on others, a stroked even-odd path with an opacity mask and Pathfinder › Add +/// on a group. +fn styles_text() -> &'static str { + static TEXT: std::sync::OnceLock = std::sync::OnceLock::new(); + TEXT.get_or_init(|| { + use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; + use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeKind, OpacityMask, StrokeLayer, WidthProfile}; + use vectorcraft_geom::{FillRule, Rect, shapes}; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let fx = |id: &str| vectorcraft_render::effects::new_effect(id, &json!({})).unwrap(); + let mut ap = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::WHITE), 2.0); + ap.effects = vec![fx("stylize.dropShadow")]; + let shaded = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap.clone()); + doc.insert(Some(layer), usize::MAX, shaded).unwrap(); + let ramp = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)]); + let mut graded = Appearance::basic(Paint::Gradient(Box::new(GradientPaint::new(ramp))), Paint::None, 0.0); + graded.effects = vec![fx("stylize.outerGlow")]; + let n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 50.0, 50.0, 90.0)), graded); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let mut tapered = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0); + tapered.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 0.5), (1.0, 0.0, 1.0)] }); + let profiled = Appearance { items: vec![AppearanceItem::Stroke(tapered)], effects: vec![fx("stylize.roundCorners")], contents_index: None }; + let n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(100.0, 10.0, 150.0, 40.0)), profiled); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let mut stroked = Appearance::basic(Paint::None, Paint::solid(Color::WHITE), 2.0); + stroked.effects = vec![fx("stylize.dropShadow")]; + let mut odd = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(100.0, 50.0, 150.0, 90.0)), stroked); + if let NodeKind::Path { rule, .. } = &mut odd.kind { + *rule = FillRule::EvenOdd; + } + let mask_art = Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(100.0, 50.0, 150.0, 70.0)), + Appearance::basic(Paint::solid(Color::gray(0.0)), Paint::None, 0.0), + ); + odd.extras_mut().mask = Some(Box::new(OpacityMask::new(mask_art, false))); + doc.insert(Some(layer), usize::MAX, odd).unwrap(); + let square = |doc: &mut Document, x: f64| { + Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(x, 50.0, x + 30.0, 80.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ) + }; + let (a, b) = (square(&mut doc, 60.0), square(&mut doc, 80.0)); + let mut merged = Node::group(doc.alloc_id(), vec![std::sync::Arc::new(a), std::sync::Arc::new(b)]); + merged.appearance = Appearance { effects: vec![fx("pathfinder.add")], ..Appearance::default() }.into(); + doc.insert(Some(layer), usize::MAX, merged).unwrap(); + let out = vectorcraft_eps::export_ai_editing(&doc, &vectorcraft_eps::AiEditingOptions::default()).unwrap(); + String::from_utf8(out.program).unwrap() + }) +} + +/// The data [`ai_written_styles_never_panic`] damages, as it is, opens with its styles live, and the +/// property's cuts and edits reach all of it. +#[test] +fn the_written_styles_fuzz_data_opens_live() { + let text = styles_text(); + assert!(text.chars().count() < STYLES_FUZZ_CHARS, "{}", text.chars().count()); + for mark in ["%AI9_BeginArtStyles", "%AI5_BeginLayer", "\n6 () XW", "\n1 (Anon", "(BSLeftWidthsArray)", "0 /Bool (UseEvenOdd)", "Bb"] { + assert!(text.contains(mark), "{mark}"); + } + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(text)].concat(); + let (d, warnings, layered) = vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0); + assert!(layered, "{warnings:?}"); + let art = d.layers[0].children().unwrap(); + assert_eq!(art.iter().map(|n| n.appearance.effects.len()).collect::>(), [1, 1, 1, 1, 1]); } /// Editing data with symbols (S2's art holds an instance of S1, F's holds non-native art), the diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 88b9f0fe2..06a3f2935 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -100,6 +100,17 @@ //! instance with isolation, knockout or knockout shape of its own is written as its art). The //! definitions are at most the reader's 4,096. In a symbol's art, an instance's matrix and //! a gradient's take the art to the canvas's space turned y up about art space's origin there. +//! - Art styles, as the reader reads them (`style.rs`, `read/ops.rs`): a path, compound path or +//! group with live effects is written as its drawn look (`u`, its vector art as its geometry +//! effects leave it, without shadows, glows or feathers, `U`, its transparency, `0 0 Xd`, +//! `6 () XW`), then the object itself on `%_` lines (its points painted +//! with nothing, or with its gradient fill; a group with its members) with its name and opacity +//! mask, then `1 (style) XW`. The styles are written in a non-printing block after the symbols +//! (`%AI9_BeginArtStyles`, each `/KnownStyle :` with its name and a filter tree: a chain of the +//! stack of fills and strokes with the contents among them, the effects and the blend filter). A +//! fill filter fills by the non-zero rule (`0 /Bool (UseEvenOdd)`), so a filled object that fills by +//! the even-odd rule is written as its drawn look. A style is written only when the reader reads it +//! back as the object's appearance. //! //! Lines end in CR LF. Strings are written in UTF-8. Art space is ruler space: y up, its origin at //! the first artboard's top-left corner. On the canvas ([`CANVAS_SIZE`] points square, y down) the @@ -133,7 +144,9 @@ //! as their filled outlines, several fills or strokes as a group of a path for each, freeform //! gradient swatches, gradient swatches of more than 256 stops, swatches of patterns written as //! tiles and the placement of pattern swatches that move, turn or scale their pattern, the opacity and blending modes of single fills and strokes, opacity masks on -//! layers and opacity masks nested too deep, and live effects other than geometry effects left out. +//! layers and opacity masks nested too deep, and, on objects written without an art style, live +//! effects other than geometry effects left out; graphic styles, links to them and every art style +//! on a large canvas are left out. //! //! The data streams hold the program compressed with Zstandard after `%AI24_ZStandard_Data`, with //! the version numbers of the newest files users sent ([`private_data`]). @@ -210,11 +223,15 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result 0.0 && board.height() > 0.0) { return Err("the document has no artboard".into()); } + // Artboards that don't fit on the canvas are written on a large canvas, at 1/scale. + let scale = canvas_scale(doc); + // The objects that may keep their effects in an art style, as they are before baking (not on + // a large canvas, whose scale the styles' strokes and effects would need). + let styled = writer::styled_objects(doc); + let graphic_styles = writer::has_graphic_styles(doc); // Live geometry effects as their result, as the PDF part draws them. let baked = baked(doc); let doc = baked.as_ref().unwrap_or(doc); - // Artboards that don't fit on the canvas are written on a large canvas, at 1/scale. - let scale = canvas_scale(doc); let reduced = (scale != 1.0).then(|| { let mut d = doc.clone(); crate::import::scale_document(&mut d, 1.0 / scale, true); @@ -243,6 +260,14 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result Result vectorcraft_doc::Effect { + vectorcraft_effects::new_effect(id, ¶ms).unwrap() +} + +/// A path `name` over `r` filled with `fill` and stroked 2 pt in `stroke` (none when `Paint::None`), +/// with `effects`. +fn styled_rect(doc: &mut Document, name: &str, r: Rect, fill: Paint, stroke: Paint, effects: Vec) -> Node { + let mut ap = Appearance::basic(fill, stroke, 2.0); + ap.effects = effects; + named(Node::path(doc.alloc_id(), shapes::rectangle(r), ap), name) +} + +/// A 400 × 300 document whose objects keep their live effects as art styles: a drop shadow in a +/// color and one by darkness, an inner and an outer glow in that order, Feather, Round Corners, +/// Scribble, Convert to Shape, Offset Path, Outline Stroke on an open path, a drop shadow on a +/// gradient fill and on a group with transparency, a hidden effect, Pathfinder › Add on a group, a +/// hidden locked object, an opacity mask, an object linked to a graphic style; and a graphic style +/// of its own with two fills, a stroke, item transparency and effects. +fn styles_document() -> Document { + let mut doc = Document::new(400.0, 300.0); + let layer = doc.layers[0].id; + let add = |doc: &mut Document, n: Node| { + doc.insert(Some(layer), usize::MAX, n).unwrap(); + }; + let red = solid(Color::rgb(0.9, 0.2, 0.1)); + let blue = solid(Color::rgb(0.1, 0.3, 0.8)); + let shadow = fx("stylize.dropShadow", serde_json::json!({"x": 6.0, "y": 4.0, "blur": 8.0, "opacity": 60.0, "color": "#203040"})); + let n = styled_rect(&mut doc, "Shadowed", Rect::new(10.0, 10.0, 60.0, 50.0), red.clone(), blue.clone(), vec![shadow.clone()]); + add(&mut doc, n); + let dark = fx("stylize.dropShadow", serde_json::json!({"shade": "darkness", "darkness": 40.0, "mode": "darken"})); + let n = styled_rect(&mut doc, "Darkened", Rect::new(70.0, 10.0, 120.0, 50.0), blue.clone(), Paint::None, vec![dark]); + add(&mut doc, n); + let glows = vec![ + fx("stylize.innerGlow", serde_json::json!({"source": "center", "blur": 3.0})), + fx("stylize.outerGlow", serde_json::json!({"blur": 9.0, "opacity": 50.0})), + ]; + let n = styled_rect(&mut doc, "Glowing", Rect::new(130.0, 10.0, 180.0, 50.0), red.clone(), Paint::None, glows); + add(&mut doc, n); + let n = styled_rect( + &mut doc, + "Soft", + Rect::new(190.0, 10.0, 240.0, 50.0), + blue.clone(), + Paint::None, + vec![fx("stylize.feather", serde_json::json!({"radius": 7.0}))], + ); + add(&mut doc, n); + let round = fx("stylize.roundCorners", serde_json::json!({"radius": 9.0})); + let n = styled_rect(&mut doc, "Rounded", Rect::new(250.0, 10.0, 300.0, 50.0), red.clone(), blue.clone(), vec![round]); + add(&mut doc, n); + let n = styled_rect( + &mut doc, + "Scribbled", + Rect::new(310.0, 10.0, 360.0, 50.0), + blue.clone(), + Paint::None, + vec![fx("stylize.scribble", serde_json::json!({}))], + ); + add(&mut doc, n); + let shape = fx("convertToShape.roundedRectangle", serde_json::json!({"relative": true, "extraW": 12.0, "extraH": 8.0, "radius": 5.0})); + let n = styled_rect(&mut doc, "Shaped", Rect::new(10.0, 70.0, 60.0, 110.0), red.clone(), Paint::None, vec![shape]); + add(&mut doc, n); + let offset = fx("path.offsetPath", serde_json::json!({"offset": 4.0, "joins": "miter", "miterLimit": 6.0})); + let n = styled_rect(&mut doc, "Offset", Rect::new(70.0, 70.0, 120.0, 110.0), blue.clone(), red.clone(), vec![offset]); + add(&mut doc, n); + let mut open = PathData::from_bezpath(&kurbo::BezPath::from_vec(vec![ + kurbo::PathEl::MoveTo((130.0, 110.0).into()), + kurbo::PathEl::LineTo((180.0, 70.0).into()), + ])); + open.subpaths.iter_mut().for_each(|sp| sp.closed = false); + let mut ap = Appearance::basic(Paint::None, red.clone(), 6.0); + ap.effects = vec![fx("path.outlineStroke", serde_json::json!({}))]; + let n = named(Node::path(doc.alloc_id(), open, ap), "Outlined"); + add(&mut doc, n); + let ramp = Gradient { + kind: GradientKind::Linear, + stops: vec![GradientStop::new(0.0, Color::rgb(1.0, 1.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.6, 0.2))], + ..Gradient::default() + }; + let graded = Paint::Gradient(Box::new(GradientPaint::new(ramp))); + let n = styled_rect(&mut doc, "Graded", Rect::new(190.0, 70.0, 240.0, 110.0), graded, Paint::None, vec![shadow.clone()]); + add(&mut doc, n); + let mut hidden_fx = fx("stylize.outerGlow", serde_json::json!({})); + hidden_fx.visible = false; + let mut quiet = styled_rect(&mut doc, "Quiet", Rect::new(250.0, 70.0, 300.0, 110.0), blue.clone(), Paint::None, vec![hidden_fx]); + quiet.visible = false; + quiet.locked = true; + add(&mut doc, quiet); + // A group with a shadow, transparency and a mask; a group merged by Pathfinder › Add. + let a = square(&mut doc, Rect::new(10.0, 130.0, 50.0, 170.0), Color::rgb(0.2, 0.6, 0.3)); + let b = square(&mut doc, Rect::new(30.0, 150.0, 70.0, 190.0), Color::rgb(0.6, 0.2, 0.5)); + let mut shaded = see_through(group(&mut doc, vec![a, b], false), 0.5, BlendMode::Multiply); + shaded.appearance = Appearance { effects: vec![shadow.clone()], ..Appearance::default() }.into(); + let mask_art = square(&mut doc, Rect::new(10.0, 130.0, 70.0, 160.0), Color::gray(0.0)); + shaded.extras_mut().mask = Some(OpacityMask { art: Arc::new(mask_art), clip: true, invert: false, disabled: false, linked: true }.into()); + add(&mut doc, named(shaded, "Shaded group")); + let a = square(&mut doc, Rect::new(90.0, 130.0, 130.0, 170.0), Color::rgb(0.9, 0.6, 0.1)); + let b = square(&mut doc, Rect::new(110.0, 150.0, 150.0, 190.0), Color::rgb(0.9, 0.6, 0.1)); + let mut merged = group(&mut doc, vec![a, b], false); + merged.appearance = Appearance { effects: vec![fx("pathfinder.add", serde_json::json!({}))], ..Appearance::default() }.into(); + add(&mut doc, named(merged, "Merged")); + doc +} + +/// Each object's name, kind, transparency, state, mask and appearance, and whether it is linked to a +/// graphic style (by name), compared with [`writer::same_appearance`] apart. +fn style_view(doc: &Document) -> Vec<(String, Appearance)> { + let mut out = vec![]; + for n in doc.layers.iter().flat_map(|l| l.children().cloned().unwrap_or_default()) { + let kind = match &n.kind { + NodeKind::Group { .. } => "group", + NodeKind::Path { .. } => "path", + NodeKind::Compound { .. } => "compound", + _ => "other", + }; + let link = n.extras().graphic_style.and_then(|id| doc.graphic_style_by_id(id)).map(|g| g.name.clone()); + let head = format!( + "{:?} {kind} {} {:?} isolate={} {:?} shown={} locked={} mask={} link={link:?} members={}", + n.name, + r3(f64::from(n.opacity)), + n.blend, + n.isolate, + n.knockout, + n.visible, + n.locked, + n.extras().mask.is_some(), + n.children().map_or(0, Vec::len) + ); + out.push((head, (*n.appearance).clone())); + } + out +} + +/// `ap` with its gradients' stops and kind only: their placement is the slice of gradients' (a +/// gradient comes back placed on the object, named after its definition). +fn unplaced(ap: &Appearance) -> Appearance { + let mut ap = ap.clone(); + for item in ap.items.iter_mut() { + let paint = match item { + AppearanceItem::Fill(f) => &mut f.paint, + AppearanceItem::Stroke(s) => &mut s.paint, + }; + if let Paint::Gradient(g) = paint { + (g.geom, g.swatch, g.angle) = (None, None, 0.0); + } + } + ap +} + +fn same_styles(got: &Document, want: &Document) { + let (g, w) = (style_view(got), style_view(want)); + same_lines(&g.iter().map(|(h, _)| h.clone()).collect::>(), &w.iter().map(|(h, _)| h.clone()).collect::>()); + for ((h, a), (_, b)) in g.iter().zip(&w) { + assert!(writer::same_appearance(&unplaced(a), &unplaced(b)), "{h}:\n got {a:?}\n want {b:?}"); + } + same_lines(&outlines(got), &outlines(want)); +} + +/// The points of `doc`'s objects and of their opacity masks, each path's fill rule and each +/// object's nesting, to the written precision. +fn outlines(doc: &Document) -> Vec { + fn walk(n: &Node, depth: usize, out: &mut Vec) { + let mut points = |path: &PathData, rule: Option| { + let mut s = format!("{depth} {:?} {rule:?}", n.name); + for sp in &path.subpaths { + s += &format!(" [closed={}", sp.closed); + for a in &sp.anchors { + s += &format!(" {:.2},{:.2}", a.p.x + 0.0, a.p.y + 0.0); + } + s += "]"; + } + out.push(s); + }; + match &n.kind { + NodeKind::Path { path, rule, .. } => points(path, Some(*rule)), + NodeKind::Compound { rule, children } => { + for c in children { + points(c.path_data().unwrap(), Some(*rule)); + } + } + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => { + out.push(format!("{depth} {:?} container", n.name)); + for c in children { + walk(c, depth + 1, out); + } + } + other => out.push(format!("{depth} {other:?}")), + } + if let Some(mask) = &n.extras().mask { + out.push(format!("{depth} mask clip={} invert={} disabled={} linked={}", mask.clip, mask.invert, mask.disabled, mask.linked)); + // The reader holds a mask's art in a group. + match &mask.art.kind { + NodeKind::Group { children, .. } if children.len() == 1 => walk(&children[0], depth + 1, out), + _ => walk(&mask.art, depth + 1, out), + } + } + } + let mut out = vec![]; + for l in doc.layers.iter() { + walk(l, 0, &mut out); + } + out +} + +/// Fails with the lines where `got` and `want` differ in more than their numbers' last written +/// decimal (a color read back from four decimals gives its other model's values again from them). +fn near_lines(got: &str, want: &str) { + let near = |a: &str, b: &str| { + let (a, b): (Vec<&str>, Vec<&str>) = (a.split(' ').collect(), b.split(' ').collect()); + a.len() == b.len() + && a.iter().zip(&b).all(|(x, y)| match (x.trim_start_matches("%_").parse::(), y.trim_start_matches("%_").parse::()) { + (Ok(x), Ok(y)) => (x - y).abs() <= 2e-3, + _ => x == y, + }) + }; + let (g, w): (Vec<&str>, Vec<&str>) = (got.lines().collect(), want.lines().collect()); + let differ: Vec = (0..g.len().max(w.len())) + .filter(|&i| !matches!((g.get(i), w.get(i)), (Some(a), Some(b)) if near(a, b))) + .map(|i| format!("line {i}:\n got {:?}\n want {:?}", g.get(i), w.get(i))) + .collect(); + assert!(differ.is_empty(), "{}", differ.join("\n")); +} + +#[test] +fn objects_with_live_effects_are_read_back() { + let doc = styles_document(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let back = read_back(&doc); + same_styles(&back, &doc); + // A group's members come back as they were. + let members = |d: &Document, name: &str| { + let mut out = vec![]; + let g = d.layers[0].children().unwrap().iter().find(|n| n.name.as_deref() == Some(name)).unwrap().clone(); + view(g.children().unwrap(), 0, &mut out); + out + }; + same_lines(&members(&back, "Shaded group"), &members(&doc, "Shaded group")); + same_lines(&members(&back, "Merged"), &members(&doc, "Merged")); +} + +#[test] +fn art_styles_are_written_in_their_forms() { + let p = program_shown(&styles_document()); + // The styles in a non-printing block after the symbols' and before the palette. + let styles = p.find("%AI5_Begin_NonPrinting\nNp\n%AI9_BeginArtStyles\n/KnownStyle :\n(Anon 1) /Name ,\n/ActiveStyle :\n").unwrap(); + assert!(styles < p.find("%AI5_BeginPalette").unwrap(), "{p}"); + assert!(!p.contains("%AI9_BeginArtStyleList"), "{p}"); + // A drop shadow on the object, after its stack and before its transparency. + assert!( + p.contains( + "/BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n1 /Int (blnd) ,\n0.6 /Real (opac) ,\n\ + 4 /Real (vert) ,\n6 /Real (horz) ,\n4 /Real (blur) ,\n/FillStyle : 0 O\n" + ), + "{p}" + ); + // The drawn look, its state block with the object's transparency, the object on `%_` lines + // painted with nothing, its name, and its style. + assert!(p.contains("U\n0 1 0 2 0 Xy\n0 0 Xd\n6 () XW\n%_0 XR\n%_0 1 0 0 0 Xy\n%_10 -10 m\n"), "{p}"); + assert!(p.contains("%_n\n%_/ArtDictionary :\n%_/XMLUID : (Shadowed) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Anon 1) XW\n"), "{p}"); + assert!(p.contains("%_n\n%_/ArtDictionary :\n%_/XMLUID : (Offset) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Anon "), "{p}"); + // A group's members on `%_` lines, then its name and style. + assert!(p.contains("6 () XW\n%_u\n"), "{p}"); + assert!(p.contains("%_U\n%_/ArtDictionary :\n%_/XMLUID : (Merged) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Anon "), "{p}"); + // Objects of the same appearance share their style. + let two = { + let mut doc = Document::new(200.0, 100.0); + let shadow = fx("stylize.dropShadow", serde_json::json!({})); + for x in [10.0, 60.0] { + let n = styled_rect(&mut doc, "Twin", Rect::new(x, 10.0, x + 30.0, 40.0), solid(Color::BLACK), Paint::None, vec![shadow.clone()]); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + program(&doc) + }; + assert_eq!((two.matches("/KnownStyle :").count(), two.matches("1 (Anon 1) XW\n").count()), (1, 2), "{two}"); + assert!(!two.contains("%AI9_BeginArtStyleList"), "{two}"); +} + +/// Objects whose appearance the reader wouldn't read with their style are written as their +/// drawn look, with the warnings they were written with before; and a document without effects has +/// no art styles. +#[test] +fn appearances_the_reader_wouldnt_read_are_their_drawn_look() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let shadow = fx("stylize.dropShadow", serde_json::json!({})); + // Two fills and an effect: the reader keeps such an object's drawn look. + let mut two = styled_rect(&mut doc, "Two fills", Rect::new(10.0, 10.0, 40.0, 40.0), solid(Color::BLACK), Paint::None, vec![shadow.clone()]); + let mut ap = (*two.appearance).clone(); + ap.items.push(AppearanceItem::Fill(FillLayer { opacity: 0.5, ..FillLayer::new(solid(Color::WHITE)) })); + two.appearance = ap.into(); + doc.insert(Some(layer), usize::MAX, two).unwrap(); + // A warp on a path: the reader keeps a path's drawn look for it. + let warped = styled_rect( + &mut doc, + "Warped", + Rect::new(50.0, 10.0, 80.0, 40.0), + solid(Color::BLACK), + Paint::None, + vec![fx("warp.arc", serde_json::json!({}))], + ); + doc.insert(Some(layer), usize::MAX, warped).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let p = String::from_utf8(out.program).unwrap(); + assert!(!p.contains("%AI9_BeginArtStyles") && !p.lines().any(|l| l.starts_with("1 (")), "{p}"); + let back = read_back(&doc); + assert!(back.layers[0].children().unwrap().iter().all(|n| n.appearance.effects.is_empty())); + // Nothing styled: no art styles. + assert!(!program(&sample()).contains("ArtStyle")); +} + +/// A compound shape draws as one area, which an art style's object wouldn't keep: it and a group +/// holding one are written as their drawn look, with a warning. +#[test] +fn compound_shapes_are_not_written_with_an_art_style() { + let shadow = || fx("stylize.dropShadow", serde_json::json!({})); + let shape = |doc: &mut Document| { + let a = square(doc, Rect::new(10.0, 10.0, 50.0, 50.0), Color::BLACK); + let b = square(doc, Rect::new(30.0, 30.0, 70.0, 70.0), Color::BLACK); + let mut g = group(doc, vec![a, b], false); + g.extras_mut().compound_shape = true; + g + }; + let mut own = Document::new(200.0, 100.0); + let mut n = shape(&mut own); + n.appearance = Appearance { effects: vec![shadow()], ..Appearance::default() }.into(); + let layer = own.layers[0].id; + own.insert(Some(layer), usize::MAX, n).unwrap(); + let mut nested = Document::new(200.0, 100.0); + let member = shape(&mut nested); + let mut n = group(&mut nested, vec![member], false); + n.appearance = Appearance { effects: vec![shadow()], ..Appearance::default() }.into(); + let layer = nested.layers[0].id; + nested.insert(Some(layer), usize::MAX, n).unwrap(); + for doc in [own, nested] { + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + assert!(!shown(&out.program).contains("1 (Anon 1) XW")); + } +} + +/// A member of a group whose Transform effect moves it is written where baking put it, not with +/// its own effects where it was. +#[test] +fn a_styled_member_of_a_moved_group_stays_where_it_draws() { + fn left_of_paths(n: &Node) -> f64 { + let own = n.path_data().map_or(f64::INFINITY, |p| kurbo::Shape::bounding_box(&p.to_bezpath()).x0); + n.children().map_or(own, |c| c.iter().map(|m| left_of_paths(m)).fold(own, f64::min)) + } + let mut doc = Document::new(300.0, 100.0); + let layer = doc.layers[0].id; + let shadow = fx("stylize.dropShadow", serde_json::json!({})); + let member = styled_rect(&mut doc, "Member", Rect::new(10.0, 10.0, 40.0, 40.0), solid(Color::BLACK), Paint::None, vec![shadow]); + let mut moved = group(&mut doc, vec![member], false); + moved.appearance = Appearance { effects: vec![fx("distort.transform", serde_json::json!({"moveH": 70.0}))], ..Appearance::default() }.into(); + doc.insert(Some(layer), usize::MAX, moved).unwrap(); + let back = read_back(&doc); + let left = left_of_paths(&back.layers[0]); + assert!((left - 80.0).abs() < 1e-3, "the art starts at x = {left}"); +} + +/// A hidden, locked styled group keeps its state when its members are shown and unlocked. +#[test] +fn a_styled_group_keeps_its_state_after_members_in_another() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let member = square(&mut doc, Rect::new(10.0, 10.0, 40.0, 40.0), Color::BLACK); + let mut shaded = group(&mut doc, vec![member], false); + shaded.appearance = Appearance { effects: vec![fx("stylize.dropShadow", serde_json::json!({}))], ..Appearance::default() }.into(); + shaded.visible = false; + shaded.locked = true; + doc.insert(Some(layer), usize::MAX, shaded).unwrap(); + let back = read_back(&doc); + let n = &back.layers[0].children().unwrap()[0]; + assert!(matches!(n.kind, NodeKind::Group { .. })); + assert_eq!(n.appearance.effects.len(), 1); + assert!(!n.visible && n.locked, "visible {} locked {}", n.visible, n.locked); +} + +/// Effects in a styled object's opacity mask are not its style's: leaving them out warns. +#[test] +fn effects_in_a_styled_objects_mask_are_warned_about() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let shadow = fx("stylize.dropShadow", serde_json::json!({})); + let mut n = styled_rect(&mut doc, "Masked", Rect::new(10.0, 10.0, 40.0, 40.0), solid(Color::BLACK), Paint::None, vec![shadow]); + let blurred = styled_rect( + &mut doc, + "Mask art", + Rect::new(10.0, 10.0, 40.0, 25.0), + solid(Color::gray(0.0)), + Paint::None, + vec![fx("blur.gaussian", serde_json::json!({}))], + ); + n.extras_mut().mask = Some(OpacityMask { art: Arc::new(blurred), clip: true, invert: false, disabled: false, linked: true }.into()); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + assert!(shown(&out.program).contains("\n1 (Anon 1) XW\n")); +} + +/// On a large canvas no art style is written (the reader scales the styles of a large canvas): +/// objects with effects are their drawn look, with a warning. +#[test] +fn a_large_canvas_writes_no_art_styles() { + let mut doc = Document::new(20000.0, 100.0); + let n = styled_rect( + &mut doc, + "Far", + Rect::new(10.0, 10.0, 40.0, 40.0), + solid(Color::BLACK), + Paint::None, + vec![fx("stylize.feather", serde_json::json!({}))], + ); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::LARGE_CANVAS_STYLES), "{:?}", out.warnings); + assert!(!String::from_utf8(out.program).unwrap().contains("ArtStyle")); +} + +#[test] +fn art_styles_write_the_same_bytes_again() { + let doc = styles_document(); + let first = export_ai_editing(&doc, &opts()).unwrap(); + assert!(shown(&first.program).contains("\n1 (Anon 1) XW\n")); + assert_eq!(first.program, export_ai_editing(&doc, &opts()).unwrap().program); + assert_eq!(private_data(&doc, &opts()).unwrap().0, private_data(&doc, &opts()).unwrap().0); + // What is read back is written as it was. + let again = export_ai_editing(&read_back(&doc), &opts()).unwrap().program; + near_lines(&shown(&again), &shown(&first.program)); +} + +/// `n` on the first layer of `doc`. +fn put(doc: &mut Document, n: Node) { + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, n).unwrap(); +} + +fn drop_shadow() -> vectorcraft_doc::Effect { + fx("stylize.dropShadow", serde_json::json!({})) +} + +fn black_fill() -> Paint { + solid(Color::BLACK) +} + +/// An even-odd compound path of a square inside a square, with `ap`. +fn ring(doc: &mut Document, ap: Appearance) -> Node { + let outer = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(70.0, 10.0, 110.0, 50.0)), Appearance::default()); + let inner = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(80.0, 20.0, 100.0, 40.0)), Appearance::default()); + let mut n = Node::new(doc.alloc_id(), NodeKind::Compound { children: vec![Arc::new(outer), Arc::new(inner)], rule: FillRule::EvenOdd }); + n.appearance = ap.into(); + n +} + +/// A path filled black with no stroke and `effects`. +fn plain_rect(doc: &mut Document, name: &str, effects: Vec) -> Node { + styled_rect(doc, name, Rect::new(10.0, 10.0, 50.0, 40.0), black_fill(), Paint::None, effects) +} + +/// A 200 × 100 document's first object as it reads back from a document of only `n`. +fn read_one(make: impl FnOnce(&mut Document) -> Node) -> (Arc, Vec, String) { + let mut doc = Document::new(200.0, 100.0); + let n = make(&mut doc); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + let back = read_back(&doc); + (back.layers[0].children().unwrap()[0].clone(), out.warnings, shown(&out.program)) +} + +/// A filled path or compound path with live effects that fills by the even-odd rule is written as its +/// drawn look with a warning, since a style's fill fills by the non-zero rule. A stroke-only object +/// keeps its style and its own fill rule. +#[test] +fn a_filled_even_odd_object_is_written_as_its_drawn_look() { + let mut doc = Document::new(300.0, 100.0); + let mut odd = plain_rect(&mut doc, "Odd", vec![drop_shadow()]); + if let NodeKind::Path { rule, .. } = &mut odd.kind { + *rule = FillRule::EvenOdd; + } + put(&mut doc, odd); + let ap = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(black_fill(), Paint::None, 0.0) }; + let n = named(ring(&mut doc, ap), "Ring"); + put(&mut doc, n); + let n = plain_rect(&mut doc, "Even", vec![drop_shadow()]); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let p = shown(&out.program); + assert_eq!((p.matches("/KnownStyle :").count(), p.matches("\n1 (Anon 1) XW\n").count()), (1, 1), "{p}"); + assert!(!p.contains("1 /Bool (UseEvenOdd)"), "{p}"); + + let mut doc = Document::new(300.0, 100.0); + let ap = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(Paint::None, solid(Color::BLACK), 2.0) }; + let n = named(ring(&mut doc, ap), "Stroked"); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let back = read_back(&doc); + same_styles(&back, &doc); + assert!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::Compound { rule: FillRule::EvenOdd, .. })); +} + +/// A compound path filled by the non-zero rule keeps its effects and its gradient fill. +#[test] +fn a_compound_path_keeps_its_style() { + let mut doc = Document::new(300.0, 100.0); + let non_zero = |mut n: Node| { + if let NodeKind::Compound { rule, .. } = &mut n.kind { + *rule = FillRule::NonZero; + } + n + }; + let ap = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(black_fill(), solid(Color::WHITE), 2.0) }; + let n = named(non_zero(ring(&mut doc, ap)), "Shadowed"); + put(&mut doc, n); + let stops = vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)]; + let graded = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let ap = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(graded, Paint::None, 0.0) }; + let n = named(non_zero(ring(&mut doc, ap)), "Graded"); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let back = read_back(&doc); + same_styles(&back, &doc); + assert!(back.layers[0].children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Compound { .. }))); +} + +/// A group holding an image is written as its drawn look: the image stays a placed file, once. +#[test] +fn a_group_holding_an_image_is_written_as_its_drawn_look() { + let mut doc = Document::new(200.0, 100.0); + let link = Some(vectorcraft_doc::LinkInfo::new("/tmp/ramp.png")); + let im = image_node(&mut doc, ImageBlob::png(rgb_png()), (3, 2), Affine::scale(10.0), link); + let mut g = group(&mut doc, vec![im], false); + g.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::default() }.into(); + put(&mut doc, g); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + assert!(!out.warnings.iter().any(|w| w == writer::LINKS_EMBEDDED), "{:?}", out.warnings); + assert!(!shown(&out.program).contains("(Anon 1)")); + let back = read_back(&doc); + let g = &back.layers[0].children().unwrap()[0]; + let member = g.children().map_or(g, |c| &c[0]); + let NodeKind::Image(im) = &member.kind else { panic!("{member:?}") }; + assert!(im.link.is_some()); +} + +/// Graphic styles and links to them are left out with a warning: an object linked to a style +/// keeps its effects in a style of its own. A new document's styles without a link don't warn. +#[test] +fn graphic_styles_and_links_are_left_out_with_a_warning() { + let warns = |doc: &Document| export_ai_editing(doc, &opts()).unwrap().warnings.iter().any(|w| w == writer::GRAPHIC_STYLES); + assert!(!warns(&Document::new(200.0, 100.0))); + // A style of the document's own. + let mut own = Document::new(200.0, 100.0); + own.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Mine", Appearance::basic(black_fill(), Paint::None, 0.0))); + assert!(warns(&own)); + assert!(!shown(&export_ai_editing(&own, &opts()).unwrap().program).contains("ArtStyle")); + assert_eq!(read_back(&own).graphic_style("Mine"), None); + // A link to a new document's style, on an object with an effect. + let mut linked = Document::new(200.0, 100.0); + let id = linked.graphic_style("Sunshine").unwrap().id; + let mut n = plain_rect(&mut linked, "Linked", vec![drop_shadow()]); + n.extras_mut().graphic_style = Some(id); + put(&mut linked, n); + assert!(warns(&linked)); + let back = read_back(&linked); + let n = &back.layers[0].children().unwrap()[0]; + assert_eq!(n.extras().graphic_style, None); + assert_eq!(n.appearance.effects.len(), 1); + // A link in a symbol's art. + let mut in_symbol = Document::new(200.0, 100.0); + let id = in_symbol.graphic_style("Sunshine").unwrap().id; + let mut art = square(&mut in_symbol, Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK); + art.extras_mut().graphic_style = Some(id); + in_symbol.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); + assert!(warns(&in_symbol)); +} + +/// A path or compound path with no points writes nothing, with or without a style; a group with no +/// members keeps its effects. +#[test] +fn objects_with_nothing_to_write_are_left_out() { + let mut doc = Document::new(200.0, 100.0); + let mut empty = plain_rect(&mut doc, "Empty", vec![drop_shadow()]); + if let NodeKind::Path { path, .. } = &mut empty.kind { + path.subpaths.clear(); + } + put(&mut doc, empty); + let mut hollow = Node::new(doc.alloc_id(), NodeKind::Compound { children: vec![], rule: FillRule::NonZero }); + hollow.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(black_fill(), Paint::None, 0.0) }.into(); + put(&mut doc, named(hollow, "Hollow")); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let p = shown(&out.program); + assert!(!p.contains("(Anon 1)") && !p.contains("(Empty)") && !p.contains("6 () XW"), "{p}"); + assert_eq!(read_back(&doc).layers[0].children().unwrap().len(), 0); + + let (n, ..) = read_one(|doc| { + let mut g = group(doc, vec![], false); + g.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::default() }.into(); + named(g, "Bare") + }); + assert_eq!((n.name.as_deref(), n.appearance.effects.len(), n.children().map(Vec::len)), (Some("Bare"), 1, Some(0))); +} + +/// Outline Object, every Convert to Shape form and size, Offset Path's joins, Inner Glow's sources +/// and a stroke's width profile, each on an object, read back as they are. Drop Shadow and the glows +/// keep their colors, in CMYK too. +#[test] +fn every_effect_setting_written_on_an_object_is_read_back() { + use serde_json::json; + let cases = [ + ("path.outlineObject", json!({})), + ("path.outlineStroke", json!({})), + ("convertToShape.rectangle", json!({"relative": true, "extraW": 6.0, "extraH": 4.0})), + ("convertToShape.rectangle", json!({"relative": false, "width": 30.0, "height": 20.0})), + ("convertToShape.roundedRectangle", json!({"relative": false, "width": 30.0, "height": 20.0, "radius": 3.0})), + ("convertToShape.roundedRectangle", json!({"relative": true, "extraW": 6.0, "extraH": 4.0, "radius": 3.0})), + ("convertToShape.ellipse", json!({"relative": false, "width": 30.0, "height": 20.0})), + ("path.offsetPath", json!({"offset": 3.0, "joins": "round", "miterLimit": 4.0})), + ("path.offsetPath", json!({"offset": -3.0, "joins": "bevel", "miterLimit": 4.0})), + ("path.offsetPath", json!({"offset": 3.0, "joins": "miter", "miterLimit": 9.0})), + ("stylize.innerGlow", json!({"source": "edge", "blur": 2.5, "opacity": 40.0, "mode": "screen", "color": "#102030"})), + ("stylize.innerGlow", json!({"source": "center", "blur": 2.5})), + ("stylize.outerGlow", json!({"blur": 7.5, "opacity": 33.333, "mode": "normal", "color": "#a0b0c0"})), + ("stylize.dropShadow", json!({"x": 1.25, "y": -2.5, "blur": 3.5, "opacity": 12.5, "mode": "overlay", "color": "#405060"})), + ("stylize.dropShadow", json!({"shade": "darkness", "darkness": 62.5, "x": 0.0, "y": 0.0, "blur": 0.0})), + ("stylize.feather", json!({"radius": 11.5})), + ("stylize.roundCorners", json!({"radius": 0.5})), + ( + "stylize.scribble", + json!({"angle": 15.0, "overlap": 2.0, "overlapVariation": 1.0, "strokeWidth": 1.5, "curviness": 12.5, "curvinessVariation": 7.5, "spacing": 4.0, "spacingVariation": 1.5}), + ), + ]; + for (id, params) in cases { + let effect = fx(id, params.clone()); + let (n, warnings, p) = read_one(|doc| plain_rect(doc, "One", vec![effect.clone()])); + assert!(!warnings.iter().any(|w| w == writer::EFFECTS), "{id} {params}: {warnings:?}\n{p}"); + assert_eq!(n.appearance.effects.len(), 1, "{id} {params}"); + let (got, want) = (&n.appearance.effects[0], &effect); + assert_eq!(got.id, want.id); + let (g, w) = (vectorcraft_effects::merged_params(&got.id, &got.params), vectorcraft_effects::merged_params(&want.id, &want.params)); + for (key, value) in w.as_object().unwrap() { + let read = g.get(key).unwrap_or(&serde_json::Value::Null); + match (key.as_str(), value.as_f64()) { + // A case the file doesn't keep: `seed` and the like, which the reader fills in as their defaults. + ("mode" | "shade" | "source" | "joins", _) => { + let lower = |v: &serde_json::Value| v.as_str().unwrap_or_default().to_ascii_lowercase(); + assert_eq!(lower(read), lower(value), "{id} {key}"); + } + ("color", _) => { + let color = |p: &serde_json::Value| vectorcraft_effects::color_param(p, Color::BLACK).to_hex(); + assert_eq!(color(&g), color(&w), "{id}"); + } + (_, Some(v)) => assert!((read.as_f64().unwrap() - v).abs() < 6e-3, "{id} {key}: {read} for {v}"), + _ => assert_eq!(read, value, "{id} {key}"), + } + } + } + // A relative Ellipse: the reader keeps the drawn look, which is the effect's result. + let effect = fx("convertToShape.ellipse", json!({"relative": true, "extraW": 6.0, "extraH": 4.0})); + let (n, _, p) = read_one(|doc| plain_rect(doc, "One", vec![effect.clone()])); + assert!(!p.contains("%AI9_BeginArtStyles"), "{p}"); + assert!(n.appearance.effects.is_empty()); + // A stroke's width profile. + let profile = vectorcraft_doc::WidthProfile { points: vec![(0.0, 1.0, 0.5), (0.5, 0.25, 0.75), (1.0, 0.0, 1.0)] }; + let (n, warnings, p) = read_one(|doc| { + let mut stroke = StrokeLayer::new(black_fill(), 4.0); + stroke.profile = Some(profile.clone()); + let ap = Appearance { items: vec![AppearanceItem::Stroke(stroke)], effects: vec![drop_shadow()], contents_index: None }; + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap) + }); + assert!(!warnings.iter().any(|w| w == writer::EFFECTS), "{warnings:?}"); + assert!(p.contains("(BSVariableWidthTValuesArray)") && p.contains("(BSLeftWidthsArray)") && p.contains("(BSRightWidthsArray)"), "{p}"); + assert_eq!(n.appearance.stroke().and_then(|s| s.profile.clone()), Some(profile)); + // A CMYK document: a CMYK and a spot fill, a CMYK stroke, and an effect's CMYK color stay as they are. + let mut doc = Document::new(200.0, 100.0); + doc.color_mode = vectorcraft_doc::ColorMode::Cmyk; + let mut swatches = (*doc.swatches).clone(); + swatches.push(Swatch { name: "Ink".into(), paint: solid(INK), global: true, spot: true }); + swatches.push(Swatch { name: "Brand".into(), paint: solid(BRAND), global: true, spot: false }); + doc.swatches = swatches.into(); + let shadow = fx("stylize.dropShadow", json!({"color": serde_json::to_value(GOLD).unwrap()})); + let n = styled_rect(&mut doc, "Process", Rect::new(10.0, 10.0, 40.0, 40.0), solid(GOLD), solid(BRAND), vec![shadow.clone()]); + put(&mut doc, n); + let n = styled_rect(&mut doc, "Spot", Rect::new(50.0, 10.0, 80.0, 40.0), linked("Ink", INK, 0.5), Paint::None, vec![shadow.clone()]); + put(&mut doc, n); + let n = styled_rect(&mut doc, "Global", Rect::new(90.0, 10.0, 120.0, 40.0), linked("Brand", BRAND, 1.0), Paint::None, vec![shadow]); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let back = read_back(&doc); + same_styles(&back, &doc); + for n in back.layers[0].children().unwrap() { + assert!(matches!(vectorcraft_effects::color_param(&n.appearance.effects[0].params, Color::BLACK), Color::Cmyk { .. }), "{n:?}"); + } + let spot = &back.layers[0].children().unwrap()[1]; + assert!(matches!(&spot.appearance.fill().unwrap().paint, Paint::Solid { swatch: Some(s), .. } if s == "Ink"), "{spot:?}"); +} + +/// Every kind of appearance a style isn't written for is written as its drawn look, with a warning. +#[test] +fn appearances_written_as_their_drawn_look() { + type Make = Box Node>; + let cases: Vec<(&str, Make)> = vec![ + ( + "a pattern fill", + Box::new(|doc| { + let tile = square(doc, Rect::new(0.0, 0.0, 5.0, 5.0), Color::BLACK); + let mut dots = vectorcraft_doc::PatternDef::new("Dots", vec![Arc::new(tile)]); + dots.tile = Rect::new(0.0, 0.0, 10.0, 10.0); + doc.patterns.push(dots); + let fill = Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY }; + styled_rect(doc, "P", Rect::new(10.0, 10.0, 50.0, 40.0), fill, Paint::None, vec![drop_shadow()]) + }), + ), + ( + "an overprinting fill", + Box::new(|doc| { + let ap = Appearance { + items: vec![AppearanceItem::Fill(FillLayer { overprint: true, ..FillLayer::new(black_fill()) })], + effects: vec![drop_shadow()], + contents_index: None, + }; + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap) + }), + ), + ( + "an overprinting stroke", + Box::new(|doc| { + let ap = Appearance { + items: vec![AppearanceItem::Stroke(StrokeLayer { overprint: true, ..StrokeLayer::new(black_fill(), 2.0) })], + effects: vec![drop_shadow()], + contents_index: None, + }; + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap) + }), + ), + ( + "a gradient stroke", + Box::new(|doc| { + let stops = vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)]; + let graded = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + styled_rect(doc, "G", Rect::new(10.0, 10.0, 50.0, 40.0), Paint::None, graded, vec![drop_shadow()]) + }), + ), + ( + "a fill with an opacity of its own", + Box::new(|doc| { + let ap = Appearance { + items: vec![AppearanceItem::Fill(FillLayer { opacity: 0.5, ..FillLayer::new(black_fill()) })], + effects: vec![drop_shadow()], + contents_index: None, + }; + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap) + }), + ), + ( + "a fill with an effect of its own", + Box::new(|doc| { + let fill = FillLayer { effects: vec![fx("stylize.feather", serde_json::json!({}))], ..FillLayer::new(black_fill()) }; + let ap = Appearance { items: vec![AppearanceItem::Fill(fill)], ..Appearance::default() }; + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap) + }), + ), + ("Pathfinder on a path", Box::new(|doc| plain_rect(doc, "P", vec![fx("pathfinder.add", serde_json::json!({}))]))), + ( + "a group with a fill of its own", + Box::new(|doc| { + let member = square(doc, Rect::new(10.0, 10.0, 40.0, 40.0), Color::BLACK); + let mut g = group(doc, vec![member], false); + g.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(solid(Color::WHITE), Paint::None, 0.0) }.into(); + g + }), + ), + ("an effect the style doesn't hold", Box::new(|doc| plain_rect(doc, "B", vec![fx("blur.gaussian", serde_json::json!({}))]))), + ( + "a group whose member's stroke is written as its outline", + Box::new(|doc| { + let mut stroke = StrokeLayer::new(solid(Color::BLACK), 4.0); + stroke.profile = Some(vectorcraft_doc::WidthProfile { points: vec![(0.0, 1.0, 0.5), (0.5, 0.25, 0.75), (1.0, 0.0, 1.0)] }); + let ap = Appearance { items: vec![AppearanceItem::Stroke(stroke)], ..Appearance::default() }; + let member = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), ap); + let mut g = group(doc, vec![member], false); + let effects = vec![fx("stylize.roundCorners", serde_json::json!({})), drop_shadow()]; + g.appearance = Appearance { effects, ..Appearance::default() }.into(); + g + }), + ), + ( + "a group whose effects reshape a member filled with pattern tiles", + Box::new(|doc| { + let big = square(doc, Rect::new(-2.0, -2.0, 6.0, 6.0), Color::rgb(1.0, 0.0, 0.0)); + let mut over = vectorcraft_doc::PatternDef::new("Over", vec![Arc::new(big)]); + over.tile = Rect::new(0.0, 0.0, 10.0, 10.0); + doc.patterns.push(over); + let tiles = Paint::Pattern { pattern: "Over".into(), xf: Affine::IDENTITY }; + let member = + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)), Appearance::basic(tiles, Paint::None, 0.0)); + let mut g = group(doc, vec![member], false); + let offset = fx("path.offsetPath", serde_json::json!({"offset": 3.0})); + g.appearance = Appearance { effects: vec![offset, drop_shadow()], ..Appearance::default() }.into(); + g + }), + ), + ( + "a group whose effects reshape a member with a gradient that fits it", + Box::new(|doc| { + let stops = vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)]; + let graded = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let member = + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(30.0, 10.0, 100.0, 40.0)), Appearance::basic(graded, Paint::None, 0.0)); + let mut g = group(doc, vec![member], false); + let offset = fx("path.offsetPath", serde_json::json!({"offset": 15.0})); + g.appearance = Appearance { effects: vec![offset, drop_shadow()], ..Appearance::default() }.into(); + g + }), + ), + ( + "Define Knockout Shape", + Box::new(|doc| { + let mut n = plain_rect(doc, "K", vec![drop_shadow()]); + n.knockout_shape = true; + n + }), + ), + ]; + for (what, make) in cases { + let mut doc = Document::new(200.0, 100.0); + let n = make(&mut doc); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{what}: {:?}", out.warnings); + let p = shown(&out.program); + assert!(!p.contains("%AI9_BeginArtStyles") && !p.contains("(Anon 1) XW"), "{what}:\n{p}"); + let back = read_back(&doc); + let n = &back.layers[0].children().unwrap()[0]; + assert!(n.appearance.effects.is_empty(), "{what}: {n:?}"); + } +} + +/// A gradient fill that fits its object is placed on the outline its geometry effects draw: an +/// object with Offset Path reads back with the gradient a plain object of the drawn size has. +#[test] +fn a_styled_objects_gradient_fits_its_drawn_outline() { + let stops = || vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)]; + let graded = || Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops())))); + let first = |doc: &Document| doc.layers[0].children().unwrap()[0].clone(); + let mut doc = Document::new(200.0, 100.0); + let offset = fx("path.offsetPath", serde_json::json!({"offset": 20.0, "joins": "miter"})); + let n = styled_rect(&mut doc, "G", Rect::new(50.0, 30.0, 100.0, 60.0), graded(), Paint::None, vec![offset]); + put(&mut doc, n); + let styled = first(&read_back(&doc)); + assert_eq!(styled.appearance.effects.len(), 1); + let mut doc = Document::new(200.0, 100.0); + let n = styled_rect(&mut doc, "P", Rect::new(30.0, 10.0, 120.0, 80.0), graded(), Paint::None, vec![]); + put(&mut doc, n); + let plain = first(&read_back(&doc)); + let geom = |n: &Node| match &n.appearance.fill().unwrap().paint { + Paint::Gradient(g) => { + let g = g.geom.unwrap(); + format!("{:.2} {:.2} {:.2} {:.2} {:.2}", g.start.x, g.start.y, g.end.x, g.end.y, g.aspect) + } + other => panic!("{other:?}"), + }; + assert_eq!(geom(&styled), geom(&plain)); +} + +/// The effects of a pattern's tile art are its own: a styled group's drawn look that first uses the +/// pattern still warns that the tile's Gaussian Blur is left out. +#[test] +fn effects_in_a_pattern_a_styled_look_uses_are_warned_about() { + let mut doc = Document::new(200.0, 100.0); + let dot = styled_rect( + &mut doc, + "Dot", + Rect::new(2.0, 2.0, 8.0, 8.0), + solid(Color::BLACK), + Paint::None, + vec![fx("blur.gaussian", serde_json::json!({}))], + ); + let mut grid = vectorcraft_doc::PatternDef::new("Grid", vec![Arc::new(dot)]); + grid.tile = Rect::new(0.0, 0.0, 10.0, 10.0); + doc.patterns.push(grid); + let tiles = Paint::Pattern { pattern: "Grid".into(), xf: Affine::IDENTITY }; + let member = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)), Appearance::basic(tiles, Paint::None, 0.0)); + let mut g = group(&mut doc, vec![member], false); + g.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::default() }.into(); + put(&mut doc, g); + let out = export_ai_editing(&doc, &opts()).unwrap(); + let p = shown(&out.program); + assert!(p.contains("%AI3_BeginPattern") && p.contains("\n1 (Anon 1) XW\n"), "{p}"); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); +} + +/// A style that isn't written defines no gradient: a hidden gradient fill that only the style +/// would have held leaves no gradient the art uses. +#[test] +fn a_style_that_isnt_written_defines_no_gradient() { + let mut doc = Document::new(200.0, 100.0); + let stops = vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)]; + let graded = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let ap = Appearance { + items: vec![ + AppearanceItem::Fill(FillLayer { visible: false, ..FillLayer::new(graded) }), + AppearanceItem::Stroke(StrokeLayer::new(solid(Color::BLACK), 2.0)), + ], + effects: vec![fx("blur.gaussian", serde_json::json!({}))], + contents_index: None, + }; + let n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + let p = shown(&out.program); + assert!(!p.contains("(Anon 1) XW"), "{p}"); + assert!(p.contains("%%BeginSetup\n0 Bn\n"), "{p}"); +} + +/// An object in a plain group, a sublayer or a clipping group by a path keeps its style there. One +/// in a symbol's art, in an opacity mask or in art cut to a clipping object other than a path is +/// written as its drawn look, with a warning. +#[test] +fn where_a_styled_object_keeps_its_style() { + let inside = |doc: &mut Document| plain_rect(doc, "Inside", vec![drop_shadow()]); + let found = |doc: &Document| { + fn find(nodes: &[Arc]) -> Option<&Arc> { + nodes.iter().find_map(|n| if n.name.as_deref() == Some("Inside") { Some(n) } else { n.children().and_then(|c| find(c)) }) + } + find(&doc.layers).cloned() + }; + // In a plain group. + let mut doc = Document::new(200.0, 100.0); + let n = inside(&mut doc); + let g = group(&mut doc, vec![n], false); + put(&mut doc, g); + assert_eq!(found(&read_back(&doc)).unwrap().appearance.effects.len(), 1); + same_styles(&read_back(&doc), &doc); + // In a sublayer. + let mut doc = Document::new(200.0, 100.0); + let n = inside(&mut doc); + let mut sub = Node::layer(doc.alloc_id(), "Sub", LayerColor::Preset(1)); + sub.children_mut().unwrap().push(Arc::new(n)); + put(&mut doc, sub); + assert_eq!(found(&read_back(&doc)).unwrap().appearance.effects.len(), 1); + // In a clipping group. + let mut doc = Document::new(200.0, 100.0); + let clip = Node::new( + doc.alloc_id(), + NodeKind::Path { + path: shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 60.0)), + rule: FillRule::NonZero, + live: None, + clipping: true, + guide: false, + }, + ); + let n = inside(&mut doc); + let g = group(&mut doc, vec![clip, n], true); + put(&mut doc, g); + assert_eq!(found(&read_back(&doc)).unwrap().appearance.effects.len(), 1); + // In a symbol's art: the instance keeps the symbol, the effects are left out. + let mut doc = Document::new(200.0, 100.0); + let n = inside(&mut doc); + doc.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(n) }); + let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((50.0, 20.0)) }); + put(&mut doc, instance); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + assert!(!shown(&out.program).contains("(Anon 1) XW")); + // In an opacity mask. + let mut doc = Document::new(200.0, 100.0); + let art = inside(&mut doc); + let mut n = square(&mut doc, Rect::new(10.0, 10.0, 60.0, 60.0), Color::BLACK); + n.extras_mut().mask = Some(OpacityMask { art: Arc::new(art), clip: true, invert: false, disabled: false, linked: true }.into()); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + assert!(!shown(&out.program).contains("(Anon 1) XW")); + // In art cut to a clipping object other than a path: type. + let mut doc = Document::new(200.0, 100.0); + let t = vectorcraft_doc::TextObject::point( + vectorcraft_geom::Point::new(10.0, 70.0), + "Type", + vectorcraft_doc::CharStyle { size: 18.0, fill: solid(Color::BLACK), ..vectorcraft_doc::CharStyle::default() }, + ); + let t = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + let n = inside(&mut doc); + let g = group(&mut doc, vec![t, n], true); + put(&mut doc, g); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + assert!(!shown(&out.program).contains("(Anon 1) XW")); +} + +/// A styled group's members of every kind allowed come back as they were: a compound path, a named +/// path, a hidden and a locked path, a nested group and a clipping group. +#[test] +fn a_styled_groups_members_of_each_kind_are_read_back() { + let mut doc = Document::new(200.0, 100.0); + let solid_ap = Appearance::basic(solid(Color::rgb(0.2, 0.4, 0.6)), Paint::None, 0.0); + let compound = named(ring(&mut doc, solid_ap.clone()), "Compound"); + let plain = named(square(&mut doc, Rect::new(10.0, 10.0, 30.0, 30.0), Color::rgb(0.8, 0.1, 0.1)), "Named"); + let mut quiet = named(square(&mut doc, Rect::new(30.0, 10.0, 50.0, 30.0), Color::rgb(0.1, 0.8, 0.1)), "Hidden"); + quiet.visible = false; + let mut fixed = named(square(&mut doc, Rect::new(50.0, 10.0, 70.0, 30.0), Color::rgb(0.1, 0.1, 0.8)), "Locked"); + fixed.locked = true; + let a = square(&mut doc, Rect::new(10.0, 40.0, 30.0, 60.0), Color::BLACK); + let nested = named(group(&mut doc, vec![a], false), "Nested"); + let clip = Node::new( + doc.alloc_id(), + NodeKind::Path { + path: shapes::rectangle(Rect::new(40.0, 40.0, 60.0, 60.0)), + rule: FillRule::NonZero, + live: None, + clipping: true, + guide: false, + }, + ); + let b = square(&mut doc, Rect::new(40.0, 40.0, 80.0, 80.0), Color::WHITE); + let cut = named(group(&mut doc, vec![clip, b], true), "Cut"); + let mut g = named(group(&mut doc, vec![compound, plain, quiet, fixed, nested, cut], false), "Holder"); + g.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::default() }.into(); + put(&mut doc, g); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let back = read_back(&doc); + same_styles(&back, &doc); + let members = |d: &Document| { + let mut out = vec![]; + view(d.layers[0].children().unwrap()[0].children().unwrap(), 0, &mut out); + out + }; + same_lines(&members(&back), &members(&doc)); +} + +/// What compares two appearances for the writer's checks and for these tests tells them apart. +#[test] +fn same_appearance_tells_appearances_apart() { + use serde_json::json; + let base = || Appearance { + items: vec![ + AppearanceItem::Fill(FillLayer::new(solid(Color::rgb(0.5, 0.25, 0.75)))), + AppearanceItem::Stroke(StrokeLayer::new(black_fill(), 2.0)), + ], + effects: vec![fx("stylize.dropShadow", json!({"x": 10.0, "mode": "Multiply", "color": "#102030"}))], + contents_index: None, + }; + let same = writer::same_appearance; + assert!(same(&base(), &base())); + // Within the written precision, a mode in another case and a color in another form. + let mut other = base(); + other.effects[0].params["x"] = json!(10.005); + other.effects[0].params["mode"] = json!("multiply"); + other.effects[0].params["color"] = serde_json::to_value(Color::from_hex("#102030").unwrap()).unwrap(); + assert!(same(&other, &base())); + // A fill or stroke that doesn't paint. + let mut other = base(); + other.items.push(AppearanceItem::Fill(FillLayer::new(Paint::None))); + assert!(same(&other, &base())); + type Change = Box; + let change: Vec = vec![ + Box::new(|a| a.effects[0].params["x"] = json!(10.01)), + Box::new(|a| a.effects[0].params["color"] = json!("#102031")), + Box::new(|a| a.effects[0].params["mode"] = json!("screen")), + Box::new(|a| a.effects[0].visible = false), + Box::new(|a| a.effects[0].id = "stylize.outerGlow".into()), + Box::new(|a| a.effects.clear()), + Box::new(|a| a.items.truncate(1)), + Box::new(|a| a.items.swap(0, 1)), + Box::new(|a| a.contents_index = Some(2)), + Box::new(|a| { + if let AppearanceItem::Fill(f) = &mut a.items[0] { + f.paint = solid(Color::rgb(0.5, 0.25, 0.76)); + } + }), + Box::new(|a| { + if let AppearanceItem::Fill(f) = &mut a.items[0] { + f.opacity = 0.5; + } + }), + Box::new(|a| { + if let AppearanceItem::Stroke(s) = &mut a.items[1] { + s.width = 2.5; + } + }), + ]; + for (i, f) in change.iter().enumerate() { + let mut other = base(); + f(&mut other); + assert!(!same(&other, &base()), "change {i}"); + } +} + +/// A gradient fill in a style is placed as on an object without a style: a turned linear gradient +/// and a radial one with a focal point, and on a first artboard away from the origin among several. +#[test] +fn a_styled_objects_gradient_is_placed_as_a_plain_objects_is() { + use vectorcraft_color::gradient::GradientGeom; + let stops = || vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::rgb(0.1, 0.2, 0.8))]; + let turned = GradientGeom { start: (120.0, 60.0).into(), end: (170.0, 90.0).into(), aspect: 1.0, focal: None }; + let mut focal = GradientGeom { start: (140.0, 70.0).into(), end: (170.0, 70.0).into(), aspect: 0.5, focal: None }; + focal.set_focal(Some((150.0, 65.0).into())); + let boards = [Rect::new(0.0, 0.0, 300.0, 200.0), Rect::new(100.0, 50.0, 400.0, 250.0)]; + for (kind, geom) in [(GradientKind::Linear, turned), (GradientKind::Radial, focal)] { + for board in boards { + let paint = gradient_paint(Gradient::new(kind, stops()), Some(geom), None); + let make = |effects: Vec| { + let mut doc = Document::new(300.0, 200.0); + doc.artboards[0].rect = board; + let second = doc.artboards[0].clone(); + doc.artboards.push(Artboard { rect: board + vectorcraft_geom::Vec2::new(400.0, 0.0), ..second }); + let n = styled_rect(&mut doc, "G", Rect::new(120.0, 60.0, 180.0, 100.0), paint.clone(), Paint::None, effects); + put(&mut doc, n); + doc + }; + let (with, without) = (read_back(&make(vec![drop_shadow()])), read_back(&make(vec![]))); + let (a, b) = (&with.layers[0].children().unwrap()[0], &without.layers[0].children().unwrap()[0]); + assert_eq!(a.appearance.effects.len(), 1); + let geom = |n: &Node| match &n.appearance.fill().unwrap().paint { + Paint::Gradient(g) => { + let g = g.geom.unwrap(); + format!( + "{:.2} {:.2} {:.2} {:.2} {:.2} {:?}", + g.start.x, + g.start.y, + g.end.x, + g.end.y, + g.aspect, + g.focal.map(|f| (r3(f.x), r3(f.y))) + ) + } + other => panic!("{other:?}"), + }; + assert_eq!(geom(a), geom(b)); + same_lines(&outlines(&with), &outlines(&without)); + } + } +} + +/// On a large canvas an object with an effect warns and comes back as its drawn look at its place. +#[test] +fn a_large_canvas_writes_every_object_as_its_look() { + let warns = |doc: &Document| export_ai_editing(doc, &opts()).unwrap().warnings.iter().any(|w| w == writer::LARGE_CANVAS_STYLES); + let mut plain = Document::new(20000.0, 100.0); + let n = plain_rect(&mut plain, "None", vec![]); + put(&mut plain, n); + assert!(!warns(&plain)); + let mut shaded = Document::new(20000.0, 100.0); + let n = plain_rect(&mut shaded, "Shaded", vec![drop_shadow()]); + put(&mut shaded, n); + assert!(warns(&shaded)); + let back = read_back(&shaded); + let n = &back.layers[0].children().unwrap()[0]; + assert!(n.appearance.effects.is_empty()); + let p = n.path_data().unwrap().subpaths[0].anchors[0].p; + assert!((p.x - 10.0).abs() < 1e-2 && (p.y - 10.0).abs() < 1e-2, "{p:?}"); +} diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 14fa89fe6..89fd5abb7 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -17,9 +17,14 @@ use crate::ps; mod images; mod paints; +mod styles; mod symbols; mod texts; +#[cfg(test)] +pub(super) use styles::same_appearance; +pub(super) use styles::{GRAPHIC_STYLES, LARGE_CANVAS_STYLES, has_graphic_styles, styled_objects}; + pub(super) use images::PAYLOAD; #[cfg(test)] pub(super) use images::{IMAGE_DEPTH, IMAGES, LINKS_EMBEDDED}; @@ -208,6 +213,14 @@ pub(super) struct Writer<'a> { symbol_scales: HashMap, /// The entries a symbol's dictionary adds to its art's dictionary, and the art's id. root_entries: Option<(vectorcraft_doc::NodeId, String)>, + /// The objects that may be written with an art style, as they were before baking, by id + /// ([`styles::styled_objects`]). + pub(super) styled: HashMap>, + /// The art styles written ([`styles`]). + styles: styles::Styles, + /// An object's drawn look is being written: no object in it is written with a style, and its + /// effects are the style's. + in_look: bool, } impl<'a> Writer<'a> { @@ -257,6 +270,9 @@ impl<'a> Writer<'a> { inner_scale: 1.0, symbol_scales: HashMap::new(), root_entries: None, + styled: HashMap::new(), + styles: styles::Styles::default(), + in_look: false, } } @@ -445,6 +461,9 @@ impl<'a> Writer<'a> { /// Warn when `n` has effects the data leaves out. fn effects(&mut self, n: &Node) { + if self.in_look { + return; + } let items = n.appearance.items.iter().filter(|i| i.visible()).flat_map(|i| i.effects()); if n.appearance.effects.iter().chain(items).any(|e| e.visible) { self.warn(EFFECTS); @@ -459,6 +478,17 @@ impl<'a> Writer<'a> { if self.in_symbol { self.wrote_fixed |= symbols::redrawn(n); } + // On the layers, outside art cut to a clipping object: with its art style, when it has one. + if !self.in_look + && self.hidden == 0 + && self.nest == 0 + && self.clip.is_none() + && !self.in_symbol + && let Some(orig) = self.styled.get(&n.id).cloned() + && let Some(name) = self.object_style(n, &orig) + { + return self.styled_object(n, &orig, &name); + } self.effects(n); self.state(n.locked, !n.visible); let xy = Xy::of(n); diff --git a/crates/eps/src/ai_editing/writer/paints.rs b/crates/eps/src/ai_editing/writer/paints.rs index a29ab3df9..b4966968e 100644 --- a/crates/eps/src/ai_editing/writer/paints.rs +++ b/crates/eps/src/ai_editing/writer/paints.rs @@ -103,6 +103,22 @@ impl Writer<'_> { name } + /// The gradient definitions' state, for [`Self::gradient_rollback`]: their count, which ones the + /// art uses, and the next name's number. + pub(super) fn gradient_mark(&self) -> (usize, Vec, u64) { + (self.gradients.len(), self.gradients.iter().map(|d| d.used).collect(), self.next_gradient) + } + + /// The gradient definitions as they were at `mark` ([`Self::gradient_mark`]). + pub(super) fn gradient_rollback(&mut self, mark: (usize, Vec, u64)) { + let (len, used, next) = mark; + self.gradients.truncate(len); + for (d, u) in self.gradients.iter_mut().zip(used) { + d.used = u; + } + self.next_gradient = next; + } + /// `p` as the paint of a fill or stroke whose gradient is placed on `bounds`: a gradient is /// placed there (it then no longer depends on the shape it paints, which may be cut); one the /// data can't hold is its average color, with a warning. @@ -222,24 +238,11 @@ impl Writer<'_> { /// last stop; a radial one's `… Bm` (the unit circle onto its extent ellipse). fn instance(&mut self, g: &GradientPaint, stroke: bool, clip: bool, path: &BezPath) { let Some(geom) = g.geom else { return }; - let name = self.define_gradient(&g.gradient, g.swatch.as_deref()); self.line(if stroke { "1 Bb" } else { "Bb" }); + self.instance_head(g, clip, path); let m = self.grad_space; let u = geom.end - geom.start; let radial = g.gradient.kind == GradientKind::Radial; - let place = placement(&geom, radial, &(self.canvas * path.clone()), self.canvas); - if radial { - let c = m * geom.start; - let off = geom.focal.map_or(Vec2::ZERO, |f| m * f - c); - let (angle, fraction) = if off.hypot() > 1e-9 { place.focal } else { (0.0, 0.0) }; - let off = if fraction > 0.0 { off } else { Vec2::ZERO }; - ps::push_nums(&mut self.body, &[off.x, off.y, angle, fraction]); - self.line("Bh"); - } - let _ = write!(self.body, "{} {} ", u8::from(!clip), ps::bytes(name.as_bytes())); - ps::push_nums(&mut self.body, &place.bg); - ps::push_nums(&mut self.body, &place.transform.as_coeffs()); - self.line("1 Bg"); // The unit vector along the gradient, and across it as long. let frame = |from: f64, to: f64| { let p = geom.start + u * from; @@ -267,6 +270,28 @@ impl Writer<'_> { matrix(frame(last, last + CAP / len), "Bc"); } + /// A gradient instance's focal point (`Bh`, radial gradients only) and its `Bg` line, as + /// [`Self::instance`] writes them; also what a fill's art style holds after its `Bb`. + pub(super) fn instance_head(&mut self, g: &GradientPaint, clip: bool, path: &BezPath) { + let Some(geom) = g.geom else { return }; + let name = self.define_gradient(&g.gradient, g.swatch.as_deref()); + let m = self.grad_space; + let radial = g.gradient.kind == GradientKind::Radial; + let place = placement(&geom, radial, &(self.canvas * path.clone()), self.canvas); + if radial { + let c = m * geom.start; + let off = geom.focal.map_or(Vec2::ZERO, |f| m * f - c); + let (angle, fraction) = if off.hypot() > 1e-9 { place.focal } else { (0.0, 0.0) }; + let off = if fraction > 0.0 { off } else { Vec2::ZERO }; + ps::push_nums(&mut self.body, &[off.x, off.y, angle, fraction]); + self.line("Bh"); + } + let _ = write!(self.body, "{} {} ", u8::from(!clip), ps::bytes(name.as_bytes())); + ps::push_nums(&mut self.body, &place.bg); + ps::push_nums(&mut self.body, &place.transform.as_coeffs()); + self.line("1 Bg"); + } + /// A gradient definition: `%AI5_BeginGradient: (name)`, `(name) type stops Bd` (type 0 linear, /// 1 radial), `[`, each stop on a `%_` line (`color style opacity 6 midpoint rampPoint Bs`), a /// linear gradient's from the last to the first and a radial one's from the first to the last, @@ -390,9 +415,12 @@ impl Writer<'_> { self.line("n\n%_/ArtDictionary :\n%_1 /Bool (AIPattern_Editor_Backing_Tile_Rect) ,\n%_;\n%_"); self.nest += 1; self.hidden += 1; + // The tile's effects are its own, not those of a drawn look that first uses it. + let in_look = std::mem::take(&mut self.in_look); for a in &def.art { self.node(a); } + self.in_look = in_look; self.hidden -= 1; self.nest -= 1; let art; diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs new file mode 100644 index 000000000..de74bc99e --- /dev/null +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -0,0 +1,685 @@ +//! Art styles: objects with live effects a plain path can't hold, written as their drawn look, the +//! object itself on `%_` lines and `1 (style) XW`, and the styles they name in +//! `%AI9_BeginArtStyles` (see [`crate::ai_editing`] for the form). Each style is read back before it +//! is written ([`crate::import::read_art_style`]: the `.ai` reader's style parser and its reading of +//! a graphic style, with the object's own gradient): one that reads back differently is not +//! written, and the object is written as its drawn look alone. + +use std::collections::HashMap; +use std::fmt::Write; +use std::sync::Arc; + +use serde_json::Value; +use vectorcraft_color::{BlendMode, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, Knockout, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_geom::{BezPath, FillRule, Rect, Shape}; + +use super::{Writer, Xy, blend_code, compound_path, hide}; +use crate::ai_ops; +use crate::ps; + +pub(in crate::ai_editing) const LARGE_CANVAS_STYLES: &str = + "on a large canvas, objects with live effects are written as their drawn look in the editing data for other apps"; +pub(in crate::ai_editing) const GRAPHIC_STYLES: &str = "graphic styles and links to them are left out of the editing data for other apps"; + +/// Does `doc` have graphic styles other than a new document's, or an object in its layers, opacity +/// masks or symbols linked to a graphic style? +pub(in crate::ai_editing) fn has_graphic_styles(doc: &Document) -> bool { + fn linked(n: &Node) -> bool { + n.extras().graphic_style.is_some() + || n.extras().mask.as_ref().is_some_and(|m| linked(&m.art)) + || n.children().is_some_and(|c| c.iter().any(|m| linked(m))) + } + doc.graphic_styles != Document::new(1.0, 1.0).graphic_styles || doc.layers.iter().any(|l| linked(l)) || doc.symbols.iter().any(|s| linked(&s.art)) +} + +/// The objects of `doc`'s layers that may be written with an art style ([`Writer::styled_object`]), +/// by id: paths and compound paths with points and groups, with live effects, without Opacity & +/// Mask Define Knockout Shape (the reader takes an object's transparency from its look, which +/// doesn't carry it). A group's members are paths, compound paths or such groups, none with effects +/// or anything baking changes. +pub(in crate::ai_editing) fn styled_objects(doc: &Document) -> HashMap> { + fn walk(nodes: &[Arc], out: &mut HashMap>) { + for n in nodes { + if candidate(n) { + out.insert(n.id, n.clone()); + continue; + } + // Baking a container with effects of its own changes its members, which keep their ids: + // they are written as baking left them. + if let NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } = &n.kind + && !bakes_members(n) + { + walk(children, out); + } + } + } + let mut out = HashMap::new(); + walk(&doc.layers, &mut out); + out +} + +/// Does container `n` need baking for what it has itself, whatever its members hold? +fn bakes_members(n: &Node) -> bool { + let mut own = n.clone(); + if let Some(children) = own.children_mut() { + children.clear(); + } + vectorcraft_effects::needs_bake(&own) +} + +/// Whether `n` may be written with an art style ([`styled_objects`]). +fn candidate(n: &Node) -> bool { + if n.appearance.effects.is_empty() || n.knockout_shape { + return false; + } + match &n.kind { + NodeKind::Path { path, guide: false, clipping: false, .. } => has_points(path), + NodeKind::Compound { .. } => has_points(&compound_path(n)), + NodeKind::Group { children, clip: false } => !n.is_compound_shape() && children.iter().all(|c| plain_member(c)), + _ => false, + } +} + +/// Does `path` write anything ([`Writer::plain_object`] skips subpaths without points)? +fn has_points(path: &vectorcraft_geom::PathData) -> bool { + path.subpaths.iter().any(|sp| !sp.anchors.is_empty()) +} + +/// A member of a group written with an art style: written as it is, and read back as it is. +fn plain_member(n: &Node) -> bool { + let quiet = n.appearance.effects.is_empty() && n.appearance.items.iter().all(|i| i.effects().is_empty()) && !vectorcraft_effects::needs_bake(n); + quiet + && match &n.kind { + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => true, + NodeKind::Group { children, .. } => children.iter().all(|c| plain_member(c)), + _ => false, + } +} + +/// The art styles objects name, each with its text after its name (`/ActiveStyle :` to `/Def ;`). +#[derive(Default)] +pub(in crate::ai_editing) struct Styles { + /// Every style, in the order written: its name and text. + known: Vec<(String, String)>, + /// The name of each style by its text. + by_text: HashMap, + next_anon: u64, +} + +impl Styles { + /// The name of an object style of `text`: a style with the same text already written, else a + /// new one named `Anon` and a number. + fn object_style(&mut self, text: String) -> String { + if let Some(name) = self.by_text.get(&text) { + return name.clone(); + } + self.next_anon += 1; + let name = format!("Anon {}", self.next_anon); + self.by_text.insert(text.clone(), name.clone()); + self.known.push((name.clone(), text)); + name + } +} + +/// One style as the reader's section holds it: `/KnownStyle :`, `(name) /Name ,`, its text. +fn known_style(name: &str, text: &str) -> String { + format!("/KnownStyle :\n{} /Name ,\n{text}", ps::bytes(name.as_bytes())) +} + +/// The number transparency's `Xy` gives the blending mode a live effect's `mode` names (any case, +/// spaces and dashes ignored). +fn mode_code(mode: &str) -> Option { + let key: String = mode.chars().filter(char::is_ascii_alphanumeric).collect::().to_ascii_lowercase(); + BlendMode::ALL.iter().find(|m| format!("{m:?}").to_ascii_lowercase() == key).map(|m| blend_code(*m)) +} + +/// A filter's `Xy` line: `mode opacity isolate knockout shape Xy`. +fn xy_line(s: &mut String, xy: Xy) { + let knockout = match xy.knockout { + Knockout::Off => 0, + Knockout::On => 1, + Knockout::Neutral => 2, + }; + let _ = write!(s, "{} ", blend_code(xy.blend)); + ps::push_num(s, f64::from(xy.opacity)); + let _ = writeln!(s, " {} {knockout} {} Xy", u8::from(xy.isolate), u8::from(xy.shape)); +} + +/// The line style of a paint style that isn't a stroke's. +const NO_LINE: &str = "0 J 0 j 1 w 10 M []0 d\n"; + +/// A basic filter's start: its name and kind numbers (`1 0` for an effect, `0 0` for the +/// style's own filters), `/Visible` and the dictionary's start. +fn basic(s: &mut String, name: &str, effect: bool, visible: bool) { + let _ = write!(s, "/BasicFilter :\n{} {} 0 /Filter ,\n{} /Visible ,\n", ps::bytes(name.as_bytes()), u8::from(effect), u8::from(visible)); +} + +/// The filter that stands for the object's contents in its stack. +const CONDUIT: &str = "/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n"; + +/// A basic filter's end. +const END_BASIC: &str = "; /Dict ;\n /Part ,\n"; + +/// The blend filter of transparency `xy`. +fn blend_filter(s: &mut String, xy: Xy) { + basic(s, "Blend Style Filter", false, true); + s.push_str("/Dictionary : /NotRecorded ,\n/BlendStyle : "); + xy_line(s, xy); + s.push_str(NO_LINE); + s.push_str("0 XR\n/Def ; \n (BlendStyle) ,\n"); + s.push_str(END_BASIC); +} + +/// A live effect's dictionary entries. +#[derive(Default)] +struct Entries(String); + +impl Entries { + fn real(&mut self, key: &str, v: f64) { + ps::push_num(&mut self.0, v); + let _ = writeln!(self.0, " /Real {} ,", ps::bytes(key.as_bytes())); + } + + fn int(&mut self, key: &str, v: i64) { + let _ = writeln!(self.0, "{v} /Int {} ,", ps::bytes(key.as_bytes())); + } + + fn bool(&mut self, key: &str, v: bool) { + let _ = writeln!(self.0, "{} /Bool {} ,", u8::from(v), ps::bytes(key.as_bytes())); + } +} + +/// `p`'s entry `key` as a number. +fn num(p: &Value, key: &str) -> Option { + p.get(key)?.as_f64().filter(|v| v.is_finite()) +} + +/// `p`'s entry `key` as text, for comparing: lower case, letters and digits only. +fn word(p: &Value, key: &str) -> Option { + Some(p.get(key)?.as_str()?.chars().filter(char::is_ascii_alphanumeric).collect::().to_ascii_lowercase()) +} + +/// `p`'s entry `key` as a switch. +fn flag(p: &Value, key: &str) -> Option { + p.get(key)?.as_bool() +} + +impl Writer<'_> { + /// What `paint` writes with [`Self::color`] (a color, a spot or global color), as a string. + fn color_ops(&mut self, paint: &Paint, stroke: bool) -> String { + let saved = std::mem::take(&mut self.body); + self.color(paint, stroke); + std::mem::replace(&mut self.body, saved) + } + + /// A fill style holding a color, as an effect's entry `key` or a fill's own. + fn fill_style(&mut self, s: &mut String, color: vectorcraft_color::Color, key: &str) { + let ops = self.color_ops(&Paint::solid(color), false); + let _ = write!(s, "/FillStyle : 0 O\n{ops}0 1 0 0 0 Xy\n{NO_LINE}0 XR\n0 1 Xd\n/Def ; \n {} ,\n", ps::bytes(key.as_bytes())); + } + + /// A fill's basic filter: a color, or a gradient placed on the object's outline (`on`: the + /// object's own outline and rule), filling by the non-zero rule. `None` for other paint. + fn fill_filter(&mut self, s: &mut String, f: &FillLayer, visible: bool, on: Option<(&BezPath, FillRule)>) -> Option<()> { + if f.overprint { + return None; + } + // A color comes before the paint state, a gradient's instance after it. + let (color, gradient) = match &f.paint { + Paint::Solid { .. } => (self.color_ops(&f.paint, false), String::new()), + Paint::Gradient(_) => { + let (path, _) = on?; + let Paint::Gradient(placed) = self.placed(&f.paint, path.bounding_box()) else { return None }; + (String::new(), format!("Bb\n{}0 BB\n", self.gradient_bg(&placed, path)?)) + } + Paint::Pattern { .. } | Paint::None => return None, + }; + basic(s, "Fill Style Filter", false, visible); + let _ = write!( + s, + "/Dictionary : /NotRecorded ,\n0 /Bool (UseEvenOdd) ,\n/FillStyle : 0 O\n{color}0 1 0 0 0 Xy\n{NO_LINE}0 XR\n0 1 Xd\n{gradient}" + ); + s.push_str("/Def ; \n (FillStyle) ,\n"); + s.push_str(END_BASIC); + Some(()) + } + + /// A stroke's basic filter: a color, its line style, and its width profile as the arrays + /// `BSVariableWidthTValuesArray`, `BSLeftWidthsArray` and `BSRightWidthsArray`. `None` for + /// other paint. + fn stroke_filter(&mut self, s: &mut String, st: &StrokeLayer, visible: bool) -> Option<()> { + if !matches!(st.paint, Paint::Solid { .. }) || st.overprint { + return None; + } + let ops = self.color_ops(&st.paint, true); + basic(s, "Stroke Style Filter", false, visible); + s.push_str("2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n/StrokeStyle : 0 R\n"); + s.push_str(&ops); + s.push_str("0 1 0 0 0 Xy\n"); + ai_ops::line_style(s, st); + s.push_str("0 XR\n1 0 Xd\n/Def ; \n (StrokeStyle) ,\n"); + if let Some(p) = &st.profile { + let array = |s: &mut String, key: &str, values: &mut dyn Iterator| { + s.push_str("/Array :"); + for v in values { + s.push(' '); + ps::push_num(s, v); + s.push_str(" /Real ,"); + } + let _ = writeln!(s, " ; {} ,", ps::bytes(key.as_bytes())); + }; + array(s, "BSVariableWidthTValuesArray", &mut p.points.iter().map(|q| q.0)); + array(s, "BSLeftWidthsArray", &mut p.points.iter().map(|q| q.1)); + array(s, "BSRightWidthsArray", &mut p.points.iter().map(|q| q.2)); + } + s.push_str(END_BASIC); + Some(()) + } + + /// A gradient instance's focal point (`Bh`, radial gradients only) and its `flag (name) x y + /// angle length a b c d tx ty 1 Bg` line for placed gradient `g` on `path`, as a fill's style + /// holds them ([`Self::instance_head`]). + fn gradient_bg(&mut self, g: &vectorcraft_color::GradientPaint, path: &BezPath) -> Option { + let saved = std::mem::take(&mut self.body); + self.instance_head(g, false, path); + let line = std::mem::replace(&mut self.body, saved); + (!line.is_empty()).then_some(line) + } + + /// An appearance item's basic filter. `None` for an item with transparency or effects of its + /// own, which the reader doesn't read on an object. + fn item_filter(&mut self, s: &mut String, item: &AppearanceItem, on: Option<(&BezPath, FillRule)>) -> Option<()> { + if item.opacity() < 1.0 || item.blend() != BlendMode::Normal || !item.effects().is_empty() { + return None; + } + match item { + AppearanceItem::Fill(f) => self.fill_filter(s, f, item.visible(), on), + AppearanceItem::Stroke(st) => self.stroke_filter(s, st, item.visible()), + } + } + + /// The text of a style of appearance `ap` and transparency `xy` (after its name): the chain + /// of its stack (the fills and strokes that paint, the top one first, the contents among them + /// where the appearance puts them), its effects and its blend filter. A gradient fill is + /// placed on the outline of `on`, and a fill fills by its rule (`None`: the non-zero rule, on no + /// object). `None` when a fill, stroke or effect isn't one written here. + pub(super) fn style_text(&mut self, ap: &Appearance, xy: Xy, on: Option<(&BezPath, FillRule)>) -> Option { + let mut s = String::from( + "/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n", + ); + let ci = ap.contents_index.unwrap_or(0).min(ap.items.len()); + let painting: Vec<(usize, &AppearanceItem)> = ap.items.iter().enumerate().filter(|(_, i)| !i.paint().is_none()).collect(); + let below = painting.iter().filter(|(at, _)| *at < ci).count(); + // Listed top first: the contents go above the `below` fills and strokes under them. + for (written, (_, item)) in painting.iter().rev().enumerate() { + if painting.len() - written == below { + s.push_str(CONDUIT); + } + self.item_filter(&mut s, item, on)?; + } + if below == 0 { + s.push_str(CONDUIT); + } + s.push_str(";\n /Part ,\n"); + for e in &ap.effects { + s.push_str(&self.effect_filter(e)?); + } + blend_filter(&mut s, xy); + s.push_str(";\n\n/Execution ;\n /Def ;\n"); + Some(s) + } + + /// A live effect's basic filter, with the settings the reader reads + /// ([`crate::import`]'s `.ai` reader, `style.rs`), in the units it reads them in. `None` for an + /// effect, or a setting, that isn't written here. + fn effect_filter(&mut self, e: &Effect) -> Option { + let p = vectorcraft_effects::merged_params(&e.id, &e.params); + let mut d = Entries::default(); + let id = e.id.as_str(); + let name = if id.starts_with("stylize.") { self.stylize_entries(id, &p, &mut d)? } else { Self::path_entries(id, &p, &mut d)? }; + let mut s = String::new(); + basic(&mut s, name, true, e.visible); + s.push_str("/Dictionary : /NotRecorded ,\n"); + s.push_str(&d.0); + s.push_str(END_BASIC); + Some(s) + } + + /// A Stylize effect's entries ([`Self::effect_filter`]) and its filter's name. + fn stylize_entries(&mut self, id: &str, p: &Value, d: &mut Entries) -> Option<&'static str> { + Some(match id { + "stylize.dropShadow" => { + d.int("blnd", i64::from(mode_code(p.get("mode")?.as_str()?)?)); + d.real("opac", num(p, "opacity")? / 100.0); + let darkness = word(p, "shade").as_deref() == Some("darkness"); + if darkness { + d.real("dark", num(p, "darkness")?); + } + d.real("vert", num(p, "y")?); + d.real("horz", num(p, "x")?); + d.real("blur", num(p, "blur")? / 2.0); + let color = vectorcraft_effects::color_param(p, vectorcraft_color::Color::BLACK); + self.fill_style(&mut d.0, color, "sclr"); + d.int("csrc", i64::from(darkness)); + "Adobe Drop Shadow" // brand-ok: the filter's name in the files + } + "stylize.innerGlow" => { + d.int("blnd", i64::from(mode_code(p.get("mode")?.as_str()?)?)); + d.real("opac", num(p, "opacity")? / 100.0); + d.real("blur", num(p, "blur")?); + let color = vectorcraft_effects::color_param(p, vectorcraft_color::Color::WHITE); + self.fill_style(&mut d.0, color, "gclr"); + d.int("gtyp", i64::from(word(p, "source").as_deref() == Some("edge"))); + "Adobe Inner Glow" // brand-ok: the filter's name in the files + } + "stylize.outerGlow" => { + d.int("blnd", i64::from(mode_code(p.get("mode")?.as_str()?)?)); + d.real("opac", num(p, "opacity")? / 100.0); + d.real("blur", num(p, "blur")? / 2.0); + let color = vectorcraft_effects::color_param(p, vectorcraft_color::Color::BLACK); + self.fill_style(&mut d.0, color, "sclr"); + "Adobe Outer Glow" // brand-ok: the filter's name in the files + } + "stylize.feather" => { + d.real("Radius", num(p, "radius")?); + "Adobe Fuzzy Mask" // brand-ok: the filter's name in the files + } + "stylize.roundCorners" => { + d.real("radius", num(p, "radius")?); + "Adobe Round Corners" // brand-ok: the filter's name in the files + } + "stylize.scribble" => { + d.real("Angle", num(p, "angle")?); + d.real("EdgeOverlap", num(p, "overlap")?); + d.real("EdgeOverlapVariation", num(p, "overlapVariation")?); + d.real("StrokeWidth", num(p, "strokeWidth")?); + d.real("Scribbliness", num(p, "curviness")? / 100.0); + d.real("ScribbleVariation", num(p, "curvinessVariation")? / 100.0); + d.real("Spacing", num(p, "spacing")?); + d.real("SpacingVariation", num(p, "spacingVariation")?); + "Adobe Scribble Fill" // brand-ok: the filter's name in the files + } + _ => return None, + }) + } + + /// Convert to Shape's, a Path effect's or Pathfinder › Add's entries ([`Self::effect_filter`]) + /// and its filter's name. + fn path_entries(id: &str, p: &Value, d: &mut Entries) -> Option<&'static str> { + Some(match id { + "convertToShape.rectangle" | "convertToShape.roundedRectangle" | "convertToShape.ellipse" => { + let shape = match id { + "convertToShape.rectangle" => 0, + "convertToShape.roundedRectangle" => 1, + _ => 2, + }; + let relative = flag(p, "relative")?; + d.int("Shape", shape); + d.real("Absolute", if relative { 0.0 } else { 1.0 }); + d.real("AbsWidth", num(p, "width")?); + d.real("AbsHeight", num(p, "height")?); + d.real("RelWidth", num(p, "extraW")? / 2.0); + d.real("RelHeight", num(p, "extraH")? / 2.0); + if shape == 1 { + d.real("CornerRadius", num(p, "radius")?); + } + "Adobe Shape Effects" // brand-ok: the filter's name in the files + } + "path.offsetPath" => { + d.real("ofst", num(p, "offset")?); + let joins = match word(p, "joins")?.as_str() { + "round" => 0, + "bevel" => 1, + "miter" => 2, + _ => return None, + }; + d.int("jntp", joins); + d.real("mlim", num(p, "miterLimit")?); + "Adobe Offset Path" // brand-ok: the filter's name in the files + } + "path.outlineObject" => "Adobe Outline Type", // brand-ok: the filter's name in the files + "path.outlineStroke" => "Adobe Outline Stroke", // brand-ok: the filter's name in the files + "pathfinder.add" => { + d.int("Command", 0); + d.bool("ConvertCustom", true); + d.bool("ExtractUnpainted", true); + d.real("Mix", 0.5); + d.real("Precision", 10.0); + d.bool("RemovePoints", false); + d.real("TrapAspect", 1.0); + d.bool("TrapConvertCustom", false); + d.real("TrapMaxTint", 1.0); + d.bool("TrapReverse", false); + d.real("TrapThickness", 0.25); + d.real("TrapTint", 0.4); + d.real("TrapTintTolerance", 0.05); + "Adobe Pathfinder" // brand-ok: the filter's name in the files + } + _ => return None, + }) + } + + /// The style object `orig` (`n` before baking) is written with; `None` when the reader wouldn't + /// read the object as it is with it. A gradient fill is placed on `n`'s outline, as the object + /// draws it. A style that isn't written leaves no gradient definition or warning behind. + pub(super) fn object_style(&mut self, n: &Node, orig: &Node) -> Option { + if let NodeKind::Group { children, .. } = &orig.kind + && bakes_members(orig) + && !children.iter().all(|c| self.member_keeps_outline(c)) + { + return None; + } + let graded = orig.appearance.items.iter().any(|i| matches!(i.paint(), Paint::Gradient(_))); + let saved = (graded.then(|| self.gradient_mark()), self.warnings.len()); + let path = drawn_outline(n, orig); + let rule = object_rule(orig); + let text = self.style_text(&orig.appearance, Xy::of(orig), path.as_ref().map(|p| (p, rule))).filter(|t| object_reads(orig, t)); + let Some(text) = text else { + if let Some(mark) = saved.0 { + self.gradient_rollback(mark); + } + self.warnings.truncate(saved.1); + return None; + }; + Some(self.styles.object_style(text)) + } + + /// Is member `m` of a styled group whose effects change its members' outlines (and each of its + /// own members) written on the outline those effects draw on: every fill and stroke as the + /// object paints it ([`Self::direct`], [`Self::direct_stroke`]), not as filled outlines, brush + /// art or pattern tiles, and no gradient that fits its object, which the effects would fit to + /// the new outline? + fn member_keeps_outline(&self, m: &Node) -> bool { + let items_keep = m.appearance.items.iter().all(|i| match i { + AppearanceItem::Fill(f) => f.paint.is_none() || self.direct(&f.paint), + AppearanceItem::Stroke(s) => s.paint.is_none() || self.direct_stroke(s), + }); + items_keep && m.children().is_none_or(|c| c.iter().all(|c| self.member_keeps_outline(c))) + } + + /// Object `n` (as baking left it; `orig` before baking) with the style `name`: its lock and + /// hidden state; its drawn look (a group of its vector art as baking left it, without shadows, + /// glows or feathers, the object's transparency + /// after the group's end, `0 0 Xd`, `6 () XW`); the object itself on `%_` lines (a path's or + /// compound path's points painted with nothing, or with its gradient fill when its style has + /// one; a group with its members), its name and opacity mask; then `1 (name) XW`. + pub(super) fn styled_object(&mut self, n: &Node, orig: &Node, name: &str) { + self.state(orig.locked, !orig.visible); + let xy = Xy::of(orig); + let was = self.in_look; + self.in_look = true; + self.line("u"); + let mut look = n.clone(); + (look.name, look.opacity, look.blend, look.isolate, look.knockout, look.knockout_shape) = + (None, 1.0, BlendMode::Normal, false, Knockout::Neutral, false); + if look.extras().mask.is_some() { + look.extras_mut().mask = None; + } + match &look.kind { + NodeKind::Group { children, clip: false } => { + for c in children.clone() { + self.node(&c); + } + } + _ => self.node(&look), + } + self.line("U"); + self.write_xy(xy); + self.line("0 0 Xd\n6 () XW"); + self.in_look = was; + // The object, on `%_` lines, in its own state (the look's members may have changed it). + self.state(orig.locked, !orig.visible); + let saved = std::mem::take(&mut self.body); + self.hidden += 1; + let fit = drawn_outline(n, orig).map(|p| p.bounding_box()); + match &orig.kind { + NodeKind::Path { path, rule, .. } => self.styled_outline(orig, path, *rule, fit, xy), + NodeKind::Compound { rule, .. } => self.styled_outline(orig, &compound_path(orig), *rule, fit, xy), + NodeKind::Group { children, .. } => { + self.line("u"); + for c in children { + self.node(c); + } + self.line("U"); + } + _ => {} + } + self.dictionary(orig); + self.hidden -= 1; + let part = std::mem::replace(&mut self.body, saved); + hide(&part, &mut self.body); + self.block_end = usize::MAX; + let _ = writeln!(self.body, "1 {} XW", ps::bytes(name.as_bytes())); + } + + /// A styled path's or compound path's points: painted with nothing, or with its gradient fill + /// (which its style's gradient fill is), placed on `fit` (the outline's bounds when `None`). + fn styled_outline(&mut self, orig: &Node, path: &vectorcraft_geom::PathData, rule: vectorcraft_geom::FillRule, fit: Option, xy: Xy) { + let bounds = fit.unwrap_or_else(|| path.to_bezpath().bounding_box()); + let gradient = orig.appearance.items.iter().find_map(|i| match i { + AppearanceItem::Fill(f) if matches!(f.paint, Paint::Gradient(_)) => Some(f), + _ => None, + }); + match gradient { + Some(f) => { + let fill = FillLayer { paint: self.placed(&f.paint, bounds), ..f.clone() }; + self.plain_object(path, rule, Some(&fill), None, xy.object()); + } + None => self.plain_object(path, rule, None, None, xy.object()), + } + } + + /// The art styles written, in a non-printing block: `%AI9_BeginArtStyles`, each style, + /// `%AI9_EndArtStyles`. + pub(in crate::ai_editing) fn art_styles(&self, s: &mut String) { + if self.styles.known.is_empty() { + return; + } + s.push_str("%AI5_Begin_NonPrinting\nNp\n%AI9_BeginArtStyles\n"); + for (name, text) in &self.styles.known { + s.push_str(&known_style(name, text)); + } + s.push_str("%AI9_EndArtStyles\n%AI5_End_NonPrinting--\n"); + } +} + +/// The outline a path's or compound path's style places a gradient fill on. +fn object_outline(n: &Node) -> Option { + match &n.kind { + NodeKind::Path { path, .. } => Some(path.to_bezpath()), + NodeKind::Compound { .. } => Some(compound_path(n).to_bezpath()), + _ => None, + } +} + +/// The outline a styled path or compound path draws: `n`'s, as baking left it, when it is still a +/// path or compound path, else `orig`'s. +fn drawn_outline(n: &Node, orig: &Node) -> Option { + object_outline(orig)?; + object_outline(n).or_else(|| object_outline(orig)) +} + +/// The fill rule of a path or compound path (a group's is the default). +fn object_rule(n: &Node) -> FillRule { + match &n.kind { + NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } => *rule, + _ => FillRule::NonZero, + } +} + +/// Does the reader read object `n` written with style text `text` as it is: a path or compound +/// path with a style it reads on any object, filled by the non-zero rule when the style fills it, +/// or a group with no fills or strokes of its own? +fn object_reads(n: &Node, text: &str) -> bool { + let section = known_style("x", text); + let own = n.appearance.fill().map(|f| &f.paint).filter(|p| matches!(p, Paint::Gradient(_))); + let Some(mut read) = crate::import::read_art_style(section.as_bytes(), "x", own) else { return false }; + let group = matches!(n.kind, NodeKind::Group { .. }); + if !read.simple || (group && !read.appearance.items.is_empty()) || (!group && read.pathfinder) { + return false; + } + if !group { + let filled = n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(_)) && !i.paint().is_none()); + if filled && object_rule(n) == FillRule::EvenOdd { + return false; + } + // A path's contents are nothing: the reader keeps the object's own. + read.appearance.contents_index = n.appearance.contents_index; + } + same_appearance(&read.appearance, &n.appearance) +} + +/// Do appearances `a` and `b` draw the same, as the reader reads one written from the other: the +/// fills and strokes that paint, within the written numbers' precision, and the effects with +/// their settings filled in from their defaults (blending modes in any case, colors as colors). +pub(in crate::ai_editing) fn same_appearance(a: &Appearance, b: &Appearance) -> bool { + let norm = |ap: &Appearance| -> Value { + let mut ap = ap.clone(); + let ci = ap.contents_index.unwrap_or(0).min(ap.items.len()); + let below = ap.items.iter().take(ci).filter(|i| !i.paint().is_none()).count(); + ap.items.retain(|i| !i.paint().is_none()); + ap.contents_index = (below > 0).then_some(below); + for e in ap.effects.iter_mut().chain(ap.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut())) { + e.params = normalized_params(e); + } + serde_json::to_value(&ap).unwrap_or(Value::Null) + }; + near(&norm(a), &norm(b)) +} + +/// Effect `e`'s settings with its defaults filled in, its blending mode in lower case and its color as +/// `#rrggbb`. +fn normalized_params(e: &Effect) -> Value { + let mut p = vectorcraft_effects::merged_params(&e.id, &e.params); + if let Some(m) = word(&p, "mode") { + p["mode"] = Value::String(m); + } + if p.get("color").is_some() { + p["color"] = Value::String(vectorcraft_effects::color_param(&p, vectorcraft_color::Color::BLACK).to_hex()); + } + for key in ["shade", "source", "joins", "points", "orientation"] { + if let Some(w) = word(&p, key) { + p[key] = Value::String(w); + } + } + p +} + +/// How far apart two numbers may read in [`near`]: a number written to 4 decimals, then converted +/// from a fraction to a percentage (`5e-3`) or from radians to degrees (`3e-3`). +const NEAR: f64 = 6e-3; + +/// JSON values equal but for numbers within the written numbers' precision. +fn near(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) { + (Some(x), Some(y)) => (x - y).abs() <= NEAR, + _ => x == y, + }, + (Value::Array(x), Value::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(x, y)| near(x, y)), + (Value::Object(x), Value::Object(y)) => x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| near(v, w))), + _ => a == b, + } +} diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index 87f1505d6..4b5b47c28 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -70,7 +70,7 @@ use graphics::{GState, Out}; use interp::{Fault, Interp}; use obj::PsError; -pub(crate) use ai::{MAX_GRADIENT_STOPS, MAX_SYMBOLS, symbol_art_member}; +pub(crate) use ai::{MAX_GRADIENT_STOPS, MAX_SYMBOLS, read_art_style, symbol_art_member}; pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES}; pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, is_loss, layered_ai, layered_ai_from}; pub(crate) use native::{MAX_TEXT, scale_document}; diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index c1fd84db3..a0720d3c1 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -41,7 +41,7 @@ pub use read::{ BindingRef, Brushes, IMAGE_TOO_LARGE, PaletteSwatches, Structure, artboards, bindings_note, brushes, graphic_styles, palette, read, read_beside, variables, }; -pub(crate) use read::{MAX_SYMBOLS, join_and, slot_frame, slot_of, symbol_art_member}; +pub(crate) use read::{MAX_SYMBOLS, join_and, read_art_style, slot_frame, slot_of, symbol_art_member}; pub(crate) use read::is_look_slot; diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 41479444a..3386f5098 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -136,6 +136,7 @@ pub(crate) use art::join_and; use art::preview_png; pub use document::{BindingRef, Brushes, PaletteSwatches, VariableRead, artboards, bindings_note, brushes, graphic_styles, palette, variables}; pub(crate) use document::{slot_frame, slot_of}; +pub(crate) use ops::read_art_style; pub use types::IMAGE_TOO_LARGE; use types::{BLEND_BUDGET, FLIP, Frame, GState, Graph, Kind, PatternHead, PendingEnvelope, Place, Raster, SymbolHead}; #[cfg(test)] diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 9535aa062..1be4f716e 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -1121,6 +1121,37 @@ pub(super) fn appearance_from_art_style(st: &crate::import::ai::style::ArtStyle) Some(Appearance { items, effects: st.effects.clone(), contents_index: st.contents_index }) } +/// What the reader makes of art style `name` in `section` (the text between `%AI9_BeginArtStyles` +/// and its end), for writers that check a style before they write it. +#[derive(Clone, Debug)] +pub(crate) struct StyleRead { + /// The style's appearance: a gradient fill is the object's own gradient. + pub appearance: Appearance, + /// An object other than type is read with it ([`crate::import::ai::style::ArtStyle::simple`]). + pub simple: bool, + /// It has Effect › Pathfinder effects, which only a group with no fills or strokes is read with. + pub pathfinder: bool, +} + +/// Art style `name` of `section` as the reader reads it ([`StyleRead`]): [`appearance_from_art_style`], +/// with a style's one gradient fill as `own_gradient`, the object's own gradient, as [`styled`] reads +/// it on objects other than type. `None` when the section doesn't define it, the reader doesn't read +/// it, or it paints with a pattern, a brush or a gradient that isn't the object's own. +pub(crate) fn read_art_style(section: &[u8], name: &str, own_gradient: Option<&Paint>) -> Option { + use crate::import::ai::style::StyleItem; + let mut st = crate::import::ai::style::art_styles(section).remove(name)?; + if st.items.iter().filter(|i| matches!(i, StyleItem::Gradient { .. })).count() == 1 { + let own = own_gradient?; + for item in &mut st.items { + if matches!(item, StyleItem::Gradient { .. }) { + *item = StyleItem::Fill(own.clone()); + } + } + } + let appearance = appearance_from_art_style(&st)?; + Some(StyleRead { appearance, simple: st.simple, pathfinder: st.pathfinder }) +} + /// A style's stroke as an appearance item (`None` for a fill). fn stroke_layer(item: &crate::import::ai::style::StyleItem) -> Option { let crate::import::ai::style::StyleItem::Stroke { paint, width, cap, join, miter, dash, profile, brush } = item else { return None }; diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index bdb9a3d6e..da9a83ad7 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -1186,3 +1186,32 @@ fn convert_to_shape_settings_that_arent_read_keep_the_drawn_look() { format!("/KnownStyle :\n(Odd) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &[compound("Stack Style Filter", &[item])])); assert!(!art_styles(data.as_bytes())["Odd"].plain); } + +/// A style as a writer checks it: the reader's appearance, with a gradient fill as the object's own, +/// and nothing for styles it doesn't read. +#[test] +fn a_style_is_read_as_a_writer_checks_it() { + let read = |data: &str, own: Option<&Paint>| crate::import::read_art_style(data.as_bytes(), "Odd", own); + let style = |kids: &[String]| format!("/KnownStyle :\n(Odd) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", kids)); + let gradient = fill("Bb\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n0 BB"); + let stack = compound("Stack Style Filter", std::slice::from_ref(&gradient)); + let data = style(&[stack, blend(1, 0.5)]); + let own = Paint::solid(vectorcraft_color::Color::WHITE); + let got = read(&data, Some(&own)).unwrap(); + assert_eq!(got.appearance.items.len(), 1); + assert_eq!(got.appearance.items[0].paint(), &own); + assert!(got.simple && !got.pathfinder); + // A gradient that isn't the object's own, a pattern, another name, two gradients: nothing. + assert!(read(&data, None).is_none()); + let pattern = style(&[compound("Stack Style Filter", &[fill("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p")])]); + assert!(read(&pattern, None).is_none()); + assert!(crate::import::read_art_style(data.as_bytes(), "Even", Some(&own)).is_none()); + assert!(read(&style(&[compound("Stack Style Filter", &[gradient.clone(), gradient])]), Some(&own)).is_none()); + // Damaged text: every cut of the style, and stray bytes, without a panic. + for cut in 0..data.len() { + let _ = read(data.get(..cut).unwrap_or_default(), Some(&own)); + } + for junk in ["(", ")", ";", "/KnownStyle :\n(Odd) /Name ,\n;;;;", "/KnownStyle :\n(Odd) /Name ,\n/Array : 1 /Real , ; (x) ,"] { + let _ = read(junk, Some(&own)); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index aeecaa140..369127d3e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -85,7 +85,15 @@ private editing data. VectorCraft: instances that scale their symbol more than 100 times and instances on the layers of a large canvas are written as their art, pattern fills in a symbol's art are written as their tiles cut to the shape, symbols past 4,096 are written only as their instances' art, and symbols' 9-slice guides, export types and dynamic type are left out, each with a - warning. + warning. Paths, compound paths and groups with live effects keep them as art styles: the drawn look, the object + itself and the style it names, for a fill and a stroke at most in solid colors or the object's gradient (a stroke + with its variable-width profile), filled by the non-zero rule, with Drop Shadow, Inner Glow, Outer Glow, Feather, + Round Corners, Scribble, Convert to Shape, Offset Path, Outline Object or Outline Stroke, and groups with Pathfinder › + Add. Groups keep them when their members are paths, compound paths or such groups, without effects of their own; when the + group's effects change its members' outlines, a member's strokes are plain lines, and its fills are not pattern + fills written as tiles or gradients that fit their object. The look holds the object's vector art, without the shadow, glow or feather. Other objects are + written as their drawn look with a warning. Graphic styles and links to them are left out with a warning. No art style is written on a large + canvas. Point type, area type (with its rows, columns, gutters and first baseline, threaded frames included) and type on a path are written as text: a text object for each, naming its story in the text document, with the characters, fonts, sizes, leading, tracking, baseline shift, @@ -100,7 +108,8 @@ private editing data. VectorCraft: color, each with a warning. Gradients that dither or mix their colors perceptually are written as classic gradients, and gradient stops linked to global process colors as their colors, each with a warning. The opacity and blending mode of single fills and strokes, opacity masks on layers, the placement of pattern swatches that - move, turn or scale their pattern, and effects other than geometry effects are left out. + move, turn or scale their pattern, and the effects of objects written without an art style, other than geometry + effects, are left out. - **Writes** a legacy `.ai` of version 7 (Save As › `ai` › Version: Legacy (version 7), `aiVersion: "7"`): the PostScript form, from the published *Adobe Illustrator File Format Specification* version 7.0 ([PRONOM fmt/423](https://www.nationalarchives.gov.uk/PRONOM/fmt/423)), which apps reading `.ai` open with its From 05c1be35488229ac752bd614e3089a430f4b2b82 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 10:29:51 -0800 Subject: [PATCH 059/140] JavaScript: parser for ES5 scripts, with cargo xtask test262 New crate vectorcraft-js reads ECMAScript 5.1 source into a syntax tree with source positions: a UTF-16 lexer (line and column tracking, automatic semicolon insertion, the regexp and division goals), preprocessor lines read as directive tokens, a recursive-descent parser to an arena tree, and the ES5 early errors, strict mode included. Nesting deeper than Options::max_depth (200 by default) gives a SyntaxError with its line and column rather than overflowing the stack. Nothing runs scripts yet. cargo xtask test262 downloads test262 at a pinned commit, checks its SHA-256, unpacks it into the gitignored corpus/ folder and runs the ES5 syntax tests. A differential test in the engine compares the lexer with scriptcheck on line counts, directives and unterminated literals. --- Cargo.lock | 8 + Cargo.toml | 1 + crates/engine/Cargo.toml | 2 + .../tests/it/js_lexer_vs_scriptcheck.rs | 102 ++ crates/engine/tests/it/main.rs | 1 + crates/js/Cargo.toml | 13 + crates/js/src/ast.rs | 334 +++++ crates/js/src/chars.rs | 99 ++ crates/js/src/error.rs | 29 + crates/js/src/lexer.rs | 637 +++++++++ crates/js/src/lib.rs | 149 ++ crates/js/src/number.rs | 69 + crates/js/src/parser.rs | 1233 +++++++++++++++++ crates/js/src/regexp.rs | 191 +++ crates/js/src/tests.rs | 760 ++++++++++ crates/js/src/token.rs | 391 ++++++ crates/js/tests/test262.rs | 178 +++ docs/target-app-parity.md | 1 + xtask/src/layers.rs | 1 + xtask/src/main.rs | 6 + xtask/src/test262.rs | 73 + 21 files changed, 4278 insertions(+) create mode 100644 crates/engine/tests/it/js_lexer_vs_scriptcheck.rs create mode 100644 crates/js/Cargo.toml create mode 100644 crates/js/src/ast.rs create mode 100644 crates/js/src/chars.rs create mode 100644 crates/js/src/error.rs create mode 100644 crates/js/src/lexer.rs create mode 100644 crates/js/src/lib.rs create mode 100644 crates/js/src/number.rs create mode 100644 crates/js/src/parser.rs create mode 100644 crates/js/src/regexp.rs create mode 100644 crates/js/src/tests.rs create mode 100644 crates/js/src/token.rs create mode 100644 crates/js/tests/test262.rs create mode 100644 xtask/src/test262.rs diff --git a/Cargo.lock b/Cargo.lock index 6f92cb310..c48d0daae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6265,6 +6265,7 @@ dependencies = [ "vectorcraft-eps", "vectorcraft-format", "vectorcraft-geom", + "vectorcraft-js", "vectorcraft-metafile", "vectorcraft-pathops", "vectorcraft-pdf", @@ -6332,6 +6333,13 @@ dependencies = [ "vectorcraft-testkit", ] +[[package]] +name = "vectorcraft-js" +version = "0.21.0" +dependencies = [ + "proptest", +] + [[package]] name = "vectorcraft-mcp" version = "0.21.0" diff --git a/Cargo.toml b/Cargo.toml index 924a1a68d..528718b78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,7 @@ vectorcraft-metafile = { path = "crates/metafile" } vectorcraft-plugins = { path = "crates/plugins" } vectorcraft-affinity = { path = "crates/affinity" } vectorcraft-scriptcheck = { path = "crates/scriptcheck" } +vectorcraft-js = { path = "crates/js" } # foundation kurbo = { version = "0.13", features = ["serde"] } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 6789a2116..eddf966db 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -66,3 +66,5 @@ vectorcraft-pdf = { workspace = true, features = ["read-count"] } wat = { workspace = true } # Zstandard streams for the Illustrator editing data fuzz tests. ruzstd = { workspace = true } +# The JavaScript parser's lexer, compared with the script checker's on the same scripts. +vectorcraft-js = { workspace = true } diff --git a/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs b/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs new file mode 100644 index 000000000..6c84c66b3 --- /dev/null +++ b/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs @@ -0,0 +1,102 @@ +//! The JavaScript parser's lexer (`vectorcraft-js`) and the script checker's (`vectorcraft-scriptcheck`) +//! read the same scripts the same way: both crates stand alone, so each has its own lexer, and these +//! tests run both over one set of scripts and fail on any disagreement about the lines a script has, +//! its preprocessor lines, and where a string, comment or regular expression that is never closed +//! starts. The scripts stay inside ECMAScript 3, the language both read (no XML literals, which +//! only the checker reads). + +use vectorcraft_js::{Options, TokenKind, parse, tokenize}; +use vectorcraft_scriptcheck::{Limits, check}; + +/// The problems the checker reports from its lexer, which the parser reports as a `SyntaxError`. +const LEXICAL: [(&str, &str); 3] = [ + ("unterminatedString", "unterminated string"), + ("unterminatedComment", "unterminated comment"), + ("unterminatedRegExp", "unterminated regular expression"), +]; + +/// Scripts both lexers read without a problem, each with a `/` or a quote in a place where reading +/// it the other way would change the tokens. +const VALID: &[&str] = &[ + "var a = b / c / d;\nvar r = /ab+c/gi;\n", + "if (x) /re/.test(y);\nwhile (z) /q/g.exec(w);\n", + "function f() { return /x/; }\nvar g = function () {} / 2;\n", + "{}\n/re/.test(s);\n", + "x = {a: 1} / 2;\n", + "a = b\n/c/g\n", + "s = 'it\\'s \"q\" // not a comment';\nt = \"/* nor this */\";\n", + "s = 'line\\\ncontinued';\nu = 1;\n", + "x = a++ / 2;\ny = (b) / 2;\nz = c[0] / 2;\n", + "r = /[/]/;\nq = /\\//;\n", + "#target vectorcraft\n#include \"lib.jsxinc\"\n//@includepath \"~/x\"\nvar a = 1;\n#targetengine session\n", + "a = 1;\r\nb = 2;\r\nc = 3;", + "a = 1;\u{2028}b = 2;\u{2029}c = 3;\n", + "typeof /x/ === 'object';\nvoid /y/;\n", + "switch (x) { case /a/.test(y): break; }\n", + "x = y ? /a/ : /b/;\n", + "label: for (;;) { break label; }\n/* multi\nline */ z = 1 / 2;\n", + "var o = { re: /x/, d: 4 / 2 };\n", + "x = [/a/, 1 / 2];\n", + "if (a) {\n} else /x/.test(b);\n", + "a = 'q' + \"r\";\n// a comment with a 'quote\n", + "x = a\n++b\n", + "for (var i = 0; i < n / 2; i++) { s += /\\d/.exec(t[i]) ? 1 : 0; }\n", + "", + "\n\n", +]; + +/// Scripts with a string, comment or regular expression that is never closed: the problem the +/// checker gives and the line it starts on. +const INVALID: &[(&str, &str, usize)] = &[ + ("var a = 1;\nvar s = 'abc;\n", "unterminatedString", 2), + ("a = 1;\n/* never closed\nb = 2;\n", "unterminatedComment", 2), + ("x = 1;\ny = /abc\n", "unterminatedRegExp", 2), + ("if (x)\n /abc\n", "unterminatedRegExp", 2), + ("a = 1;\r\nb = \"x\r\n", "unterminatedString", 2), + ("q = 2 / 3;\nr = /[/]\n", "unterminatedRegExp", 2), +]; + +fn js_options() -> Options { + Options { preprocessor_lines: true, ..Options::default() } +} + +#[test] +fn both_lexers_count_the_same_lines_and_read_the_same_directives() { + for src in VALID { + let program = parse(src, &js_options()).unwrap_or_else(|e| panic!("{src:?}: {e}")); + let report = check("script.jsx", src.as_bytes(), None, &Limits::default()); + assert_eq!(report.lines, program.line_count() as usize, "lines of {src:?}"); + let lexical: Vec<_> = report.problems.iter().filter(|p| LEXICAL.iter().any(|(code, _)| *code == p.code)).collect(); + assert!(lexical.is_empty(), "{src:?}: the checker reports {lexical:?}"); + let theirs: Vec<(String, usize)> = report.directives.iter().map(|d| (d.name.to_string(), d.line)).collect(); + let ours: Vec<(String, usize)> = program.directives.iter().map(|d| (d.name.to_lowercase(), d.line as usize)).collect(); + assert_eq!(ours, theirs, "directives of {src:?}"); + } +} + +#[test] +fn both_lexers_find_the_same_unclosed_token_on_the_same_line() { + for (src, code, line) in INVALID { + let report = check("script.jsx", src.as_bytes(), None, &Limits::default()); + let theirs = report.problems.iter().find(|p| LEXICAL.iter().any(|(c, _)| *c == p.code)); + let theirs = theirs.unwrap_or_else(|| panic!("{src:?}: the checker reports no lexical problem")); + assert_eq!((theirs.code, theirs.line), (*code, Some(*line)), "{src:?}"); + let ours = parse(src, &js_options()).expect_err(src); + let message = LEXICAL.iter().find(|(c, _)| c == code).map(|(_, m)| *m).unwrap(); + assert!(ours.message.contains(message), "{src:?}: {}", ours.message); + assert_eq!(ours.line as usize, *line, "{src:?}"); + } +} + +#[test] +fn the_parser_reads_a_regexp_wherever_the_checker_does() { + // Each `/` that divides has no second `/` after it on its line, so a lexer that read one as a + // regular expression would report it as never closed. The checker reports nothing, and the + // parser reads regular expressions on lines 2 and 3 only. + let src = "a = b / c;\nr = /x/;\nif (a) /y/.test(b);\nz = (1) / 2;\n"; + let tokens = tokenize(src, &js_options()).unwrap(); + let lines: Vec = tokens.iter().filter(|t| matches!(t.kind, TokenKind::RegExp { .. })).map(|t| t.line).collect(); + assert_eq!(lines, [2, 3]); + let report = check("script.jsx", src.as_bytes(), None, &Limits::default()); + assert!(report.problems.iter().all(|p| !LEXICAL.iter().any(|(c, _)| *c == p.code)), "{:?}", report.problems); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index f17d27e4d..55edb3dcb 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -18,6 +18,7 @@ mod dimensions; mod eyedropper_sources; mod find_replace_paras; mod inline_text; +mod js_lexer_vs_scriptcheck; mod known_bugs; mod lossy_overwrite; mod model_based; diff --git a/crates/js/Cargo.toml b/crates/js/Cargo.toml new file mode 100644 index 000000000..f39ab33b6 --- /dev/null +++ b/crates/js/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "vectorcraft-js" +description = "JavaScript for scripts: an ECMAScript 5.1 lexer and parser to a syntax tree with source positions" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dev-dependencies] +proptest = { workspace = true } diff --git a/crates/js/src/ast.rs b/crates/js/src/ast.rs new file mode 100644 index 000000000..f0dd6b44b --- /dev/null +++ b/crates/js/src/ast.rs @@ -0,0 +1,334 @@ +//! The syntax tree: expressions, statements and functions kept in arenas ([`Ast`]) and referred to +//! by index, each with its source span. Nodes hold indices rather than boxes, so dropping a deeply +//! nested tree does not recurse. + +use crate::token::Span; + +/// An expression's index in [`Ast::exprs`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ExprId(pub u32); + +/// A statement's index in [`Ast::stmts`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct StmtId(pub u32); + +/// A function's index in [`Ast::functions`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct FunctionId(pub u32); + +/// An identifier as written, with its span. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Name { + pub name: String, + pub span: Span, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UnaryOp { + Delete, + Void, + Typeof, + Plus, + Minus, + BitNot, + Not, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UpdateOp { + Increment, + Decrement, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BinaryOp { + Mul, + Div, + Mod, + Add, + Sub, + Shl, + Shr, + UShr, + Lt, + Gt, + LtEq, + GtEq, + Instanceof, + In, + Eq, + NotEq, + StrictEq, + StrictNotEq, + BitAnd, + BitXor, + BitOr, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LogicalOp { + And, + Or, +} + +/// `=` (`None`) or a compound assignment's operator (`+=` is `Some(BinaryOp::Add)`). +pub type AssignOp = Option; + +/// A property name in an object literal. +#[derive(Clone, Debug, PartialEq)] +pub enum PropertyKey { + Ident(String), + String(Vec), + Number(f64), +} + +impl PropertyKey { + /// The key as the string a property is stored under (a number through ToString). + pub fn to_utf16(&self) -> Vec { + match self { + PropertyKey::Ident(name) => name.encode_utf16().collect(), + PropertyKey::String(s) => s.clone(), + PropertyKey::Number(n) => crate::number::to_string(*n).encode_utf16().collect(), + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum PropertyValue { + Init(ExprId), + Get(FunctionId), + Set(FunctionId), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Property { + pub key: PropertyKey, + pub value: PropertyValue, + pub span: Span, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Expr { + This, + Ident(String), + Null, + Bool(bool), + Number(f64), + String(Vec), + RegExp { + pattern: Vec, + flags: String, + }, + /// An array literal; `None` is a hole (`[1, , 3]`). + Array(Vec>), + Object(Vec), + Function(FunctionId), + /// `object.property`. + Member { + object: ExprId, + property: Name, + }, + /// `object[index]`. + Index { + object: ExprId, + index: ExprId, + }, + Call { + callee: ExprId, + args: Vec, + }, + New { + callee: ExprId, + args: Vec, + }, + Update { + op: UpdateOp, + prefix: bool, + target: ExprId, + }, + Unary { + op: UnaryOp, + arg: ExprId, + }, + Binary { + op: BinaryOp, + left: ExprId, + right: ExprId, + }, + Logical { + op: LogicalOp, + left: ExprId, + right: ExprId, + }, + Conditional { + test: ExprId, + consequent: ExprId, + alternate: ExprId, + }, + Assign { + op: AssignOp, + target: ExprId, + value: ExprId, + }, + /// Comma-separated expressions. + Sequence(Vec), + /// A parenthesized expression, kept so a rule about the bare form (a label, a directive) can + /// tell them apart. + Paren(ExprId), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct VarDecl { + pub name: Name, + pub init: Option, +} + +/// What a `for (… in …)` statement assigns each property name to. +#[derive(Clone, Debug, PartialEq)] +pub enum ForInTarget { + /// `for (var x in o)`, with the declaration's initializer if it has one. + Var(VarDecl), + Expr(ExprId), +} + +#[derive(Clone, Debug, PartialEq)] +pub enum ForInit { + Var(Vec), + Expr(ExprId), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SwitchCase { + /// `None` for `default`. + pub test: Option, + pub body: Vec, + pub span: Span, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CatchClause { + pub param: Name, + pub body: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Stmt { + Var(Vec), + FunctionDecl(FunctionId), + Expr(ExprId), + Block(Vec), + Empty, + If { test: ExprId, consequent: StmtId, alternate: Option }, + DoWhile { body: StmtId, test: ExprId }, + While { test: ExprId, body: StmtId }, + For { init: Option, test: Option, update: Option, body: StmtId }, + ForIn { target: ForInTarget, object: ExprId, body: StmtId }, + Continue(Option), + Break(Option), + Return(Option), + With { object: ExprId, body: StmtId }, + Switch { discriminant: ExprId, cases: Vec }, + Labeled { label: Name, body: StmtId }, + Throw(ExprId), + Try { block: Vec, handler: Option, finalizer: Option> }, + Debugger, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FunctionKind { + Declaration, + Expression, + Getter, + Setter, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Function { + pub kind: FunctionKind, + pub name: Option, + pub params: Vec, + pub body: Vec, + /// The function's code is strict mode code: its own directive prologue holds `"use strict"`, + /// or the code around it is strict. + pub strict: bool, + pub span: Span, +} + +/// The arenas of a parsed script. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Ast { + pub exprs: Vec<(Expr, Span)>, + pub stmts: Vec<(Stmt, Span)>, + pub functions: Vec, +} + +impl Ast { + pub fn expr(&self, id: ExprId) -> Option<&Expr> { + self.exprs.get(id.0 as usize).map(|(e, _)| e) + } + + pub fn expr_span(&self, id: ExprId) -> Span { + self.exprs.get(id.0 as usize).map(|(_, s)| *s).unwrap_or_default() + } + + pub fn stmt(&self, id: StmtId) -> Option<&Stmt> { + self.stmts.get(id.0 as usize).map(|(s, _)| s) + } + + pub fn stmt_span(&self, id: StmtId) -> Span { + self.stmts.get(id.0 as usize).map(|(_, s)| *s).unwrap_or_default() + } + + pub fn function(&self, id: FunctionId) -> Option<&Function> { + self.functions.get(id.0 as usize) + } + + /// The expression with any parentheses around it removed. + pub fn unparenthesized(&self, mut id: ExprId) -> ExprId { + while let Some(Expr::Paren(inner)) = self.expr(id) { + id = *inner; + } + id + } + + pub(crate) fn push_expr(&mut self, expr: Expr, span: Span) -> ExprId { + self.exprs.push((expr, span)); + ExprId(u32::try_from(self.exprs.len() - 1).unwrap_or(u32::MAX)) + } + + pub(crate) fn push_stmt(&mut self, stmt: Stmt, span: Span) -> StmtId { + self.stmts.push((stmt, span)); + StmtId(u32::try_from(self.stmts.len() - 1).unwrap_or(u32::MAX)) + } + + pub(crate) fn push_function(&mut self, function: Function) -> FunctionId { + self.functions.push(function); + FunctionId(u32::try_from(self.functions.len() - 1).unwrap_or(u32::MAX)) + } +} + +/// A parsed script. +#[derive(Clone, Debug, PartialEq)] +pub struct Program { + pub ast: Ast, + pub body: Vec, + /// The script is strict mode code (its directive prologue holds `"use strict"`, or it was + /// parsed with [`crate::Options::strict`]). + pub strict: bool, + /// Its preprocessor lines, in source order. + pub directives: Vec, + pub(crate) lines: crate::LineTable, +} + +impl Program { + /// The line and column (both from 1, the column in UTF-16 code units) of a source offset. + pub fn line_column(&self, offset: u32) -> (u32, u32) { + self.lines.line_column(offset) + } + + /// The number of lines in the source: its line terminators plus one for a last line without + /// one. + pub fn line_count(&self) -> u32 { + self.lines.line_count() + } +} diff --git a/crates/js/src/chars.rs b/crates/js/src/chars.rs new file mode 100644 index 000000000..fb6da4f36 --- /dev/null +++ b/crates/js/src/chars.rs @@ -0,0 +1,99 @@ +//! Character classes of ECMA-262 5.1 section 7: white space, line terminators and the characters +//! identifiers are made of. + +/// A line terminator: LF, CR, U+2028 or U+2029 (section 7.3). +pub fn is_line_terminator(c: u32) -> bool { + matches!(c, 0x0A | 0x0D | 0x2028 | 0x2029) +} + +/// White space (section 7.2): tab, vertical tab, form feed, space, no-break space, the byte order +/// mark and the other space separators (Unicode category Zs). +pub fn is_whitespace(c: u32) -> bool { + matches!(c, 0x09 | 0x0B | 0x0C | 0x20 | 0xA0 | 0xFEFF | 0x1680 | 0x2000..=0x200A | 0x202F | 0x205F | 0x3000) +} + +/// A character an identifier may start with: a letter (categories Lu, Ll, Lt, Lm, Lo and Nl), +/// `$` or `_` (section 7.6). `\u` escapes are decoded before this check. +pub fn is_id_start(c: u32) -> bool { + match c { + 0x24 | 0x5F => true, + 0x41..=0x5A | 0x61..=0x7A => true, + 0..=0x7F => false, + // U+2E2F VERTICAL TILDE is a letter (Lm) that Unicode leaves out of identifiers. + 0x2E2F => false, + _ => char::from_u32(c).is_some_and(|ch| ch.is_alphabetic() && !is_combining_mark(c)), + } +} + +/// A character an identifier may hold after its first: those it may start with, decimal digits +/// (Nd), combining marks (Mn, Mc), connector punctuation (Pc), and the zero width non-joiner and +/// joiner. +pub fn is_id_part(c: u32) -> bool { + match c { + 0x30..=0x39 => true, + 0x200C | 0x200D => true, + 0x2E2F => false, + _ if is_id_start(c) => true, + 0..=0x7F => false, + _ => char::from_u32(c).is_some_and(|ch| ch.is_alphanumeric()) || is_combining_mark(c) || is_connector(c), + } +} + +/// The blocks of combining marks (categories Mn and Mc) that scripts are most likely to use. +fn is_combining_mark(c: u32) -> bool { + matches!( + c, + 0x0300..=0x036F + | 0x0483..=0x0487 + | 0x0591..=0x05BD + | 0x05BF + | 0x05C1..=0x05C2 + | 0x05C4..=0x05C5 + | 0x05C7 + | 0x0610..=0x061A + | 0x064B..=0x065F + | 0x0670 + | 0x06D6..=0x06DC + | 0x06DF..=0x06E4 + | 0x06E7..=0x06E8 + | 0x06EA..=0x06ED + | 0x0900..=0x0903 + | 0x093A..=0x093C + | 0x093E..=0x094F + | 0x0951..=0x0957 + | 0x0962..=0x0963 + | 0x0E31 + | 0x0E34..=0x0E3A + | 0x0E47..=0x0E4E + | 0x1AB0..=0x1AFF + | 0x1DC0..=0x1DFF + | 0x20D0..=0x20FF + | 0x302A..=0x302F + | 0x3099..=0x309A + | 0xFE00..=0xFE0F + | 0xFE20..=0xFE2F + ) +} + +/// Connector punctuation (category Pc) other than `_`. +fn is_connector(c: u32) -> bool { + matches!(c, 0x203F | 0x2040 | 0x2054 | 0xFE33 | 0xFE34 | 0xFE4D..=0xFE4F | 0xFF3F) +} + +/// The value of a hexadecimal digit. +pub fn hex_value(c: u32) -> Option { + match c { + 0x30..=0x39 => Some(c - 0x30), + 0x41..=0x46 => Some(c - 0x41 + 10), + 0x61..=0x66 => Some(c - 0x61 + 10), + _ => None, + } +} + +pub fn is_decimal_digit(c: u32) -> bool { + (0x30..=0x39).contains(&c) +} + +pub fn is_octal_digit(c: u32) -> bool { + (0x30..=0x37).contains(&c) +} diff --git a/crates/js/src/error.rs b/crates/js/src/error.rs new file mode 100644 index 000000000..9c17bf8be --- /dev/null +++ b/crates/js/src/error.rs @@ -0,0 +1,29 @@ +//! The error a script that cannot be read gives: a `SyntaxError` with its message and where in the +//! source it is. + +use std::fmt; + +/// A script that does not follow the grammar, or breaks one of its early-error rules. `line` and +/// `column` count from 1; the column counts UTF-16 code units from the start of the line, as +/// JavaScript source positions do. `offset` is the UTF-16 offset from the start of the source. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SyntaxError { + pub message: String, + pub line: u32, + pub column: u32, + pub offset: u32, +} + +impl fmt::Display for SyntaxError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "SyntaxError: {} (line {}, column {})", self.message, self.line, self.column) + } +} + +impl std::error::Error for SyntaxError {} + +/// The message of the error nesting deeper than [`crate::Options::max_depth`] gives. +pub const TOO_DEEP: &str = "nesting too deep"; + +/// The message of the error a source longer than [`crate::Options::max_source_units`] gives. +pub const TOO_LONG: &str = "source too long"; diff --git a/crates/js/src/lexer.rs b/crates/js/src/lexer.rs new file mode 100644 index 000000000..9b16b16c7 --- /dev/null +++ b/crates/js/src/lexer.rs @@ -0,0 +1,637 @@ +//! The lexer (ECMA-262 5.1 section 7): source text in UTF-16 code units into tokens, with each +//! token's line and column and whether a line terminator comes before it. +//! +//! A `/` starts a division punctuator or a regular expression literal depending on where the +//! grammar is (section 7, the InputElementDiv and InputElementRegExp goals). The lexer reads a +//! token for the goal it is given; the parser reads tokens for the division goal and asks for a +//! token to be read again as a regular expression where an expression starts +//! ([`Lexer::rescan_regexp`]). +//! +//! With [`crate::Options::preprocessor_lines`], a `#name value` line, or a `//@name value` comment +//! that names a directive, at the start of a line (only blanks before it) gives a +//! [`TokenKind::Directive`] token. +//! +//! The lexer moves forward through the source one character at a time and never recurses. + +use crate::chars::{hex_value, is_decimal_digit, is_id_part, is_id_start, is_line_terminator, is_octal_digit, is_whitespace}; +use crate::error::SyntaxError; +use crate::regexp; +use crate::token::{Directive, DirectiveName, Keyword, Punct, Span, Token, TokenKind}; + +/// Which goal symbol the next token is read for: whether a `/` there divides or starts a regular +/// expression literal. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Goal { + Div, + RegExp, +} + +const BACKSLASH: u32 = b'\\' as u32; +const SLASH: u32 = b'/' as u32; +const STAR: u32 = b'*' as u32; +const DOT: u32 = b'.' as u32; +const CR: u32 = 0x0D; +const LF: u32 = 0x0A; + +/// The lexer's state. It is small and cheap to clone, which is how the parser looks one token +/// ahead. +#[derive(Clone, Debug)] +pub struct Lexer<'s> { + src: &'s [u16], + pos: usize, + /// The current line (from 1) and the offset where it starts. + line: u32, + line_start: usize, + /// Only blanks since the start of the line: a preprocessor line may start here. + blank_line: bool, + preprocessor_lines: bool, + /// The deepest nesting of groups a regular expression literal may have. + max_depth: usize, +} + +impl<'s> Lexer<'s> { + /// A lexer of `src`. `preprocessor_lines` turns on the reading of preprocessor lines; + /// `max_depth` limits how deeply a regular expression literal's groups may nest. + pub fn new(src: &'s [u16], preprocessor_lines: bool, max_depth: usize) -> Self { + Self { src, pos: 0, line: 1, line_start: 0, blank_line: true, preprocessor_lines, max_depth } + } + + /// The code unit at `pos`, as a code point value. + fn unit(&self, pos: usize) -> Option { + self.src.get(pos).map(|&u| u32::from(u)) + } + + fn peek(&self) -> Option { + self.unit(self.pos) + } + + fn peek_at(&self, n: usize) -> Option { + self.unit(self.pos + n) + } + + /// The code point at `pos` (a surrogate pair read as one) and how many units it takes. + fn code_point(&self, pos: usize) -> Option<(u32, usize)> { + let hi = self.unit(pos)?; + if (0xD800..0xDC00).contains(&hi) + && let Some(lo) = self.unit(pos + 1).filter(|lo| (0xDC00..0xE000).contains(lo)) + { + return Some((0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00), 2)); + } + Some((hi, 1)) + } + + fn column_of(&self, pos: usize) -> u32 { + u32::try_from(pos.saturating_sub(self.line_start) + 1).unwrap_or(u32::MAX) + } + + fn offset(pos: usize) -> u32 { + u32::try_from(pos).unwrap_or(u32::MAX) + } + + /// An error at `pos` on the current line. + fn error_at(&self, pos: usize, message: impl Into) -> SyntaxError { + SyntaxError { message: message.into(), line: self.line, column: self.column_of(pos), offset: Self::offset(pos) } + } + + /// An error at a position recorded earlier. + fn error_from(start: (usize, u32, u32), message: impl Into) -> SyntaxError { + SyntaxError { message: message.into(), line: start.1, column: start.2, offset: Self::offset(start.0) } + } + + /// Reads a line terminator at `pos` (CR LF as one), counting the line. False when there is + /// none. + fn line_terminator(&mut self) -> bool { + match self.peek() { + Some(CR) if self.peek_at(1) == Some(LF) => self.pos += 2, + Some(c) if is_line_terminator(c) => self.pos += 1, + _ => return false, + } + self.line = self.line.saturating_add(1); + self.line_start = self.pos; + self.blank_line = true; + true + } + + /// The next token, read for `goal`. + pub fn next_token(&mut self, goal: Goal) -> Result { + let newline_before = match self.skip_trivia()? { + Trivia::Directive(token) => return Ok(*token), + Trivia::Skipped { newline } => newline, + }; + let start = self.pos; + let (line, column) = (self.line, self.column_of(start)); + let Some(c) = self.peek() else { + return Ok(self.token(TokenKind::Eof, start, line, column, newline_before)); + }; + self.blank_line = false; + let mut token = if c == BACKSLASH || is_id_start(c) || self.code_point(start).is_some_and(|(cp, n)| n == 2 && is_id_start(cp)) { + self.identifier(start, line, column)? + } else if is_decimal_digit(c) || (c == DOT && self.peek_at(1).is_some_and(is_decimal_digit)) { + self.number(start, line, column)? + } else if c == u32::from(b'"') || c == u32::from(b'\'') { + self.string(start, line, column)? + } else if c == SLASH && goal == Goal::RegExp { + self.regexp(start, line, column)? + } else { + let punct = self.punct().ok_or_else(|| self.error_at(start, unexpected_character(c)))?; + self.token(TokenKind::Punct(punct), start, line, column, false) + }; + // A string continued over a line ends on a new line, but not at its start. + self.blank_line = false; + token.newline_before = newline_before; + Ok(token) + } + + /// Reads the token `token` again as a regular expression literal: the parser read a `/` or + /// `/=` where an expression starts. + pub fn rescan_regexp(&mut self, token: &Token) -> Result { + let start = usize::try_from(token.span.start).unwrap_or(usize::MAX); + self.pos = start; + self.line = token.line; + self.line_start = start.saturating_sub(usize::try_from(token.column.saturating_sub(1)).unwrap_or(0)); + self.blank_line = false; + let mut regexp = self.regexp(start, token.line, token.column)?; + regexp.newline_before = token.newline_before; + Ok(regexp) + } + + fn token(&self, kind: TokenKind, start: usize, line: u32, column: u32, newline_before: bool) -> Token { + Token { + kind, + span: Span::new(Self::offset(start), Self::offset(self.pos)), + line, + column, + newline_before, + escaped: false, + legacy_octal: false, + } + } + + /// Skips white space, line terminators and comments, and reads a preprocessor line when one + /// starts here. + fn skip_trivia(&mut self) -> Result { + let mut newline = false; + while let Some(c) = self.peek() { + if is_whitespace(c) { + self.pos += 1; + } else if self.line_terminator() { + newline = true; + } else if c == SLASH && self.peek_at(1) == Some(SLASH) { + if self.blank_line && self.preprocessor_lines && self.names_directive(self.pos + 2) { + return Ok(Trivia::Directive(Box::new(self.directive("//@", newline)))); + } + while self.peek().is_some_and(|c| !is_line_terminator(c)) { + self.pos += 1; + } + } else if c == SLASH && self.peek_at(1) == Some(STAR) { + newline |= self.block_comment()?; + // The comment's end is on this line, so a `#` after it is not at the line's start. + self.blank_line = false; + } else if c == u32::from(b'#') && self.blank_line && self.preprocessor_lines && self.peek_at(1).is_some_and(is_name_char) { + return Ok(Trivia::Directive(Box::new(self.directive("#", newline)))); + } else { + break; + } + } + Ok(Trivia::Skipped { newline }) + } + + /// Skips a `/* */` comment; true when it holds a line terminator. + fn block_comment(&mut self) -> Result { + let start = (self.pos, self.line, self.column_of(self.pos)); + self.pos += 2; + let mut newline = false; + loop { + match self.peek() { + None => return Err(Self::error_from(start, "unterminated comment")), + Some(STAR) if self.peek_at(1) == Some(SLASH) => { + self.pos += 2; + return Ok(newline); + } + Some(_) if self.line_terminator() => newline = true, + Some(_) => self.pos += 1, + } + } + } + + /// `//@` at `pos - 2` is followed by a directive's name. + fn names_directive(&self, pos: usize) -> bool { + if self.unit(pos) != Some(u32::from(b'@')) { + return false; + } + let name: String = self.src[pos + 1..].iter().take_while(|&&u| is_name_char(u32::from(u))).map(|&u| char::from(u as u8)).collect(); + DirectiveName::from_name(&name).is_some() + } + + /// Reads a preprocessor line written with `form` (`#` or `//@`), up to the end of the line. + fn directive(&mut self, form: &'static str, newline_before: bool) -> Token { + let start = self.pos; + let (line, column) = (self.line, self.column_of(start)); + self.pos += form.encode_utf16().count(); + let name_start = self.pos; + while self.peek().is_some_and(is_name_char) { + self.pos += 1; + } + let name = String::from_utf16_lossy(&self.src[name_start..self.pos]); + let value_start = self.pos; + while self.peek().is_some_and(|c| !is_line_terminator(c)) { + self.pos += 1; + } + let value = String::from_utf16_lossy(&self.src[value_start..self.pos]).trim().to_string(); + let directive = Directive { + known: DirectiveName::from_name(&name), + name, + value, + form, + span: Span::new(Self::offset(start), Self::offset(self.pos)), + line, + }; + self.blank_line = false; + self.token(TokenKind::Directive(directive), start, line, column, newline_before) + } + + /// Reads an identifier or a keyword (section 7.6), `\u` escapes included. + fn identifier(&mut self, start: usize, line: u32, column: u32) -> Result { + let mut name = String::new(); + let mut escaped = false; + let mut first = true; + loop { + let at = self.pos; + let (c, len, is_escape) = match self.code_point(at) { + Some((BACKSLASH, _)) => { + let c = self.identifier_escape()?; + (c, 0, true) + } + Some((c, len)) => (c, len, false), + None => break, + }; + let ok = if first { is_id_start(c) } else { is_id_part(c) }; + if !ok { + if is_escape { + return Err(self.error_at(at, "invalid character in identifier escape")); + } + break; + } + self.pos += len; + escaped |= is_escape; + first = false; + match char::from_u32(c) { + Some(ch) => name.push(ch), + None => return Err(self.error_at(at, "invalid character in identifier")), + } + } + let kind = match Keyword::from_word(&name) { + Some(k) if !escaped => TokenKind::Keyword(k), + _ => TokenKind::Ident(name), + }; + let mut token = self.token(kind, start, line, column, false); + token.escaped = escaped; + Ok(token) + } + + /// Reads a `\uXXXX` escape in an identifier and returns its code point. + fn identifier_escape(&mut self) -> Result { + let at = self.pos; + if self.peek_at(1) != Some(u32::from(b'u')) { + return Err(self.error_at(at, "invalid escape in identifier")); + } + self.pos += 2; + self.unicode_escape().ok_or_else(|| self.error_at(at, "invalid Unicode escape sequence")) + } + + /// After `\u`: four hexadecimal digits, or a code point in braces (`\u{1F600}`, ECMA-262 2015 + /// section 11.8.4) up to U+10FFFF. + fn unicode_escape(&mut self) -> Option { + if self.peek() != Some(u32::from(b'{')) { + return self.hex_digits(4); + } + let mut n = 1; + let mut value: u32 = 0; + while let Some(d) = self.peek_at(n).and_then(hex_value) { + value = value.checked_mul(16)?.checked_add(d).filter(|&v| v <= 0x10FFFF)?; + n += 1; + } + if n == 1 || self.peek_at(n) != Some(u32::from(b'}')) { + return None; + } + self.pos += n + 1; + Some(value) + } + + /// Reads `n` hexadecimal digits and returns their value, or `None` (leaving the position) when + /// fewer follow. + fn hex_digits(&mut self, n: usize) -> Option { + let mut value = 0; + for i in 0..n { + value = value * 16 + hex_value(self.peek_at(i)?)?; + } + self.pos += n; + Some(value) + } + + /// Reads a numeric literal (section 7.8.3, with the legacy octal literals of Annex B). + fn number(&mut self, start: usize, line: u32, column: u32) -> Result { + let mut legacy_octal = false; + let value = if self.peek() == Some(u32::from(b'0')) && self.peek_at(1).is_some_and(|c| c == u32::from(b'x') || c == u32::from(b'X')) { + self.pos += 2; + self.radix_digits(16).ok_or_else(|| self.error_at(start, "hexadecimal literal without digits"))? + } else if self.peek() == Some(u32::from(b'0')) && self.peek_at(1).is_some_and(is_decimal_digit) { + legacy_octal = true; + self.legacy_octal_or_decimal() + } else { + self.decimal() + }; + if self.peek().is_some_and(|c| c == BACKSLASH || is_id_start(c) || is_decimal_digit(c)) { + return Err(self.error_at(self.pos, "identifier starts immediately after numeric literal")); + } + let mut token = self.token(TokenKind::Number(value), start, line, column, false); + token.legacy_octal = legacy_octal; + Ok(token) + } + + /// Reads digits in `radix` (8 or 16) and returns their value, or `None` when there are none. + fn radix_digits(&mut self, radix: u32) -> Option { + let begin = self.pos; + let mut exact: u128 = 0; + let mut value = 0.0_f64; + let mut overflow = false; + while let Some(d) = self.peek().and_then(hex_value).filter(|&d| d < radix) { + match exact.checked_mul(u128::from(radix)).and_then(|v| v.checked_add(u128::from(d))) { + Some(v) if !overflow => exact = v, + _ => { + if !overflow { + value = exact as f64; + overflow = true; + } + value = value * f64::from(radix) + f64::from(d); + } + } + self.pos += 1; + } + if self.pos == begin { + return None; + } + Some(if overflow { value } else { exact as f64 }) + } + + /// After `0` and a digit: an octal literal when every digit is octal, else a decimal literal + /// whose integer part starts with `0` (`08`, `09.5`). + fn legacy_octal_or_decimal(&mut self) -> f64 { + let begin = self.pos; + let mut end = begin; + while self.unit(end).is_some_and(is_decimal_digit) { + end += 1; + } + if self.src[begin..end].iter().all(|&u| is_octal_digit(u32::from(u))) { + self.pos += 1; + return self.radix_digits(8).unwrap_or(0.0); + } + self.decimal() + } + + /// Reads a decimal literal: digits, an optional fraction and an optional exponent. + fn decimal(&mut self) -> f64 { + let mut text = String::new(); + self.push_digits(&mut text); + if text.is_empty() { + text.push('0'); + } + if self.peek() == Some(DOT) { + self.pos += 1; + text.push('.'); + let before = text.len(); + self.push_digits(&mut text); + if text.len() == before { + text.push('0'); + } + } + if self.peek().is_some_and(|c| c == u32::from(b'e') || c == u32::from(b'E')) { + let sign = self.peek_at(1).filter(|&c| c == u32::from(b'+') || c == u32::from(b'-')); + let digit_at = if sign.is_some() { 2 } else { 1 }; + if self.peek_at(digit_at).is_some_and(is_decimal_digit) { + self.pos += digit_at; + text.push('e'); + if sign == Some(u32::from(b'-')) { + text.push('-'); + } + self.push_digits(&mut text); + } + } + text.parse().unwrap_or(f64::NAN) + } + + fn push_digits(&mut self, text: &mut String) { + while let Some(c) = self.peek().filter(|&c| is_decimal_digit(c)) { + text.push(char::from(c as u8)); + self.pos += 1; + } + } + + /// Reads a string literal (section 7.8.4, with the octal escapes of Annex B) and returns its + /// value. + fn string(&mut self, start: usize, line: u32, column: u32) -> Result { + let quote = self.peek().unwrap_or_default(); + self.pos += 1; + let mut value = Vec::new(); + let mut legacy_octal = false; + loop { + match self.peek() { + None => return Err(Self::error_from((start, line, column), "unterminated string literal")), + // U+2028 and U+2029 may stand in a string, as ECMA-262 2019 allows; they still + // start a new line for positions. + Some(c @ (0x2028 | 0x2029)) => { + value.push(c as u16); + self.line_terminator(); + } + Some(c) if is_line_terminator(c) => { + return Err(Self::error_from((start, line, column), "unterminated string literal")); + } + Some(c) if c == quote => { + self.pos += 1; + break; + } + Some(BACKSLASH) => legacy_octal |= self.string_escape(&mut value)?, + Some(c) => { + value.push(c as u16); + self.pos += 1; + } + } + } + let mut token = self.token(TokenKind::String(value), start, line, column, false); + token.legacy_octal = legacy_octal; + Ok(token) + } + + /// Reads an escape in a string literal into `value`; true for an octal escape (`\1`, `\08`) + /// or `\8` and `\9`. + fn string_escape(&mut self, value: &mut Vec) -> Result { + let at = self.pos; + self.pos += 1; + let Some(c) = self.peek() else { + return Err(self.error_at(at, "unterminated string literal")); + }; + if self.line_terminator() { + return Ok(false); + } + let single = match char::from_u32(c) { + Some('b') => Some(0x08), + Some('t') => Some(0x09), + Some('n') => Some(0x0A), + Some('v') => Some(0x0B), + Some('f') => Some(0x0C), + Some('r') => Some(0x0D), + _ => None, + }; + if let Some(unit) = single { + value.push(unit); + self.pos += 1; + return Ok(false); + } + match char::from_u32(c) { + Some('x') => { + self.pos += 1; + let unit = self.hex_digits(2).ok_or_else(|| self.error_at(at, "invalid hexadecimal escape sequence"))?; + value.push(unit as u16); + Ok(false) + } + Some('u') => { + self.pos += 1; + let code_point = self.unicode_escape().ok_or_else(|| self.error_at(at, "invalid Unicode escape sequence"))?; + match char::from_u32(code_point) { + Some(ch) if code_point > 0xFFFF => value.extend(ch.encode_utf16(&mut [0; 2]).iter()), + _ => value.push(code_point as u16), + } + Ok(false) + } + Some('0') if !self.peek_at(1).is_some_and(is_decimal_digit) => { + self.pos += 1; + value.push(0); + Ok(false) + } + Some('0'..='7') => { + value.push(self.octal_escape(c)); + Ok(true) + } + Some('8' | '9') => { + self.pos += 1; + value.push(c as u16); + Ok(true) + } + _ => { + self.pos += 1; + value.push(c as u16); + Ok(false) + } + } + } + + /// Reads an octal escape whose first digit is `first`: up to three digits when it is 0 to 3, + /// up to two when it is 4 to 7. + fn octal_escape(&mut self, first: u32) -> u16 { + let max = if first <= u32::from(b'3') { 3 } else { 2 }; + let mut value = 0; + let mut n = 0; + while n < max + && let Some(d) = self.peek().filter(|&d| is_octal_digit(d)) + { + value = value * 8 + (d - u32::from(b'0')); + self.pos += 1; + n += 1; + } + value as u16 + } + + /// Reads a regular expression literal (section 7.8.5) and checks its flags and pattern. + fn regexp(&mut self, start: usize, line: u32, column: u32) -> Result { + let at = (start, line, column); + self.pos = start + 1; + let mut in_class = false; + loop { + match self.peek() { + None => return Err(Self::error_from(at, "unterminated regular expression literal")), + Some(c) if is_line_terminator(c) => return Err(Self::error_from(at, "unterminated regular expression literal")), + Some(BACKSLASH) => { + if self.peek_at(1).is_none_or(is_line_terminator) { + return Err(Self::error_from(at, "unterminated regular expression literal")); + } + self.pos += 2; + } + Some(c) if c == u32::from(b'[') => { + in_class = true; + self.pos += 1; + } + Some(c) if c == u32::from(b']') => { + in_class = false; + self.pos += 1; + } + Some(SLASH) if !in_class => break, + Some(_) => self.pos += 1, + } + } + let pattern = self.src[start + 1..self.pos].to_vec(); + self.pos += 1; + let mut flags = String::new(); + while let Some((c, len)) = self.code_point(self.pos) { + if c == BACKSLASH { + return Err(Self::error_from(at, "invalid regular expression flags")); + } + if !is_id_part(c) { + break; + } + flags.push(char::from_u32(c).unwrap_or('\u{fffd}')); + self.pos += len; + } + if !valid_flags(&flags) { + return Err(Self::error_from(at, "invalid regular expression flags")); + } + if let Err(message) = regexp::check(&pattern, self.max_depth) { + return Err(Self::error_from(at, format!("invalid regular expression: {message}"))); + } + Ok(self.token(TokenKind::RegExp { pattern, flags }, start, line, column, false)) + } + + /// Reads the longest punctuator that starts here. + fn punct(&mut self) -> Option { + let rest = &self.src[self.pos..]; + for (spelling, punct) in Punct::ALL { + let bytes = spelling.as_bytes(); + if rest.len() >= bytes.len() && rest.iter().zip(bytes).all(|(&u, &b)| u == u16::from(b)) { + self.pos += bytes.len(); + return Some(*punct); + } + } + None + } +} + +/// What skipping the blanks and comments before a token found. +enum Trivia { + Skipped { newline: bool }, + Directive(Box), +} + +/// A character of a directive's name. +fn is_name_char(c: u32) -> bool { + c < 0x80 && (char::from(c as u8).is_ascii_alphanumeric() || c == u32::from(b'_')) +} + +/// The flags `g`, `i` and `m`, each at most once (section 15.10.4.1). +fn valid_flags(flags: &str) -> bool { + let mut seen = [false; 3]; + flags.chars().all(|c| { + let i = match c { + 'g' => 0, + 'i' => 1, + 'm' => 2, + _ => return false, + }; + !std::mem::replace(&mut seen[i], true) + }) +} + +fn unexpected_character(c: u32) -> String { + match char::from_u32(c) { + Some(ch) if !ch.is_control() => format!("unexpected character '{ch}'"), + _ => format!("unexpected character U+{c:04X}"), + } +} diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs new file mode 100644 index 000000000..4d8d32271 --- /dev/null +++ b/crates/js/src/lib.rs @@ -0,0 +1,149 @@ +//! JavaScript for VectorCraft's scripts: an ECMAScript 5.1 lexer and parser +//! (), with the legacy forms of Annex B that older scripts +//! use (octal literals and escapes outside strict mode, function declarations in blocks, the +//! lenient regular expression patterns). +//! +//! [`parse`] reads a script into a [`Program`]: a syntax tree whose expressions, statements and +//! functions live in arenas and carry their source spans. A script that breaks the grammar or one +//! of its early-error rules (section 16: strict mode's restrictions, duplicate parameters in strict +//! code, invalid assignment targets, `break` and `continue` without a target, regular expression +//! literals that are not valid) gives a [`SyntaxError`] with its line and column. +//! +//! With [`Options::preprocessor_lines`], the lexer also reads the preprocessor lines scripts begin +//! with (`#include`, `#includepath`, `#script`, `#strict`, `#target`, `#targetengine`) into +//! [`Directive`]s. They are kept in [`Program::directives`] and never run. +//! +//! The lexer reads the source as UTF-16 code units, as JavaScript does: columns and spans count +//! code units. Nesting is counted: input nested deeper than [`Options::max_depth`] gives a +//! `SyntaxError` ("nesting too deep") instead of exhausting the stack, binary operators, `if` … +//! `else if` chains, labels and prefix operators are read with loops, and the syntax tree is +//! dropped without recursion. +#![forbid(unsafe_code)] + +pub mod ast; +mod chars; +mod error; +mod lexer; +mod number; +mod parser; +mod regexp; +mod token; + +pub use ast::{ + AssignOp, Ast, BinaryOp, CatchClause, Expr, ExprId, ForInTarget, ForInit, Function, FunctionId, FunctionKind, LogicalOp, Name, Program, Property, + PropertyKey, PropertyValue, Stmt, StmtId, SwitchCase, UnaryOp, UpdateOp, VarDecl, +}; +pub use error::{SyntaxError, TOO_DEEP, TOO_LONG}; +pub use lexer::{Goal, Lexer}; +pub use number::to_string as number_to_string; +pub use token::{Directive, DirectiveName, Keyword, Punct, Span, Token, TokenKind}; + +/// How a script is read. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Options { + /// The script is strict mode code from its start, as code passed to a strict `eval` is. + pub strict: bool, + /// Read preprocessor lines (`#target …` and the other directives) into [`Directive`]s. Off, + /// a `#` is an unexpected character, as ECMA-262 has it. + pub preprocessor_lines: bool, + /// How deeply statements, expressions, functions and regular expression groups may nest. + pub max_depth: usize, + /// The most UTF-16 code units a source may hold. + pub max_source_units: usize, +} + +impl Default for Options { + fn default() -> Self { + Self { strict: false, preprocessor_lines: false, max_depth: DEFAULT_MAX_DEPTH, max_source_units: 64 << 20 } + } +} + +/// The default [`Options::max_depth`]: deeper than scripts nest, and shallow enough for the stack +/// of a wasm build or a secondary thread. +pub const DEFAULT_MAX_DEPTH: usize = 200; + +/// Parses `source` as a script. +pub fn parse(source: &str, options: &Options) -> Result { + let units = to_utf16(source, options)?; + parse_utf16(&units, options) +} + +/// Parses `source`, in UTF-16 code units, as a script. +pub fn parse_utf16(source: &[u16], options: &Options) -> Result { + check_length(source.len(), options)?; + parser::Parser::new(source, options, false)?.parse_program().map(|(program, _)| program) +} + +/// The tokens of `source`, in order, as the parser reads them: preprocessor lines included, and +/// each `/` read as division or as the start of a regular expression literal as the grammar +/// decides. The source must be a valid script. +pub fn tokenize(source: &str, options: &Options) -> Result, SyntaxError> { + let units = to_utf16(source, options)?; + check_length(units.len(), options)?; + parser::Parser::new(&units, options, true)?.parse_program().map(|(_, tokens)| tokens) +} + +fn to_utf16(source: &str, options: &Options) -> Result, SyntaxError> { + // A source's UTF-16 length is at most its UTF-8 length; count only a source that may be too + // long. + if source.len() > options.max_source_units { + check_length(source.encode_utf16().count(), options)?; + } + Ok(source.encode_utf16().collect()) +} + +fn check_length(units: usize, options: &Options) -> Result<(), SyntaxError> { + if units > options.max_source_units || u32::try_from(units).is_err() { + return Err(SyntaxError { message: TOO_LONG.into(), line: 1, column: 1, offset: 0 }); + } + Ok(()) +} + +/// Where each line of a source starts, for turning offsets into lines and columns. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct LineTable { + starts: Vec, + /// The source ends with a line terminator. + ends_with_break: bool, + empty: bool, +} + +impl LineTable { + pub(crate) fn new(src: &[u16]) -> Self { + let mut starts = vec![0]; + let mut i = 0; + while let Some(&u) = src.get(i) { + i += 1; + let terminator = match u { + 0x0D if src.get(i) == Some(&0x0A) => { + i += 1; + true + } + 0x0A | 0x0D | 0x2028 | 0x2029 => true, + _ => false, + }; + if terminator { + starts.push(u32::try_from(i).unwrap_or(u32::MAX)); + } + } + let ends_with_break = src.last().is_some_and(|&u| matches!(u, 0x0A | 0x0D | 0x2028 | 0x2029)); + Self { starts, ends_with_break, empty: src.is_empty() } + } + + pub(crate) fn line_column(&self, offset: u32) -> (u32, u32) { + let line = self.starts.partition_point(|&s| s <= offset).max(1); + let start = self.starts.get(line - 1).copied().unwrap_or(0); + (u32::try_from(line).unwrap_or(u32::MAX), offset.saturating_sub(start) + 1) + } + + pub(crate) fn line_count(&self) -> u32 { + if self.empty { + return 0; + } + let breaks = self.starts.len() - 1; + u32::try_from(breaks + usize::from(!self.ends_with_break)).unwrap_or(u32::MAX) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/js/src/number.rs b/crates/js/src/number.rs new file mode 100644 index 000000000..4afd48076 --- /dev/null +++ b/crates/js/src/number.rs @@ -0,0 +1,69 @@ +//! Number to string conversion (ECMA-262 5.1 section 9.8.1), for numeric property names. + +/// The string ToString gives for the number `x`: the shortest decimal digits that read back as +/// `x`, laid out as section 9.8.1 says. +pub fn to_string(x: f64) -> String { + if x.is_nan() { + return "NaN".into(); + } + if x == 0.0 { + return "0".into(); + } + if x.is_infinite() { + return if x > 0.0 { "Infinity".into() } else { "-Infinity".into() }; + } + if x < 0.0 { + return format!("-{}", to_string(-x)); + } + // Rust's `{:e}` writes the shortest digits that round-trip: `d.ddde±x`. + let sci = format!("{x:e}"); + let (mantissa, exp) = sci.split_once('e').unwrap_or((&sci, "0")); + let digits: String = mantissa.chars().filter(char::is_ascii_digit).collect(); + let k = digits.len() as i64; + let n = exp.parse::().unwrap_or(0) + 1; + if k <= n && n <= 21 { + let zeros = usize::try_from(n - k).unwrap_or(0); + format!("{digits}{}", "0".repeat(zeros)) + } else if 0 < n && n <= 21 { + let (int, frac) = digits.split_at_checked(usize::try_from(n).unwrap_or(0)).unwrap_or((&digits, "")); + format!("{int}.{frac}") + } else if -6 < n && n <= 0 { + let zeros = usize::try_from(-n).unwrap_or(0); + format!("0.{}{digits}", "0".repeat(zeros)) + } else { + let e = n - 1; + let sign = if e < 0 { '-' } else { '+' }; + let (first, rest) = digits.split_at_checked(1).unwrap_or((&digits, "")); + if rest.is_empty() { format!("{first}e{sign}{}", e.abs()) } else { format!("{first}.{rest}e{sign}{}", e.abs()) } + } +} + +#[cfg(test)] +mod tests { + use super::to_string; + + #[test] + fn numbers_print_as_section_9_8_1_lays_them_out() { + let cases: &[(f64, &str)] = &[ + (0.0, "0"), + (-0.0, "0"), + (1.0, "1"), + (-1.5, "-1.5"), + (123.456, "123.456"), + (1e21, "1e+21"), + (1e20, "100000000000000000000"), + (0.000001, "0.000001"), + (1e-7, "1e-7"), + (1.5e-7, "1.5e-7"), + (0.1 + 0.2, "0.30000000000000004"), + (f64::NAN, "NaN"), + (f64::INFINITY, "Infinity"), + (f64::NEG_INFINITY, "-Infinity"), + (5e-324, "5e-324"), + (1.7976931348623157e308, "1.7976931348623157e+308"), + ]; + for (x, want) in cases { + assert_eq!(to_string(*x), *want, "{x:?}"); + } + } +} diff --git a/crates/js/src/parser.rs b/crates/js/src/parser.rs new file mode 100644 index 000000000..ebecab4ca --- /dev/null +++ b/crates/js/src/parser.rs @@ -0,0 +1,1233 @@ +//! The parser: tokens into a [`Program`] (ECMA-262 5.1 sections 11 to 14), with the early errors +//! of section 16 and of strict mode (Annex C). +//! +//! It is a recursive-descent parser whose recursion is counted: each statement, assignment +//! expression, primary expression and function body it enters adds one to a depth, and going past +//! [`crate::Options::max_depth`] gives a `SyntaxError`. Binary operators are read with an operator +//! stack, and prefix operators, assignment chains, `else if` chains and labels with loops, so long +//! runs of them do not nest at all. + +use crate::LineTable; +use crate::ast::{ + AssignOp, Ast, BinaryOp, CatchClause, Expr, ExprId, ForInTarget, ForInit, Function, FunctionKind, LogicalOp, Name, Program, Property, + PropertyKey, PropertyValue, Stmt, StmtId, SwitchCase, UnaryOp, UpdateOp, VarDecl, +}; +use crate::error::{SyntaxError, TOO_DEEP}; +use crate::lexer::{Goal, Lexer}; +use crate::token::{Directive, Keyword, Punct, Span, Token, TokenKind, strict_reserved}; + +type Result = std::result::Result; + +/// A label in scope, and whether it labels an iteration statement (`continue` may name only +/// those). +#[derive(Clone, Debug)] +struct Label { + name: String, + is_loop: bool, +} + +/// What the code being parsed allows, saved and replaced at each function body. +#[derive(Clone, Debug, Default)] +struct Context { + strict: bool, + in_function: bool, + labels: Vec