From 47571008944b8e46322ad432ce30601002cad954 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 22:15:31 -0700 Subject: [PATCH 001/160] 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/160] 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/160] 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/160] 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/160] 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/160] 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/160] 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/160] 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/160] 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/160] 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/160] 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/160] 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/160] 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/160] 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/160] 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 8596230289ad003e82e32a97ec11e18f2b5836b1 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Thu, 22 Oct 2026 14:24:41 +0400 Subject: [PATCH 016/160] i18n(ka): Georgian for 7 strings added since #2045 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/ui-egui/src/i18n/ka.tsv | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 924f58dd3..36ce31c7c 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -3722,6 +3722,7 @@ effect Regular ჩვეულებრივი @msg a page tree node has no kids გვერდების ხის კვანძს შვილობილები არ აქვს @msg a page's placement is not finite გვერდის განლაგება სასრული არ არის @msg a preset named `{_1}` exists პრესეტი სახელით `{_1}` არსებობს +@msg a swatch or color group named `{n}` exists ფერის ნიმუში ან ფერების ჯგუფი სახელით `{n}` არსებობს @msg a slice size must be a positive number of points ნაჭრის ზომა წერტილების დადებითი რიცხვი უნდა იყოს @msg a stream runs past the end ნაკადი ბოლოს სცილდება @msg a stream's length is wrong ნაკადის სიგრძე არასწორია @@ -5217,6 +5218,8 @@ imageTrace Outline კონტური @msg {id} opens another app: only the person at the keyboard can run it {id} სხვა აპს ხსნის: მისი გაშვება მხოლოდ კლავიატურასთან მყოფ ადამიანს შეუძლია @msg “{name}” is not a file „{name}“ ფაილი არ არის Cancel Export ექსპორტირების გაუქმება +@plural {n} more export waiting|{n} more exports waiting რიგში მომლოდინე სხვა ექსპორტირება: {n} +@msg Export cancelled ექსპორტირება გაუქმდა Clear History ისტორიის გასუფთავება Continue in Background ფონურ რეჟიმში გაგრძელება Artboard {n} of {total} სამუშაო დაფა {n} / {total} @@ -5328,3 +5331,7 @@ axis Y Axis Y ღერძი Packaged {n} file(s) in {folder} შეფუთულია ფაილი ({n}) საქაღალდეში {folder} Packaged {n} file(s) {note} შეფუთულია ფაილი ({n}) {note} {count} file(s) in {folder}. Show the package folder? ფაილები: {count}, ადგილი: {folder}. ვაჩვენოთ შეფუთვის საქაღალდე? + Create PDF layers from top-level layers PDF-ის ფენების შექმნა ზედა დონის ფენებიდან +@msg this kind of brush has no options dialog yet ამ ტიპის ფუნჯს პარამეტრების დიალოგი ჯერ არ აქვს + Drag art, colors and text styles here to reuse them across documents. Libraries are stored on this machine — no account required. გადმოათრიეთ აქ გრაფიკა, ფერები და ტექსტის სტილები, რომ სხვა დოკუმენტებშიც გამოიყენოთ. ბიბლიოთეკები ამ კომპიუტერზე ინახება — ანგარიში არ არის საჭირო. + Choose a filter ფილტრის არჩევა From 6ac62214eb4476542ab58cc4a7cb3db91204d00c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 23 Oct 2026 01:35:44 -0700 Subject: [PATCH 017/160] 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 018/160] 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 019/160] 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 020/160] 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 021/160] 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 022/160] 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 023/160] 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 024/160] 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 025/160] 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 026/160] 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 027/160] 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 028/160] 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 029/160] 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 030/160] 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 031/160] 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 032/160] 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 033/160] 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 034/160] 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 035/160] 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 036/160] 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 037/160] 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 038/160] 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 039/160] 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 040/160] 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 041/160] 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 042/160] 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 043/160] 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 044/160] 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 045/160] 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 046/160] 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 047/160] 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 048/160] 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 a31968683376d9c132dfd8123994607b58c51195 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 04:53:43 -0800 Subject: [PATCH 049/160] UI: toggle the Help Bar independently and save its visibility --- ROADMAP.md | 4 +- crates/ui-egui/src/app.rs | 5 +- crates/ui-egui/src/chrome/status_bar.rs | 3 +- crates/ui-egui/src/chrome/tests.rs | 62 ++++++++++++++++++++ crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.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/id.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/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 + crates/ui-egui/src/layout_migration.rs | 11 ++++ crates/ui-egui/src/layout_migration/tests.rs | 20 +++++++ crates/ui-egui/src/menus/run.rs | 1 + crates/ui-egui/src/menus/state.rs | 1 + crates/ui-egui/src/menus/tests.rs | 23 ++++++++ crates/ui-egui/src/menus/tree.rs | 1 + crates/ui-egui/src/menus/ui_commands.rs | 1 + crates/ui-egui/src/state.rs | 2 + crates/ui-egui/src/workspaces.rs | 3 + docs/gaps.md | 5 +- docs/roadmap.md | 5 +- docs/target-app-parity.md | 5 +- docs/ui-parity.md | 5 +- 42 files changed, 175 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index bce1cab0c..304db22dc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # VectorCraft Roadmap +- UI: Window → Help Bar toggles the tool-help strip separately from the status bar and saves visibility with workspaces. Scores and estimates unchanged. + - PDF.R1: editable PDF import uses the shared reader, scans page content in windows, and rejects known incomplete input before returning a document. - PDF.W1: PDF export and print composition use the shared optional writer for configuration, metadata, font preparation and guarded finalization; drawing and output policy remain app-owned. @@ -9,7 +11,7 @@ **Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (font-family fields accept the mouse wheel, #5253; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (independent Help Bar visibility; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 63bb4de7b..0a178359b 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -511,8 +511,11 @@ impl VectorcraftApp { } if self.ui.status_bar && self.ui.screen_mode < 3 { chrome::status_bar(self, ui); + } + if self.ui.help_bar && self.ui.screen_mode < 3 { chrome::hint_bar(self, ui); - } else { + } + if self.ui.screen_mode >= 3 || (!self.ui.status_bar && !self.ui.help_bar) { // Keeps the previous frame's value valid for menus drawn above, before the bars. widgets::set_menu_floor(&ctx, ctx.content_rect().bottom()); } diff --git a/crates/ui-egui/src/chrome/status_bar.rs b/crates/ui-egui/src/chrome/status_bar.rs index 9a1ffbfbb..9fbd02327 100644 --- a/crates/ui-egui/src/chrome/status_bar.rs +++ b/crates/ui-egui/src/chrome/status_bar.rs @@ -4,7 +4,7 @@ use super::{Arc, ROTATE_VIEW_ANGLES, Sense, Stroke, Tokens, Ui, VectorcraftApp, /// Draw the status message, bounded readouts and artboard navigation controls. pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - egui::Panel::bottom("status_bar") + let panel = egui::Panel::bottom("status_bar") .exact_size(24.0) .frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 0)).stroke(Stroke::new(1.0, t.border))) .show(ui, |ui| { @@ -84,6 +84,7 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) { status_readouts(app, ui, &t); }); }); + widgets::set_menu_floor(ui.ctx(), panel.response.rect.top()); } /// The most recently measured status widths, scoped to this UI and display settings. diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 774de7540..b4e6fcc34 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -14,6 +14,68 @@ use crate::theme::Tokens; use serde_json::json; use vectorcraft_engine::Session; +#[test] +fn help_bar_and_status_bar_render_independently() { + for (help, status, screen_mode) in [(true, true, 0), (false, true, 0), (true, false, 0), (false, false, 0), (true, true, 3)] { + let (mut app, ctx) = status_readout_app(); + app.ui.help_bar = help; + app.ui.status_bar = status; + app.ui.screen_mode = screen_mode; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); + let mut parent = None; + let mut output = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| { + parent = Some(ui.scope_id()); + app.ui(ui); + }); + output.textures_delta.clear(); + for _ in 0..2 { + output = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| app.ui(ui)); + output.textures_delta.clear(); + } + fn text(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| text(s, out)), + _ => {} + } + } + let mut shown = Vec::new(); + for shape in &output.shapes { + text(&shape.shape, &mut shown); + } + assert_eq!(shown.iter().any(|t| t == "?"), help && screen_mode < 3, "{help}/{status}/{screen_mode}: {shown:?}"); + assert_eq!(shown.iter().any(|t| t.contains("Saved recovery data")), status && screen_mode < 3, "{help}/{status}/{screen_mode}: {shown:?}"); + let floor = ctx.data(|data| data.get_temp::(egui::Id::unique("menu_floor"))).unwrap(); + let expected = if screen_mode < 3 && (help || status) { + let id = parent.unwrap().with(if help { "hint_bar" } else { "status_bar" }); + egui::containers::panel::PanelState::load(&ctx, id).unwrap().outer_rect.top() + } else { + screen.bottom() + }; + assert!((floor - expected).abs() < 1.0, "menu floor {floor}, expected {expected}"); + } +} + +#[test] +#[ignore = "Renders the Help Bar visibility examples; set HELP_BAR_SCREENSHOTS to an output directory"] +fn help_bar_visibility_screenshots() { + let dir = std::path::PathBuf::from(std::env::var_os("HELP_BAR_SCREENSHOTS").expect("output directory")); + for shown in [true, false] { + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 500, "height": 400})).unwrap(); + if !shown { + crate::menus::run_ui_command(&mut app, "window.helpBar", &serde_json::Value::Null).unwrap().unwrap(); + } + let mut h = egui_kittest::Harness::builder() + .with_size(egui::vec2(1000.0, 700.0)) + .with_accessibility_check(false) + .wgpu() + .build_ui_state(|ui, app: &mut crate::VectorcraftApp| app.ui(ui), app); + h.run_steps(6); + h.render().unwrap().save(dir.join(if shown { "help-visible.png" } else { "help-hidden.png" })).unwrap(); + } +} + #[test] fn hints_name_keys_as_the_menus_do() { let text = |tool: &str| super::hint_segments(tool).into_iter().map(|(s, _)| s).collect::(); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 614054f41..df04d2843 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6111,3 +6111,4 @@ breakText Glyphs Глифи Create Swatch Legend Създаване на легенда на образците Create Swatch Legend… Създаване на легенда на образците… HEX HEX + Help Bar Лента за помощ diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index a0b4e236d..95c7fadfa 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6145,3 +6145,4 @@ shape Open Otvoren Create Swatch Legend Kreiraj legendu uzoraka boja Create Swatch Legend… Kreiraj legendu uzoraka boja… HEX HEX + Help Bar Traka pomoći diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 2a1f0a518..b656559e4 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6245,3 +6245,4 @@ breakText Glyphs Glifs Create Swatch Legend Crea una llegenda de mostres Create Swatch Legend… Crea una llegenda de mostres… HEX HEX + Help Bar Barra d’ajuda diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index b9e6fd740..fc7698da6 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6259,3 +6259,4 @@ breakText Glyphs Glyfy Create Swatch Legend Vytvořit legendu vzorníku Create Swatch Legend… Vytvořit legendu vzorníku… HEX HEX + Help Bar Panel nápovědy diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 20a0a4dfe..c8a92d7d3 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6127,3 +6127,4 @@ breakText Glyphs Glyffer Create Swatch Legend Opret farveprøveforklaring Create Swatch Legend… Opret farveprøveforklaring… HEX HEX + Help Bar Hjælpelinje diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 94430d21e..1504af382 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5845,3 +5845,4 @@ breakText Glyphs Glyphen Create Swatch Legend Farbfeldlegende erstellen Create Swatch Legend… Farbfeldlegende erstellen… HEX HEX + Help Bar Hilfeleiste diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 0a7d2be71..de8b95f0a 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6211,3 +6211,4 @@ breakText Glyphs Γλύφοι Create Swatch Legend Δημιουργία υπομνήματος δειγμάτων χρωμάτων Create Swatch Legend… Δημιουργία υπομνήματος δειγμάτων χρωμάτων… HEX HEX + Help Bar Γραμμή βοήθειας diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4e5ba28d4..ccf04f1f4 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6201,3 +6201,4 @@ breakText Glyphs Glifos Create Swatch Legend Crear leyenda de muestras Create Swatch Legend… Crear leyenda de muestras… HEX HEX + Help Bar Barra de ayuda diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index c75f15ba5..048efe166 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6289,3 +6289,4 @@ breakText Glyphs Merkit Create Swatch Legend Luo värimallien selite Create Swatch Legend… Luo värimallien selite… HEX HEX + Help Bar Ohjepalkki diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 20b608bb4..a42c01e8d 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6254,3 +6254,4 @@ breakText Glyphs Glyphes Create Swatch Legend Créer une légende des nuances Create Swatch Legend… Créer une légende des nuances… HEX HEX + Help Bar Barre d’aide diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 8e4aa69f2..19d46d86c 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6127,3 +6127,4 @@ breakText Glyphs Glif Create Swatch Legend Buat Legenda Swatch Create Swatch Legend… Buat Legenda Swatch… HEX HEX + Help Bar Bilah Bantuan diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 6fd38b0f7..6ff9da125 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5879,3 +5879,4 @@ breakText Glyphs Glifi Create Swatch Legend Crea legenda campioni Create Swatch Legend… Crea legenda campioni… HEX HEX + Help Bar Barra della guida diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5096fef13..53c779802 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5944,3 +5944,4 @@ breakText Glyphs 字形 Create Swatch Legend スウォッチの凡例を作成 Create Swatch Legend… スウォッチの凡例を作成… HEX HEX + Help Bar ヘルプバー diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 5389e1f09..937db59f0 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6247,3 +6247,4 @@ breakText Glyphs გლიფები Create Swatch Legend ფერის ნიმუშების ლეგენდის შექმნა Create Swatch Legend… ფერის ნიმუშების ლეგენდის შექმნა… HEX HEX + Help Bar დახმარების ზოლი diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 8596570c7..550556091 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5840,3 +5840,4 @@ breakText Glyphs 글리프 Create Swatch Legend 견본 범례 만들기 Create Swatch Legend… 견본 범례 만들기… HEX HEX + Help Bar 도움말 표시줄 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 4cde5d403..a32aa110e 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6171,3 +6171,4 @@ breakText Glyphs Glyphs Create Swatch Legend Stalenlegenda maken Create Swatch Legend… Stalenlegenda maken… HEX HEX + Help Bar Helpbalk diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 52800ade0..0ca41d1c3 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6114,3 +6114,4 @@ breakText Glyphs Glify Create Swatch Legend Utwórz legendę próbek Create Swatch Legend… Utwórz legendę próbek… HEX HEX + Help Bar Pasek pomocy diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8369f4d23..2639bf40e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6332,3 +6332,4 @@ swatch Yellow Green Verde-amarelado Create Swatch Legend Criar Legenda de Amostras Create Swatch Legend… Criar Legenda de Amostras… HEX HEX + Help Bar Barra de ajuda diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e6bf3f77c..5b00ad23f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6348,3 +6348,4 @@ breakText Glyphs Глифы digits Hindi Хинди font-script Japanese Японский shape Open Открытый + Help Bar Панель подсказок diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index f12351040..49f70795a 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6315,3 +6315,4 @@ breakText Glyphs Glyfer # The strings the report listed as missing for sv. Font Size: Teckenstorlek: digits Hindi Hindi + Help Bar Hjälpfält diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index b198e1af1..9e9a07513 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6216,3 +6216,4 @@ breakText Glyphs Glifler Create Swatch Legend Renk Örneği Açıklaması Oluştur Create Swatch Legend… Renk Örneği Açıklaması Oluştur… HEX HEX + Help Bar Yardım Çubuğu diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index d468a63d7..cc19692ed 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5859,3 +5859,4 @@ breakText Glyphs Гліфи Create Swatch Legend Створити легенду зразків Create Swatch Legend… Створити легенду зразків… HEX HEX + Help Bar Панель підказок diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 9d4acddcb..cb106b4ef 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5860,3 +5860,4 @@ breakText Glyphs Glyph Create Swatch Legend Tạo chú giải mẫu màu Create Swatch Legend… Tạo chú giải mẫu màu… HEX HEX + Help Bar Thanh trợ giúp diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0426b415f..234d02339 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6100,3 +6100,4 @@ breakText Glyphs 字形 Create Swatch Legend 创建色板图例 Create Swatch Legend… 创建色板图例… HEX HEX + Help Bar 帮助栏 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 92a1a9808..21f1b12af 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5897,3 +5897,4 @@ breakText Glyphs 字符 Create Swatch Legend 建立色票圖例 Create Swatch Legend… 建立色票圖例… HEX HEX + Help Bar 說明列 diff --git a/crates/ui-egui/src/layout_migration.rs b/crates/ui-egui/src/layout_migration.rs index e5c6b1f9c..b56b46720 100644 --- a/crates/ui-egui/src/layout_migration.rs +++ b/crates/ui-egui/src/layout_migration.rs @@ -44,10 +44,12 @@ impl UiState { pub fn read_preferences(bytes: &[u8]) -> Option { let mut saved: Value = serde_json::from_slice(bytes).ok()?; let fields = saved.as_object_mut()?; + help_bar_visibility(fields, "status_bar", "help_bar"); let mut intact = recover(fields, Keys::SNAKE); for key in ["custom_workspaces", "workspace_changes"] { if let Some(Value::Array(workspaces)) = fields.get_mut(key) { for workspace in workspaces.iter_mut().filter_map(Value::as_object_mut) { + help_bar_visibility(workspace, "statusBar", "helpBar"); intact &= recover(workspace, Keys::CAMEL); } } @@ -58,6 +60,15 @@ impl UiState { } } +/// Older layouts used status-bar visibility for both bottom bars. Preserve that choice until +/// the layout has an independent Help Bar setting. +fn help_bar_visibility(fields: &mut Map, status: &str, help: &str) { + if !fields.contains_key(help) { + let visible = fields.get(status).and_then(Value::as_bool).unwrap_or(true); + fields.insert(help.into(), Value::Bool(visible)); + } +} + /// The JSON keys of the docking fields: `UiState` is snake_case, [`Workspace`] camelCase. struct Keys { hidden: &'static str, diff --git a/crates/ui-egui/src/layout_migration/tests.rs b/crates/ui-egui/src/layout_migration/tests.rs index d97567eb1..9540f57bf 100644 --- a/crates/ui-egui/src/layout_migration/tests.rs +++ b/crates/ui-egui/src/layout_migration/tests.rs @@ -14,6 +14,26 @@ fn read(saved: &Value) -> ReadPreferences { UiState::read_preferences(&serde_json::to_vec(saved).unwrap()).unwrap() } +#[test] +fn legacy_help_bar_visibility_follows_each_layouts_status_bar() { + let prefs = read(&json!({ + "status_bar": false, + "custom_workspaces": [{"name":"Hidden", "statusBar": false}, {"name":"Shown", "statusBar":true}], + "workspace_changes": [{"name":"Changed", "statusBar":false}] + })); + assert!(!prefs.ui.help_bar); + assert!(!prefs.ui.custom_workspaces[0].help_bar); + assert!(prefs.ui.custom_workspaces[1].help_bar); + assert!(!prefs.ui.workspace_changes[0].help_bar); + assert!(read(&json!({})).ui.help_bar); + for (status, help) in [(true, false), (false, true)] { + let prefs = read(&json!({"status_bar":status, "help_bar":help, + "custom_workspaces":[{"name":"Explicit", "statusBar":status, "helpBar":help}]})); + assert_eq!(prefs.ui.help_bar, help); + assert_eq!(prefs.ui.custom_workspaces[0].help_bar, help); + } +} + /// The dock of a layout in the current shape: the canvas beside a dock at the end edge. fn dock(layout: &Layout) -> &[Column] { assert!(docking::valid(layout), "{layout:?}"); diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index b788cd58b..1d403f889 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -433,6 +433,7 @@ fn run_ui_part5(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option flag(&mut app.ui.control_bar), + "window.helpBar" => flag(&mut app.ui.help_bar), "window.toolbar" => flag(&mut app.ui.toolbar), "window.toolbarColumns" => { let toolbar = match crate::custom_toolbars::target(&app.ui, p) { diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index 523d930e0..0c50df321 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -59,6 +59,7 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { // a document no item is checked. "view.rotate" => app.view().zip(p.get("angle").and_then(Value::as_f64)).is_some_and(|(view, a)| (view.rotation - a).abs() < 1e-9), "window.control" => app.ui.control_bar, + "window.helpBar" => app.ui.help_bar, "window.toolbar" => app.ui.toolbar, "window.toolbarAdvanced" => app.ui.toolbar_advanced, "window.toolbarColumns" => match p.get("toolbar").and_then(Value::as_str) { diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 4b46453ca..c3a05c6ed 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -1,6 +1,29 @@ use super::*; use crate::theme; +#[test] +fn help_bar_toggle_is_independent_and_saved_in_workspaces() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + assert!(app.ui.help_bar); + assert_eq!(checked(&app, "window.helpBar", &Value::Null), Some(true)); + assert_eq!(run_ui_command(&mut app, "window.helpBar", &Value::Null).unwrap().unwrap(), json!(false)); + assert!(app.ui.status_bar); + assert_eq!(checked(&app, "window.helpBar", &Value::Null), Some(false)); + let saved = crate::workspaces::capture(&app.ui, "No help"); + let saved: crate::workspaces::Workspace = serde_json::from_value(serde_json::to_value(saved).unwrap()).unwrap(); + app.ui.help_bar = true; + app.ui.status_bar = false; + crate::workspaces::apply(&mut app.ui, &saved); + assert!(!app.ui.help_bar && app.ui.status_bar); + assert_eq!(run_ui_command(&mut app, "window.helpBar", &Value::Null).unwrap().unwrap(), json!(true)); + let legacy: crate::workspaces::Workspace = serde_json::from_value(json!({"name":"Old workspace"})).unwrap(); + assert!(legacy.help_bar); + let restored: crate::state::UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap(); + assert!(restored.help_bar); + let (_, window) = menu_tree().into_iter().find(|(title, _)| *title == "Window").unwrap(); + assert!(window.iter().any(|item| matches!(item, Item::Cmd("Help Bar", "window.helpBar", _)))); +} + /// One headless frame of the in-window menu bar; returns its titles (left to right) as /// (rect, id of the title's popup). fn bar_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(egui::Rect, egui::Id)> { diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index 3a55ab37c..987453567 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -622,6 +622,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { sub("Workspace", crate::workspaces::menu_items()), Sep, c("Control", "window.control"), + c("Help Bar", "window.helpBar"), c("Contextual Task Bar", "window.taskBar"), c("Tools", "window.toolbar"), sub("Toolbars", crate::custom_toolbars::menu_items()), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 97b7926e8..4242f29b2 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -441,6 +441,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ), ("view.rotateReset", "Reset Rotate View", "Cmd+Shift+1", "{}"), ("window.control", "Control", "", "{}"), + ("window.helpBar", "Help Bar", "", "{} toggle the tool-help strip independently of the status bar; returns the new visibility"), ("window.toolbar", "Tools", "", "{}"), ( "window.toolbarColumns", diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 4dd6d9012..83dede42b 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -432,6 +432,7 @@ pub struct UiState { #[serde(default)] pub toolbar_pos: Option<[f32; 2]>, pub status_bar: bool, + pub help_bar: bool, pub dock: bool, /// The bars that Tab (`window.dock`) hid, which the next Tab shows again; none when Tab has not /// hidden them since it last showed them, or a workspace was applied since. @@ -940,6 +941,7 @@ impl Default for UiState { dock_width: None, toolbar_pos: None, status_bar: true, + help_bar: true, dock: true, tab_hid: None, view: ViewFlags::default(), diff --git a/crates/ui-egui/src/workspaces.rs b/crates/ui-egui/src/workspaces.rs index 28abe518b..0251bc82d 100644 --- a/crates/ui-egui/src/workspaces.rs +++ b/crates/ui-egui/src/workspaces.rs @@ -32,6 +32,7 @@ pub struct Workspace { pub dock_collapsed: bool, pub open_panel: Option, pub status_bar: bool, + pub help_bar: bool, /// Tool groups torn off the toolbar into floating strips (built-in workspaces: none). pub floating_flyouts: Vec, /// Floating panel groups in workspaces saved before the canvas joined `docking`; read only @@ -185,6 +186,7 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace { dock_collapsed: ui.dock_collapsed, open_panel: ui.open_panel.clone(), status_bar: ui.status_bar, + help_bar: ui.help_bar, floating_flyouts: ui.floating_flyouts.clone(), floating_panels: Vec::new(), docking: ui.docking.clone(), @@ -212,6 +214,7 @@ pub fn apply(ui: &mut UiState, w: &Workspace) { ui.dock_collapsed = w.dock_collapsed; ui.open_panel = w.open_panel.clone(); ui.status_bar = w.status_bar; + ui.help_bar = w.help_bar; ui.floating_flyouts = w.floating_flyouts.clone(); ui.docking_generation = ui.docking_generation.wrapping_add(1); ui.docking = w.docking.clone().filter(crate::panel_docking::valid); diff --git a/docs/gaps.md b/docs/gaps.md index 783dc9711..f5c455306 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,9 +1,11 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (G15: a compound path's assembled path is kept between frames; estimate unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (independent Help Bar visibility; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence +Window → Help Bar toggles the bottom tool-help strip independently of the status bar. Its visibility is saved with workspaces and UI preferences; Presentation Mode hides both bars. Scores and estimates are unchanged. + The closed font-family fields in Character, Properties and Control now accept the mouse wheel for selected text (#5253). Each effective notch applies one family and adds one undo step; the list follows saved font filters, ignores search text, and @@ -26,6 +28,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Help Bar has its own Window menu toggle and workspace visibility setting. Scores and estimates unchanged. | | 2026-11-02 | minor | Font-family fields step selected text with the mouse wheel (#5253); no scored gap or estimate reassessed. | | 2026-10-24 | minor | G12: General › Corner Radius is the Rounded Rectangle tool's radius, a change while the tool is active reaches it, and the radius ↑/↓ set while drawing (or a Tool Options value) becomes the preference, within 0–1296 pt (#394, `crates/engine/src/tests/constrain_tools.rs`); no entry closed, estimate unchanged. | | 2026-10-22 | minor | Reconcile upstream documentation after review F-3; preserve every dated revision and the original scaleArt runtime evidence without repeating completed jobs. | diff --git a/docs/roadmap.md b/docs/roadmap.md index bf5f67279..18cb6a340 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,9 +1,11 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (font-family fields accept the mouse wheel, #5253; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (independent Help Bar visibility; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence +Window → Help Bar toggles the bottom tool-help strip independently of the status bar. Its visibility is saved with workspaces and UI preferences; Presentation Mode hides both bars. Scores and estimates are unchanged. + **Feature evidence last updated:** 2026-11-03. The shared assessment status line and revision history are current upstream; this dated section records the submission's feature evidence and revisions without rescoring upstream or claiming a new combined build or runtime measurement. @@ -19,6 +21,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Help Bar has its own Window menu toggle and workspace visibility setting. Scores and estimates unchanged. | | 2026-10-30 | minor | M4: G9: `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout) in its state block after its record, so its steps knock each other out as the page draws them; a user's file comes in 0.6% from its page instead of 1.3%. Scores and estimates unchanged. | | 2026-10-30 | minor | M4: File formats: `.ai`/EPS editing-data fidelity from an audit of a user's files: All Caps and Small Caps (#4708); placed files at their box's corner, kept where they draw closer to the page (#4664, #4723, #4742); pages cropped to the art, with `UserUnit` (#4658, #4727); gradient mesh corners with handle weights of 0 and the state written after a mesh (#4738, #4744, #4748); type on a path spaced as the file lays it out (#4743); synthetic regressions in `crates/eps`; scores and estimates unchanged. | | 2026-10-30 | minor | Raster effects: bounded length fields in effect dialogs clamp on commit and reject invalid inputs visibly (#4638); estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 15d40489a..497b507b5 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 (font-family fields accept the mouse wheel, #5253; 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 (independent Help Bar visibility; 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 +Window → Help Bar toggles the bottom tool-help strip independently of the status bar. Its visibility is saved with workspaces and UI preferences; Presentation Mode hides both bars. Scores and estimates are unchanged. + **Feature evidence last updated:** 2026-11-03. The shared assessment status line and revision history are current upstream; this dated section records the submission's feature evidence and revisions without rescoring upstream or claiming a new combined build or runtime measurement. @@ -19,6 +21,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Help Bar has its own Window menu toggle and workspace visibility setting. Scores and estimates unchanged. | | 2026-11-02 | minor | Legacy `.ai` saves (version 7) write type with rotated characters as outlines that keep each character's rotation, with a warning unless `outlineText` is on (#5186); scores unchanged. | | 2026-10-31 | minor | Object › Path › Smooth smooths a path along its length before refitting it, so Auto-Smooth takes a shaky line to one smooth curve instead of leaving its jags, and with anchor points direct-selected it smooths only that section; scores unchanged. | | 2026-10-31 | minor | New Document: More Settings has the Save Preset button beside the name too, saving its settings (artboard layout included) as a preset (#4893); scores unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index ae4eb5626..1fdcc0b9e 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,9 +1,11 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (font-family fields accept the mouse wheel, #5253; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (independent Help Bar visibility; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence +Window → Help Bar toggles the bottom tool-help strip independently of the status bar. Its visibility is saved with workspaces and UI preferences; Presentation Mode hides both bars. Scores and estimates are unchanged. + **Feature evidence last updated:** 2026-11-03. The shared assessment status line and revision history are current upstream; this dated section records the submission's feature evidence and revisions without rescoring upstream or claiming a new combined build or runtime measurement. @@ -19,6 +21,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Help Bar has its own Window menu toggle and workspace visibility setting. Scores and estimates unchanged. | | 2026-10-26 | minor | Pencil Fidelity goes down to 0.01 pt (dialog, tool and engine; Smooth keeps 0.05) on a logarithmic slider across the Tool Options row, with hundredths in the field and key steps that fit the range (#3328); scores and estimates unchanged (estimated) | | 2026-10-22 | minor | Reconcile upstream documentation after review F-3; preserve every dated revision and the original scaleArt runtime evidence without repeating completed jobs. | | 2026-10-22 | minor | Reconcile upstream documentation after review F-2; preserve every dated revision and the original scaleArt runtime evidence without repeating completed jobs. | From c4a8c43dc511faf982b884a73ef64cb963f2d565 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 11:43:07 -0800 Subject: [PATCH 050/160] 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 051/160] 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 4c791f0f8140c05186ee31f0cbd094c11c7b2c04 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:58:45 -0500 Subject: [PATCH 052/160] EPS import: gradients drawn as abutting strips come in as one gradient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A page that paints a gradient as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imported as hundreds of separate fills. Each anti-aliased on its own, they left seams where the art below showed through every shared edge (PDF 32000-1 §10.6.4, conflation): thin stripes, and a pale tint over strips a pixel or two wide. An Illustrator EPS whose page drew its gradients that way then differed from its layers and opened as its page with empty layers. A run of at least six such strips under the same clips, side by side in one direction with no gap, each a small colour step from the last, now becomes one object filled with a linear gradient through the strips' colours (stops on a straight colour ramp dropped), and the clip path itself when it was the clip's only art. Abutting blocks of distinct colours stay apart. --- crates/eps/src/import.rs | 3 +- crates/eps/src/import/graphics.rs | 2 + crates/eps/src/import/graphics/strips.rs | 301 +++++++++++++++++++++++ crates/eps/src/import/tests.rs | 69 ++++++ docs/file-format-parity.md | 5 +- docs/gaps.md | 4 +- 6 files changed, 380 insertions(+), 4 deletions(-) create mode 100644 crates/eps/src/import/graphics/strips.rs diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index 1b0ad8604..bf5ad4583 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -302,7 +302,8 @@ pub fn import_trusting(bytes: &[u8], editing_data: bool, trust: bool) -> Result< /// The document of what `out` drew: one layer, clipped art in clipping groups (grouped art in /// groups), the spot inks as spot swatches, in CMYK when most process colours were. fn finish(mut out: Out) -> Imported { - let drawn = std::mem::take(&mut out.drawn); + let mut drawn = std::mem::take(&mut out.drawn); + graphics::merge_strips(&mut drawn, &mut out.shadings); let mut children = vectorcraft_doc::clipnest::nest(&mut out.doc, drawn); if !out.shadings.is_empty() { graphics::collapse(&mut children, &out.shadings); diff --git a/crates/eps/src/import/graphics.rs b/crates/eps/src/import/graphics.rs index a21d6c9f6..ea7a43ee8 100644 --- a/crates/eps/src/import/graphics.rs +++ b/crates/eps/src/import/graphics.rs @@ -21,9 +21,11 @@ mod ops; mod paint; mod path; mod state; +mod strips; pub(crate) use paint::collapse; pub(crate) use state::{GState, Out, Space, process}; +pub(crate) use strips::merge_strips; /// Most objects one file makes. const MAX_NODES: usize = 1 << 20; diff --git a/crates/eps/src/import/graphics/strips.rs b/crates/eps/src/import/graphics/strips.rs new file mode 100644 index 000000000..84146c338 --- /dev/null +++ b/crates/eps/src/import/graphics/strips.rs @@ -0,0 +1,301 @@ +//! Gradients a program paints as a row of abutting strips, each in one colour (the way many +//! PostScript writers draw a smooth shading at Level 2, which has no `shfill`), read as one +//! linear gradient. +//! +//! Drawn one by one, abutting strips leave seams: a renderer that anti-aliases each fill lets the +//! art below show through along every shared edge (PDF 32000-1:2008 §10.6.4 notes the effect, +//! "conflation", for adjacent areas painted separately), which reads as thin stripes across the +//! gradient, and as a tint of the colour below over the whole of it where the strips are only a +//! pixel or two wide. A run of them under the same clips becomes one object filled with a linear +//! gradient through the strips' colours, which has no seams and stays editable. +//! +//! Only strips that clearly are a gradient merge: at least [`MIN_STRIPS`] parallelograms of one +//! length, side by side in one direction with no gap or overlap, each a small step in colour from +//! the one before ([`MAX_STEP`]). Abutting blocks of distinct colours (a bar chart, a row of +//! swatches) stay as they are. + +use super::{ + Affine, Appearance, AppearanceItem, BezPath, Color, Drawn, FillLayer, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, HashSet, + MAX_STITCH_STOPS, Node, NodeId, NodeKind, Paint, PathData, Point, Vec2, +}; + +/// Fewest strips (that paint something) a gradient is read from. +const MIN_STRIPS: usize = 6; +/// Largest change of any colour component (0 to 1) from one strip to the next. +const MAX_STEP: f32 = 0.08; +/// How far apart corners may be and still meet (points). +const EPS: f64 = 0.01; +/// Largest change of any colour component a stop left out may make to the gradient. +const TOLERANCE: f32 = 0.5 / 255.0; + +/// A filled parallelogram in one process colour. +struct Strip { + corners: [Point; 4], + model: u8, + comps: [f32; 4], + color: Color, + overprint: bool, +} + +/// The parallelogram `n` fills, in one process colour: `None` for anything else. +fn strip_of(n: &Node) -> Option { + let NodeKind::Path { path, live: None, clipping: false, guide: false, .. } = &n.kind else { return None }; + if n.appearance.items.len() != 1 { + return None; + } + let Some(AppearanceItem::Fill(f)) = n.appearance.items.first() else { return None }; + if f.opacity != 1.0 || !f.visible || !f.effects.is_empty() || f.blend != vectorcraft_color::BlendMode::Normal { + return None; + } + let Paint::Solid { color, swatch: None, .. } = &f.paint else { return None }; + let (model, comps) = match *color { + Color::Rgb { r, g, b } => (0, [r, g, b, 0.0]), + Color::Cmyk { c, m, y, k } => (1, [c, m, y, k]), + Color::Gray { k } => (2, [k, 0.0, 0.0, 0.0]), + Color::Lab { .. } => return None, + }; + let [sp] = path.subpaths.as_slice() else { return None }; + let mut pts: Vec = Vec::with_capacity(5); + for a in &sp.anchors { + if a.h_in != a.p || a.h_out != a.p { + return None; + } + pts.push(a.p); + } + if pts.len() == 5 && pts.first().zip(pts.last()).is_some_and(|(a, b)| a.distance(*b) <= EPS) { + pts.pop(); + } + let [a, b, c, d] = pts.as_slice() else { return None }; + // Opposite corners share a midpoint. + if (a.to_vec2() + c.to_vec2() - b.to_vec2() - d.to_vec2()).hypot() > EPS { + return None; + } + Some(Strip { corners: [*a, *b, *c, *d], model, comps, color: *color, overprint: f.overprint }) +} + +/// The edge two strips share, as it runs along the first: `(k, from, to)`, from its corner `k`. +fn shared_edge(a: &Strip, b: &Strip) -> Option<(usize, Point, Point)> { + let near = |p: Point, q: Point| p.distance(q) <= EPS; + for k in 0..4 { + let (p, q) = (a.corners[k], a.corners[(k + 1) % 4]); + if p.distance(q) <= EPS { + continue; + } + for j in 0..4 { + let (r, s) = (b.corners[j], b.corners[(j + 1) % 4]); + if (near(p, r) && near(q, s)) || (near(p, s) && near(q, r)) { + return Some((k, p, q)); + } + } + } + None +} + +/// Coordinates along a run of strips: a point is `o + s·w + t·v`, `v` along the strips (their +/// shared edges run from `t` = 0 to 1), `w` across them. +struct Axes { + o: Point, + w: Vec2, + v: Vec2, + inv: Affine, +} + +impl Axes { + fn of(a: &Strip, b: &Strip) -> Option { + let (k, from, to) = shared_edge(a, b)?; + let w = a.corners[(k + 2) % 4] - a.corners[(k + 1) % 4]; + let v = to - from; + let m = Affine::new([w.x, w.y, v.x, v.y, from.x, from.y]); + if w.hypot() <= EPS || m.determinant().abs() < 1e-9 * w.hypot() * v.hypot() { + return None; + } + Some(Self { o: from, w, v, inv: m.inverse() }) + } + + fn at(&self, s: f64, t: f64) -> Point { + self.o + self.w * s + self.v * t + } + + /// The strip's extent across the run (`s` from, to), when it spans the run's length exactly. + fn band(&self, st: &Strip) -> Option<(f64, f64)> { + let (ts, ss) = (EPS / self.v.hypot(), EPS / self.w.hypot()); + let local: Vec = st.corners.iter().map(|p| self.inv * *p).collect(); + let lo = local.iter().map(|p| p.x).fold(f64::INFINITY, f64::min); + let hi = local.iter().map(|p| p.x).fold(f64::NEG_INFINITY, f64::max); + let fits = local + .iter() + .all(|p| ((p.y).abs() <= ts || (p.y - 1.0).abs() <= ts) && ((p.x - lo).abs() <= ss || (p.x - hi).abs() <= ss) && p.x.is_finite()); + // Each end of the strip touches both edges of the run. + let ends = [0.0, 1.0].iter().all(|t| local.iter().filter(|p| (p.y - t).abs() <= ts).count() == 2); + (fits && ends).then_some((lo, hi)) + } +} + +/// A run of strips found at one place in what was drawn: where it ends (exclusive) and the +/// object it becomes. +struct Run { + start: usize, + end: usize, + node: Node, +} + +/// Is `s` (from, to) too thin to paint anything? +fn thin(b: (f64, f64), axes: &Axes) -> bool { + (b.1 - b.0) * axes.w.hypot() <= EPS +} + +/// The run of strips starting at `i`, if one does. +fn run_at(drawn: &Drawn, strips: &[Option], i: usize) -> Option { + let chain = |k: usize| drawn.get(k).map(|(c, _)| c.iter().map(|c| c.id).collect::>()); + let here = chain(i)?; + let strip = |k: usize| strips.get(k).and_then(Option::as_ref).filter(|_| chain(k).as_ref() == Some(&here)); + let first = strip(i)?; + // The run's axes come from its first two strips that paint something; a line before them + // (a strip of no width, as some writers draw first) joins the run where it lies on its edge. + let (lead, a, b) = match (strip(i + 1), strip(i + 2)) { + (Some(b), _) if Axes::of(first, b).is_some_and(|ax| ax.band(first).is_some_and(|f| !thin(f, &ax))) => (None, first, b), + (Some(a), Some(b)) => (Some(first), a, b), + _ => return None, + }; + let axes = Axes::of(a, b)?; + let (ba, bb) = (axes.band(a)?, axes.band(b)?); + if thin(ba, &axes) || thin(bb, &axes) { + return None; + } + let ss = EPS / axes.w.hypot(); + let forward = if (bb.0 - ba.1).abs() <= ss { + true + } else if (bb.1 - ba.0).abs() <= ss { + false + } else { + return None; + }; + let mut edge = if forward { ba.0 } else { ba.1 }; + if let Some(l) = lead { + let lb = axes.band(l)?; + if !thin(lb, &axes) || (lb.0 - edge).abs() > ss { + return None; + } + } + let at = i + usize::from(lead.is_some()); + let mut bands: Vec<(f64, f64, &Strip)> = vec![]; + let mut end = at; + while bands.len() < MAX_STITCH_STOPS { + let Some(st) = strip(end) else { break }; + let Some(band) = axes.band(st) else { break }; + let meets = if forward { (band.0 - edge).abs() <= ss } else { (band.1 - edge).abs() <= ss }; + if !meets || st.overprint != a.overprint { + break; + } + if !thin(band, &axes) { + if let Some((_, _, prev)) = bands.last() + && (prev.model != st.model || prev.comps.iter().zip(&st.comps).any(|(x, y)| (x - y).abs() > MAX_STEP)) + { + break; + } + bands.push((band.0, band.1, st)); + } + edge = if forward { band.1 } else { band.0 }; + end += 1; + } + if bands.len() < MIN_STRIPS { + return None; + } + let lo = bands.iter().map(|b| b.0).fold(f64::INFINITY, f64::min); + let hi = bands.iter().map(|b| b.1).fold(f64::NEG_INFINITY, f64::max); + if !(hi - lo).is_finite() || hi - lo <= 0.0 { + return None; + } + bands.sort_by(|x, y| x.0.total_cmp(&y.0)); + let stops: Vec<(f32, &Strip)> = bands.iter().map(|(s0, s1, st)| ((((s0 + s1) / 2.0 - lo) / (hi - lo)) as f32, *st)).collect(); + let stops: Vec = simplify(&stops).into_iter().map(|(t, st)| GradientStop::new(t, st.color)).collect(); + // The gradient runs across the strips, square to them, from the run's edge at `lo`. + let len = axes.v.hypot(); + let mut n = Vec2::new(-axes.v.y / len, axes.v.x / len); + let across = axes.w.dot(n) * (hi - lo); + if across < 0.0 { + n = -n; + } + let start = axes.at(lo, 0.0); + let mut g = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + g.geom = Some(GradientGeom { start, end: start + n * across.abs(), aspect: 1.0, focal: None }); + let mut bp = BezPath::new(); + bp.move_to(axes.at(lo, 0.0)); + bp.line_to(axes.at(hi, 0.0)); + bp.line_to(axes.at(hi, 1.0)); + bp.line_to(axes.at(lo, 1.0)); + bp.close_path(); + let item = AppearanceItem::Fill(FillLayer { overprint: a.overprint, ..FillLayer::new(Paint::Gradient(Box::new(g))) }); + let id = drawn.get(i).map(|(_, n)| n.id)?; + let node = Node::path(id, PathData::from_bezpath(&bp), Appearance { items: vec![item], ..Appearance::default() }); + Some(Run { start: i, end, node }) +} + +/// The stops `stops` (offset, strip) needs: those the gradient between the stops kept on either +/// side wouldn't come within [`TOLERANCE`] of are kept. +fn simplify<'a>(stops: &[(f32, &'a Strip)]) -> Vec<(f32, &'a Strip)> { + let mut kept: Vec<(f32, &Strip)> = vec![]; + let Some(&first) = stops.first() else { return kept }; + kept.push(first); + let mut from = 0; + while from + 1 < stops.len() { + // The furthest stop reachable from `from` with every stop between on the line. + let mut to = from + 1; + while to + 1 < stops.len() && on_line(stops, from, to + 1) { + to += 1; + } + if let Some(&s) = stops.get(to) { + kept.push(s); + } + from = to; + } + kept +} + +/// Do the stops between `a` and `b` lie on the blend between those two? +fn on_line(stops: &[(f32, &Strip)], a: usize, b: usize) -> bool { + let (Some((ta, sa)), Some((tb, sb))) = (stops.get(a), stops.get(b)) else { return false }; + let span = tb - ta; + stops.get(a + 1..b).unwrap_or_default().iter().all(|(t, s)| { + let f = if span > 0.0 { (t - ta) / span } else { 0.0 }; + sa.comps.iter().zip(&sb.comps).zip(&s.comps).all(|((x, y), z)| (x + (y - x) * f - z).abs() <= TOLERANCE) + }) +} + +/// Runs of strips in `drawn` become one gradient-filled object each (marked in `shadings`, so a +/// clipping group of nothing else is its clip path filled with the gradient). +pub(crate) fn merge_strips(drawn: &mut Drawn, shadings: &mut HashSet) { + let strips: Vec> = drawn.iter().map(|(_, n)| strip_of(n)).collect(); + if strips.iter().filter(|s| s.is_some()).count() < MIN_STRIPS { + return; + } + let mut runs: Vec = vec![]; + let mut i = 0; + while i < drawn.len() { + match run_at(drawn, &strips, i) { + Some(r) => { + i = r.end.max(i + 1); + runs.push(r); + } + None => i += 1, + } + } + if runs.is_empty() { + return; + } + let old = std::mem::take(drawn); + let mut runs = runs.into_iter().peekable(); + let mut skip_to = 0; + for (k, item) in old.into_iter().enumerate() { + if k < skip_to { + continue; + } + if let Some(r) = runs.next_if(|r| r.start == k) { + skip_to = r.end; + shadings.insert(r.node.id); + drawn.push((item.0, r.node)); + continue; + } + drawn.push(item); + } +} diff --git a/crates/eps/src/import/tests.rs b/crates/eps/src/import/tests.rs index 86fa8e35d..6bdaa8819 100644 --- a/crates/eps/src/import/tests.rs +++ b/crates/eps/src/import/tests.rs @@ -1407,3 +1407,72 @@ fn bitshift_by_the_most_negative_integer_does_not_panic() { let r = read("0 0 10 10 rectfill 1 -9223372036854775808 bitshift pop -1 -9223372036854775808 bitshift pop"); assert_eq!(objects(&r.document).len(), 1, "{:?}", r.warnings); } + +/// The stops (offset, grey's black ink or red) and the start and end of the linear gradient `n` is +/// filled with. +fn linear_stops(n: &Node) -> (Vec<(f32, f32)>, Point, Point) { + let Some(Paint::Gradient(g)) = fill(n) else { panic!("not a gradient: {:?}", fill(n)) }; + assert_eq!(g.gradient.kind, GradientKind::Linear); + let level = |c: &Color| match *c { + Color::Gray { k } => k, + Color::Rgb { r, .. } => r, + other => panic!("{other:?}"), + }; + let geom = g.geom.unwrap(); + (g.gradient.stops.iter().map(|s| (s.offset, level(&s.color))).collect(), geom.start, geom.end) +} + +#[test] +fn abutting_strips_of_a_gradient_become_one_gradient() { + // A grey ramp drawn right to left as 40 strips 2 pt wide under a clip, after a strip of no + // width on its right edge: the clip path filled with one linear gradient, without seams. + let r = read( + "gsave 10 10 80 80 rectclip 90 10 0 80 rectfill + 0 1 39 { /i exch def i 39 div setgray 88 i 2 mul sub 10 2 80 rectfill } for grestore", + ); + let o = objects(&r.document); + assert_eq!(o.len(), 1, "{o:#?}"); + let n = &o[0]; + assert!(matches!(n.kind, NodeKind::Path { .. })); + assert!(near(bounds(n), Rect::new(10.0, 10.0, 90.0, 90.0)), "{:?}", bounds(n)); + let (stops, start, end) = linear_stops(n); + // A straight ramp needs only its two ends, at the middles of the end strips: black on the + // right, white on the left. + assert_eq!(stops.len(), 2, "{stops:?}"); + let x = |t: f32| start.x + (end.x - start.x) * f64::from(t); + assert!((start.y - end.y).abs() < 1e-6); + let ((t0, k0), (t1, k1)) = (stops[0], stops[1]); + // A grey's level is its black ink: 1 for black. + let (dark, light) = if k0 > k1 { ((x(t0), k0), (x(t1), k1)) } else { ((x(t1), k1), (x(t0), k0)) }; + assert!((dark.0 - 89.0).abs() < 0.01 && (dark.1 - 1.0).abs() < 1e-6, "{dark:?}"); + assert!((light.0 - 11.0).abs() < 0.01 && light.1.abs() < 1e-6, "{light:?}"); +} + +#[test] +fn turned_strips_become_a_gradient_across_them() { + // Strips drawn in a turned space: the gradient runs square to them. + let r = read("50 50 translate 30 rotate 0 1 19 { /i exch def i 19 div 0 0 setrgbcolor i 2 mul 20 sub -20 2 40 rectfill } for"); + let o = objects(&r.document); + assert_eq!(o.len(), 1, "{o:#?}"); + let (stops, start, end) = linear_stops(&o[0]); + assert_eq!(stops.len(), 2, "{stops:?}"); + let d = end - start; + // 30° in PostScript's y-up space is −30° in the document's y-down one. + let angle = d.y.atan2(d.x).to_degrees(); + assert!((angle + 30.0).abs() < 0.01 || (angle - 150.0).abs() < 0.01, "{angle}"); + assert!((d.hypot() - 40.0).abs() < 0.01, "{}", d.hypot()); +} + +#[test] +fn abutting_blocks_of_distinct_colours_stay_apart() { + // Side by side but each a different colour (a row of swatches): not a gradient. + let blocks: String = + (0..8).map(|i| format!("{} {} {} setrgbcolor {} 10 10 80 rectfill ", i % 2, (i / 2) % 2, (i / 4) % 2, 10 + i * 10)).collect(); + assert_eq!(objects(&read(&blocks).document).len(), 8); + // Too few strips to be sure of. + let few: String = (0..5).map(|i| format!("{} setgray {} 10 2 80 rectfill ", f64::from(i) / 40.0, 10 + i * 2)).collect(); + assert_eq!(objects(&read(&few).document).len(), 5); + // Strips with gaps between them. + let gaps: String = (0..10).map(|i| format!("{} setgray {} 10 2 80 rectfill ", f64::from(i) / 40.0, 10 + i * 3)).collect(); + assert_eq!(objects(&read(&gaps).document).len(), 10); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d3e2f0303..af857ac0b 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (EPS import: gradients drawn as abutting strips come in as one gradient, without seams; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -153,7 +153,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Illustrator template `.ait` | open, save | yes | PDF-compatible only, from Save as Template (`.vctemplate` stays its default) | New from Template opens a saved `.ait` untitled with its layers and art (`tests_pdfedit.rs`) | | Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure with template layers and ruler guides, File Info) | PDF-compatible, or legacy version 7 (layers and groups) | see above; no real-file corpus | | PDF | open, place, save | yes (layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160) | -| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CID-keyed CJK type not read; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | +| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CID-keyed CJK type not read; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | | PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` | | SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as ``, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`) | | DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). CNC preset and VCarve QA remain open | @@ -191,6 +191,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | EPS import: a gradient the page paints as a row of abutting one-colour strips imports as one object filled with a linear gradient through the strips' colours (fewer stops where the colours change evenly), its clip path filled with it when it was the clip's only art. Drawn one by one, the strips left seams where the art below showed through each shared edge (PDF 32000-1 §10.6.4, conflation), a pale tint across strips a pixel or two wide; an Illustrator EPS whose page drew its gradients that way differed from its layers and opened as its page. At least 6 strips side by side, each a small colour step from the last; abutting blocks of distinct colours stay apart. Tests `import::tests::abutting_strips_of_a_gradient_become_one_gradient`, `turned_strips_become_a_gradient_across_them`, `abutting_blocks_of_distinct_colours_stay_apart`; scores unchanged | | 2026-11-06 | minor | SVG import: filter and mask regions clip (SVG 1.1 §15.5, §14.4). An object whose imported blur, shadow or glow reaches past its filter's region comes in inside a clip group cut to the region (#5876), and a mask's art is cut to the mask's region, in user space or the object's bounding box (#5875). Objects that fit their regions, and our own exports, are unchanged; primitive subregions are still ignored. Tests `import_live::a_blur_is_cut_at_its_filter_region`, `import_live::a_mask_counts_only_inside_its_region` (crate `vectorcraft-svg`); scores unchanged | | 2026-11-06 | minor | SVG import: a Gaussian blur keeps the colour space its filter asks for with `color-interpolation-filters` (initially `linearRGB`, SVG 1.1 §15.7.6): a linear-light blur becomes Gaussian Blur with the new `linear` parameter, which blurs the colours in linear light (black beside white meets at about 188/255 instead of 128), and SVG export writes it back on the `feGaussianBlur` (#5877). Blurs that ask for sRGB, and the blurs our own export writes, are as before. CSS export still writes `blur()`. Tests `pixel::tests::linear_light_blur_mixes_light_not_values` (crate `vectorcraft-effects`), `import_live::a_gaussian_blur_keeps_its_colour_space` (crate `vectorcraft-svg`); scores unchanged | | 2026-11-06 | minor | PDF export and PDF-compatible `.ai` saves write each object's URL (Attributes panel) as a link annotation with a URI action (web and mail URLs only) over the bounds of the object that shows, trimmed by its clipping groups (ISO 32000-1, 12.5.6.5 and 12.6.4.7); hidden objects, layers that don't print and prints have none, and PDF/X files leave them out with a warning (#5785). Own-fixture tests in `tests::links`; PDF import doesn't read links back as URLs yet; scores unchanged | diff --git a/docs/gaps.md b/docs/gaps.md index adb50d60f..dbddf1277 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -36,6 +36,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | G9: EPS import reads gradients a page paints as abutting strips as one linear gradient each (no seams); an Illustrator EPS whose page drew them that way keeps its layers. Estimate unchanged. | | 2026-11-05 | minor | G15: long open strokes with an undashed width profile are outlined near the view while keeping profile positions on the full path; a 200,000-segment outline took 59.95 ms before and 8.44 ms after in local median runs. Closed and dashed profile strokes remain whole; estimate unchanged. | | 2026-11-05 | minor | G15: Direct Selection reuses one document hit result when choosing the cursor over a path; a 200,000-anchor path's segment cursor took 58.15 ms before and 29.33 ms after in local median runs. Cursor behavior is unchanged; estimate unchanged. | | 2026-11-05 | minor | G15: Selection skips building corner and pie widgets when the pointer is well outside a large selection; empty-canvas hover with 20,000 selected paths took 28.72 ms before and 16.41 ms after in local median runs. Other cursor targets remain active; estimate unchanged. | @@ -182,6 +183,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G9 Illustrator `.ai` and EPS files from real users +- **Done (2026-11-06):** an EPS page that paints its gradients as rows of abutting one-colour strips imports them as linear gradients, so the seams between strips no longer show and an Illustrator EPS drawn that way keeps its layers instead of opening as its page. This narrows G9; scores and estimates unchanged. - **Done (2026-10-30):** from an audit of a user's `.ai`/EPS files: All Caps and Small Caps; placed files at their box's corner; pages cropped to the art; gradient mesh corners with handle weights of 0 and the transparency written after a mesh, a masked mesh and a blend; a layer's state block with the stroke style; type on a path spaced as the file lays it out; EPS10 legacy-format type; a PDF of some artboards compared on the artboard its page shows. Comparison limits remain unchanged. This narrows G9; scores and estimates unchanged. - **Save-back update (2026-10-26):** ordinary EPS Save retains page margins and the whole editable document; source-loss confirmation remains. Import fidelity gaps and estimates below are unchanged. From fc7e1bf0bdd9ace6ba42c604bb4b2b9c92ebc4c1 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:11:56 -0500 Subject: [PATCH 053/160] Docs: keep this branch's entries that the merge with main dropped --- docs/gaps.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/gaps.md b/docs/gaps.md index 4dac9431e..bda46c561 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -37,6 +37,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | G9: EPS import reads gradients a page paints as abutting strips as one linear gradient each (no seams); an Illustrator EPS whose page drew them that way keeps its layers. Estimate unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 2026-11-05 | minor | G15: long open strokes with an undashed width profile are outlined near the view while keeping profile positions on the full path; a 200,000-segment outline took 59.95 ms before and 8.44 ms after in local median runs. Closed and dashed profile strokes remain whole; estimate unchanged. | | 2026-11-05 | minor | G15: Direct Selection reuses one document hit result when choosing the cursor over a path; a 200,000-anchor path's segment cursor took 58.15 ms before and 29.33 ms after in local median runs. Cursor behavior is unchanged; estimate unchanged. | @@ -184,6 +185,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G9 Illustrator `.ai` and EPS files from real users +- **Done (2026-11-06):** an EPS page that paints its gradients as rows of abutting one-colour strips imports them as linear gradients, so the seams between strips no longer show and an Illustrator EPS drawn that way keeps its layers instead of opening as its page. This narrows G9; scores and estimates unchanged. - **Done (2026-10-30):** from an audit of a user's `.ai`/EPS files: All Caps and Small Caps; placed files at their box's corner; pages cropped to the art; gradient mesh corners with handle weights of 0 and the transparency written after a mesh, a masked mesh and a blend; a layer's state block with the stroke style; type on a path spaced as the file lays it out; EPS10 legacy-format type; a PDF of some artboards compared on the artboard its page shows. Comparison limits remain unchanged. This narrows G9; scores and estimates unchanged. - **Save-back update (2026-10-26):** ordinary EPS Save retains page margins and the whole editable document; source-loss confirmation remains. Import fidelity gaps and estimates below are unchanged. From 72af76d418a43cbc3cb111297626fe8ff4e0f56b Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 6 Nov 2026 21:35:41 -0800 Subject: [PATCH 054/160] 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<CommandSpec> { "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<CommandSpec> { "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<NodeId>)> { Ok((direct, roots)) } -fn copy(s: &mut Session, _: &Value) -> Result<Value> { +/// 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<Value> { + let given: serde_json::Map<String, Value> = + ["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<Value> { + 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<Value> { } fn cut(s: &mut Session, p: &Value) -> Result<Value> { - 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<usize>, 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<usize>, 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<usize>, 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 <RobertLeeLW@gmail.com> Date: Fri, 6 Nov 2026 22:08:13 -0800 Subject: [PATCH 055/160] 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<CommandSpec> { "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<CommandSpec> { "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.<plug-in id>, 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 <stem>.html / <stem>.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.<plug-in id>, 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 <stem>.html / <stem>.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<CommandSpec> { "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<CommandSpec> { "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<CommandSpec> { "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<CommandSpec> { "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<CommandSpec> { "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<String, Value>, schema: &serde_json::Map<String, Value>) -> 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<CommandSpec> { "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<Value, String> { + // 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`), `<path stem>-<artboard>.<ext>`, 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 <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 00:01:56 -0800 Subject: [PATCH 056/160] 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 <RobertLeeLW@gmail.com> Date: Fri, 6 Nov 2026 16:31:39 -0800 Subject: [PATCH 057/160] 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<CommandSpec> { "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<CommandSpec> { "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<CommandSpec> { "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<CommandSpec> { "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<CommandSpec> { "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<CommandSpec> { "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<CommandSpec> { "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<CommandSpec> { "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<CommandSpec> { "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<CommandSpec> { "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<CommandSpec> { "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<Value> { } } 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<Value> { } } 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<Item = (NodeId, bool)>) -> 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<Value> { } 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<Value> { } } 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<Value> { 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<Value> { 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<Value> { } } 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<Value> { 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<Value> { /// `appearance.showAllHidden`: shows every hidden fill, stroke and effect of the selected objects. fn show_all_hidden(s: &mut Session, p: &Value) -> Result<Value> { 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<NodeId> = 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::<Vec<_>>() })) @@ -962,7 +1002,8 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> { } } 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<V } } 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)) })?; let m = idx.len(); let landed = first_landed.unwrap_or(to); @@ -1160,8 +1202,41 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> { 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::<Vec<_>>() })) } 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<f64>], 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<f64>]| 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<f64>)>) -> 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<NodeId>) { + 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<vectorcraft_doc::Node>) { + 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<Vec<f64>> = (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 0bc150c51eee171b11cd4cfee4dfb7d1564b6be6 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 00:18:45 -0800 Subject: [PATCH 058/160] UI: save the Help Bar setting to preference files that lack it A preferences file from before the Help Bar setting gets `help_bar` from `status_bar` when it is read. A save that changed only the status bar left `help_bar` out of the file, so the next start hid the Help Bar with the status bar. A save now writes `help_bar` whenever the file lacks it, and keeps a value another window saved. Docs: the UI evidence list, the UI checklist and the M14 row record Window > Help Bar. --- apps/vectorcraft/src/prefs.rs | 26 ++++++++++++++++++++++++++ docs/roadmap.md | 2 +- docs/target-app-parity.md | 1 + docs/ui-parity.md | 1 + 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/vectorcraft/src/prefs.rs b/apps/vectorcraft/src/prefs.rs index cf4e59b28..9050071f4 100644 --- a/apps/vectorcraft/src/prefs.rs +++ b/apps/vectorcraft/src/prefs.rs @@ -163,6 +163,11 @@ fn same_lasting(a: &serde_json::Value, b: &serde_json::Value) -> bool { } } +/// Fields that a read fills in from other fields when the file lacks them +/// (`vectorcraft_ui_egui::layout_migration`): `help_bar` follows `status_bar`. A save writes them +/// whenever the file lacks them, so the next read does not fill them in again from a changed field. +const DERIVED: &[&str] = &["help_bar"]; + /// `on_disk` with what changed from `base` to `mine` put over it: each top-level field of the UI /// state, and each engine preference (`engine_prefs`) on its own, so two windows that change /// different settings keep both. A field both changed takes this window's value (it saves last). @@ -214,6 +219,13 @@ fn merge_changes(base: &serde_json::Value, mine: &serde_json::Value, on_disk: se } } } + for key in DERIVED { + if !out.contains_key(*key) + && let Some(v) = mine.get(*key) + { + out.insert((*key).to_string(), v.clone()); + } + } if use_mine { match mine.get("action_set") { Some(v) => { @@ -365,6 +377,20 @@ mod tests { assert!(!same_lasting(&base, &mine)); } + /// A file from before the Help Bar setting: the read shows the Help Bar with the status bar. + /// After the status bar is hidden and the Help Bar left showing, the save writes `help_bar`, + /// so the next read does not hide it with the status bar. A value on disk is kept. + #[test] + fn a_help_bar_setting_the_file_lacks_is_saved() { + let base = json!({"status_bar": true, "help_bar": true}); + let mine = json!({"status_bar": false, "help_bar": true}); + let on_disk = json!({"status_bar": true}); + assert_eq!(merge_changes(&base, &mine, on_disk), json!({"status_bar": false, "help_bar": true})); + // Another window saved its own Help Bar setting: it stays. + let on_disk = json!({"status_bar": true, "help_bar": false}); + assert_eq!(merge_changes(&base, &mine, on_disk), json!({"status_bar": false, "help_bar": false})); + } + /// One window deleted the first Actions set and saved; this one made the second set current. /// The saved index follows that set to its place in the merged list. #[test] diff --git a/docs/roadmap.md b/docs/roadmap.md index 18cb6a340..bbe235327 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -111,7 +111,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; broader scripting and batch work remains | 18–27 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 | -| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Dock groups collapse to their tabs by a double-click on a tab (UI.8); Preferences › General › Constrain Angle acts for the arrow keys, Shift drags and new objects (#394, 2026-10-21); numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces (each keeps its layout changes until Reset <name>, UI.7), panels docked by dragging into any dock group (as tabs or a top/bottom split) or into the icon column, and closed from a dock tab's context menu (UI.6), a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); titled PDF exports set `DisplayDocTitle` for viewers and accessibility checkers (#3646); accessibility and broader Windows/Linux packaging/QA pending | 18–28 | +| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Dock groups collapse to their tabs by a double-click on a tab (UI.8); Preferences › General › Constrain Angle acts for the arrow keys, Shift drags and new objects (#394, 2026-10-21); numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces (each keeps its layout changes until Reset <name>, UI.7), Window › Help Bar shown or hidden apart from the status bar and kept in saved workspaces, panels docked by dragging into any dock group (as tabs or a top/bottom split) or into the icon column, and closed from a dock tab's context menu (UI.6), a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); titled PDF exports set `DisplayDocTitle` for viewers and accessibility checkers (#3646); accessibility and broader Windows/Linux packaging/QA pending | 18–28 | | — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed) | ⬜ (new ruler-guide Alt orientation implemented; existing-guide Escape cancellation regression-tested; broader pass remains) | 60–90 | | — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 PngSuite integrity and bounded fetching verified (#1729); real-world corpus/manual QA remain | 50–80 | | M14 follow-up | Color Picker layout (#2008) | ✅ Compact controls, centered header, stacked actions, aligned fields and screen sampling; narrow windows scroll vertically | Included above | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ece0bc408..b19036496 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -607,6 +607,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Screen readers get the value of the color and channel sliders that panels and dialogs draw (the Color panel, the Color Picker, Swatch Options, the Navigator's Zoom, the Character panel's variable font axes, the Perspective Grid and flattener options, and the sliders of the effect, tool option and transform dialogs): the number and text of the field beside each one, such as 128 for G or 50% for an opacity, with the slider's range, which extends to include a number past the slider's end. The harmony wheel's Brightness slider, which has no field, reports its brightness in percent. - Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel (in an OS window of its own on the desktop, #4135), and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on a dock group's tabs, which light up under the pointer, its panels join that group, and on the group's upper or lower half they make a new group above or below it (`window.panel.dock {anchor, zone}`); dropped on the icon column they become icons there, the tabbed group's panels too (`window.panel.dock {icon: true}`); dropped anywhere else on the dock, which lights up, they go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating groups stack into sets: dropped on the upper or lower half of another group (a line shows where), a group joins it right above or below it, and the set's groups stack top to bottom, each showing one panel, moving together by the top group's title bar, whose × docks them all; a group dragged by its tab strip leaves the set (`window.panel.float {below, above}`). A double-click on a tab collapses a floating group to its tabs (`collapsed`), and a dock group too, a group below it moving up into the room; a second double-click, or a click on another of its tabs, expands it (`window.panel.collapse`, kept with the preferences and in saved workspaces). Sets dropped on the left edge of the icon column or of another dock column dock as dock columns (a multi-column dock, `window.panel.dock {column}`), whose groups stack, tab and tear out as a floating set's do, and whose top strip floats the column again. Floating panels, their sets and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, reordering tabs by dragging, collapsing a set or dock column to icons, dock columns right of the default dock, resizing floating panels and the groups of a set. - The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done. + - Window › Help Bar shows or hides the tool-help strip at the bottom of the window apart from the status bar (`window.helpBar`). Each setting is kept with the UI preferences and in saved workspaces, and Presentation Mode hides both bars. Layouts saved before the setting existed show the Help Bar where they show the status bar (`help_bar_toggle_is_independent_and_saved_in_workspaces`, `help_bar_and_status_bar_render_independently`, `legacy_help_bar_visibility_follows_each_layouts_status_bar`, `a_help_bar_setting_the_file_lacks_is_saved`). - Window › Toolbars › New Toolbar… and New Toolbar… in the All Tools drawer's menu ask for a name and make a blank toolbar that floats on its own, moved by its title bar and listed under Window › Toolbars, whose item shows or hides it. The title bar's × hides it, and its double arrow shows its tools in one column or two. Its ••• button opens the All Tools drawer beside it, and tools dragged from the drawer fill it. The toolbars are kept with the workspace they were made in: switching workspaces shows that workspace's toolbars, Reset Workspace keeps them, and New Workspace starts the new workspace with a copy of them (<https://helpx.adobe.com/illustrator/using/tools.html>; `window.toolbar.new`, `window.toolbar.custom`, the `toolbar` param of `window.editToolbar` and `toolbar.addTool`/`removeTool`/`reset`; `tools_added_to_a_new_toolbar_survive_a_workspace_reload`). Manage Toolbars is not done. - Czech render-site labels: desktop/web file filters, diagnostics, suggested preset names and contributor tooltips/plurals now use catalog lookups; gradients have Czech adjective agreement and printer marks have a separate context (PDF/Print and Summary). Search, Home, toolbar/panel tooltips and Preferences already look up their labels. Every complete catalog has rows for these labels and for the Roman and Japanese printer mark styles. Evidence: `i18n::tests::czech_render_site_follow_up`, `i18n::tests::complete_catalogs_translate_render_site_labels`, `dialogs::tests_modal::printer_mark_labels_are_distinct_from_language_names`; see [localization-parity.md](localization-parity.md). - The History panel, Edit › Undo and Edit › Redo translate the names of the steps that apply or redefine a character or paragraph style and of the step that redefines a graphic style: each such step keeps its template (`Apply {name}`, `Redefine {name}`, `Redefine Graphic Style “{name}”`) and the style's name, every complete catalog has a row for each template, and the panel and the menu show the style's name as the document holds it, except the Normal styles' names, which they show in the interface language (`steps_named_after_a_style_list_the_template_around_the_name`, `undo_and_redo_name_a_style_step_around_the_style_name`). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 1fdcc0b9e..f640aa036 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -334,6 +334,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Gradient panel: Presets (30.0) | done | Presets menu by library (theme) plus Saved: applies a preset to the gradient behind the active proxy, keeping its placement, one undo step; panel menu Save Gradient Preset…, Delete Gradient Preset, Hide/Show Presets (`gradient.presets.*`); `panels::gradient` tests | | SVG Interactivity, Retype, Comments, Version History panels | missing | the last two are cloud features (out of scope) | | Workspaces (save, reset, switch); each remembers the layout changes made in it across switches and restarts until Reset <name> | done | `UiState::workspace_changes` | +| Window › Help Bar shows or hides the tool-help strip apart from the status bar; kept with the preferences and in saved workspaces; Presentation Mode hides both bars | done | `window.helpBar`; `help_bar_toggle_is_independent_and_saved_in_workspaces`, `help_bar_and_status_bar_render_independently`, `legacy_help_bar_visibility_follows_each_layouts_status_bar` | | Background Export: a progress dialog with the file name, the artboard count and a bar (busy for one artboard and for formats made in one go), Cancel (Esc) and Continue in Background; it opens after 0.4 s and never over another dialog. A cancelled export writes no file | done | #2564; on the shared `vectorcraft_doc::cancel` token (#2546), so a cancelled render keeps no half-drawn pattern tile or effect raster. The bar moves between artboards, not within one. Save As, Export for Screens and Export Selection don't show it yet | | Swatch, graphic style and symbol library panels: each library opens in a panel of its own, several at once, that floats, groups, docks and closes like any panel; opening an open library brings its panel forward; Previous / Next Library change the library in place; the panel menu's Persistent reopens the panel at startup with its view and find field (a library loaded from a file is read again; when its file is missing the panel reopens showing the file's path with Locate Library…, and the status bar names the file) | done | #2603; panel ids `library:<kind>:<id>`, `ui.libraryPanel {panel, library?, view?, showFind?, persistent?}`, `ui.libraryPanel.locate {panel, path?}`; the library panels have no sort option, as before | | DXF export/import preserves intended curve closure | done | #1299; R13–2018 closed SPLINE bit set/read; an open curve ending at its start stays open; knots and control points unchanged; native CIRCLE/ARC export and CNC preset remain pending | From 28f8f84fd5a64b71e1195a920968ddbbf8e7096b Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sat, 7 Nov 2026 05:01:25 -0500 Subject: [PATCH 059/160] EPS import: strips under a larger clip keep the clip A run of gradient strips under a clip larger than the strips became the clip path filled with the gradient, painting where the strips did not. The clip path now takes the gradient only when every point of the clip lies inside the run; otherwise the gradient stays clipped to the strips' area. --- crates/eps/src/import/graphics/strips.rs | 40 ++++++++++++++++++------ crates/eps/src/import/tests.rs | 17 ++++++++++ 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/crates/eps/src/import/graphics/strips.rs b/crates/eps/src/import/graphics/strips.rs index 84146c338..9f0b39a24 100644 --- a/crates/eps/src/import/graphics/strips.rs +++ b/crates/eps/src/import/graphics/strips.rs @@ -16,7 +16,7 @@ use super::{ Affine, Appearance, AppearanceItem, BezPath, Color, Drawn, FillLayer, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, HashSet, - MAX_STITCH_STOPS, Node, NodeId, NodeKind, Paint, PathData, Point, Vec2, + MAX_STITCH_STOPS, Node, NodeId, NodeKind, Paint, PathData, PathEl, Point, Vec2, }; /// Fewest strips (that paint something) a gradient is read from. @@ -116,6 +116,13 @@ impl Axes { self.o + self.w * s + self.v * t } + /// Does `p` lie within the run's length, and across it from `lo` to `hi`? + fn holds(&self, p: Point, lo: f64, hi: f64) -> bool { + let (ts, ss) = (EPS / self.v.hypot(), EPS / self.w.hypot()); + let l = self.inv * p; + l.x >= lo - ss && l.x <= hi + ss && l.y >= -ts && l.y <= 1.0 + ts + } + /// The strip's extent across the run (`s` from, to), when it spans the run's length exactly. fn band(&self, st: &Strip) -> Option<(f64, f64)> { let (ts, ss) = (EPS / self.v.hypot(), EPS / self.w.hypot()); @@ -131,12 +138,14 @@ impl Axes { } } -/// A run of strips found at one place in what was drawn: where it ends (exclusive) and the -/// object it becomes. +/// A run of strips found at one place in what was drawn: where it ends (exclusive), the object +/// it becomes, and whether that object covers the clip around it (so the clip path filled with +/// the gradient paints no more than the strips did). struct Run { start: usize, end: usize, node: Node, + covers_clip: bool, } /// Is `s` (from, to) too thin to paint anything? @@ -226,9 +235,19 @@ fn run_at(drawn: &Drawn, strips: &[Option<Strip>], i: usize) -> Option<Run> { bp.line_to(axes.at(lo, 1.0)); bp.close_path(); let item = AppearanceItem::Fill(FillLayer { overprint: a.overprint, ..FillLayer::new(Paint::Gradient(Box::new(g))) }); - let id = drawn.get(i).map(|(_, n)| n.id)?; - let node = Node::path(id, PathData::from_bezpath(&bp), Appearance { items: vec![item], ..Appearance::default() }); - Some(Run { start: i, end, node }) + let (clips, first_node) = drawn.get(i)?; + // Every point of a clip's path, control points included, inside the run: its curves are too + // (each lies in the hull of its points), and the run's area is convex. + let covers_clip = clips.last().and_then(|c| c.region.as_ref()).is_some_and(|(clip, _)| { + clip.elements().iter().all(|el| match *el { + PathEl::MoveTo(p) | PathEl::LineTo(p) => axes.holds(p, lo, hi), + PathEl::QuadTo(a, p) => axes.holds(a, lo, hi) && axes.holds(p, lo, hi), + PathEl::CurveTo(a, b, p) => axes.holds(a, lo, hi) && axes.holds(b, lo, hi) && axes.holds(p, lo, hi), + PathEl::ClosePath => true, + }) + }); + let node = Node::path(first_node.id, PathData::from_bezpath(&bp), Appearance { items: vec![item], ..Appearance::default() }); + Some(Run { start: i, end, node, covers_clip }) } /// The stops `stops` (offset, strip) needs: those the gradient between the stops kept on either @@ -262,8 +281,9 @@ fn on_line(stops: &[(f32, &Strip)], a: usize, b: usize) -> bool { }) } -/// Runs of strips in `drawn` become one gradient-filled object each (marked in `shadings`, so a -/// clipping group of nothing else is its clip path filled with the gradient). +/// Runs of strips in `drawn` become one gradient-filled object each; one that covers its clip is +/// marked in `shadings`, so a clipping group of nothing else is its clip path filled with the +/// gradient (a clip larger than the strips keeps clipping the gradient to them). pub(crate) fn merge_strips(drawn: &mut Drawn, shadings: &mut HashSet<NodeId>) { let strips: Vec<Option<Strip>> = drawn.iter().map(|(_, n)| strip_of(n)).collect(); if strips.iter().filter(|s| s.is_some()).count() < MIN_STRIPS { @@ -292,7 +312,9 @@ pub(crate) fn merge_strips(drawn: &mut Drawn, shadings: &mut HashSet<NodeId>) { } if let Some(r) = runs.next_if(|r| r.start == k) { skip_to = r.end; - shadings.insert(r.node.id); + if r.covers_clip { + shadings.insert(r.node.id); + } drawn.push((item.0, r.node)); continue; } diff --git a/crates/eps/src/import/tests.rs b/crates/eps/src/import/tests.rs index 03bce703d..4bc8a65be 100644 --- a/crates/eps/src/import/tests.rs +++ b/crates/eps/src/import/tests.rs @@ -1486,6 +1486,23 @@ fn abutting_strips_of_a_gradient_become_one_gradient() { assert!((light.0 - 11.0).abs() < 0.01 && light.1.abs() < 1e-6, "{light:?}"); } +#[test] +fn strips_under_a_larger_clip_keep_the_clip() { + // The same ramp under a clip larger than the strips: the gradient fills only where the + // strips were, still clipped, not the whole clip. + let r = read( + "gsave 5 5 90 90 rectclip + 0 1 39 { /i exch def i 39 div setgray 88 i 2 mul sub 10 2 80 rectfill } for grestore", + ); + let o = objects(&r.document); + assert_eq!(o.len(), 1, "{o:#?}"); + let NodeKind::Group { children, clip: true } = &o[0].kind else { panic!("not a clipping group: {:#?}", o[0]) }; + let [_, fill] = children.as_slice() else { panic!("{children:#?}") }; + assert!(near(bounds(fill), Rect::new(10.0, 10.0, 90.0, 90.0)), "{:?}", bounds(fill)); + let (stops, _, _) = linear_stops(fill); + assert_eq!(stops.len(), 2, "{stops:?}"); +} + #[test] fn turned_strips_become_a_gradient_across_them() { // Strips drawn in a turned space: the gradient runs square to them. From 434cf3e692dadbdcd482d249ed9b902948fd45d7 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 02:38:40 -0800 Subject: [PATCH 060/160] 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<Item = (N rows.into_iter().filter(|(id, fill)| d.node(*id).is_some_and(|n| !super::graph::shown_paint(n, *fill).0.is_none())).collect() } +/// Store on the graph what an edit of `ids` (from `before` to `d`) leaves on the topmost visible fill and stroke of the +/// series marks among them: a paint, weight or opacity that changed, and the paint (`paints`) and opacity (`opacities`) of +/// the kinds of row the edit wrote, as (fill, stroke). With `top_only` the edit wrote the topmost row of each kind, which +/// counts only when it is visible. Returns whether the stored paint changed. +pub(super) fn capture_series_writes( + before: &vectorcraft_doc::Document, + d: &mut vectorcraft_doc::Document, + ids: &[NodeId], + paints: (bool, bool), + opacities: (bool, bool), + top_only: bool, +) -> 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<NodeId>) { + 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<CommandSpec> { "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<CommandSpec> { "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<Value> { 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<Value> { _ => 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<CommandSpec> { "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<CommandSpec> { "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<Value> { 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<Value> { 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<Value> { 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<Value> { let (follow, edited): (Vec<NodeId>, Vec<NodeId>) = 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 <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 04:01:31 -0800 Subject: [PATCH 061/160] 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<f32>, + /// 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<vectorcraft_color::BlendMode>, + /// 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<vectorcraft_color::BlendMode>, + /// 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<f32>, + /// 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<vectorcraft_color::BlendMode>, } /// 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<CommandSpec> { "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<CommandSpec> { "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<CommandSpec> { "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<CommandSpec> { "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<Value> { } 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<NodeId> { + 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<Item = (N } /// Store on the graph what an edit of `ids` (from `before` to `d`) leaves on the topmost visible fill and stroke of the -/// series marks among them: a paint, weight or opacity that changed, and the paint (`paints`) and opacity (`opacities`) of -/// the kinds of row the edit wrote, as (fill, stroke). With `top_only` the edit wrote the topmost row of each kind, which -/// counts only when it is visible. Returns whether the stored paint changed. +/// series marks among them: a paint, weight, opacity or blend mode that changed, and the paint (`paints`) and opacity and +/// blend mode (`opacities`) of the kinds of row the edit wrote, as (fill, stroke). With `top_only` the edit wrote the +/// topmost row of each kind, which counts only when it is visible. It also stores the marks' object opacity and blend mode +/// when they changed, or on every mark when the edit wrote them (`object`). Returns whether the stored paint changed. pub(super) fn capture_series_writes( before: &vectorcraft_doc::Document, d: &mut vectorcraft_doc::Document, @@ -664,6 +673,7 @@ pub(super) fn capture_series_writes( paints: (bool, bool), opacities: (bool, bool), top_only: bool, + object: bool, ) -> 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<Value> { 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<Value> { 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<Value> { 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::<Vec<_>>() })) } 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<Arc<Node>>) -> Vec<Arc<Node>> { let Some(slot) = g.series_paints.get(i) else { return items }; let kept = |o: Option<f32>| 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<f32>, Option<BlendMode>)> = 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<f32>, Option<BlendMode>)> { + 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<CommandSpec> { "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<Value> { }) }; 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<Value> { }); } } + 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<CommandSpec> { "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<Value> { 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<Value> { _ => 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<CommandSpec> { "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<CommandSpec> { "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<Value> { // 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<Value> { 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 <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 05:01:15 -0800 Subject: [PATCH 062/160] .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<GraphicStyle>, 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<String, ImageBlob>, + /// 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<Swatch>, +} + +/// 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<Symbols> { + 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<NodeId, u32> = 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<Swatch> = 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<usize>) { 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<u8> = 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<String> = + 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::<Vec<_>>(), 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<Node>]) -> Vec<Arc<Node>> { /// 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<String>) { /// 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<String>) { + 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<String>, _: &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<vectorcraft_doc::NodeId>) { + 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 (`<Layer>` 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 (`<Layer>` 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 (`<Layer>` 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 (`<Layer>` 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 `<tspan>` 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 `<tspan>`, 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 349bdfa86717b864862b684b5fe44c0d3d608a5f Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:57:54 +0530 Subject: [PATCH 063/160] 5949: step numeric fields on wheel hover --- crates/ui-egui/src/tests/numfields.rs | 74 +++++++++++++++++++++------ crates/ui-egui/src/widgets/numeric.rs | 20 +++++--- docs/roadmap.md | 5 +- docs/target-app-parity.md | 7 ++- docs/ui-parity.md | 9 ++-- 5 files changed, 86 insertions(+), 29 deletions(-) diff --git a/crates/ui-egui/src/tests/numfields.rs b/crates/ui-egui/src/tests/numfields.rs index 1cd02a9d2..4ba4b4df1 100644 --- a/crates/ui-egui/src/tests/numfields.rs +++ b/crates/ui-egui/src/tests/numfields.rs @@ -645,11 +645,10 @@ fn wheel(at: Pos2, unit: egui::MouseWheelUnit, dy: f32, modifiers: Modifiers) -> ] } -/// The mouse wheel over a focused numeric field steps it as ↑/↓ do (#485): a notch a step, Shift -/// ten, Ctrl/Cmd a tenth; a trackpad's points add up to notches. Unfocused, or with the pointer -/// elsewhere, the wheel isn't the field's. +/// Hover stepping (#5949) keeps the existing units, modifiers and trackpad accumulation, +/// without taking keyboard focus. Outside the field, the wheel is left alone. #[test] -fn the_wheel_steps_a_focused_field_under_the_pointer() { +fn the_wheel_steps_a_hovered_field_without_taking_focus() { use egui::MouseWheelUnit::{Line, Point}; let mut f = Field::new(); let u = Unit::Millimeters; @@ -657,16 +656,13 @@ fn the_wheel_steps_a_focused_field_under_the_pointer() { let draw = |ui: &mut egui::Ui| widgets::num_field(ui, "f", Some(u.to_pt(mm.get())), u, 80.0).inspect(|&v| mm.set(u.from_pt(v))); f.frame(vec![], &draw); let at = f.rect.get().center(); - assert_eq!(f.frame(wheel(at, Line, 1.0, Modifiers::NONE), &draw), None, "unfocused"); - f.frame(f.click(1), &draw); - f.frame(vec![], &draw); for (dy, mods, want) in [(1.0, Modifiers::NONE, 5.0), (2.0, Modifiers::SHIFT, 25.0), (-1.0, Modifiers::COMMAND, 24.9), (-1.0, Modifiers::NONE, 24.0)] { assert!(f.frame(wheel(at, Line, dy, mods), &draw).is_some(), "{dy} {mods:?}"); assert!((mm.get() - want).abs() < 1e-6, "{dy} {mods:?}: {}", mm.get()); } - assert_eq!(f.selection(), Some((0, "24 mm".len())), "the new value is selected"); + assert!(!f.ctx.memory(|m| m.has_focus(f.id.get())), "hover never takes keyboard focus"); // Points: a step for each line's worth, what is left carried to the next turn. let line = f.ctx.options(|o| o.input_options.line_scroll_speed); assert_eq!(f.frame(wheel(at, Point, line * 0.6, Modifiers::NONE), &draw), None); @@ -682,16 +678,16 @@ fn the_wheel_steps_a_focused_field_under_the_pointer() { assert_eq!(f.frame(wheel(f.rect.get().center(), Line, -1.0, Modifiers::NONE), &count), Some(2.0)); } -/// The wheel over a focused field steps it and leaves the panel where it is; over an unfocused one -/// it scrolls the panel as before. +/// Both focused and unfocused hovered fields own the wheel, including its smoothing tail. #[test] -fn the_wheel_scrolls_the_panel_unless_over_the_focused_field() { +fn the_wheel_scrolls_the_panel_unless_over_a_numeric_field() { for focus in [false, true] { let mut f = Field::new(); let offset = Cell::new(0.0); + let value = Cell::new(10.0); let draw = |ui: &mut egui::Ui| { let out = egui::ScrollArea::vertical().max_height(60.0).show(ui, |ui| { - let v = widgets::plain_field(ui, "f", 10.0, "", 0, 80.0); + let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); ui.add_space(600.0); v }); @@ -719,11 +715,57 @@ fn the_wheel_scrolls_the_panel_unless_over_the_focused_field() { for _ in 0..20 { f.frame(vec![], &draw); } - if focus { - assert_eq!((stepped, offset.get()), (Some(8.0), 0.0)); - } else { - assert!(stepped.is_none() && offset.get() > 0.0, "{stepped:?} {}", offset.get()); + assert_eq!((stepped, offset.get()), (Some(8.0), 0.0), "focus {focus}"); + let line = f.ctx.options(|o| o.input_options.line_scroll_speed); + assert_eq!(f.frame(wheel(at, egui::MouseWheelUnit::Point, -line * 0.6, Modifiers::NONE), &draw), None); + assert_eq!(offset.get(), 0.0); + assert_eq!(f.frame(wheel(at, egui::MouseWheelUnit::Point, -line * 0.6, Modifiers::NONE), &draw), Some(7.0)); + for _ in 0..20 { + f.frame(vec![], &draw); } + assert_eq!(offset.get(), 0.0); + assert_eq!(f.frame(wheel(Pos2::new(20.0, 45.0), egui::MouseWheelUnit::Line, -2.0, Modifiers::NONE), &draw), None); + for _ in 0..20 { + f.frame(vec![], &draw); + } + assert!(offset.get() > 0.0, "outside the field scrolls the panel"); + } +} + +#[test] +fn hover_wheel_keeps_arrow_keys_for_the_keyboard_focus() { + let mut f = Field::new(); + let arrows = Cell::new(false); + let draw = |ui: &mut egui::Ui| { + let v = widgets::plain_field(ui, "f", 1.123, "", 3, 80.0); + arrows.set(ui.input(|i| i.events.iter().any(|e| matches!(e, Event::Key { key: Key::ArrowUp, .. })))); + v + }; + f.frame(vec![], &draw); + let at = f.rect.get().center(); + assert_eq!(f.frame(vec![Event::PointerMoved(at), key(Key::ArrowUp, Modifiers::NONE)], &draw), None); + assert!(arrows.get()); + let mut events = wheel(at, egui::MouseWheelUnit::Line, 1.0, Modifiers::NONE); + events.push(key(Key::ArrowUp, Modifiers::NONE)); + assert_eq!(f.frame(events, &draw), Some(2.0), "existing next-multiple rounding, wheel only"); + assert!(arrows.get(), "hover does not consume arrows"); + assert!(!f.ctx.memory(|m| m.has_focus(f.id.get()))); +} + +#[test] +fn hover_wheel_preserves_limits_mixed_values_and_disabled_fields() { + for (value, enabled, dy, want) in [ + (Some(0.5), true, -1.0, Some(0.0)), + (Some(0.0), true, -1.0, None), + (Some(9.5), true, 1.0, Some(10.0)), + (Some(10.0), true, -1.0, Some(9.0)), + (None, true, -1.0, None), + (Some(10.0), false, -1.0, None), + ] { + let mut f = Field::new(); + let draw = |ui: &mut egui::Ui| ui.add_enabled_ui(enabled, |ui| widgets::range_field(ui, "f", value, 0.0..=10.0, "", 3, 80.0)).inner; + f.frame(vec![], &draw); + assert_eq!(f.frame(wheel(f.rect.get().center(), egui::MouseWheelUnit::Line, dy, Modifiers::NONE), &draw), want); } } diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs index d38838782..13a6c50ba 100644 --- a/crates/ui-egui/src/widgets/numeric.rs +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -245,9 +245,9 @@ pub(super) fn shown_alike(show: impl Fn(f64) -> String) -> impl Fn(f64, f64) -> /// a weight stops at 0, a miter limit at 1), and a plain step also stops at each of `stops` /// ([`step_through`]). The new value is applied at once, as in /// Illustrator's panels and dialogs, and the new text replaces `buf`, all selected so typing -/// replaces it. Returns the new number when a step was taken. While the pointer is over the focused -/// field the wheel is the field's (the panel under it doesn't scroll); an unfocused field leaves the -/// wheel to the panel and the canvas. +/// replaces it when focused. Returns the new number when a step was taken. While the pointer is +/// over an enabled field the wheel is the field's (the panel under it doesn't scroll), without +/// taking keyboard focus. Arrow keys still require keyboard focus. fn step_value( ui: &Ui, resp: &Response, @@ -259,11 +259,15 @@ fn step_value( ) -> Option<f64> { // Memory focus, as the field's highlight and its typing use: `Response::has_focus` also needs the // window to report keyboard focus. - if !ui.memory(|m| m.has_focus(resp.id)) { + if !resp.enabled() { return None; } + let focused = ui.memory(|m| m.has_focus(resp.id)); use egui::{Event, Key, MouseWheelUnit}; let wheel = resp.contains_pointer(); + if !focused && !wheel { + return None; + } // Wheel turns in notches: a line each, or `line` points of a trackpad or a fine wheel; what is // left of a notch carries over to the next turn. let (line, notch_id) = (ui.ctx().options(|o| o.input_options.line_scroll_speed), resp.id.with("wheel")); @@ -276,8 +280,8 @@ fn step_value( let (Event::Key { modifiers, .. } | Event::MouseWheel { modifiers, .. }) = e else { return true }; let Some(&(mods, size)) = STEPS.iter().find(|(m, _)| modifiers.matches_logically(*m)) else { return true }; let n = match e { - Event::Key { key: Key::ArrowUp, pressed: true, .. } => 1.0, - Event::Key { key: Key::ArrowDown, pressed: true, .. } => -1.0, + Event::Key { key: Key::ArrowUp, pressed: true, .. } if focused => 1.0, + Event::Key { key: Key::ArrowDown, pressed: true, .. } if focused => -1.0, Event::MouseWheel { unit, delta, .. } if wheel => { // Shift may turn the wheel sideways (the Mac does). let d = if modifiers.shift { delta.x + delta.y } else { delta.y }; @@ -323,7 +327,9 @@ fn step_value( let v = within(if stops.contains(&v) { v } else { round_to(v, STEP_DECIMALS) }, bounds); *buf = show(v); ui.data_mut(|d| d.insert_temp(resp.id, buf.clone())); - select_all(ui, resp.id, buf); + if focused { + select_all(ui, resp.id, buf); + } Some(v) } diff --git a/docs/roadmap.md b/docs/roadmap.md index c4246c711..2667e955e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,9 +1,11 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (numeric wheel stepping on hover, #5949; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence +Numeric fields step on wheel hover without taking keyboard focus (#5949). Arrow keys still require focus; modifiers, units, limits, mixed values and existing rounding stay in the shared stepping path. Headless regressions in `tests/numfields.rs` cover hovered unfocused fields, focused fields, trackpad accumulation and panel scroll exclusion. Scores and estimates unchanged. + Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. **PR #3273 revision — 2026-11-04:** Thin curved stroke coverage ported to the current raster-only cache, cropped-long-stroke and effect paths with guarded flattening; vector export geometry unchanged. Scores and estimates unchanged. @@ -180,6 +182,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. | | 2026-11-06 | minor | M2: a shape just drawn moves by its centre point (Show Center) and turns from the rotate zone just outside a bounding-box corner with its tool still active, as the Selection tool's gestures do (#5477); estimates unchanged. | | 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged | | 2026-11-04 | minor | New Document: the units chosen in Preset Details belong to the chosen preset; Recent and Saved cards keep their own units; scores and estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0af2f0670..d2bd2b15b 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-07 · **Change:** minor (the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (numeric wheel stepping on hover, #5949; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence +Numeric fields step on wheel hover without taking keyboard focus (#5949). Arrow keys still require focus; modifiers, units, limits, mixed values and existing rounding stay in the shared stepping path. Headless regressions in `tests/numfields.rs` cover hovered unfocused fields, focused fields, trackpad accumulation and panel scroll exclusion. Scores and estimates unchanged. + Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. **PR #3273 revision — 2026-11-04:** Thin curved stroke coverage ported to the current raster-only cache, cropped-long-stroke and effect paths with guarded flattening; vector export geometry unchanged. Scores and estimates unchanged. @@ -723,7 +725,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Performance › Real-time Drawing and Editing off defers a drag's edits to its release, but a ruler guide dragged out of a ruler or moved still shows as it is dragged, where only its position label showed (#4646; `a_guide_dragged_out_of_a_ruler_shows_while_dragged`, `real_time_drawing_off_still_shows_a_dragged_guide`). - With Real-time Drawing and Editing off, a dragged selection whose outlines have more than 100,000 points shows its moved bounds instead, as the selection at rest does (#525, #2460; `real_time_drawing_off_draws_the_bounds_of_a_selection_too_big_to_outline`). - Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423). - - Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). A dialog opened from a menu takes the keyboard focus every time it opens, not only the first (#1367), and one whose first field starts greyed out (Blend Options on Smooth Color, Layers Panel Options) gives the focus to that field once it is enabled (#1395). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the focused field (#485: a notch a step; the panel under it then stays put, and an unfocused field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once. + - Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). A dialog opened from a menu takes the keyboard focus every time it opens, not only the first (#1367), and one whose first field starts greyed out (Blend Options on Smooth Color, Layers Panel Options) gives the focus to that field once it is enabled (#1395). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the hovered field without requiring focus (#485: a notch a step; the panel under it then stays put, and an unfocused field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once. - While Alt (Option on macOS) is held, the Flare Tool Options' Cancel button reads Reset, which puts every option back to the Flare tool's default and leaves the dialog open (`ui.dialogReset`). - A plain step of a number field (Up/Down or a wheel notch) goes to the next whole unit in its direction and a Ctrl/Cmd step goes to the next tenth, so 5.1 mm steps up to 6 mm and down to 5 mm (#2881). A Shift step adds or takes ten and keeps the fraction. - In the number, text and color fields of the panels and the Control bar, Shift+Enter (Shift+Return) applies the value and keeps the field active with its value selected; a text box of several lines takes it as a line break. In a dialog, Shift+Enter applies the value and confirms the dialog, as Enter does. @@ -1725,6 +1727,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. | | 2026-11-06 | minor | Direct Selection: with Snap to Grid, a dragged straight segment lands its anchors on the grid (#3835); evidence added; scores unchanged. | | 2026-11-06 | minor | Snap to Glyph › Glyph Bounds: the corners of each glyph's box are points a drawn point lands on, also on a curved side (`B`), and the box turns with rotated type, its edges then Angular Guides (#5112; [Snap to Glyph options](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/snap-to-glyph-options.html)); scores and estimates unchanged. | | 2026-11-06 | minor | Drop Shadow: a swatch beside Color opens the Color Picker over the dialog, as the other effect colour fields do (#5364); correction: the #5365 entry listed Drop Shadow, whose Color is drawn outside the shared grid and had no swatch; scores unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 4efbee4aa..559df7763 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,9 +1,11 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (numeric wheel stepping on hover, #5949; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) ## Feature submission evidence +Numeric fields step on wheel hover without taking keyboard focus (#5949). Arrow keys still require focus; modifiers, units, limits, mixed values and existing rounding stay in the shared stepping path. Headless regressions in `tests/numfields.rs` cover hovered unfocused fields, focused fields, trackpad accumulation and panel scroll exclusion. Scores and estimates unchanged. + | 2026-11-06 | minor | Opening a file that takes longer than 0.4 s shows a progress window: the file name, a moving bar (reading reports no steps), how many more files wait, Cancel (Esc; the file doesn't open) and Continue in Background (`file.cancelOpen`, `file.openInBackground`); it never opens over another dialog or progress window (part of #5930); scores and estimates unchanged | Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. @@ -257,7 +259,7 @@ tools, never checked side by side) isn't captured row by row. | Wheel over a closed font-family field in Character, Properties or Control | done | #5253: applies the next or previous family to selected text, one undo step per notch; uses saved font filters, ignores search text and stops at the list's ends. Escape and pointer departure keep the change. Other font-picking menus and modifier-wheel input keep their existing behavior. | | Arrow keys nudge by Keyboard Increment (0.001–1296 pt); Shift ×10; Alt copies | done | | | Fields commit what was typed on a click away or a move to another field, as Enter does; Escape discards it | done | #1787 | -| Number fields: Up/Down step, Shift ×10, Cmd/Ctrl ÷10, wheel over a focused field; steps, the wheel and label scrubs stop at the field's bounds (a stroke weight at 0, a miter limit at 1) | done | Shift+arrow in the Stroke weight field doesn't step by 10 (#991); bounds #3370 | +| Number fields: Up/Down step, Shift ×10, Cmd/Ctrl ÷10, wheel over a hovered field without a click; steps, the wheel and label scrubs stop at the field's bounds (a stroke weight at 0, a miter limit at 1) | done | Shift+arrow in the Stroke weight field doesn't step by 10 (#991); bounds #3370 | | Arithmetic in fields (`+ - * /`, and `%` of the value before it: `3cm*50%`, `50pt+25%`) and unit suffixes (`3 mm`, `1in`, `2p6`) | done | `Unit::parse`; mixed units in one expression not verified | | Numeric fields: focus click selects the whole value; dragging selects from the pressed character | partial | #1326; select-all waits for click release; px/pt/plain/optional fields, both directions and replacement text tested; native API input, 17 px partial replacement, Undo/Redo, saved-file round trip and screenshots verified; Tab, double-click, arrows and wheel preserved; short movements still classified as clicks can select all and remain open | | Units: pt, pc, in, mm, cm, px, ft-in, m, yd, ft; General/Stroke/Type units | done | units preference reverting to points on SVG output reported (#864) | @@ -266,7 +268,7 @@ tools, never checked side by side) isn't captured row by row. | Scrubby labels (beyond Illustrator) | done | #400; the labels that are links (the Control bar's Stroke: and Opacity:, the Properties panel's Stroke) scrub too, and a click on them still opens their panel (#4088) | | 3D Lighting Color picker, preview, reset dot and Reset all | initial | One to ten opaque RGB lights for Revolve, Extrude & Bevel, Inflate and Rotate; select, add or delete a light and edit its settings or gizmo; Standard, Diffuse, Top Left and Right select original rigs, with Custom for manual changes; Cancel restores all lights | | Dialogs open with the first field focused and selected; Enter confirms | done | on every open, not only the first (#1367); a first field that starts greyed out takes the focus once enabled (#1395) | -| Value fields: selected text readable in every brightness; a focused field follows its value when a slider or preview changes it | partial | #1490 (part of #1465: the wheel over a hovered, unfocused field still scrolls the panel), #1457 | +| Value fields: selected text readable in every brightness; a focused field follows its value when a slider or preview changes it | partial | #1490, #1457; hover wheel stepping #5949 | | Sliders in dialogs and popups (Save for Web, raster export options, Preferences, the opacity popup) show their rail, filled up to the handle | done | #1483 | | Rectangular Grid and Polar Grid click dialogs: size, divider counts with a skew slider each, Use Outside Rectangle As Frame / Create Compound Path From Ellipses, Fill Grid; kept as the tool's options, used by drags too | done | #1377. The options are the documented ones; how far a skew percentage moves the dividers and which way the radial skew turns are our own choice (the public documentation gives neither). The reference point locator is in the dialog, and a double-click on the tool opens it as the tool's Tool Options (next row) | | Double-click the Line Segment, Arc, Rectangular Grid or Polar Grid tool: its Tool Options ([Rectangular Grid tool](https://helpx.adobe.com/illustrator/using/tool-techniques/rectangular-grid-tool.html) and [Arc tool](https://helpx.adobe.com/illustrator/using/tool-techniques/arc-tool.html) step 3, [Tools](https://helpx.adobe.com/illustrator/using/tools.html)) | done | #2691. The tool's click dialog without a place to draw: Line Segment's Length, Angle and Fill Line; Arc's lengths, Reference Point, Type, Base Along, Slope and Fill Arc; the grids' Reference Point, Default Size, dividers, skews and switches. OK draws nothing and sets what the next drag draws with (Fill Line, the arc's type, base, slope and fill, the grids' dividers, skews and switches) and what a click's dialog opens at (with the Length, Angle and Default Size); Cancel changes nothing (`double_clicking_the_line_arc_and_grid_tools_opens_their_tool_options`) | @@ -478,6 +480,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. | | 2026-11-06 | minor | Keyboard shortcuts: Cmd/Ctrl+Alt+. and Cmd/Ctrl+Alt+, (Stroke Weight Up and Down) step each selected stroke's weight, or the next object drawn's (#5797); scores and estimates unchanged | | 2026-11-06 | minor | Properties: Quick Actions has Repeat Options for one selected radial, grid or mirror repeat, opening Object › Repeat › Options with its values (#5181); scores and estimates unchanged | | 2026-11-06 | minor | Control bar: with type selected, a Paragraph: link (the Paragraph panel in a popover) and the Align left, Align center and Align right buttons follow the Character controls; the bar leaves them out from its right end when short of room, after X/Y/W/H and the Align buttons (#5372); scores and estimates unchanged | From f8e0639d14d2b40663ef6fdcb154a6482fc6d8be Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 5 Nov 2026 08:25:31 -0800 Subject: [PATCH 064/160] .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<AiEditi } d }); + // Symbols are defined at their own size, whatever the canvas. + let symbol_doc = doc; let doc = reduced.as_ref().unwrap_or(doc); let board = doc.artboards.first().map_or(board, |a| a.rect); // The document is y down; art space is ruler space: y up, its origin the first artboard's @@ -213,10 +239,15 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result<AiEditi let to_art = Affine::new([1.0, 0.0, 0.0, -1.0, -board.x0, board.y0]); let origin = ruler_origin(doc, to_art); let mut w = Writer::new(doc, to_art, origin); + // On a large canvas the layers' symbol instances are written as their art. + w.symbols_live = scale == 1.0; + w.symbol_doc = symbol_doc; w.reserve_swatch_gradients(); for layer in &doc.layers { w.layer(layer); } + // The symbols no instance uses too; their art adds definitions. + w.symbols(); // The palette first: its gradient and pattern swatches add definitions. let mut palette = String::new(); w.palette(&mut palette); @@ -234,7 +265,9 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result<AiEditi let mut s = header.clone(); s.push_str("%%BeginProlog\n%%EndProlog\n%%BeginSetup\n"); w.definitions(&mut s); + w.symbol_definitions(&mut s); s.push_str(&palette); + w.symbol_list(&mut s); artboards(&mut s, doc, to_art, origin); if !w.texts.is_empty() { s.push_str(&w.texts.section(doc)); diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 7ebece9d3..a9c061dda 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -241,6 +241,11 @@ fn view(nodes: &[Arc<Node>], depth: usize, out: &mut Vec<String>) { } out.push(s); } + // The matrix is written to ten decimals. + NodeKind::SymbolInstance { symbol, xf } => { + let xf: Vec<String> = 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<Node>], out: &mut Vec<Node>) { 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<Arc<Node>> = 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<Node>], depth: usize, out: &mut Vec<String>) { 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<String> { 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<String> { + 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<String> { + 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::<Vec<_>>(), &shown(&first.program).lines().map(String::from).collect::<Vec<_>>()); + 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<f64> = 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::<usize>(), 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<bool> = 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<String> { + 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::<Vec<_>>(), ["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::<Vec<_>>(), ["Framed", "Outer"]); + let kids = back.layers[0].children().unwrap(); + let kinds: Vec<bool> = 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<String> { + 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<bool> = 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<Node>]) -> Vec<String> { + 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::<Vec<_>>(); + 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::<Vec<_>>(), names); + let refs: Vec<String> = 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::<Vec<_>>(), &shown(&out.program).lines().map(String::from).collect::<Vec<_>>()); + 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<String>, + defined_symbols: HashSet<String>, + /// The symbols whose definitions are being written. + defining_symbols: Vec<String>, + /// Whether each symbol's own art paints with a pattern, and the symbols its instances use + /// ([`Self::symbol_own_patterns`]). + pattern_symbols: HashMap<String, (bool, Vec<String>)>, + /// Whether each symbol's art paints with a pattern, nested symbols included + /// ([`Self::symbol_paints_pattern`]). + patterned_symbols: HashMap<String, bool>, + /// 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<String>, + /// 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<String>, + /// 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<String, f64>, + /// 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<String>) { + 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 `<circle>` and `<ellipse>` 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 `<ellipse>` 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 3fcd157f17ae26e3bdc039e365aa6af802a76eff Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 08:44:50 -0800 Subject: [PATCH 065/160] Path ops: region building lists the shapes covering each region instead of a flag per shape Finding the distinct regions of an arrangement built a flag for every input shape at each edge and kept them in a sorted set, so it cost edges times shapes in time and memory. Each coverage is now the sorted list of the shapes that cover it, deduplicated in a hash set and sorted into the order the flag masks had, so regions, their order and the output of Divide, Hard Mix, Soft Mix, Shape Builder and the flattener are the same. The face lookup and Pathfinder merging take the same lists. Tests compare the lists and the regions with the flag masks for overlapping, nested, touching and disjoint shapes and for 72 shapes. --- crates/pathops/src/boolean.rs | 26 ++++++--- crates/pathops/src/pathfinder.rs | 90 ++++++++++++++++++++++++-------- docs/target-app-parity.md | 1 + 3 files changed, 86 insertions(+), 31 deletions(-) diff --git a/crates/pathops/src/boolean.rs b/crates/pathops/src/boolean.rs index 6fa499467..756b5a193 100644 --- a/crates/pathops/src/boolean.rs +++ b/crates/pathops/src/boolean.rs @@ -5,7 +5,8 @@ //! [`tidy_segments`] then re-joins pieces that came from the same smooth curve by refitting, so //! results have roughly as few anchors as the inputs. -use std::collections::BTreeSet; +use std::cmp::Reverse; +use std::collections::HashSet; use kurbo::{BezPath, CubicBez, PathSeg, Point, Shape as _}; use linesweeper::topology::{ContourIdx, Contours, Topology, WindingNumber}; @@ -351,19 +352,28 @@ impl Arrangement { std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.contours(pred))).map_err(|_| PathOpsError::Degenerate) } - /// All distinct non-empty coverage masks that occur next to some edge, sorted. - pub(crate) fn distinct_masks(&self) -> Vec<Vec<bool>> { - let mut set = BTreeSet::new(); + /// All distinct non-empty coverages that occur next to some edge, each as the inputs it covers + /// (ascending, as [`Self::covered`] gives them), in the order of their masks ([`Self::mask`]) + /// sorted. + pub(crate) fn distinct_masks(&self) -> Vec<Vec<usize>> { + let mut set: HashSet<Vec<usize>> = HashSet::new(); + let mut cover = Vec::new(); for s in self.top.segment_indices() { let h = s.first_half(); for w in [self.top.winding_clockwise(h), self.top.winding_counter_clockwise(h)] { - let m = self.mask(w); - if m.iter().any(|&b| b) { - set.insert(m); + cover.clear(); + cover.extend(self.covered(w)); + if !cover.is_empty() && !set.contains(cover.as_slice()) { + set.insert(cover.clone()); } } } - set.into_iter().collect() + let mut masks: Vec<Vec<usize>> = set.into_iter().collect(); + // Masks sort by the first input only one of them covers: the one without it comes first. + // That input is the smaller of the first two entries that differ, or the next entry of the + // longer list where the shorter one ends. + masks.sort_unstable_by(|a, b| a.iter().map(Reverse).cmp(b.iter().map(Reverse))); + masks } /// Vertices where three or more edges meet (true intersections / touch points). diff --git a/crates/pathops/src/pathfinder.rs b/crates/pathops/src/pathfinder.rs index f0c5ab505..8f0fb163e 100644 --- a/crates/pathops/src/pathfinder.rs +++ b/crates/pathops/src/pathfinder.rs @@ -222,7 +222,7 @@ pub fn pathfinder_with(op: PathfinderOp, shapes: &[Shape], precision: f64) -> Ve /// and the holes of its mask directly inside it ([`face_groups`]). fn regions_of(arr: &Arrangement) -> Vec<Region> { let masks = arr.distinct_masks(); - let refs: Vec<&[bool]> = masks.iter().map(Vec::as_slice).collect(); + let refs: Vec<&[usize]> = masks.iter().map(Vec::as_slice).collect(); let mut faces: Vec<Vec<PathData>> = vec![Vec::new(); masks.len()]; each_face(arr, &refs, |m, c, group| { if let Some(f) = faces.get_mut(m) { @@ -230,8 +230,7 @@ fn regions_of(arr: &Arrangement) -> Vec<Region> { } }); let mut out = Vec::new(); - for (mask, paths) in masks.iter().zip(faces) { - let sources: Vec<usize> = mask.iter().enumerate().filter(|(_, b)| **b).map(|(i, _)| i).collect(); + for (sources, paths) in masks.iter().zip(faces) { out.extend(paths.into_iter().filter(|p| !p.is_empty()).map(|path| Region { path, sources: sources.clone() })); } out @@ -315,22 +314,23 @@ fn each_classified_face( } } -/// Calls `f` with each face of `arr` whose coverage is one of `masks` (distinct): the index of -/// its mask in `masks`, the contour pass it is in, and its contours, an outer contour and the -/// holes of its mask directly inside it ([`face_groups`]). Each mask's faces come in the order -/// of that mask's own contour pass. +/// Calls `f` with each face of `arr` whose coverage is one of `masks` (distinct, each the inputs +/// it covers, ascending as [`Arrangement::covered`] gives them): the index of its mask in +/// `masks`, the contour pass it is in, and its contours, an outer contour and the holes of its +/// mask directly inside it ([`face_groups`]). Each mask's faces come in the order of that mask's +/// own contour pass. /// /// A contour pass walks the whole arrangement, so one pass per mask would cost masks × edges /// (two blends crossing make thousands of each). Instead the masks go into batches whose faces /// share no vertex (so no edge either): one pass per batch walks each face's contours exactly as /// its own mask's pass would, and the mask a contour bounds is the one of its batch at its first /// point. -fn each_face(arr: &Arrangement, masks: &[&[bool]], f: impl FnMut(usize, &Contours, &[ContourIdx])) { +fn each_face(arr: &Arrangement, masks: &[&[usize]], f: impl FnMut(usize, &Contours, &[ContourIdx])) { if masks.is_empty() { return; } // Each mask is found by the inputs it covers. - let ids = CoverIds::new(masks.iter().map(|m| m.iter().enumerate().filter(|(_, b)| **b).map(|(k, _)| k).collect()).collect()); + let ids = CoverIds::new(masks.iter().map(|m| m.to_vec()).collect()); each_classified_face(arr, masks.len(), |w| ids.of(arr, w), f); } @@ -572,19 +572,21 @@ impl FaceMerger { /// The contours of the faces of `arr` that are `faces`, each found by its coverage and a point /// inside it. fn contours_of(&self, arr: &Arrangement, faces: &[&Region]) -> Option<BezPath> { - let mut picks: Vec<(Vec<bool>, Point)> = Vec::with_capacity(faces.len()); + let mut picks: Vec<(Vec<usize>, Point)> = Vec::with_capacity(faces.len()); for r in faces { - let mut mask = vec![false; self.shapes]; - for &s in &r.sources { - *mask.get_mut(s)? = true; + if r.sources.iter().any(|&s| s >= self.shapes) { + return None; } + let mut mask = r.sources.clone(); + mask.sort_unstable(); + mask.dedup(); picks.push((mask, crate::planar::interior_point(&r.path)?)); } // `masks` holds the coverages in the order they were first picked, `points` the picked // points of each, and `found` the faces of each that contain one of its points. - let mut masks: Vec<&[bool]> = vec![]; + let mut masks: Vec<&[usize]> = vec![]; let mut points: Vec<Vec<Point>> = vec![]; - let mut index: HashMap<&[bool], usize> = HashMap::new(); + let mut index: HashMap<&[usize], usize> = HashMap::new(); for (mask, p) in &picks { let m = *index.entry(mask.as_slice()).or_insert_with(|| { masks.push(mask); @@ -657,17 +659,14 @@ fn add_slivers(arr: &Arrangement, faces: &[&Region], rest: Option<&Rest>, raw: & let ours = |w: &Multi| rest.is_none_or(|r| arr.covered(w).any(|i| i == r.shape)); // The coverage of each face next to a picked one, if only shapes of `faces` cover it, and the // ends of the edges along faces of `rest` that would go to their merge. - let mut next_to: Vec<Vec<bool>> = Vec::new(); + let mut next_to: Vec<Vec<usize>> = Vec::new(); let mut taken: HashSet<(u64, u64)> = HashSet::new(); for s in top.segment_indices() { let h = s.first_half(); let (a, b) = (top.winding_clockwise(h), top.winding_counter_clockwise(h)); for (w, other) in [(a, b), (b, a)] { - if within(w, &cover) && ours(w) && !is_picked(w) && is_picked(other) { - let mask = arr.mask(w); - if !next_to.contains(&mask) { - next_to.push(mask); - } + if within(w, &cover) && ours(w) && !is_picked(w) && is_picked(other) && !next_to.iter().any(|m| arr.covered(w).eq(m.iter().copied())) { + next_to.push(arr.covered(w).collect()); } if let Some(r) = rest && within(w, &merged_cover) @@ -681,7 +680,7 @@ fn add_slivers(arr: &Arrangement, faces: &[&Region], rest: Option<&Rest>, raw: & } } let along_merged = |path: &BezPath| path.elements().iter().any(|el| el.end_point().is_some_and(|p| taken.contains(&vertex_key(p)))); - let masks: Vec<&[bool]> = next_to.iter().map(Vec::as_slice).collect(); + let masks: Vec<&[usize]> = next_to.iter().map(Vec::as_slice).collect(); let mut found: Vec<Vec<BezPath>> = vec![Vec::new(); masks.len()]; each_face(arr, &masks, |m, c, group| { if contours_to_path(c, group.iter().copied(), &arr.tidy).is_empty() @@ -1016,7 +1015,7 @@ mod tests { /// face lies inside a hole of its own mask (`grouped` puts such an island in the frame's face). fn regions_one_mask_at_a_time(arr: &Arrangement) -> Vec<Region> { let mut out = Vec::new(); - for mask in arr.distinct_masks() { + for mask in flag_masks(arr) { let c = arr.contours(|m| m == mask.as_slice()); let sources: Vec<usize> = mask.iter().enumerate().filter(|(_, b)| **b).map(|(i, _)| i).collect(); for g in c.grouped() { @@ -1029,8 +1028,27 @@ mod tests { out } + /// The distinct non-empty coverages next to some edge as a flag per input, sorted. + fn flag_masks(arr: &Arrangement) -> Vec<Vec<bool>> { + let mut set = std::collections::BTreeSet::new(); + for s in arr.top.segment_indices() { + let h = s.first_half(); + for w in [arr.top.winding_clockwise(h), arr.top.winding_counter_clockwise(h)] { + let m = arr.mask(w); + if m.iter().any(|&b| b) { + set.insert(m); + } + } + } + set.into_iter().collect() + } + + /// [`Arrangement::distinct_masks`] lists the inputs of each of [`flag_masks`] in its order, and + /// [`regions_of`] finds the faces one contour pass per flag mask finds, in the same order. fn same_as_one_mask_at_a_time(shapes: &[Shape]) { let arr = arrangement(shapes, DEFAULT_PRECISION).expect("arrangement"); + let listed: Vec<Vec<usize>> = flag_masks(&arr).iter().map(|m| m.iter().enumerate().filter(|(_, b)| **b).map(|(i, _)| i).collect()).collect(); + assert_eq!(arr.distinct_masks(), listed); assert_eq!(regions_of(&arr), regions_one_mask_at_a_time(&arr)); } @@ -1147,6 +1165,32 @@ mod tests { assert_eq!(regions(&v).len(), 12 * 12 + 2 * 12 * 13); } + /// The coverages come in the order of their flag masks, with the same regions, for shapes that + /// overlap, nest, touch or lie apart, and for more than 64 shapes stacked out of their order + /// across the page. + #[test] + fn coverages_keep_the_order_of_their_flag_masks() { + let r = |x0: f64, y0: f64, x1: f64, y1: f64| Shape::new(rect(x0, y0, x1, y1), FillRule::NonZero, 1); + let overlapping = vec![r(0.0, 0.0, 100.0, 100.0), r(50.0, 50.0, 150.0, 150.0), r(25.0, 75.0, 125.0, 175.0)]; + let nested = vec![r(0.0, 0.0, 300.0, 300.0), r(50.0, 50.0, 250.0, 250.0), r(100.0, 100.0, 200.0, 200.0), r(140.0, 140.0, 160.0, 160.0)]; + let touching = vec![r(0.0, 0.0, 100.0, 100.0), r(100.0, 0.0, 200.0, 100.0), r(0.0, 100.0, 100.0, 200.0), r(100.0, 100.0, 200.0, 200.0)]; + let apart = vec![r(0.0, 0.0, 10.0, 10.0), r(50.0, 0.0, 60.0, 10.0), r(0.0, 50.0, 10.0, 60.0)]; + // 70 strips, each overlapping its neighbors, stacked in an order other than across the + // page, and two bars across them. + let mut many: Vec<Shape> = (0..70) + .map(|i| { + let x = 10.0 * f64::from((i * 37) % 70); + r(x, 0.0, x + 15.0, 300.0) + }) + .collect(); + many.insert(20, r(-10.0, 100.0, 730.0, 110.0)); + many.push(r(-10.0, 200.0, 730.0, 205.0)); + for (what, shapes) in [("overlapping", overlapping), ("nested", nested), ("touching", touching), ("apart", apart), ("many", many)] { + assert!(!regions(&shapes).is_empty(), "{what}"); + same_as_one_mask_at_a_time(&shapes); + } + } + proptest! { #![proptest_config(ProptestConfig { cases: 256, failure_persistence: None, rng_seed: proptest::test_runner::RngSeed::Fixed(0x5eed_9a7f), ..ProptestConfig::default() })] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0af2f0670..c87bbd017 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1333,6 +1333,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With Overprint Preview or Separations Preview on, the renderer keeps the art it prepared for the preview from one frame to the next. It prepares again the objects that changed, and all of the art when Discard White Overprint changes or, with Separations Preview, when the plates shown, the proof setup, the swatches, the Spot Colors option or the color settings change or a profile is loaded. It draws the other objects from the art, geometry and stroke outlines it kept for them. The Pathfinder panel, Offset Path, Clean Up, Divide Objects Below, Make and Release Blend, Envelope Distort's Make and Release, Repeat (Radial, Grid, Mirror and Release), Vector Halftone, New and Redefine Symbol, Delete Symbol, erasing with the Symbol Sprayer, Rasterize, Convert to Artboards, Flatten Transparency, Clear Guides, Shape Builder, Live Paint, Release Image Trace, Make and Release Opacity Mask, Delete with direct-selected anchor points, Remove Anchor Points, Cut Path, and the Shaper, Knife, Eraser, Path Eraser, Line Cut, Rectangle Cut, Mirror & Cut and Blob Brush tools add and remove objects in one pass over each layer or group they change, and Outline Stroke and Expand write each new object in the place of the object it replaces (#1429). Offset Path looks up the segments near each candidate hole or island in a tree of the source path's segment bounds when it decides whether to drop that contour, instead of testing the bounds of every segment. The output paths are the same. On compound and open paths of 25,000 to 40,000 anchors with 6,400 to 11,976 candidate contours, that check takes 11 to 13 ms instead of 140 to 649 ms (medians of five interleaved runs). The limit on overlapping segment pairs is the same, so a path that stops with "Offset Path: the path is too complex to offset" still stops there. + Divide, Hard Mix, Soft Mix, Shape Builder and Flatten Transparency list the shapes that cover each region when they find the distinct regions of an arrangement, instead of keeping a flag for every shape, so that step's time and memory follow how many shapes cover each region rather than how many shapes there are. The regions, their order and the output are the same. Divide on 3,000 overlapping rectangles takes 0.73 to 1.15 billion CPU cycles on its thread instead of 1.49 to 1.73 billion (dev build, on a machine loaded by other builds). With more than 10,000 type on a path objects selected, the Selection and Direct Selection tools neither draw nor hit-test the objects' brackets. The renderer outlines the stroke of a path object with more than 1,024 path elements, without dashes or a width profile, only in and around the view, with a margin of the view's size on each side, and outlines it again when the view moves past that margin. The pixels are the same. At 1600 × 1200, the frame after one anchor of an open path of 200,000 anchors moves takes 28 to 30 ms instead of 103 ms, and with a closed path of 50,000 anchors 10 ms instead of 30 ms (`cargo run --release -p vectorcraft-engine --example anchor_bench`). The renderer outlines a dashed stroke of such a path the same way, without a width profile, and draws its dashes and dots in the same places as when the whole path is dashed, so the pixels are the same. A pattern too fine to draw is drawn solid, as before, and its stroke is outlined near the view too. At 1600 × 1200, the frame after one anchor moves takes 14 ms instead of 102 ms with a filled closed path of 50,000 anchors and a stroke of 6 pt dashes and 4 pt gaps (`cargo run --release -p vectorcraft-engine --example anchor_bench`, which now includes that document). From db61a4743583b731eafc4828862ddb5a70d9ed37 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 09:16:05 -0800 Subject: [PATCH 066/160] Docs: the region-building line gives the latest Divide timings and more of the commands that use it The line names trapping, Intertwine and the Pathfinder effects next to Divide, Hard Mix, Soft Mix, Shape Builder and Flatten Transparency, and gives six alternating runs of each build of Divide on 3,000 overlapping rectangles, timed on this branch's final code. --- 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 c87bbd017..8e8712100 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1333,7 +1333,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With Overprint Preview or Separations Preview on, the renderer keeps the art it prepared for the preview from one frame to the next. It prepares again the objects that changed, and all of the art when Discard White Overprint changes or, with Separations Preview, when the plates shown, the proof setup, the swatches, the Spot Colors option or the color settings change or a profile is loaded. It draws the other objects from the art, geometry and stroke outlines it kept for them. The Pathfinder panel, Offset Path, Clean Up, Divide Objects Below, Make and Release Blend, Envelope Distort's Make and Release, Repeat (Radial, Grid, Mirror and Release), Vector Halftone, New and Redefine Symbol, Delete Symbol, erasing with the Symbol Sprayer, Rasterize, Convert to Artboards, Flatten Transparency, Clear Guides, Shape Builder, Live Paint, Release Image Trace, Make and Release Opacity Mask, Delete with direct-selected anchor points, Remove Anchor Points, Cut Path, and the Shaper, Knife, Eraser, Path Eraser, Line Cut, Rectangle Cut, Mirror & Cut and Blob Brush tools add and remove objects in one pass over each layer or group they change, and Outline Stroke and Expand write each new object in the place of the object it replaces (#1429). Offset Path looks up the segments near each candidate hole or island in a tree of the source path's segment bounds when it decides whether to drop that contour, instead of testing the bounds of every segment. The output paths are the same. On compound and open paths of 25,000 to 40,000 anchors with 6,400 to 11,976 candidate contours, that check takes 11 to 13 ms instead of 140 to 649 ms (medians of five interleaved runs). The limit on overlapping segment pairs is the same, so a path that stops with "Offset Path: the path is too complex to offset" still stops there. - Divide, Hard Mix, Soft Mix, Shape Builder and Flatten Transparency list the shapes that cover each region when they find the distinct regions of an arrangement, instead of keeping a flag for every shape, so that step's time and memory follow how many shapes cover each region rather than how many shapes there are. The regions, their order and the output are the same. Divide on 3,000 overlapping rectangles takes 0.73 to 1.15 billion CPU cycles on its thread instead of 1.49 to 1.73 billion (dev build, on a machine loaded by other builds). + Finding the distinct regions of an arrangement lists the shapes that cover each region instead of keeping a flag for every shape, so that step's time follows how many shapes cover each region rather than how many shapes there are. Divide, Hard Mix, Soft Mix, Shape Builder, Flatten Transparency, trapping, Intertwine and the Pathfinder effects are among the commands that use that step. The regions, their order and the output are the same. Divide on 3,000 overlapping rectangles takes 0.84 to 1.13 billion CPU cycles on its thread instead of 1.25 to 1.46 billion (dev build, six runs of each, alternating, while other builds were running). With more than 10,000 type on a path objects selected, the Selection and Direct Selection tools neither draw nor hit-test the objects' brackets. The renderer outlines the stroke of a path object with more than 1,024 path elements, without dashes or a width profile, only in and around the view, with a margin of the view's size on each side, and outlines it again when the view moves past that margin. The pixels are the same. At 1600 × 1200, the frame after one anchor of an open path of 200,000 anchors moves takes 28 to 30 ms instead of 103 ms, and with a closed path of 50,000 anchors 10 ms instead of 30 ms (`cargo run --release -p vectorcraft-engine --example anchor_bench`). The renderer outlines a dashed stroke of such a path the same way, without a width profile, and draws its dashes and dots in the same places as when the whole path is dashed, so the pixels are the same. A pattern too fine to draw is drawn solid, as before, and its stroke is outlined near the view too. At 1600 × 1200, the frame after one anchor moves takes 14 ms instead of 102 ms with a filled closed path of 50,000 anchors and a stroke of 6 pt dashes and 4 pt gaps (`cargo run --release -p vectorcraft-engine --example anchor_bench`, which now includes that document). From 6c285d30429588852b341e1843a3bbede8f5c04f Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 6 Nov 2026 15:02:56 -0800 Subject: [PATCH 067/160] .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<String> = 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::<Vec<_>>(), [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<AiEditi if !(board.is_finite() && board.width() > 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<AiEditi w.symbols_live = scale == 1.0; w.symbol_doc = symbol_doc; w.reserve_swatch_gradients(); + if scale == 1.0 { + w.styled = styled; + } else if !styled.is_empty() { + w.warnings.push(writer::LARGE_CANVAS_STYLES.to_string()); + } + if graphic_styles { + w.warnings.push(writer::GRAPHIC_STYLES.to_string()); + } for layer in &doc.layers { w.layer(layer); } @@ -266,6 +291,7 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result<AiEditi s.push_str("%%BeginProlog\n%%EndProlog\n%%BeginSetup\n"); w.definitions(&mut s); w.symbol_definitions(&mut s); + w.art_styles(&mut s); s.push_str(&palette); w.symbol_list(&mut s); artboards(&mut s, doc, to_art, origin); diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index a9c061dda..fca9eef02 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -5141,3 +5141,1169 @@ fn type_in_a_symbols_art_is_written_as_its_outlines() { assert_eq!(back.symbols.len(), 1); assert!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::SymbolInstance { .. })); } + +// ---------- art styles ---------- + +/// Effect `id` with its defaults and `params` over them. +fn fx(id: &str, params: serde_json::Value) -> 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<vectorcraft_doc::Effect>) -> 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::<Vec<_>>(), &w.iter().map(|(h, _)| h.clone()).collect::<Vec<_>>()); + 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<String> { + fn walk(n: &Node, depth: usize, out: &mut Vec<String>) { + let mut points = |path: &PathData, rule: Option<FillRule>| { + 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::<f64>(), y.trim_start_matches("%_").parse::<f64>()) { + (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<String> = (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<vectorcraft_doc::Effect>) -> 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<Node>, Vec<String>, 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<dyn Fn(&mut Document) -> 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<Node>]) -> Option<&Arc<Node>> { + 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<dyn Fn(&mut Appearance)>; + let change: Vec<Change> = 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<vectorcraft_doc::Effect>| { + 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<String, f64>, /// 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<NodeId, Arc<Node>>, + /// 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<bool>, 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<bool>, 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<NodeId, Arc<Node>> { + fn walk(nodes: &[Arc<Node>], out: &mut HashMap<NodeId, Arc<Node>>) { + 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<String, String>, + 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<u8> { + let key: String = mode.chars().filter(char::is_ascii_alphanumeric).collect::<String>().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<f64> { + 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<String> { + Some(p.get(key)?.as_str()?.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase()) +} + +/// `p`'s entry `key` as a switch. +fn flag(p: &Value, key: &str) -> Option<bool> { + 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<Item = f64>| { + 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<String> { + 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<String> { + 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<String> { + 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<String> { + 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<Rect>, 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<BezPath> { + 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<BezPath> { + 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<StyleRead> { + 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<AppearanceItem> { 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 72c46b8aa2f66cc4895b025cb3a5de4e7f5f02d5 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 10:28:25 -0800 Subject: [PATCH 068/160] Path ops: boolean contour passes test the shapes covering each face instead of a flag per shape The contour passes of the two-path boolean operations, Offset Path and the many-shape boolean (`boolean_n`, used by compound shapes and Intertwine) built a flag for every input shape for each winding they tested. They now test the list of inputs that cover the face, ascending, refilled in one buffer per pass, so a test costs the size of the cover rather than the number of shapes. `boolean_n` and `try_boolean_n` take that list; the compound shape and Intertwine tests read it directly. The contours, their order and the output are the same: a new test compares every pass's test with the flag form on overlapping, nested, touching and apart squares and on 72 overlapping squares, and another checks that a pass over 100 squares apart hands its test one input at most. --- crates/effects/src/group.rs | 3 +- crates/engine/src/cmd/intertwine.rs | 2 +- crates/pathops/src/boolean.rs | 149 +++++++++++++++++++++++++--- crates/pathops/src/offset.rs | 2 +- docs/target-app-parity.md | 1 + 5 files changed, 141 insertions(+), 16 deletions(-) diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index ce8c38077..dd076ac4f 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -300,7 +300,8 @@ fn compound_shape_area_in(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNod Some(po::boolean_n(&refs, |inside| { let (mut start, mut area) = (0, false); for (k, (op, count)) in members.iter().enumerate() { - let here = inside.get(start..start + count).is_some_and(|flags| flags.contains(&true)); + // `inside` is ascending: the first leaf at or after `start` tells whether one of this member's is in it. + let here = inside.get(inside.partition_point(|&i| i < start)).is_some_and(|&i| i < start + count); start += count; area = if k == 0 { here } else { op.apply(area, here) }; } diff --git a/crates/engine/src/cmd/intertwine.rs b/crates/engine/src/cmd/intertwine.rs index b19206c39..122d22b04 100644 --- a/crates/engine/src/cmd/intertwine.rs +++ b/crates/engine/src/cmd/intertwine.rs @@ -328,7 +328,7 @@ fn raised_area(face: &PathData, i: usize, areas: &[Option<PathData>]) -> PathDat .filter(|a| a.bounds().is_some_and(|b| b.overlaps(near))) .map(|a| (a, FillRule::NonZero)), ); - let only = po::boolean_n(&paths, |inside| inside.first() == Some(&true) && inside.get(1) == Some(&true) && !inside.iter().skip(2).any(|b| *b)); + let only = po::boolean_n(&paths, |inside| inside == [0, 1]); if only.is_empty() { return face.clone(); } diff --git a/crates/pathops/src/boolean.rs b/crates/pathops/src/boolean.rs index 756b5a193..eda85d973 100644 --- a/crates/pathops/src/boolean.rs +++ b/crates/pathops/src/boolean.rs @@ -5,6 +5,7 @@ //! [`tidy_segments`] then re-joins pieces that came from the same smooth curve by refitting, so //! results have roughly as few anchors as the inputs. +use std::cell::RefCell; use std::cmp::Reverse; use std::collections::HashSet; @@ -318,6 +319,9 @@ impl Arrangement { Ok(Self { top, rules: paths.iter().map(|p| p.1).collect(), tidy }) } + /// A flag per input telling whether it covers a face of winding `w`: the form the tests compare + /// [`Self::covered`] with. + #[cfg(test)] pub(crate) fn mask(&self, w: &Multi) -> Vec<bool> { // A zero winding is outside under both rules, so only the non-zero ones can set a flag. let mut m = vec![false; self.rules.len()]; @@ -334,22 +338,38 @@ impl Arrangement { w.iter().filter(|&(i, k)| self.rules.get(i).is_some_and(|&r| inside(r, k))).map(|(i, _)| i) } - /// [`Arrangement::contours`] with the test on the windings themselves (see [`Self::covered`]). + /// [`Arrangement::contours_covering`] with the test on the windings themselves (see [`Self::covered`]). pub(crate) fn contours_where(&self, pred: impl Fn(&Multi) -> bool) -> Contours { #[cfg(test)] CONTOUR_PASSES.with(|n| n.set(n.get() + 1)); self.top.contours(pred) } + /// The contours of the faces where `pred` holds for the flags of [`Self::mask`]: the form the + /// tests compare [`Self::contours_covering`] with. + #[cfg(test)] pub(crate) fn contours(&self, pred: impl Fn(&[bool]) -> bool) -> Contours { self.contours_where(|w| pred(&self.mask(w))) } - /// [`Self::contours`] with a panic in the sweep's contour pass turned into an error, as `new` does for the + /// The contours of the faces where `pred` holds for the inputs that cover them + /// ([`Self::covered`], ascending). One list is refilled for each winding the pass tests, so a + /// test costs the size of the face's cover, not the number of inputs. + pub(crate) fn contours_covering(&self, pred: impl Fn(&[usize]) -> bool) -> Contours { + let cover = RefCell::new(Vec::new()); + self.contours_where(|w| { + let mut c = cover.borrow_mut(); + c.clear(); + c.extend(self.covered(w)); + pred(&c) + }) + } + + /// [`Self::contours_covering`] with a panic in the sweep's contour pass turned into an error, as `new` does for the /// sweep itself (native builds only: on the web a panic aborts, whatever is caught here). Used by the calls that can return an error; the rest have none to return and let the engine's /// guard report it. - pub(crate) fn try_contours(&self, pred: impl Fn(&[bool]) -> bool) -> Result<Contours, PathOpsError> { - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.contours(pred))).map_err(|_| PathOpsError::Degenerate) + pub(crate) fn try_contours(&self, pred: impl Fn(&[usize]) -> bool) -> Result<Contours, PathOpsError> { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.contours_covering(pred))).map_err(|_| PathOpsError::Degenerate) } /// All distinct non-empty coverages that occur next to some edge, each as the inputs it covers @@ -710,7 +730,7 @@ pub fn boolean(a: &PathData, a_rule: FillRule, b: &PathData, b_rule: FillRule, o pub fn try_boolean(a: &PathData, a_rule: FillRule, b: &PathData, b_rule: FillRule, op: BoolOp, precision: f64) -> Result<PathData, PathOpsError> { let (pa, pb) = (fill_bezpath(a), fill_bezpath(b)); let arr = Arrangement::new(vec![(pa.clone(), a_rule), (pb.clone(), b_rule)])?; - let c = arr.try_contours(|m| op.apply(m[0], m[1]))?; + let c = arr.try_contours(|c| op.apply(c.contains(&0), c.contains(&1)))?; if !arrangement_adds_up(&arr, [&pa, &pb]) && let Some(r) = resolved_boolean([(&pa, a_rule), (&pb, b_rule)], op, precision) { @@ -798,7 +818,7 @@ fn resolved_boolean(inputs: [(&BezPath, FillRule); 2], op: BoolOp, precision: f6 if !arrangement_adds_up(&arr, [&ra, &rb]) { return None; } - let c = arr.try_contours(|m| op.apply(m[0], m[1])).ok()?; + let c = arr.try_contours(|c| op.apply(c.contains(&0), c.contains(&1))).ok()?; // The refit keeps the operands' own anchors, not only those of their resolved contours. let tidy = Tidy::keeping(precision, [pa, pb]); Some(all_contours_to_path(&c, &tidy)) @@ -806,19 +826,19 @@ fn resolved_boolean(inputs: [(&BezPath, FillRule); 2], op: BoolOp, precision: f6 /// [`boolean_n`] that reports a refused input ([`PathOpsError::TooComplex`], non-finite /// coordinates) instead of returning an empty path. -pub fn try_boolean_n(paths: &[(&PathData, FillRule)], pred: impl Fn(&[bool]) -> bool) -> Result<PathData, PathOpsError> { +pub fn try_boolean_n(paths: &[(&PathData, FillRule)], pred: impl Fn(&[usize]) -> bool) -> Result<PathData, PathOpsError> { let bps: Vec<(BezPath, FillRule)> = paths.iter().map(|(p, r)| (fill_bezpath(p), *r)).collect(); let arr = Arrangement::new(bps)?; Ok(all_contours_to_path(&arr.try_contours(pred)?, &arr.tidy)) } -/// N-ary boolean: the area where `pred(inside_flags)` holds, `inside_flags[i]` telling whether a -/// point is inside `paths[i]` under its fill rule. A refused input gives an empty path; callers that -/// can report an error use [`try_boolean_n`]. -pub fn boolean_n(paths: &[(&PathData, FillRule)], pred: impl Fn(&[bool]) -> bool) -> PathData { +/// N-ary boolean: the area where `pred(inside)` holds, `inside` listing the indices `i`, ascending, +/// of the `paths[i]` a point is inside under their fill rules. A refused input gives an empty path; +/// callers that can report an error use [`try_boolean_n`]. +pub fn boolean_n(paths: &[(&PathData, FillRule)], pred: impl Fn(&[usize]) -> bool) -> PathData { let bps: Vec<(BezPath, FillRule)> = paths.iter().map(|(p, r)| (fill_bezpath(p), *r)).collect(); match Arrangement::new(bps) { - Ok(arr) => all_contours_to_path(&arr.contours(pred), &arr.tidy), + Ok(arr) => all_contours_to_path(&arr.contours_covering(pred), &arr.tidy), Err(_) => PathData::default(), } } @@ -1055,7 +1075,110 @@ mod tests { #[cfg(panic = "unwind")] fn a_panic_in_the_contour_pass_becomes_an_error() { let arr = Arrangement::new(vec![(fill_bezpath(&square(0.0, 0.0, 10.0)), FillRule::NonZero)]).unwrap(); - assert!(arr.try_contours(|m| m[0]).is_ok()); + assert!(arr.try_contours(|c| c.contains(&0)).is_ok()); assert_eq!(arr.try_contours(|_| panic!("simulated panic in the sweep")).err(), Some(PathOpsError::Degenerate)); } + + /// Squares that overlap, nest, touch or lie apart, and 72 that overlap in rows, stacked in an + /// order other than across the page. + fn square_sets() -> Vec<(&'static str, Vec<PathData>)> { + let mut many: Vec<PathData> = (0..70) + .map(|i| { + let x = 10.0 * f64::from((i * 37) % 70); + square(x, 7.0 * f64::from(i % 5), 15.0) + }) + .collect(); + many.insert(20, square(100.0, -5.0, 60.0)); + many.push(square(400.0, 10.0, 30.0)); + vec![ + ("overlapping", vec![square(0.0, 0.0, 100.0), square(50.0, 50.0, 100.0), square(25.0, 75.0, 100.0)]), + ("nested", vec![square(0.0, 0.0, 300.0), square(50.0, 50.0, 200.0), square(100.0, 100.0, 100.0), square(140.0, 140.0, 20.0)]), + ("touching", vec![square(0.0, 0.0, 100.0), square(100.0, 0.0, 100.0), square(0.0, 100.0, 100.0), square(100.0, 100.0, 100.0)]), + ("apart", vec![square(0.0, 0.0, 10.0), square(50.0, 0.0, 10.0), square(0.0, 50.0, 10.0)]), + ("many", many), + ] + } + + /// The contours of a pass, in order, each with its path and whether it is an outer contour. + fn contour_list(c: &Contours) -> Vec<(BezPath, bool)> { + c.contours().map(|ct| (ct.path.clone(), ct.outer)).collect() + } + + type FlagTest = Box<dyn Fn(&[bool]) -> bool>; + type CoverTest = Box<dyn Fn(&[usize]) -> bool>; + + /// Each test of the boolean operations, Offset Path, the Pathfinder operations, compound shapes + /// and Intertwine finds the same contours in the same order from the inputs covering each face + /// as from a flag per input. + #[test] + fn contour_passes_on_the_covering_inputs_match_the_flag_tests() { + for (what, set) in square_sets() { + let n = set.len(); + let arr = Arrangement::new(set.iter().map(|p| (fill_bezpath(p), FillRule::NonZero)).collect()).unwrap(); + let mut tests: Vec<(String, FlagTest, CoverTest)> = vec![]; + for op in [BoolOp::Union, BoolOp::Intersect, BoolOp::Difference, BoolOp::Xor] { + tests.push(( + format!("{op:?}"), + Box::new(move |m: &[bool]| op.apply(m[0], m[1])), + Box::new(move |c: &[usize]| op.apply(c.contains(&0), c.contains(&1))), + )); + } + // Offset Path sweeps three inputs, so there any cover is one of them. + tests.push(( + "offset outward".into(), + Box::new(|m: &[bool]| m[0] || m[1] || m[2]), + Box::new(|c: &[usize]| c.first().is_some_and(|&i| i < 3)), + )); + tests.push(("offset inward".into(), Box::new(|m: &[bool]| m[0] && !m[1]), Box::new(|c: &[usize]| c.contains(&0) && !c.contains(&1)))); + tests.push(("unite".into(), Box::new(|m: &[bool]| m.contains(&true)), Box::new(|c: &[usize]| !c.is_empty()))); + tests.push(("intersect".into(), Box::new(|m: &[bool]| m.iter().all(|&b| b)), Box::new(move |c: &[usize]| c.len() == n))); + tests.push(("minus front".into(), Box::new(|m: &[bool]| m[0] && !m[1..].contains(&true)), Box::new(|c: &[usize]| c == [0]))); + tests.push(( + "minus back".into(), + Box::new(move |m: &[bool]| m[n - 1] && !m[..n - 1].contains(&true)), + Box::new(move |c: &[usize]| c == [n - 1]), + )); + tests.push(( + "exclude".into(), + Box::new(|m: &[bool]| m.iter().filter(|&&b| b).count() % 2 == 1), + Box::new(|c: &[usize]| c.len() % 2 == 1), + )); + tests.push(("intertwine".into(), Box::new(|m: &[bool]| m[0] && m[1] && !m[2..].contains(&true)), Box::new(|c: &[usize]| c == [0, 1]))); + // A compound shape whose members take two inputs each, with every shape mode in turn. + let modes = [BoolOp::Union, BoolOp::Difference, BoolOp::Intersect, BoolOp::Xor]; + let compound = move |here: &dyn Fn(usize) -> bool| { + let mut area = false; + for (k, start) in (0..n).step_by(2).enumerate() { + let h = here(start); + area = if k == 0 { h } else { modes[k % modes.len()].apply(area, h) }; + } + area + }; + tests.push(( + "compound shape".into(), + Box::new(move |m: &[bool]| compound(&|s| m[s..(s + 2).min(n)].contains(&true))), + Box::new(move |c: &[usize]| compound(&|s| c.get(c.partition_point(|&i| i < s)).is_some_and(|&i| i < s + 2))), + )); + for (op, flags, cover) in &tests { + let old = contour_list(&arr.contours(flags)); + assert_eq!(contour_list(&arr.contours_covering(cover)), old, "{what}: {op}"); + assert_eq!(contour_list(&arr.try_contours(cover).unwrap()), old, "{what}: {op}"); + } + } + } + + /// A contour pass hands its test the inputs covering each face, not a flag for every input: + /// with 100 squares apart, no test sees more than one. + #[test] + fn a_contour_pass_tests_only_the_inputs_covering_each_face() { + let set: Vec<(BezPath, FillRule)> = (0..100).map(|i| (fill_bezpath(&square(20.0 * f64::from(i), 0.0, 10.0)), FillRule::NonZero)).collect(); + let arr = Arrangement::new(set).unwrap(); + let widest = std::cell::Cell::new(0); + let c = arr.contours_covering(|c| { + widest.set(widest.get().max(c.len())); + !c.is_empty() + }); + assert_eq!(widest.get(), 1); + assert_eq!(c.contours().count(), 100); + } } diff --git a/crates/pathops/src/offset.rs b/crates/pathops/src/offset.rs index 2c0db1f2a..2766379ef 100644 --- a/crates/pathops/src/offset.rs +++ b/crates/pathops/src/offset.rs @@ -151,7 +151,7 @@ fn offset_with_check( vec![(fill, FillRule::NonZero), (ring, FillRule::NonZero), (open_bp, FillRule::NonZero)], MAX_OFFSET_SEGMENT_PAIRS, )?; - let c = if delta >= 0.0 { arr.contours(|m| m[0] || m[1] || m[2]) } else { arr.contours(|m| m[0] && !m[1]) }; + let c = if delta >= 0.0 { arr.contours_covering(|c| !c.is_empty()) } else { arr.contours_covering(|c| c.contains(&0) && !c.contains(&1)) }; // When `d` exceeds a curvature radius the stroker's inner offset inverts and its loops cancel // winding, leaving faces uncovered that are really within `d` of the path. Every face of the // true offset is bounded by arrangement edges, so a contour is spurious iff a point deep inside diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8e8712100..d5793187c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1334,6 +1334,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Pathfinder panel, Offset Path, Clean Up, Divide Objects Below, Make and Release Blend, Envelope Distort's Make and Release, Repeat (Radial, Grid, Mirror and Release), Vector Halftone, New and Redefine Symbol, Delete Symbol, erasing with the Symbol Sprayer, Rasterize, Convert to Artboards, Flatten Transparency, Clear Guides, Shape Builder, Live Paint, Release Image Trace, Make and Release Opacity Mask, Delete with direct-selected anchor points, Remove Anchor Points, Cut Path, and the Shaper, Knife, Eraser, Path Eraser, Line Cut, Rectangle Cut, Mirror & Cut and Blob Brush tools add and remove objects in one pass over each layer or group they change, and Outline Stroke and Expand write each new object in the place of the object it replaces (#1429). Offset Path looks up the segments near each candidate hole or island in a tree of the source path's segment bounds when it decides whether to drop that contour, instead of testing the bounds of every segment. The output paths are the same. On compound and open paths of 25,000 to 40,000 anchors with 6,400 to 11,976 candidate contours, that check takes 11 to 13 ms instead of 140 to 649 ms (medians of five interleaved runs). The limit on overlapping segment pairs is the same, so a path that stops with "Offset Path: the path is too complex to offset" still stops there. Finding the distinct regions of an arrangement lists the shapes that cover each region instead of keeping a flag for every shape, so that step's time follows how many shapes cover each region rather than how many shapes there are. Divide, Hard Mix, Soft Mix, Shape Builder, Flatten Transparency, trapping, Intertwine and the Pathfinder effects are among the commands that use that step. The regions, their order and the output are the same. Divide on 3,000 overlapping rectangles takes 0.84 to 1.13 billion CPU cycles on its thread instead of 1.25 to 1.46 billion (dev build, six runs of each, alternating, while other builds were running). + The contour passes of the two-path boolean operations, Offset Path, compound shapes and Intertwine test the list of shapes that cover each face instead of a flag for every shape, so each test's time follows how many shapes cover the face rather than how many shapes there are. The contours, their order and the output are the same. The many-shape boolean that compound shapes use takes 0.75 to 0.83 billion CPU cycles on its thread instead of 1.28 to 1.33 billion to unite 10,000 squares that do not touch, and 2.27 to 2.30 billion instead of 2.43 to 2.47 billion for 1,500 members of two squares each over 3,000 overlapping squares (dev build, six runs of each, alternating, while other builds were running). With more than 10,000 type on a path objects selected, the Selection and Direct Selection tools neither draw nor hit-test the objects' brackets. The renderer outlines the stroke of a path object with more than 1,024 path elements, without dashes or a width profile, only in and around the view, with a margin of the view's size on each side, and outlines it again when the view moves past that margin. The pixels are the same. At 1600 × 1200, the frame after one anchor of an open path of 200,000 anchors moves takes 28 to 30 ms instead of 103 ms, and with a closed path of 50,000 anchors 10 ms instead of 30 ms (`cargo run --release -p vectorcraft-engine --example anchor_bench`). The renderer outlines a dashed stroke of such a path the same way, without a width profile, and draws its dashes and dots in the same places as when the whole path is dashed, so the pixels are the same. A pattern too fine to draw is drawn solid, as before, and its stroke is outlined near the view too. At 1600 × 1200, the frame after one anchor moves takes 14 ms instead of 102 ms with a filled closed path of 50,000 anchors and a stroke of 6 pt dashes and 4 pt gaps (`cargo run --release -p vectorcraft-engine --example anchor_bench`, which now includes that document). From 05c1be35488229ac752bd614e3089a430f4b2b82 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 10:29:51 -0800 Subject: [PATCH 069/160] 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<u32> = 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<BinaryOp>; + +/// A property name in an object literal. +#[derive(Clone, Debug, PartialEq)] +pub enum PropertyKey { + Ident(String), + String(Vec<u16>), + Number(f64), +} + +impl PropertyKey { + /// The key as the string a property is stored under (a number through ToString). + pub fn to_utf16(&self) -> Vec<u16> { + 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<u16>), + RegExp { + pattern: Vec<u16>, + flags: String, + }, + /// An array literal; `None` is a hole (`[1, , 3]`). + Array(Vec<Option<ExprId>>), + Object(Vec<Property>), + Function(FunctionId), + /// `object.property`. + Member { + object: ExprId, + property: Name, + }, + /// `object[index]`. + Index { + object: ExprId, + index: ExprId, + }, + Call { + callee: ExprId, + args: Vec<ExprId>, + }, + New { + callee: ExprId, + args: Vec<ExprId>, + }, + 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<ExprId>), + /// 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<ExprId>, +} + +/// 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<VarDecl>), + Expr(ExprId), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SwitchCase { + /// `None` for `default`. + pub test: Option<ExprId>, + pub body: Vec<StmtId>, + pub span: Span, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CatchClause { + pub param: Name, + pub body: Vec<StmtId>, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Stmt { + Var(Vec<VarDecl>), + FunctionDecl(FunctionId), + Expr(ExprId), + Block(Vec<StmtId>), + Empty, + If { test: ExprId, consequent: StmtId, alternate: Option<StmtId> }, + DoWhile { body: StmtId, test: ExprId }, + While { test: ExprId, body: StmtId }, + For { init: Option<ForInit>, test: Option<ExprId>, update: Option<ExprId>, body: StmtId }, + ForIn { target: ForInTarget, object: ExprId, body: StmtId }, + Continue(Option<Name>), + Break(Option<Name>), + Return(Option<ExprId>), + With { object: ExprId, body: StmtId }, + Switch { discriminant: ExprId, cases: Vec<SwitchCase> }, + Labeled { label: Name, body: StmtId }, + Throw(ExprId), + Try { block: Vec<StmtId>, handler: Option<CatchClause>, finalizer: Option<Vec<StmtId>> }, + 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<Name>, + pub params: Vec<Name>, + pub body: Vec<StmtId>, + /// 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<Function>, +} + +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<StmtId>, + /// 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<crate::token::Directive>, + 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<u32> { + 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<u32> { + self.src.get(pos).map(|&u| u32::from(u)) + } + + fn peek(&self) -> Option<u32> { + self.unit(self.pos) + } + + fn peek_at(&self, n: usize) -> Option<u32> { + 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<String>) -> 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<String>) -> 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<Token, SyntaxError> { + 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<Token, SyntaxError> { + 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<Trivia, SyntaxError> { + 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<bool, SyntaxError> { + 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<Token, SyntaxError> { + 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<u32, SyntaxError> { + 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<u32> { + 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<u32> { + 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<Token, SyntaxError> { + 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<f64> { + 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<Token, SyntaxError> { + 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<u16>) -> Result<bool, SyntaxError> { + 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<Token, SyntaxError> { + 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<Punct> { + 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<Token>), +} + +/// 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 +//! (<https://262.ecma-international.org/5.1/>), 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<Program, SyntaxError> { + 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<Program, SyntaxError> { + 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<Vec<Token>, 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<Vec<u16>, 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<u32>, + /// 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::<i64>().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<T> = std::result::Result<T, SyntaxError>; + +/// 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<Label>, + /// Iteration statements open in the current function. + loops: u32, + /// Iteration and `switch` statements open in the current function. + breakables: u32, +} + +/// A binary operator, with the logical ones apart. +#[derive(Clone, Copy, Debug)] +enum BinKind { + Binary(BinaryOp), + Logical(LogicalOp), +} + +/// A prefix operator. +#[derive(Clone, Copy, Debug)] +enum Prefix { + Unary(UnaryOp), + Update(UpdateOp), +} + +pub(crate) struct Parser<'s> { + src: &'s [u16], + lex: Lexer<'s>, + lines: LineTable, + tok: Token, + /// Where the last token read ends. + prev_end: u32, + ast: Ast, + depth: usize, + max_depth: usize, + ctx: Context, + directives: Vec<Directive>, + /// The tokens read, kept for [`crate::tokenize`]. + tokens: Option<Vec<Token>>, + /// The next statement may be a function declaration outside strict mode code: it is an `if` + /// branch or the body of a label in a statement list (ECMA-262 2015 Annex B.3.2 and B.3.4). + /// Read and cleared as each statement starts. + function_allowed: bool, + /// The next statement stands in a statement list, where a label's body may be a function + /// declaration. Read and cleared as each statement starts. + in_list: bool, +} + +impl<'s> Parser<'s> { + pub(crate) fn new(src: &'s [u16], options: &crate::Options, keep_tokens: bool) -> Result<Self> { + let lex = Lexer::new(src, options.preprocessor_lines, options.max_depth); + let placeholder = + Token { kind: TokenKind::Eof, span: Span::default(), line: 1, column: 1, newline_before: false, escaped: false, legacy_octal: false }; + let mut parser = Self { + src, + lex, + lines: LineTable::new(src), + tok: placeholder, + prev_end: 0, + ast: Ast::default(), + depth: 0, + max_depth: options.max_depth, + ctx: Context { strict: options.strict, ..Context::default() }, + directives: Vec::new(), + tokens: keep_tokens.then(Vec::new), + function_allowed: false, + in_list: false, + }; + parser.tok = parser.next_significant()?; + Ok(parser) + } + + pub(crate) fn parse_program(mut self) -> Result<(Program, Vec<Token>)> { + let body = self.parse_body(false)?; + if self.tok.kind != TokenKind::Eof { + return Err(self.unexpected()); + } + if let Some(tokens) = &mut self.tokens { + tokens.push(self.tok.clone()); + } + let program = Program { ast: self.ast, body, strict: self.ctx.strict, directives: self.directives, lines: self.lines }; + Ok((program, self.tokens.unwrap_or_default())) + } + + // ---- tokens ---- + + /// The next token that is not a preprocessor line; preprocessor lines are kept. + fn next_significant(&mut self) -> Result<Token> { + loop { + let token = self.lex.next_token(Goal::Div)?; + if let TokenKind::Directive(directive) = &token.kind { + self.directives.push(directive.clone()); + if let Some(tokens) = &mut self.tokens { + tokens.push(token); + } + continue; + } + return Ok(token); + } + } + + /// Reads the current token and moves to the next. + fn bump(&mut self) -> Result<Token> { + // Kept before reading on, so a preprocessor line after the token comes after it. + if let Some(tokens) = &mut self.tokens { + tokens.push(self.tok.clone()); + } + let next = self.next_significant()?; + let token = std::mem::replace(&mut self.tok, next); + self.prev_end = token.span.end; + Ok(token) + } + + /// The token after the current one, without reading it. `None` when the lexer cannot read one + /// (reading it for real reports the error). + fn peek(&self) -> Option<Token> { + let mut lex = self.lex.clone(); + loop { + match lex.next_token(Goal::Div) { + Ok(Token { kind: TokenKind::Directive(_), .. }) => {} + Ok(token) => return Some(token), + Err(_) => return None, + } + } + } + + fn at(&self, p: Punct) -> bool { + self.tok.is_punct(p) + } + + fn at_keyword(&self, k: Keyword) -> bool { + self.tok.is_keyword(k) + } + + fn eat(&mut self, p: Punct) -> Result<bool> { + if self.at(p) { + self.bump()?; + return Ok(true); + } + Ok(false) + } + + fn expect(&mut self, p: Punct) -> Result<Token> { + if self.at(p) { + return self.bump(); + } + Err(self.error_at_token(&self.tok, format!("expected '{}' but found {}", p.as_str(), describe(&self.tok)))) + } + + fn expect_keyword(&mut self, k: Keyword) -> Result<Token> { + if self.at_keyword(k) { + return self.bump(); + } + Err(self.error_at_token(&self.tok, format!("expected '{}' but found {}", k.as_str(), describe(&self.tok)))) + } + + /// Ends a statement: a `;`, or one inserted before `}`, the end of the source or a token on a + /// new line (section 7.9). + fn semicolon(&mut self) -> Result<()> { + if self.eat(Punct::Semicolon)? || self.at(Punct::RBrace) || self.tok.kind == TokenKind::Eof || self.tok.newline_before { + return Ok(()); + } + Err(self.unexpected()) + } + + // ---- errors ---- + + fn error_at_offset(&self, offset: u32, message: impl Into<String>) -> SyntaxError { + let (line, column) = self.lines.line_column(offset); + SyntaxError { message: message.into(), line, column, offset } + } + + fn error_at_token(&self, token: &Token, message: impl Into<String>) -> SyntaxError { + SyntaxError { message: message.into(), line: token.line, column: token.column, offset: token.span.start } + } + + fn unexpected(&self) -> SyntaxError { + self.error_at_token(&self.tok, format!("unexpected {}", describe(&self.tok))) + } + + /// Counts one more level of nesting; past the limit, a `SyntaxError` at the current token. + fn enter(&mut self) -> Result<()> { + if self.depth >= self.max_depth { + return Err(self.error_at_token(&self.tok, TOO_DEEP)); + } + self.depth += 1; + Ok(()) + } + + fn leave(&mut self) { + self.depth = self.depth.saturating_sub(1); + } + + fn span_from(&self, start: Span) -> Span { + Span::new(start.start, self.prev_end.max(start.start)) + } + + // ---- names ---- + + /// Checks a name used as an identifier: not a reserved word written with escapes, and not a + /// strict-mode reserved word in strict code. + fn check_identifier(&self, token: &Token, name: &str) -> Result<()> { + if token.escaped && Keyword::from_word(name).is_some() { + return Err(self.error_at_token(token, format!("keyword '{name}' must not contain escaped characters"))); + } + if self.ctx.strict && strict_reserved(name) { + return Err(self.error_at_token(token, format!("'{name}' is a reserved word in strict mode"))); + } + Ok(()) + } + + /// Reads an identifier that a declaration binds: checked as [`Self::check_identifier`] does, + /// and in strict code neither `eval` nor `arguments`. + fn binding_identifier(&mut self) -> Result<Name> { + let TokenKind::Ident(name) = &self.tok.kind else { + return Err(self.error_at_token(&self.tok, format!("expected an identifier but found {}", describe(&self.tok)))); + }; + let name = name.clone(); + self.check_identifier(&self.tok, &name)?; + if self.ctx.strict && is_eval_or_arguments(&name) { + return Err(self.error_at_token(&self.tok, format!("'{name}' cannot be declared in strict mode"))); + } + let token = self.bump()?; + Ok(Name { name, span: token.span }) + } + + // ---- program and function bodies ---- + + /// Reads source elements up to `}` (in a function body) or the end of the source, reading the + /// directive prologue at their start (section 14.1). A `"use strict"` directive makes the rest + /// strict mode code. + fn parse_body(&mut self, in_function: bool) -> Result<Vec<StmtId>> { + let mut body = Vec::new(); + let mut prologue = true; + // A string with an octal escape before a `"use strict"` directive in the same prologue. + let mut octal: Option<Token> = None; + while !(self.tok.kind == TokenKind::Eof || (in_function && self.at(Punct::RBrace))) { + if prologue && matches!(self.tok.kind, TokenKind::String(_)) { + let first = self.tok.clone(); + let stmt = self.parse_statement_item()?; + body.push(stmt); + if !self.is_directive(stmt, &first) { + prologue = false; + continue; + } + if self.is_use_strict(&first) { + self.ctx.strict = true; + if let Some(octal) = &octal { + return Err(self.error_at_token(octal, "octal escape sequences are not allowed in strict mode")); + } + } + if first.legacy_octal && octal.is_none() { + octal = Some(first); + } + continue; + } + prologue = false; + body.push(self.parse_statement_item()?); + } + Ok(body) + } + + /// Whether `stmt`, which started at `first`, is a directive: an expression statement made of + /// that one string literal. + fn is_directive(&self, stmt: StmtId, first: &Token) -> bool { + match self.ast.stmt(stmt) { + Some(Stmt::Expr(e)) => matches!(self.ast.expr(*e), Some(Expr::String(_))) && self.ast.expr_span(*e) == first.span, + _ => false, + } + } + + /// The string token is exactly `"use strict"` or `'use strict'`, without escapes or line + /// continuations. + fn is_use_strict(&self, token: &Token) -> bool { + let raw = self.src.get(token.span.start as usize..token.span.end as usize).unwrap_or_default(); + raw.len() == 12 && raw.get(1..11).is_some_and(|inner| inner.iter().copied().eq("use strict".encode_utf16())) + } + + // ---- statements ---- + + /// A source element: a function declaration or a statement. + fn parse_statement_item(&mut self) -> Result<StmtId> { + if self.at_keyword(Keyword::Function) { + self.enter()?; + let result = self.parse_function_declaration(); + self.leave(); + return result; + } + self.in_list = true; + self.parse_statement() + } + + fn parse_statement(&mut self) -> Result<StmtId> { + self.enter()?; + let result = self.statement(); + self.leave(); + result + } + + /// A statement where Annex B lets sloppy code put a function declaration: an `if` branch. + fn parse_branch(&mut self) -> Result<StmtId> { + self.function_allowed = !self.ctx.strict; + self.parse_statement() + } + + fn statement(&mut self) -> Result<StmtId> { + let start = self.tok.span; + let function_allowed = std::mem::take(&mut self.function_allowed); + let in_list = std::mem::take(&mut self.in_list); + match &self.tok.kind { + TokenKind::Keyword(Keyword::Function) => { + if !function_allowed { + let message = if self.ctx.strict { + "in strict mode code, functions can only be declared at top level or inside a block" + } else { + "a function declaration cannot stand here; put it in a block" + }; + return Err(self.error_at_token(&self.tok, message)); + } + self.parse_function_declaration() + } + TokenKind::Ident(_) if self.peek().is_some_and(|t| t.is_punct(Punct::Colon)) => self.parse_labeled(in_list), + _ => self.plain_statement(start), + } + } + + fn plain_statement(&mut self, start: Span) -> Result<StmtId> { + match &self.tok.kind { + TokenKind::Punct(Punct::LBrace) => { + let body = self.parse_block()?; + Ok(self.ast.push_stmt(Stmt::Block(body), self.span_from(start))) + } + TokenKind::Punct(Punct::Semicolon) => { + self.bump()?; + Ok(self.ast.push_stmt(Stmt::Empty, start)) + } + TokenKind::Keyword(k) => self.keyword_statement(*k, start), + _ => { + let expr = self.parse_expression(false)?; + self.semicolon()?; + Ok(self.ast.push_stmt(Stmt::Expr(expr), self.span_from(start))) + } + } + } + + fn keyword_statement(&mut self, k: Keyword, start: Span) -> Result<StmtId> { + match k { + Keyword::Var => { + self.bump()?; + let decls = self.parse_var_decls(false)?; + self.semicolon()?; + Ok(self.ast.push_stmt(Stmt::Var(decls), self.span_from(start))) + } + Keyword::If => self.parse_if(), + Keyword::Do => self.parse_do_while(start), + Keyword::While => self.parse_while(start), + Keyword::For => self.parse_for(start), + Keyword::Continue | Keyword::Break => self.parse_jump(k == Keyword::Break, start), + Keyword::Return => self.parse_return(start), + Keyword::With => self.parse_with(start), + Keyword::Switch => self.parse_switch(start), + Keyword::Throw => self.parse_throw(start), + Keyword::Try => self.parse_try(start), + Keyword::Debugger => { + self.bump()?; + self.semicolon()?; + Ok(self.ast.push_stmt(Stmt::Debugger, self.span_from(start))) + } + _ => { + let expr = self.parse_expression(false)?; + self.semicolon()?; + Ok(self.ast.push_stmt(Stmt::Expr(expr), self.span_from(start))) + } + } + } + + /// `{ … }`: its statements. + fn parse_block(&mut self) -> Result<Vec<StmtId>> { + self.expect(Punct::LBrace)?; + let mut body = Vec::new(); + while !self.at(Punct::RBrace) { + if self.tok.kind == TokenKind::Eof { + return Err(self.unexpected()); + } + body.push(self.parse_statement_item()?); + } + self.bump()?; + Ok(body) + } + + /// `var` declarations after the `var`. `no_in` leaves `in` to a `for (… in …)` around them. + fn parse_var_decls(&mut self, no_in: bool) -> Result<Vec<VarDecl>> { + let mut decls = Vec::new(); + loop { + let name = self.binding_identifier()?; + let init = if self.eat(Punct::Eq)? { Some(self.parse_assignment(no_in)?) } else { None }; + decls.push(VarDecl { name, init }); + if !self.eat(Punct::Comma)? { + return Ok(decls); + } + } + } + + /// `if (…) … else if (…) … else …`, the `else if` chain read with a loop. + fn parse_if(&mut self) -> Result<StmtId> { + let mut chain = Vec::new(); + let alternate = loop { + let start = self.expect_keyword(Keyword::If)?.span; + self.expect(Punct::LParen)?; + let test = self.parse_expression(false)?; + self.expect(Punct::RParen)?; + let consequent = self.parse_branch()?; + chain.push((start, test, consequent)); + if !self.at_keyword(Keyword::Else) { + break None; + } + self.bump()?; + if !self.at_keyword(Keyword::If) { + break Some(self.parse_branch()?); + } + }; + let mut alternate = alternate; + let mut last = None; + for (start, test, consequent) in chain.into_iter().rev() { + let id = self.ast.push_stmt(Stmt::If { test, consequent, alternate }, self.span_from(start)); + alternate = Some(id); + last = Some(id); + } + last.ok_or_else(|| self.unexpected()) + } + + /// The body of an iteration statement, with `break` and `continue` allowed in it. + fn parse_loop_body(&mut self) -> Result<StmtId> { + self.ctx.loops += 1; + self.ctx.breakables += 1; + let body = self.parse_statement(); + self.ctx.loops -= 1; + self.ctx.breakables -= 1; + body + } + + fn parse_do_while(&mut self, start: Span) -> Result<StmtId> { + self.bump()?; + let body = self.parse_loop_body()?; + self.expect_keyword(Keyword::While)?; + self.expect(Punct::LParen)?; + let test = self.parse_expression(false)?; + self.expect(Punct::RParen)?; + // A `;` after `do … while (…)` may be left out even on the same line, as ECMA-262 2015 + // section 11.9.1 allows. + self.eat(Punct::Semicolon)?; + Ok(self.ast.push_stmt(Stmt::DoWhile { body, test }, self.span_from(start))) + } + + fn parse_while(&mut self, start: Span) -> Result<StmtId> { + self.bump()?; + self.expect(Punct::LParen)?; + let test = self.parse_expression(false)?; + self.expect(Punct::RParen)?; + let body = self.parse_loop_body()?; + Ok(self.ast.push_stmt(Stmt::While { test, body }, self.span_from(start))) + } + + fn parse_for(&mut self, start: Span) -> Result<StmtId> { + self.bump()?; + self.expect(Punct::LParen)?; + let init = if self.at(Punct::Semicolon) { + None + } else if self.at_keyword(Keyword::Var) { + self.bump()?; + let mut decls = self.parse_var_decls(true)?; + if decls.len() == 1 && self.at_keyword(Keyword::In) { + let decl = decls.pop().ok_or_else(|| self.unexpected())?; + return self.parse_for_in(ForInTarget::Var(decl), start); + } + Some(ForInit::Var(decls)) + } else { + let expr = self.parse_expression(true)?; + if self.at_keyword(Keyword::In) { + self.check_target(expr, "invalid left-hand side in for-in loop")?; + return self.parse_for_in(ForInTarget::Expr(expr), start); + } + Some(ForInit::Expr(expr)) + }; + self.expect(Punct::Semicolon)?; + let test = if self.at(Punct::Semicolon) { None } else { Some(self.parse_expression(false)?) }; + self.expect(Punct::Semicolon)?; + let update = if self.at(Punct::RParen) { None } else { Some(self.parse_expression(false)?) }; + self.expect(Punct::RParen)?; + let body = self.parse_loop_body()?; + Ok(self.ast.push_stmt(Stmt::For { init, test, update, body }, self.span_from(start))) + } + + /// The rest of `for (target in object) body`, from `in`. + fn parse_for_in(&mut self, target: ForInTarget, start: Span) -> Result<StmtId> { + self.expect_keyword(Keyword::In)?; + let object = self.parse_expression(false)?; + self.expect(Punct::RParen)?; + let body = self.parse_loop_body()?; + Ok(self.ast.push_stmt(Stmt::ForIn { target, object, body }, self.span_from(start))) + } + + /// `break` or `continue`, with an optional label on the same line, which must name a label + /// around it in the same function (an iteration statement's, for `continue`). + fn parse_jump(&mut self, is_break: bool, start: Span) -> Result<StmtId> { + let keyword = self.bump()?; + let label = match &self.tok.kind { + TokenKind::Ident(name) if !self.tok.newline_before => { + let name = name.clone(); + self.check_identifier(&self.tok, &name)?; + let token = self.bump()?; + let found = self.ctx.labels.iter().rev().find(|l| l.name == name); + match found { + None => return Err(self.error_at_token(&token, format!("undefined label '{name}'"))), + Some(l) if !is_break && !l.is_loop => { + return Err(self.error_at_token(&token, format!("label '{name}' does not name a loop, so continue cannot use it"))); + } + Some(_) => {} + } + Some(Name { name, span: token.span }) + } + _ => None, + }; + if label.is_none() { + if is_break && self.ctx.breakables == 0 { + return Err(self.error_at_token(&keyword, "break outside a loop or switch")); + } + if !is_break && self.ctx.loops == 0 { + return Err(self.error_at_token(&keyword, "continue outside a loop")); + } + } + self.semicolon()?; + let stmt = if is_break { Stmt::Break(label) } else { Stmt::Continue(label) }; + Ok(self.ast.push_stmt(stmt, self.span_from(start))) + } + + fn parse_return(&mut self, start: Span) -> Result<StmtId> { + let keyword = self.bump()?; + if !self.ctx.in_function { + return Err(self.error_at_token(&keyword, "return outside a function")); + } + let ends = self.at(Punct::Semicolon) || self.at(Punct::RBrace) || self.tok.kind == TokenKind::Eof || self.tok.newline_before; + let arg = if ends { None } else { Some(self.parse_expression(false)?) }; + self.semicolon()?; + Ok(self.ast.push_stmt(Stmt::Return(arg), self.span_from(start))) + } + + fn parse_with(&mut self, start: Span) -> Result<StmtId> { + if self.ctx.strict { + return Err(self.error_at_token(&self.tok, "with statements are not allowed in strict mode")); + } + self.bump()?; + self.expect(Punct::LParen)?; + let object = self.parse_expression(false)?; + self.expect(Punct::RParen)?; + let body = self.parse_statement()?; + Ok(self.ast.push_stmt(Stmt::With { object, body }, self.span_from(start))) + } + + fn parse_switch(&mut self, start: Span) -> Result<StmtId> { + self.bump()?; + self.expect(Punct::LParen)?; + let discriminant = self.parse_expression(false)?; + self.expect(Punct::RParen)?; + self.expect(Punct::LBrace)?; + self.ctx.breakables += 1; + let cases = self.parse_cases(); + self.ctx.breakables -= 1; + let cases = cases?; + Ok(self.ast.push_stmt(Stmt::Switch { discriminant, cases }, self.span_from(start))) + } + + /// A `switch` statement's clauses, up to and with its `}`; at most one `default`. + fn parse_cases(&mut self) -> Result<Vec<SwitchCase>> { + let mut cases = Vec::new(); + let mut has_default = false; + while !self.eat(Punct::RBrace)? { + let case_start = self.tok.span; + let test = if self.at_keyword(Keyword::Case) { + self.bump()?; + Some(self.parse_expression(false)?) + } else if self.at_keyword(Keyword::Default) { + if has_default { + return Err(self.error_at_token(&self.tok, "more than one default clause in switch statement")); + } + has_default = true; + self.bump()?; + None + } else { + return Err(self.unexpected()); + }; + self.expect(Punct::Colon)?; + let mut body = Vec::new(); + while !(self.at_keyword(Keyword::Case) || self.at_keyword(Keyword::Default) || self.at(Punct::RBrace)) { + if self.tok.kind == TokenKind::Eof { + return Err(self.unexpected()); + } + body.push(self.parse_statement_item()?); + } + cases.push(SwitchCase { test, body, span: self.span_from(case_start) }); + } + Ok(cases) + } + + fn parse_throw(&mut self, start: Span) -> Result<StmtId> { + self.bump()?; + if self.tok.newline_before { + return Err(self.error_at_token(&self.tok, "line break after throw")); + } + let arg = self.parse_expression(false)?; + self.semicolon()?; + Ok(self.ast.push_stmt(Stmt::Throw(arg), self.span_from(start))) + } + + fn parse_try(&mut self, start: Span) -> Result<StmtId> { + self.bump()?; + let block = self.parse_block()?; + let handler = if self.at_keyword(Keyword::Catch) { + self.bump()?; + self.expect(Punct::LParen)?; + let param = self.binding_identifier()?; + self.expect(Punct::RParen)?; + Some(CatchClause { param, body: self.parse_block()? }) + } else { + None + }; + let finalizer = if self.at_keyword(Keyword::Finally) { + self.bump()?; + Some(self.parse_block()?) + } else { + None + }; + if handler.is_none() && finalizer.is_none() { + return Err(self.error_at_token(&self.tok, "missing catch or finally after try")); + } + Ok(self.ast.push_stmt(Stmt::Try { block, handler, finalizer }, self.span_from(start))) + } + + /// `a: b: statement`: the labels read with a loop, each unique among the labels around it. + /// `in_list`: the labeled statement stands in a statement list, so outside strict mode code + /// its body may be a function declaration. + fn parse_labeled(&mut self, in_list: bool) -> Result<StmtId> { + let mut labels = Vec::new(); + while let TokenKind::Ident(name) = &self.tok.kind { + if !self.peek().is_some_and(|t| t.is_punct(Punct::Colon)) { + break; + } + let name = name.clone(); + self.check_identifier(&self.tok, &name)?; + if self.ctx.labels.iter().any(|l| l.name == name) || labels.iter().any(|(l, _): &(Name, Span)| l.name == name) { + return Err(self.error_at_token(&self.tok, format!("duplicate label '{name}'"))); + } + let token = self.bump()?; + self.bump()?; + labels.push((Name { name, span: token.span }, token.span)); + } + let is_loop = self.at_keyword(Keyword::For) || self.at_keyword(Keyword::While) || self.at_keyword(Keyword::Do); + let outer = self.ctx.labels.len(); + self.ctx.labels.extend(labels.iter().map(|(n, _)| Label { name: n.name.clone(), is_loop })); + self.function_allowed = in_list && !self.ctx.strict; + let body = self.parse_statement(); + self.ctx.labels.truncate(outer); + let mut body = body?; + for (label, label_start) in labels.into_iter().rev() { + let span = Span::new(label_start.start, self.ast.stmt_span(body).end); + body = self.ast.push_stmt(Stmt::Labeled { label, body }, span); + } + Ok(body) + } + + // ---- functions ---- + + fn parse_function_declaration(&mut self) -> Result<StmtId> { + let start = self.expect_keyword(Keyword::Function)?.span; + let name = self.function_name()?; + let id = self.parse_function_rest(FunctionKind::Declaration, Some(name), start)?; + Ok(self.ast.push_stmt(Stmt::FunctionDecl(id), self.span_from(start))) + } + + /// A function's name, checked as an identifier; the strict-mode checks wait for the body, + /// whose prologue may make the function strict. + fn function_name(&mut self) -> Result<(Name, Token)> { + let TokenKind::Ident(name) = &self.tok.kind else { + return Err(self.error_at_token(&self.tok, format!("expected a function name but found {}", describe(&self.tok)))); + }; + let name = name.clone(); + self.check_identifier(&self.tok, &name)?; + let token = self.bump()?; + Ok((Name { name, span: token.span }, token)) + } + + /// The parameters and body of a function, from `(`. + fn parse_function_rest(&mut self, kind: FunctionKind, name: Option<(Name, Token)>, start: Span) -> Result<crate::ast::FunctionId> { + self.enter()?; + let result = self.function_rest(kind, name, start); + self.leave(); + result + } + + fn function_rest(&mut self, kind: FunctionKind, name: Option<(Name, Token)>, start: Span) -> Result<crate::ast::FunctionId> { + self.expect(Punct::LParen)?; + let mut params: Vec<(Name, Token)> = Vec::new(); + if !self.at(Punct::RParen) { + loop { + let (param, token) = self.function_name_or_param()?; + params.push((param, token)); + if !self.eat(Punct::Comma)? { + break; + } + } + } + let close = self.expect(Punct::RParen)?; + match kind { + FunctionKind::Getter if !params.is_empty() => return Err(self.error_at_token(&close, "a getter must not have parameters")), + FunctionKind::Setter if params.len() != 1 => return Err(self.error_at_token(&close, "a setter must have exactly one parameter")), + _ => {} + } + self.expect(Punct::LBrace)?; + let inner = Context { strict: self.ctx.strict, in_function: true, ..Context::default() }; + let outer = std::mem::replace(&mut self.ctx, inner); + let body = self.parse_body(true); + let strict = self.ctx.strict; + self.ctx = outer; + let body = body?; + self.expect(Punct::RBrace)?; + if strict { + self.check_strict_function(name.as_ref(), ¶ms)?; + } + Ok(self.ast.push_function(Function { + kind, + name: name.map(|(n, _)| n), + params: params.into_iter().map(|(n, _)| n).collect(), + body, + strict, + span: self.span_from(start), + })) + } + + /// A parameter's name. + fn function_name_or_param(&mut self) -> Result<(Name, Token)> { + let TokenKind::Ident(name) = &self.tok.kind else { + return Err(self.error_at_token(&self.tok, format!("expected a parameter name but found {}", describe(&self.tok)))); + }; + let name = name.clone(); + self.check_identifier(&self.tok, &name)?; + let token = self.bump()?; + Ok((Name { name, span: token.span }, token)) + } + + /// The rules strict mode code sets for a function's name and parameters (Annex C): neither + /// `eval`, `arguments` nor a strict-mode reserved word, and no parameter name twice. + fn check_strict_function(&self, name: Option<&(Name, Token)>, params: &[(Name, Token)]) -> Result<()> { + for (n, token) in name.into_iter().chain(params) { + if is_eval_or_arguments(&n.name) { + return Err(self.error_at_token(token, format!("'{}' cannot be declared in strict mode", n.name))); + } + if strict_reserved(&n.name) { + return Err(self.error_at_token(token, format!("'{}' is a reserved word in strict mode", n.name))); + } + } + for (i, (p, token)) in params.iter().enumerate() { + if params.iter().take(i).any(|(q, _)| q.name == p.name) { + return Err(self.error_at_token(token, format!("duplicate parameter name '{}' is not allowed in strict mode", p.name))); + } + } + Ok(()) + } + + // ---- expressions ---- + + /// An expression: assignment expressions separated by commas. `no_in` leaves `in` to a + /// `for (… in …)` around it. + fn parse_expression(&mut self, no_in: bool) -> Result<ExprId> { + let first = self.parse_assignment(no_in)?; + if !self.at(Punct::Comma) { + return Ok(first); + } + let mut list = vec![first]; + while self.eat(Punct::Comma)? { + list.push(self.parse_assignment(no_in)?); + } + let span = self.ast.expr_span(first).to(Span::new(self.prev_end, self.prev_end)); + Ok(self.ast.push_expr(Expr::Sequence(list), span)) + } + + fn parse_assignment(&mut self, no_in: bool) -> Result<ExprId> { + self.enter()?; + let result = self.assignment(no_in); + self.leave(); + result + } + + /// `a = b += c`: the targets kept on a stack, so a long chain does not nest. + fn assignment(&mut self, no_in: bool) -> Result<ExprId> { + let mut targets: Vec<(ExprId, AssignOp)> = Vec::new(); + loop { + let expr = self.parse_conditional(no_in)?; + let Some(op) = assign_op(&self.tok) else { + let mut value = expr; + while let Some((target, op)) = targets.pop() { + let span = self.ast.expr_span(target).to(self.ast.expr_span(value)); + value = self.ast.push_expr(Expr::Assign { op, target, value }, span); + } + return Ok(value); + }; + self.check_target(expr, "invalid assignment target")?; + self.bump()?; + targets.push((expr, op)); + } + } + + /// An assignment's, `++`'s, `--`'s or `for-in`'s target: an identifier (in strict code + /// neither `eval` nor `arguments`), a property, or outside strict mode code a call (assigning + /// to its result fails at run time, as ECMA-262 2015 Annex B and web browsers have it). + fn check_target(&self, expr: ExprId, message: &str) -> Result<()> { + let inner = self.ast.unparenthesized(expr); + match self.ast.expr(inner) { + Some(Expr::Ident(name)) => { + if self.ctx.strict && is_eval_or_arguments(name) { + return Err(self.error_at_offset(self.ast.expr_span(inner).start, format!("cannot assign to '{name}' in strict mode"))); + } + Ok(()) + } + Some(Expr::Member { .. } | Expr::Index { .. }) => Ok(()), + Some(Expr::Call { .. }) if !self.ctx.strict => Ok(()), + _ => Err(self.error_at_offset(self.ast.expr_span(expr).start, message)), + } + } + + fn parse_conditional(&mut self, no_in: bool) -> Result<ExprId> { + let test = self.parse_binary(no_in)?; + if !self.at(Punct::Question) { + return Ok(test); + } + self.bump()?; + let consequent = self.parse_assignment(false)?; + self.expect(Punct::Colon)?; + let alternate = self.parse_assignment(no_in)?; + let span = self.ast.expr_span(test).to(self.ast.expr_span(alternate)); + Ok(self.ast.push_expr(Expr::Conditional { test, consequent, alternate }, span)) + } + + /// Binary and logical operators, read with an operator stack by precedence (all are left + /// associative). + fn parse_binary(&mut self, no_in: bool) -> Result<ExprId> { + let mut operands = vec![self.parse_unary()?]; + let mut ops: Vec<(BinKind, u8)> = Vec::new(); + while let Some((op, precedence)) = binary_op(&self.tok, no_in) { + while ops.last().is_some_and(|&(_, top)| top >= precedence) { + self.reduce(&mut operands, &mut ops); + } + self.bump()?; + ops.push((op, precedence)); + operands.push(self.parse_unary()?); + } + while !ops.is_empty() { + self.reduce(&mut operands, &mut ops); + } + operands.pop().ok_or_else(|| self.unexpected()) + } + + /// Joins the top two operands with the top operator. + fn reduce(&mut self, operands: &mut Vec<ExprId>, ops: &mut Vec<(BinKind, u8)>) { + let (Some((op, _)), Some(right), Some(left)) = (ops.pop(), operands.pop(), operands.pop()) else { + return; + }; + let span = self.ast.expr_span(left).to(self.ast.expr_span(right)); + let expr = match op { + BinKind::Binary(op) => Expr::Binary { op, left, right }, + BinKind::Logical(op) => Expr::Logical { op, left, right }, + }; + operands.push(self.ast.push_expr(expr, span)); + } + + /// Prefix operators, read with a loop and applied innermost first. + fn parse_unary(&mut self) -> Result<ExprId> { + let mut prefixes = Vec::new(); + while let Some(prefix) = prefix_op(&self.tok) { + prefixes.push((prefix, self.tok.span)); + self.bump()?; + } + let mut expr = self.parse_postfix()?; + for (prefix, start) in prefixes.into_iter().rev() { + let span = start.to(self.ast.expr_span(expr)); + expr = match prefix { + Prefix::Update(op) => { + self.check_target(expr, "invalid increment or decrement operand")?; + self.ast.push_expr(Expr::Update { op, prefix: true, target: expr }, span) + } + Prefix::Unary(op) => { + if op == UnaryOp::Delete && self.ctx.strict && matches!(self.ast.expr(self.ast.unparenthesized(expr)), Some(Expr::Ident(_))) { + return Err(self.error_at_offset(start.start, "delete of an unqualified identifier in strict mode")); + } + self.ast.push_expr(Expr::Unary { op, arg: expr }, span) + } + }; + } + Ok(expr) + } + + fn parse_postfix(&mut self) -> Result<ExprId> { + let expr = self.parse_left_hand_side()?; + let op = match &self.tok.kind { + TokenKind::Punct(Punct::PlusPlus) if !self.tok.newline_before => UpdateOp::Increment, + TokenKind::Punct(Punct::MinusMinus) if !self.tok.newline_before => UpdateOp::Decrement, + _ => return Ok(expr), + }; + self.check_target(expr, "invalid increment or decrement operand")?; + let token = self.bump()?; + let span = self.ast.expr_span(expr).to(token.span); + Ok(self.ast.push_expr(Expr::Update { op, prefix: false, target: expr }, span)) + } + + /// Member accesses, calls and `new` (section 11.2): each `new` takes the first argument list + /// after its callee, and a `new` left without one constructs with no arguments. + fn parse_left_hand_side(&mut self) -> Result<ExprId> { + let mut news = Vec::new(); + while self.at_keyword(Keyword::New) { + news.push(self.bump()?.span); + } + let mut expr = self.parse_primary()?; + loop { + match &self.tok.kind { + TokenKind::Punct(Punct::Dot) => { + self.bump()?; + let Some(name) = self.tok.identifier_name().map(str::to_string) else { + return Err(self.error_at_token(&self.tok, format!("expected a property name but found {}", describe(&self.tok)))); + }; + let token = self.bump()?; + let span = self.ast.expr_span(expr).to(token.span); + expr = self.ast.push_expr(Expr::Member { object: expr, property: Name { name, span: token.span } }, span); + } + TokenKind::Punct(Punct::LBracket) => { + self.bump()?; + let index = self.parse_expression(false)?; + let close = self.expect(Punct::RBracket)?; + let span = self.ast.expr_span(expr).to(close.span); + expr = self.ast.push_expr(Expr::Index { object: expr, index }, span); + } + TokenKind::Punct(Punct::LParen) => { + let args = self.parse_arguments()?; + let span = Span::new(self.ast.expr_span(expr).start, self.prev_end); + expr = match news.pop() { + Some(new) => self.ast.push_expr(Expr::New { callee: expr, args }, Span::new(new.start, self.prev_end)), + None => self.ast.push_expr(Expr::Call { callee: expr, args }, span), + }; + } + _ => break, + } + } + while let Some(new) = news.pop() { + let span = Span::new(new.start, self.ast.expr_span(expr).end); + expr = self.ast.push_expr(Expr::New { callee: expr, args: Vec::new() }, span); + } + Ok(expr) + } + + fn parse_arguments(&mut self) -> Result<Vec<ExprId>> { + self.expect(Punct::LParen)?; + let mut args = Vec::new(); + if !self.at(Punct::RParen) { + loop { + args.push(self.parse_assignment(false)?); + if !self.eat(Punct::Comma)? { + break; + } + } + } + self.expect(Punct::RParen)?; + Ok(args) + } + + fn parse_primary(&mut self) -> Result<ExprId> { + self.enter()?; + let result = self.primary(); + self.leave(); + result + } + + fn primary(&mut self) -> Result<ExprId> { + let start = self.tok.span; + let expr = match &self.tok.kind { + TokenKind::Keyword(Keyword::This) => Expr::This, + TokenKind::Keyword(Keyword::Null) => Expr::Null, + TokenKind::Keyword(Keyword::True) => Expr::Bool(true), + TokenKind::Keyword(Keyword::False) => Expr::Bool(false), + TokenKind::Keyword(Keyword::Function) => return self.parse_function_expression(), + TokenKind::Ident(name) => { + let name = name.clone(); + self.check_identifier(&self.tok, &name)?; + Expr::Ident(name) + } + TokenKind::Number(n) => { + if self.ctx.strict && self.tok.legacy_octal { + return Err(self.error_at_token(&self.tok, "octal literals are not allowed in strict mode")); + } + Expr::Number(*n) + } + TokenKind::String(s) => { + if self.ctx.strict && self.tok.legacy_octal { + return Err(self.error_at_token(&self.tok, "octal escape sequences are not allowed in strict mode")); + } + Expr::String(s.clone()) + } + TokenKind::Punct(Punct::Slash | Punct::SlashEq) => { + let regexp = self.lex.rescan_regexp(&self.tok)?; + self.tok = regexp; + let TokenKind::RegExp { pattern, flags } = &self.tok.kind else { + return Err(self.unexpected()); + }; + Expr::RegExp { pattern: pattern.clone(), flags: flags.clone() } + } + TokenKind::Punct(Punct::LBracket) => return self.parse_array(), + TokenKind::Punct(Punct::LBrace) => return self.parse_object(), + TokenKind::Punct(Punct::LParen) => { + self.bump()?; + let inner = self.parse_expression(false)?; + self.expect(Punct::RParen)?; + return Ok(self.ast.push_expr(Expr::Paren(inner), self.span_from(start))); + } + _ => return Err(self.unexpected()), + }; + let token = self.bump()?; + Ok(self.ast.push_expr(expr, token.span)) + } + + fn parse_function_expression(&mut self) -> Result<ExprId> { + let start = self.expect_keyword(Keyword::Function)?.span; + let name = if matches!(self.tok.kind, TokenKind::Ident(_)) { Some(self.function_name()?) } else { None }; + let id = self.parse_function_rest(FunctionKind::Expression, name, start)?; + Ok(self.ast.push_expr(Expr::Function(id), self.span_from(start))) + } + + /// `[a, , b]`: elements, a comma with nothing before it leaving a hole. + fn parse_array(&mut self) -> Result<ExprId> { + let start = self.expect(Punct::LBracket)?.span; + let mut elements = Vec::new(); + loop { + if self.eat(Punct::RBracket)? { + break; + } + if self.eat(Punct::Comma)? { + elements.push(None); + continue; + } + elements.push(Some(self.parse_assignment(false)?)); + if !self.at(Punct::RBracket) { + self.expect(Punct::Comma)?; + } + } + Ok(self.ast.push_expr(Expr::Array(elements), self.span_from(start))) + } + + /// `{a: 1, "b": 2, 3: c, get d() {…}, set d(v) {…}}`. A name may repeat, a later definition + /// replacing an earlier one (ECMA-262 2015 section 12.2.6.1). + fn parse_object(&mut self) -> Result<ExprId> { + let start = self.expect(Punct::LBrace)?.span; + let mut properties = Vec::new(); + while !self.eat(Punct::RBrace)? { + let property = self.parse_property()?; + properties.push(property); + if !self.at(Punct::RBrace) { + self.expect(Punct::Comma)?; + } + } + Ok(self.ast.push_expr(Expr::Object(properties), self.span_from(start))) + } + + fn parse_property(&mut self) -> Result<Property> { + let start = self.tok.span; + // `get` and `set` start an accessor only when written without escapes. + let accessor = match self.tok.ident().filter(|_| !self.tok.escaped) { + Some("get") => Some(FunctionKind::Getter), + Some("set") => Some(FunctionKind::Setter), + _ => None, + }; + if let Some(kind) = accessor { + let word = self.bump()?; + if property_key(&self.tok).is_some() { + let (key, _) = self.parse_property_key()?; + let function = self.parse_function_rest(kind, None, start)?; + let value = if kind == FunctionKind::Getter { PropertyValue::Get(function) } else { PropertyValue::Set(function) }; + return Ok(Property { key, value, span: self.span_from(start) }); + } + let key = PropertyKey::Ident(word.ident().unwrap_or_default().to_string()); + return self.property_value(key, start); + } + let (key, _) = self.parse_property_key()?; + self.property_value(key, start) + } + + /// `: value` after a data property's name. + fn property_value(&mut self, key: PropertyKey, start: Span) -> Result<Property> { + self.expect(Punct::Colon)?; + let value = self.parse_assignment(false)?; + Ok(Property { key, value: PropertyValue::Init(value), span: self.span_from(start) }) + } + + fn parse_property_key(&mut self) -> Result<(PropertyKey, Token)> { + let Some(key) = property_key(&self.tok) else { + return Err(self.error_at_token(&self.tok, format!("expected a property name but found {}", describe(&self.tok)))); + }; + if self.ctx.strict && self.tok.legacy_octal { + return Err(self.error_at_token(&self.tok, "octal literals and escape sequences are not allowed in strict mode")); + } + let token = self.bump()?; + Ok((key, token)) + } +} + +/// The key a token gives as a property name: an identifier name, a string or a number. +fn property_key(token: &Token) -> Option<PropertyKey> { + match &token.kind { + TokenKind::String(s) => Some(PropertyKey::String(s.clone())), + TokenKind::Number(n) => Some(PropertyKey::Number(*n)), + _ => token.identifier_name().map(|n| PropertyKey::Ident(n.to_string())), + } +} + +fn is_eval_or_arguments(name: &str) -> bool { + name == "eval" || name == "arguments" +} + +fn assign_op(token: &Token) -> Option<AssignOp> { + let TokenKind::Punct(p) = &token.kind else { return None }; + Some(match p { + Punct::Eq => None, + Punct::PlusEq => Some(BinaryOp::Add), + Punct::MinusEq => Some(BinaryOp::Sub), + Punct::StarEq => Some(BinaryOp::Mul), + Punct::SlashEq => Some(BinaryOp::Div), + Punct::PercentEq => Some(BinaryOp::Mod), + Punct::ShlEq => Some(BinaryOp::Shl), + Punct::ShrEq => Some(BinaryOp::Shr), + Punct::UShrEq => Some(BinaryOp::UShr), + Punct::AmpEq => Some(BinaryOp::BitAnd), + Punct::PipeEq => Some(BinaryOp::BitOr), + Punct::CaretEq => Some(BinaryOp::BitXor), + _ => return None, + }) +} + +/// A binary operator and its precedence (higher binds tighter); `in` is left out under `no_in`. +fn binary_op(token: &Token, no_in: bool) -> Option<(BinKind, u8)> { + use BinKind::{Binary as B, Logical as L}; + match &token.kind { + TokenKind::Keyword(Keyword::Instanceof) => Some((B(BinaryOp::Instanceof), 7)), + TokenKind::Keyword(Keyword::In) if !no_in => Some((B(BinaryOp::In), 7)), + TokenKind::Punct(p) => Some(match p { + Punct::PipePipe => (L(LogicalOp::Or), 1), + Punct::AmpAmp => (L(LogicalOp::And), 2), + Punct::Pipe => (B(BinaryOp::BitOr), 3), + Punct::Caret => (B(BinaryOp::BitXor), 4), + Punct::Amp => (B(BinaryOp::BitAnd), 5), + Punct::EqEq => (B(BinaryOp::Eq), 6), + Punct::NotEq => (B(BinaryOp::NotEq), 6), + Punct::EqEqEq => (B(BinaryOp::StrictEq), 6), + Punct::NotEqEq => (B(BinaryOp::StrictNotEq), 6), + Punct::Lt => (B(BinaryOp::Lt), 7), + Punct::Gt => (B(BinaryOp::Gt), 7), + Punct::LtEq => (B(BinaryOp::LtEq), 7), + Punct::GtEq => (B(BinaryOp::GtEq), 7), + Punct::Shl => (B(BinaryOp::Shl), 8), + Punct::Shr => (B(BinaryOp::Shr), 8), + Punct::UShr => (B(BinaryOp::UShr), 8), + Punct::Plus => (B(BinaryOp::Add), 9), + Punct::Minus => (B(BinaryOp::Sub), 9), + Punct::Star => (B(BinaryOp::Mul), 10), + Punct::Slash => (B(BinaryOp::Div), 10), + Punct::Percent => (B(BinaryOp::Mod), 10), + _ => return None, + }), + _ => None, + } +} + +fn prefix_op(token: &Token) -> Option<Prefix> { + Some(match &token.kind { + TokenKind::Keyword(Keyword::Delete) => Prefix::Unary(UnaryOp::Delete), + TokenKind::Keyword(Keyword::Void) => Prefix::Unary(UnaryOp::Void), + TokenKind::Keyword(Keyword::Typeof) => Prefix::Unary(UnaryOp::Typeof), + TokenKind::Punct(Punct::Plus) => Prefix::Unary(UnaryOp::Plus), + TokenKind::Punct(Punct::Minus) => Prefix::Unary(UnaryOp::Minus), + TokenKind::Punct(Punct::Tilde) => Prefix::Unary(UnaryOp::BitNot), + TokenKind::Punct(Punct::Bang) => Prefix::Unary(UnaryOp::Not), + TokenKind::Punct(Punct::PlusPlus) => Prefix::Update(UpdateOp::Increment), + TokenKind::Punct(Punct::MinusMinus) => Prefix::Update(UpdateOp::Decrement), + _ => return None, + }) +} + +/// A token as an error message names it. +fn describe(token: &Token) -> String { + match &token.kind { + TokenKind::Eof => "end of input".into(), + TokenKind::Ident(name) => format!("identifier '{name}'"), + TokenKind::Keyword(k) => format!("token '{}'", k.as_str()), + TokenKind::Punct(p) => format!("token '{}'", p.as_str()), + TokenKind::Number(_) => "number".into(), + TokenKind::String(_) => "string".into(), + TokenKind::RegExp { .. } => "regular expression".into(), + TokenKind::Directive(_) => "preprocessor line".into(), + } +} diff --git a/crates/js/src/regexp.rs b/crates/js/src/regexp.rs new file mode 100644 index 000000000..cda4f5307 --- /dev/null +++ b/crates/js/src/regexp.rs @@ -0,0 +1,191 @@ +//! The early check of a regular expression literal's pattern (ECMA-262 5.1 section 15.10.1, with +//! the lenient forms of Annex B that scripts rely on: a `]`, `{` or `}` that starts no class or +//! quantifier is a literal character, a lookahead may take a quantifier, and a class range with a +//! class escape at one end is a set of literal characters). +//! +//! The check reads the pattern once, front to back, counting open groups instead of recursing. + +/// Checks `pattern`, the text between a literal's slashes. Groups may nest `max_depth` deep. +pub fn check(pattern: &[u16], max_depth: usize) -> Result<(), String> { + let mut i = 0; + let mut depth = 0usize; + // The term before the current position may take a quantifier. + let mut quantifiable = false; + while let Some(&unit) = pattern.get(i) { + let c = char::from_u32(u32::from(unit)).unwrap_or('\u{fffd}'); + match c { + '\\' => { + let next = pattern.get(i + 1).ok_or("\\ at end of pattern")?; + quantifiable = !matches!(char::from_u32(u32::from(*next)), Some('b' | 'B')); + i += 2; + } + '(' => { + if pattern.get(i + 1) == Some(&u16::from(b'?')) { + match pattern.get(i + 2).and_then(|&u| char::from_u32(u32::from(u))) { + Some(':' | '=' | '!') => i += 3, + _ => return Err("invalid group".into()), + } + } else { + i += 1; + } + depth += 1; + if depth > max_depth { + return Err(crate::error::TOO_DEEP.into()); + } + quantifiable = false; + } + ')' => { + depth = depth.checked_sub(1).ok_or("unmatched ')'")?; + quantifiable = true; + i += 1; + } + '[' => { + i = class(pattern, i + 1)?; + quantifiable = true; + } + '*' | '+' | '?' => { + if !quantifiable { + return Err("nothing to repeat".into()); + } + i = lazy(pattern, i + 1); + quantifiable = false; + } + '{' => match braces(pattern, i + 1) { + Some((min, max, end)) => { + if !quantifiable { + return Err("nothing to repeat".into()); + } + if max.is_some_and(|max| max < min) { + return Err("numbers out of order in {} quantifier".into()); + } + i = lazy(pattern, end); + quantifiable = false; + } + None => { + quantifiable = true; + i += 1; + } + }, + '^' | '$' | '|' => { + quantifiable = false; + i += 1; + } + _ => { + quantifiable = true; + i += 1; + } + } + } + if depth > 0 { + return Err("unterminated group".into()); + } + Ok(()) +} + +/// After a quantifier ending before `i`: past the `?` that makes it lazy, if there is one. +fn lazy(pattern: &[u16], i: usize) -> usize { + if pattern.get(i) == Some(&u16::from(b'?')) { i + 1 } else { i } +} + +/// A `{n}`, `{n,}` or `{n,m}` quantifier whose `{` is before `i`: its bounds and the position +/// after its `}`. `None` when the text there is not one. +fn braces(pattern: &[u16], i: usize) -> Option<(u64, Option<u64>, usize)> { + let (min, mut i) = digits(pattern, i)?; + let max = if pattern.get(i) == Some(&u16::from(b',')) { + i += 1; + match digits(pattern, i) { + Some((max, end)) => { + i = end; + Some(max) + } + None => None, + } + } else { + Some(min) + }; + (pattern.get(i) == Some(&u16::from(b'}'))).then_some((min, max, i + 1)) +} + +/// Decimal digits at `i`: their value (saturated) and the position after them. +fn digits(pattern: &[u16], mut i: usize) -> Option<(u64, usize)> { + let start = i; + let mut value: u64 = 0; + while let Some(d) = pattern.get(i).and_then(|&u| char::from_u32(u32::from(u))).and_then(|c| c.to_digit(10)) { + value = value.saturating_mul(10).saturating_add(u64::from(d)); + i += 1; + } + (i > start).then_some((value, i)) +} + +/// Reads a character class whose `[` is before `i` and returns the position after its `]`. +fn class(pattern: &[u16], mut i: usize) -> Result<usize, String> { + if pattern.get(i) == Some(&u16::from(b'^')) { + i += 1; + } + // The last class atom's character (`None` for a class escape such as `\d`), when a `-` after it + // may make a range. + let mut prev: Option<Option<u32>> = None; + loop { + let unit = *pattern.get(i).ok_or("unterminated character class")?; + if unit == u16::from(b']') { + return Ok(i + 1); + } + let (atom, next) = class_atom(pattern, i)?; + i = next; + let dash = pattern.get(i) == Some(&u16::from(b'-')) && pattern.get(i + 1).is_some_and(|&u| u != u16::from(b']')); + match prev.take() { + Some(low) => { + if let (Some(low), Some(high)) = (low, atom) + && low > high + { + return Err("range out of order in character class".into()); + } + } + None if dash => { + prev = Some(atom); + i += 1; + } + None => {} + } + } +} + +/// A class atom at `i`: its character (`None` for a class escape) and the position after it. +fn class_atom(pattern: &[u16], i: usize) -> Result<(Option<u32>, usize), String> { + let unit = u32::from(*pattern.get(i).ok_or("unterminated character class")?); + if unit != u32::from(b'\\') { + return Ok((Some(unit), i + 1)); + } + let next = u32::from(*pattern.get(i + 1).ok_or("\\ at end of pattern")?); + let c = char::from_u32(next).unwrap_or('\u{fffd}'); + let hex = |n: usize| -> Option<u32> { + (0..n).try_fold(0, |v, k| pattern.get(i + 2 + k).and_then(|&u| crate::chars::hex_value(u32::from(u))).map(|d| v * 16 + d)) + }; + Ok(match c { + 'd' | 'D' | 's' | 'S' | 'w' | 'W' => (None, i + 2), + 'b' => (Some(0x08), i + 2), + 'f' => (Some(0x0C), i + 2), + 'n' => (Some(0x0A), i + 2), + 'r' => (Some(0x0D), i + 2), + 't' => (Some(0x09), i + 2), + 'v' => (Some(0x0B), i + 2), + 'x' => hex(2).map_or((Some(next), i + 2), |v| (Some(v), i + 4)), + 'u' => hex(4).map_or((Some(next), i + 2), |v| (Some(v), i + 6)), + 'c' => match pattern.get(i + 2).map(|&u| u32::from(u)) { + Some(l) if char::from_u32(l).is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '_') => (Some(l % 32), i + 3), + _ => (Some(u32::from(b'\\')), i + 1), + }, + '0'..='7' => { + let mut value = 0; + let mut j = i + 1; + while j < i + 4 + && let Some(d) = pattern.get(j).and_then(|&u| char::from_u32(u32::from(u))).and_then(|ch| ch.to_digit(8)) + { + value = value * 8 + d; + j += 1; + } + (Some(value), j) + } + _ => (Some(next), i + 2), + }) +} diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs new file mode 100644 index 000000000..a4e73d275 --- /dev/null +++ b/crates/js/src/tests.rs @@ -0,0 +1,760 @@ +use proptest::prelude::*; + +use crate::{ + BinaryOp, Expr, ExprId, ForInTarget, FunctionKind, Options, Program, PropertyValue, Stmt, StmtId, SyntaxError, TOO_DEEP, TokenKind, parse, + tokenize, +}; + +fn ok(src: &str) -> Program { + match parse(src, &Options::default()) { + Ok(p) => p, + Err(e) => panic!("{src:?} should parse: {e}"), + } +} + +fn err(src: &str) -> SyntaxError { + match parse(src, &Options::default()) { + Ok(_) => panic!("{src:?} should not parse"), + Err(e) => e, + } +} + +fn strict_err(src: &str) -> SyntaxError { + let full = format!("'use strict';\n{src}"); + match parse(&full, &Options::default()) { + Ok(_) => panic!("{src:?} should not parse in strict mode code"), + Err(e) => e, + } +} + +fn with_preprocessor() -> Options { + Options { preprocessor_lines: true, ..Options::default() } +} + +/// The expression of the program's only (or first) statement. +fn first_expr(p: &Program) -> ExprId { + match p.ast.stmt(p.body[0]) { + Some(Stmt::Expr(e)) => *e, + other => panic!("not an expression statement: {other:?}"), + } +} + +fn expr(p: &Program, id: ExprId) -> &Expr { + p.ast.expr(id).unwrap() +} + +fn stmt(p: &Program, id: StmtId) -> &Stmt { + p.ast.stmt(id).unwrap() +} + +fn utf16(s: &str) -> Vec<u16> { + s.encode_utf16().collect() +} + +// ---- expressions ---- + +#[test] +fn binary_operators_follow_precedence_and_associate_left() { + let p = ok("a + b * c - d;"); + let Expr::Binary { op: BinaryOp::Sub, left, right } = expr(&p, first_expr(&p)) else { panic!() }; + assert!(matches!(expr(&p, *right), Expr::Ident(n) if n == "d")); + let Expr::Binary { op: BinaryOp::Add, right: mul, .. } = expr(&p, *left) else { panic!() }; + assert!(matches!(expr(&p, *mul), Expr::Binary { op: BinaryOp::Mul, .. })); +} + +#[test] +fn logical_operators_bind_looser_than_comparisons() { + let p = ok("a || b && c == d;"); + let Expr::Logical { right, .. } = expr(&p, first_expr(&p)) else { panic!() }; + let Expr::Logical { right: eq, .. } = expr(&p, *right) else { panic!() }; + assert!(matches!(expr(&p, *eq), Expr::Binary { op: BinaryOp::Eq, .. })); +} + +#[test] +fn assignment_associates_right() { + let p = ok("a = b += c;"); + let Expr::Assign { op: None, value, .. } = expr(&p, first_expr(&p)) else { panic!() }; + assert!(matches!(expr(&p, *value), Expr::Assign { op: Some(BinaryOp::Add), .. })); +} + +#[test] +fn new_takes_the_first_argument_list_after_its_callee() { + let p = ok("new a.b(1).c(2);"); + let Expr::Call { callee, args } = expr(&p, first_expr(&p)) else { panic!() }; + assert_eq!(args.len(), 1); + let Expr::Member { object, property } = expr(&p, *callee) else { panic!() }; + assert_eq!(property.name, "c"); + let Expr::New { callee, args } = expr(&p, *object) else { panic!() }; + assert_eq!(args.len(), 1); + assert!(matches!(expr(&p, *callee), Expr::Member { .. })); + + let p = ok("new new X()();"); + let Expr::New { callee, .. } = expr(&p, first_expr(&p)) else { panic!() }; + assert!(matches!(expr(&p, *callee), Expr::New { .. })); + + let p = ok("new X;"); + assert!(matches!(expr(&p, first_expr(&p)), Expr::New { args, .. } if args.is_empty())); +} + +#[test] +fn conditional_and_sequence_expressions() { + let p = ok("a ? b : c ? d : e, f;"); + let Expr::Sequence(list) = expr(&p, first_expr(&p)) else { panic!() }; + assert_eq!(list.len(), 2); + let Expr::Conditional { alternate, .. } = expr(&p, list[0]) else { panic!() }; + assert!(matches!(expr(&p, *alternate), Expr::Conditional { .. })); +} + +#[test] +fn array_literals_keep_holes() { + let p = ok("[1, , 3, ];"); + let Expr::Array(elements) = expr(&p, first_expr(&p)) else { panic!() }; + assert_eq!(elements.len(), 3); + assert!(elements[1].is_none()); + let p = ok("[,];"); + assert!(matches!(expr(&p, first_expr(&p)), Expr::Array(e) if e.len() == 1)); +} + +#[test] +fn object_literals_with_accessors_and_keyword_names() { + let p = ok("({a: 1, 'b': 2, 3: 3, if: 4, get: 5, set: 6, get x() { return 1; }, set x(v) {}, });"); + let Expr::Paren(inner) = expr(&p, first_expr(&p)) else { panic!() }; + let Expr::Object(props) = expr(&p, *inner) else { panic!() }; + assert_eq!(props.len(), 8); + assert!(matches!(props[6].value, PropertyValue::Get(_))); + assert!(matches!(props[7].value, PropertyValue::Set(_))); + let PropertyValue::Get(getter) = props[6].value else { panic!() }; + assert_eq!(p.ast.function(getter).unwrap().kind, FunctionKind::Getter); +} + +#[test] +fn accessors_take_the_right_number_of_parameters() { + assert!(err("({get a(x) {}});").message.contains("getter")); + assert!(err("({set a() {}});").message.contains("setter")); + assert!(err("({set a(x, y) {}});").message.contains("setter")); +} + +#[test] +fn property_names_may_repeat() { + for src in ["({a: 1, a: 2});", "({a: 1, 'a': 2});", "({get a() {}, set a(v) {}});", "({a: 1, get a() {}});", "({get a() {}, get a() {}});"] { + ok(src); + ok(&format!("'use strict'; {src}")); + } +} + +#[test] +fn escaped_get_and_set_do_not_start_accessors() { + assert!(err("({g\\u0065t a() {}});").message.contains("expected ':'")); + assert!(err("({\\u0073et a(v) {}});").message.contains("expected ':'")); + ok("({g\\u0065t: 1});"); +} + +#[test] +fn member_names_may_be_reserved_words() { + ok("a.if.var.function = b.true;"); + ok("a.\\u0076ar = 1;"); +} + +// ---- statements ---- + +#[test] +fn else_if_chains_nest_as_alternates() { + let p = ok("if (a) x; else if (b) y; else z;"); + let Stmt::If { alternate: Some(alt), .. } = stmt(&p, p.body[0]) else { panic!() }; + let Stmt::If { alternate: Some(last), .. } = stmt(&p, *alt) else { panic!() }; + assert!(matches!(stmt(&p, *last), Stmt::Expr(_))); +} + +#[test] +fn for_statements_of_every_form() { + ok("for (;;) break;"); + ok("for (var i = 0, n = a.length; i < n; i++) {}"); + ok("for (i = 0; i < 10; i++) continue;"); + ok("for (var k in o) {}"); + ok("for (k in o) {}"); + ok("for (a.b in o) {}"); + ok("for (var x = 1 in o) {}"); + ok("for (var i = (a in b); i; ) break;"); + let p = ok("for (var k in o) {}"); + assert!(matches!(stmt(&p, p.body[0]), Stmt::ForIn { target: ForInTarget::Var(_), .. })); + assert!(err("for (a + b in o) {}").message.contains("for-in")); + assert!(err("for (var a, b in o) {}").message.contains("expected ';'")); +} + +#[test] +fn switch_try_with_labels_and_debugger() { + ok("switch (x) { case 1: a(); break; default: b(); case 2: }"); + ok("try { a(); } catch (e) { b(); } finally { c(); }"); + ok("try {} finally {}"); + ok("with (o) { a = 1; }"); + ok("outer: for (;;) { inner: for (;;) { continue outer; } }"); + ok("a: b: while (1) { continue a; }"); + ok("a: { break a; }"); + ok("debugger;"); + ok("do x(); while (a) y();"); + assert!(err("switch (x) { default: default: }").message.contains("default")); + assert!(err("try {}").message.contains("catch or finally")); +} + +#[test] +fn break_and_continue_need_a_target() { + assert!(err("break;").message.contains("break outside")); + assert!(err("continue;").message.contains("continue outside")); + assert!(err("switch (x) { case 1: continue; }").message.contains("continue outside")); + assert!(err("for (;;) { break nowhere; }").message.contains("undefined label")); + assert!(err("a: { continue a; }").message.contains("does not name a loop")); + assert!(err("a: while (1) { function f() { break a; } }").message.contains("undefined label")); + assert!(err("while (1) { function f() { break; } }").message.contains("break outside")); + assert!(err("a: a: ;").message.contains("duplicate label")); + assert!(err("a: { a: ; }").message.contains("duplicate label")); + ok("a: ; a: ;"); +} + +#[test] +fn return_only_inside_functions() { + assert!(err("return 1;").message.contains("return outside")); + ok("function f() { return; }"); + ok("(function () { return 1 })();"); +} + +#[test] +fn function_declarations_in_blocks_and_branches() { + ok("{ function f() {} }"); + ok("if (a) function f() {} else function g() {}"); + ok("switch (a) { case 1: function f() {} }"); + ok("l: function f() {}"); + ok("{ l: m: function f() {} }"); + ok("'use strict'; { function f() {} }"); + assert!(strict_err("if (a) function f() {}").message.contains("functions can only be declared")); + assert!(strict_err("l: function f() {}").message.contains("functions can only be declared")); + for body in ["while (a) function f() {}", "for (;;) function f() {}", "do function f() {} while (a)", "with (a) function f() {}"] { + assert!(err(body).message.contains("cannot stand here"), "{body}"); + } + assert!(err("if (a) l: function f() {}").message.contains("cannot stand here")); + assert!(err("while (a) l: function f() {}").message.contains("cannot stand here")); +} + +// ---- automatic semicolon insertion ---- + +#[test] +fn semicolons_are_inserted_at_line_breaks_braces_and_the_end() { + ok("a = 1\nb = 2"); + ok("function f() { return 1 }"); + ok("a = 1"); + let e = err("a = 1 b = 2"); + assert_eq!((e.line, e.column), (1, 7)); +} + +#[test] +fn restricted_productions_end_at_a_line_break() { + let p = ok("function f() { return\n1 }"); + let Stmt::FunctionDecl(f) = stmt(&p, p.body[0]) else { panic!() }; + let body = &p.ast.function(*f).unwrap().body; + assert_eq!(body.len(), 2); + assert!(matches!(stmt(&p, body[0]), Stmt::Return(None))); + + let p = ok("a\n++b"); + assert_eq!(p.body.len(), 2); + assert!(matches!(expr(&p, first_expr(&p)), Expr::Ident(_))); + + ok("for (;;) { break\nfoo }"); + assert!(err("throw\nnew Error()").message.contains("line break after throw")); +} + +#[test] +fn no_semicolon_is_inserted_inside_a_for_header() { + assert!(err("for (a\nb\n) {}").message.contains("expected ';'")); +} + +// ---- the division and regular expression goals ---- + +#[test] +fn slash_divides_after_a_value_and_starts_a_regexp_elsewhere() { + let p = ok("a / b / c;"); + assert!(matches!(expr(&p, first_expr(&p)), Expr::Binary { op: BinaryOp::Div, .. })); + let p = ok("x = /a\\/b[/]c/gi;"); + let Expr::Assign { value, .. } = expr(&p, first_expr(&p)) else { panic!() }; + let Expr::RegExp { pattern, flags } = expr(&p, *value) else { panic!() }; + assert_eq!(*pattern, utf16("a\\/b[/]c")); + assert_eq!(flags, "gi"); + ok("if (x) /re/.test(y);"); + ok("{} /re/.test(y);"); + ok("x = y /= 2;"); + ok("x = /=a/;"); + ok("return_ = typeof /x/;"); + ok("a(/x/, /y/);"); + // After a line break a `/` still divides when it can: there is no semicolon to insert. + let p = ok("a = b\n/c/g"); + assert_eq!(p.body.len(), 1); +} + +#[test] +fn tokenize_reads_each_slash_as_the_grammar_decides() { + let tokens = tokenize("a = b / c; d = /e/g.exec(f);", &Options::default()).unwrap(); + let kinds: Vec<&TokenKind> = tokens.iter().map(|t| &t.kind).collect(); + assert!(kinds.iter().any(|k| matches!(k, TokenKind::RegExp { flags, .. } if flags == "g"))); + assert_eq!(kinds.iter().filter(|k| matches!(k, TokenKind::RegExp { .. })).count(), 1); + assert!(matches!(kinds.last(), Some(TokenKind::Eof))); +} + +// ---- literals ---- + +fn number(src: &str) -> f64 { + let p = ok(src); + let Expr::Number(n) = expr(&p, first_expr(&p)) else { panic!("{src}") }; + *n +} + +fn string(src: &str) -> Vec<u16> { + let p = ok(src); + let Expr::String(s) = expr(&p, first_expr(&p)) else { panic!("{src}") }; + s.clone() +} + +#[test] +fn numeric_literals() { + assert_eq!(number("0;"), 0.0); + assert_eq!(number("12.5e-1;"), 1.25); + assert_eq!(number(".5;"), 0.5); + assert_eq!(number("5.;"), 5.0); + assert_eq!(number("1E3;"), 1000.0); + assert_eq!(number("0x1F;"), 31.0); + assert_eq!(number("0XfF;"), 255.0); + assert_eq!(number("0x20000000000001;"), 9007199254740992.0); + assert_eq!(number("017;"), 15.0); + assert_eq!(number("08;"), 8.0); + assert_eq!(number("09.5;"), 9.5); + assert_eq!(number("1e400;"), f64::INFINITY); + assert!(err("3in []").message.contains("immediately after numeric literal")); + assert!(err("0x;").message.contains("hexadecimal")); + assert!(err("1.e").message.contains("immediately after")); + assert!(strict_err("017;").message.contains("octal literals")); + assert!(strict_err("08;").message.contains("octal literals")); + assert!(strict_err("({017: 1});").message.contains("octal")); +} + +#[test] +fn string_literals_and_escapes() { + assert_eq!(string(r#""a\tb\n\\\"\'";"#), utf16("a\tb\n\\\"'")); + assert_eq!(string(r#"'\x41é€';"#), utf16("A\u{e9}\u{20ac}")); + assert_eq!(string("'a\\\nb';"), utf16("ab")); + assert_eq!(string("'a\\\r\nb';"), utf16("ab")); + assert_eq!(string(r"'\0';"), vec![0]); + assert_eq!(string(r"'\101\7\08';"), vec![0x41, 7, 0, u16::from(b'8')]); + assert_eq!(string(r"'\q\8';"), utf16("q8")); + assert_eq!(string("'\u{2028}';"), vec![0x2028]); + assert_eq!(string("'\u{1F600}';"), utf16("\u{1F600}")); + assert!(err("'abc").message.contains("unterminated string")); + assert!(err("'a\nb'").message.contains("unterminated string")); + assert!(err(r"'\x4';").message.contains("hexadecimal escape")); + assert!(err(r"'\u12';").message.contains("Unicode escape")); + assert_eq!(string(r"'\u{41}\u{1F600}\u{0000E9}';"), utf16("A\u{1F600}\u{e9}")); + for bad in [r"'\u{}';", r"'\u{110000}';", r"'\u{41';", r"'\u{4g}';"] { + assert!(err(bad).message.contains("Unicode escape"), "{bad}"); + } + assert!(strict_err(r"'\101';").message.contains("octal escape")); + assert!(strict_err(r"'\8';").message.contains("octal escape")); + ok(r"'use strict'; '\0';"); +} + +#[test] +fn identifiers_with_unicode_and_escapes() { + let p = ok("var caf\\u00e9 = 1, \u{3b1}\u{3b2} = 2, $_x1 = 3, a\u{300} = 4;"); + let Stmt::Var(decls) = stmt(&p, p.body[0]) else { panic!() }; + let names: Vec<&str> = decls.iter().map(|d| d.name.name.as_str()).collect(); + assert_eq!(names, ["caf\u{e9}", "\u{3b1}\u{3b2}", "$_x1", "a\u{300}"]); + let p = ok("\\u{61}b;"); + assert!(matches!(expr(&p, first_expr(&p)), Expr::Ident(n) if n == "ab")); + assert!(err("var \\u0076ar = 1;").message.contains("escaped characters")); + assert!(err("var a\\u0020b;").message.contains("identifier escape")); + assert!(err("var \\x41;").message.contains("escape")); + assert!(err("var a = 1 @ 2;").message.contains("unexpected character '@'")); + assert!(err("var \u{2e2f};").message.contains("unexpected character")); + assert!(err("var a\u{2e2f};").message.contains("unexpected character")); +} + +#[test] +fn comments_and_line_terminators() { + let p = ok("a /* one\ntwo */ b"); + assert_eq!(p.body.len(), 2, "a comment with a line break separates statements"); + assert!(err("a /* one two */ b").message.contains("unexpected identifier 'b'")); + assert!(err("/* open").message.contains("unterminated comment")); + ok("// only a comment"); + ok("a\u{2028}b\u{2029}c\r\nd\re"); + ok("\u{feff}var\u{a0}a\u{3000}=\u{b}1\u{c};"); +} + +// ---- regular expression literals ---- + +#[test] +fn regular_expression_flags_and_patterns_are_checked() { + for good in [ + "/a|b*c+?d{2}e{1,}f{1,3}?/", + "/[a-z\\d-]/", + "/[\\d-z]/", + "/]/", + "/{/", + "/a{,}/", + "/x{a}/", + "/(?:a)(?=b)(?!c)/", + "/(?=a)*/", + "/\\1(a)/", + "/[]/", + "/[^]/", + "/\\cJ\\c/", + "/[\\b-\\n]/", + "/a/gim", + ] { + ok(&format!("x = {good};")); + } + for (bad, message) in [ + ("/(/", "unterminated group"), + ("/a)/", "unmatched"), + ("/?/", "nothing to repeat"), + ("/a**/", "nothing to repeat"), + ("/^*/", "nothing to repeat"), + ("/{1}/", "nothing to repeat"), + ("/a{2,1}/", "out of order"), + ("/[z-a]/", "out of order"), + ("/(?<a>x)/", "invalid group"), + ("/[a/", "unterminated regular expression"), + ("/a\\/", "unterminated regular expression"), + ("/a\n/", "unterminated regular expression"), + ("/a/gg", "flags"), + ("/a/x", "flags"), + ("/a/\\u0067", "flags"), + ] { + let e = err(&format!("x = {bad};")); + assert!(e.message.contains(message), "{bad}: {}", e.message); + assert_eq!((e.line, e.column), (1, 5), "{bad}"); + } +} + +// ---- strict mode ---- + +#[test] +fn use_strict_makes_the_rest_strict_mode_code() { + assert!(ok("'use strict'; a;").strict); + assert!(ok("\"use strict\"\na;").strict); + assert!(!ok("'use\\x20strict'; a;").strict, "a directive with an escape is not \"use strict\""); + assert!(!ok("('use strict'); a;").strict); + assert!(!ok("a; 'use strict';").strict); + assert!(ok("'other'; 'use strict'; a;").strict); + assert!(!ok("'use strict' + 1;").strict); + let p = ok("function f() { 'use strict'; } function g() {}"); + let funcs = &p.ast.functions; + assert!(funcs[0].strict && !funcs[1].strict && !p.strict); + assert!(parse("with (a) {}", &Options { strict: true, ..Options::default() }).is_err()); +} + +#[test] +fn strict_mode_early_errors() { + assert!(strict_err("with (a) {}").message.contains("with statements")); + assert!(strict_err("delete a;").message.contains("delete")); + assert!(strict_err("delete ((a));").message.contains("delete")); + ok("'use strict'; delete a.b; delete a[0];"); + for target in ["eval = 1;", "arguments++;", "--eval;", "arguments += 1;", "(eval) = 1;", "for (eval in o) {}"] { + assert!(strict_err(target).message.contains("cannot assign"), "{target}"); + } + for binding in ["var eval;", "function arguments() {}", "function f(eval) {}", "try {} catch (arguments) {}", "(function eval() {});"] { + assert!(strict_err(binding).message.contains("cannot be declared"), "{binding}"); + } + for word in ["implements", "interface", "let", "package", "private", "protected", "public", "static", "yield"] { + ok(&format!("var {word} = 1;")); + assert!(strict_err(&format!("var {word} = 1;")).message.contains("reserved word"), "{word}"); + assert!(strict_err(&format!("{word};")).message.contains("reserved word"), "{word}"); + } + assert!(strict_err("function f(a, a) {}").message.contains("duplicate parameter")); + ok("function f(a, a) {}"); +} + +#[test] +fn a_function_made_strict_by_its_own_prologue_checks_its_name_and_parameters() { + let e = err("function f(a, a) { 'use strict'; }"); + assert!(e.message.contains("duplicate parameter")); + assert_eq!((e.line, e.column), (1, 15)); + assert!(err("function eval() { 'use strict'; }").message.contains("cannot be declared")); + assert!(err("function f(arguments) { 'use strict'; }").message.contains("cannot be declared")); + assert!(err("function static() { 'use strict'; }").message.contains("reserved word")); + assert!(err("function f() { '\\01'; 'use strict'; }").message.contains("octal escape")); + assert!(err("'\\01'; 'use strict';").message.contains("octal escape")); + assert!(err("function f() { 'use strict'; 010 }").message.contains("octal literals")); + assert!(err("function f() { 'use strict'; var public; }").message.contains("reserved word")); +} + +// ---- assignment targets ---- + +#[test] +fn invalid_assignment_targets() { + for bad in ["1 = 2;", "a + b = c;", "(a, b) = 1;", "a = 1 = 2;", "++1;", "1++;", "--a.b++;", "this = 1;", "'a' += 1;", "(a = 1) = 2;"] { + let e = err(bad); + assert!(e.message.contains("invalid"), "{bad}: {}", e.message); + } + ok("(a) = 1; a.b = 1; a[0] += 2; a.b++; --a[1]; (a.b) = c;"); + // Assigning to a call's result fails at run time outside strict mode code, and is a + // SyntaxError in it. + ok("f() = 1; f()++; for (f() in o) {}"); + for call in ["f() = 1;", "f() += 1;", "++f();", "f()--;", "(f()) = 1;", "for (f() in o) {}"] { + assert!(strict_err(call).message.contains("invalid") || strict_err(call).message.contains("for-in"), "{call}"); + } +} + +// ---- preprocessor lines ---- + +#[test] +fn preprocessor_lines_are_read_as_directives() { + let src = "#target vectorcraft\n #include \"lib/a.jsxinc\"\n//@includepath \"~/x;~/y\"\n#strict on\n#unknown x\nvar a = 1;\n#targetengine main"; + let p = parse(src, &with_preprocessor()).unwrap(); + let names: Vec<(&str, u32, &str)> = p.directives.iter().map(|d| (d.name.as_str(), d.line, d.value.as_str())).collect(); + assert_eq!( + names, + [ + ("target", 1, "vectorcraft"), + ("include", 2, "\"lib/a.jsxinc\""), + ("includepath", 3, "\"~/x;~/y\""), + ("strict", 4, "on"), + ("unknown", 5, "x"), + ("targetengine", 7, "main"), + ] + ); + assert_eq!(p.directives[2].form, "//@"); + assert!(p.directives[4].known.is_none()); + assert_eq!(p.body.len(), 1); + assert!(parse(src, &Options::default()).is_err(), "without preprocessor lines a # is an error"); +} + +#[test] +fn a_hash_away_from_the_start_of_a_line_is_an_error() { + let e = parse("var a = 1; #include x", &with_preprocessor()).unwrap_err(); + assert!(e.message.contains("unexpected character '#'")); + assert_eq!((e.line, e.column), (1, 12)); + assert!(parse("/* c */ #include x", &with_preprocessor()).is_err()); + // A comment naming no directive stays a comment. + assert!(parse("//@other\n", &with_preprocessor()).unwrap().directives.is_empty()); +} + +#[test] +fn a_directive_between_tokens_keeps_its_place_in_the_token_list() { + let tokens = tokenize("a\n#include x\nb", &with_preprocessor()).unwrap(); + let kinds: Vec<String> = tokens + .iter() + .map(|t| match &t.kind { + TokenKind::Ident(n) => n.clone(), + TokenKind::Directive(d) => format!("#{}", d.name), + TokenKind::Eof => "eof".into(), + other => format!("{other:?}"), + }) + .collect(); + assert_eq!(kinds, ["a", "#include", "b", "eof"]); + assert!(tokens[2].newline_before); +} + +// ---- positions ---- + +#[test] +fn errors_carry_line_and_column() { + let e = err("var a = 1;\nvar b = ];"); + assert_eq!((e.line, e.column, e.offset), (2, 9, 19)); + assert_eq!(e.to_string(), "SyntaxError: unexpected token ']' (line 2, column 9)"); + let e = err("a\r\nb\r\n 'x"); + assert_eq!((e.line, e.column), (3, 3)); + let e = err("a\u{2028} )"); + assert_eq!((e.line, e.column), (2, 3)); + let e = err("'\u{1F600}' )"); + assert_eq!((e.line, e.column), (1, 6), "columns count UTF-16 code units"); + let e = err("x = 'a\\\nb' )"); + assert_eq!((e.line, e.column), (2, 4), "a line continuation in a string counts its line"); +} + +#[test] +fn line_count_and_spans() { + let p = ok("a;\r\nb;\nc;"); + assert_eq!(p.line_count(), 3); + assert_eq!(ok("a;\n").line_count(), 1); + assert_eq!(ok("").line_count(), 0); + let span = p.ast.stmt_span(p.body[2]); + assert_eq!(p.line_column(span.start), (3, 1)); + assert_eq!((span.start, span.end), (7, 9)); +} + +// ---- nesting and limits ---- + +#[test] +fn deep_nesting_gives_a_syntax_error_not_a_stack_overflow() { + const N: usize = 100_000; + let cases = [ + format!("{}1{}", "(".repeat(N), ")".repeat(N)), + format!("{}1{}", "[".repeat(N), "]".repeat(N)), + format!("x = {}{}", "{a:".repeat(N), "}".repeat(N)), + format!("{}{}", "{".repeat(N), "}".repeat(N)), + format!("{}{}", "function f(){".repeat(N), "}".repeat(N)), + format!("{}1", "a ? b : ".repeat(N)), + format!("{}x{}", "f(".repeat(N), ")".repeat(N)), + format!("{}x", "if (a) ".repeat(N)), + ]; + for src in &cases { + let e = err(src); + assert_eq!(e.message, TOO_DEEP, "{}", &src[..20]); + assert_eq!(e.line, 1); + assert!(e.column > 1); + } + let e = err(&format!("x = /{}a{}/;", "(".repeat(N), ")".repeat(N))); + assert!(e.message.contains(TOO_DEEP)); +} + +#[test] +fn nesting_up_to_the_limit_parses() { + let depth = 50; + ok(&format!("{}1{};", "(".repeat(depth), ")".repeat(depth))); + ok(&format!("{}{}", "{".repeat(depth), "}".repeat(depth))); + ok(&format!("x = {}{};", "[".repeat(depth), "]".repeat(depth))); +} + +#[test] +fn long_flat_runs_do_not_nest() { + const N: usize = 100_000; + ok(&format!("x = 1{};", " + 1".repeat(N))); + ok(&format!("x = {}y;", "!".repeat(N))); + ok(&format!("{}x = 1;", "a = ".repeat(N))); + ok(&format!("if (a) b;{} else c;", " else if (a) b;".repeat(N))); + ok(&format!("{}x;", (0..N).map(|i| format!("l{i}: ")).collect::<String>())); + ok(&format!("x{};", ".y".repeat(N))); + ok(&format!("x{};", "()".repeat(N))); + ok(&"a;\n".repeat(N)); +} + +#[test] +fn nesting_to_the_default_limit_fits_a_small_stack() { + // The deepest nesting each production allows, parsed on a thread with a 1 MiB stack (the + // stack a wasm build or a secondary thread may have). + let n = crate::DEFAULT_MAX_DEPTH; + let cases = [ + format!("x = {}1{};", "(".repeat(n), ")".repeat(n)), + format!("x = {}1{};", "[".repeat(n), "]".repeat(n)), + format!("x = {}1{};", "{a:".repeat(n), "}".repeat(n)), + format!("{}{}", "function f(){".repeat(n), "}".repeat(n)), + format!("{}x;", "if (a) ".repeat(n)), + format!("x = {}1;", "a ? b : ".repeat(n)), + format!("{}x{};", "f(".repeat(n), ")".repeat(n)), + format!("{}x{};", "a[".repeat(n), "]".repeat(n)), + ]; + let handle = std::thread::Builder::new() + .stack_size(1 << 20) + .spawn(move || cases.iter().map(|src| parse(src, &Options::default()).map(|_| ()).map_err(|e| e.message)).collect::<Vec<_>>()) + .unwrap(); + for result in handle.join().unwrap() { + // Each either parses or stops at the limit; neither overflows the stack. + assert!(result.is_ok() || result == Err(TOO_DEEP.to_string()), "{result:?}"); + } +} + +#[test] +fn sources_over_the_length_limit_are_refused() { + let options = Options { max_source_units: 10, ..Options::default() }; + assert_eq!(parse("a = 1;", &options).map(|_| ()), Ok(())); + assert_eq!(parse("a = 1 + 2 + 3;", &options).unwrap_err().message, crate::TOO_LONG); + assert_eq!(parse("'\u{e9}\u{e9}\u{e9}\u{e9}';", &options).map(|_| ()), Ok(()), "the limit counts UTF-16 code units"); +} + +// ---- robustness ---- + +/// Fragments of the language, put together at random so the parser reaches its deeper states. +const FRAGMENTS: &[&str] = &[ + "var ", + "function ", + "f", + "(", + ")", + "{", + "}", + "[", + "]", + ";", + ",", + "=", + "+", + "-", + "/", + "/=", + "*", + "?", + ":", + "!", + "++", + "--", + "if", + "else", + "for", + "in", + "while", + "do", + "return", + "break", + "continue", + "new", + "delete", + "typeof", + "this", + "with", + "switch", + "case", + "default", + "try", + "catch", + "finally", + "throw", + "get", + "set", + "'s'", + "\"\\x4", + "1", + "0x", + "017", + ".5", + "1e", + "/re/g", + "a:", + "\n", + " ", + "'use strict';", + "#include x\n", + "//c\n", + "/*", + "*/", + "\\u0061", + "\u{2028}", + "eval", + "arguments", + ".", + "x", +]; + +proptest! { + #![proptest_config(ProptestConfig::with_cases(512))] + + #[test] + fn arbitrary_text_never_panics(src in ".{0,200}") { + let _ = parse(&src, &Options::default()); + let _ = parse(&src, &Options { strict: true, preprocessor_lines: true, ..Options::default() }); + } + + #[test] + fn arbitrary_fragments_never_panic(parts in proptest::collection::vec(0..FRAGMENTS.len(), 0..80)) { + let src: String = parts.iter().map(|&i| FRAGMENTS[i]).collect(); + for options in [Options::default(), Options { strict: true, preprocessor_lines: true, max_depth: 8, ..Options::default() }] { + match parse(&src, &options) { + Ok(p) => { + for (_, span) in &p.ast.exprs { + prop_assert!(span.start <= span.end && span.end as usize <= src.encode_utf16().count()); + } + } + Err(e) => prop_assert!(e.line >= 1 && e.column >= 1), + } + } + } + + #[test] + fn arbitrary_utf16_never_panics(units in proptest::collection::vec(any::<u16>(), 0..120)) { + let _ = crate::parse_utf16(&units, &Options::default()); + } +} diff --git a/crates/js/src/token.rs b/crates/js/src/token.rs new file mode 100644 index 000000000..53bbb35af --- /dev/null +++ b/crates/js/src/token.rs @@ -0,0 +1,391 @@ +//! Tokens: what the lexer gives the parser. + +/// A range of the source in UTF-16 code units, `start` included and `end` not. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Span { + pub start: u32, + pub end: u32, +} + +impl Span { + pub fn new(start: u32, end: u32) -> Self { + Self { start, end } + } + + /// The span from the start of `self` to the end of `other`. + pub fn to(self, other: Span) -> Span { + Span { start: self.start, end: other.end.max(self.start) } + } +} + +/// The reserved words of ECMA-262 5.1 section 7.6.1 that are reserved in all code, with the +/// literals `null`, `true` and `false`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Keyword { + Break, + Case, + Catch, + Class, + Const, + Continue, + Debugger, + Default, + Delete, + Do, + Else, + Enum, + Export, + Extends, + False, + Finally, + For, + Function, + If, + Import, + In, + Instanceof, + New, + Null, + Return, + Super, + Switch, + This, + Throw, + True, + Try, + Typeof, + Var, + Void, + While, + With, +} + +impl Keyword { + /// The keyword spelled `s`, if `s` is one. + pub fn from_word(s: &str) -> Option<Keyword> { + use Keyword as K; + Some(match s { + "break" => K::Break, + "case" => K::Case, + "catch" => K::Catch, + "class" => K::Class, + "const" => K::Const, + "continue" => K::Continue, + "debugger" => K::Debugger, + "default" => K::Default, + "delete" => K::Delete, + "do" => K::Do, + "else" => K::Else, + "enum" => K::Enum, + "export" => K::Export, + "extends" => K::Extends, + "false" => K::False, + "finally" => K::Finally, + "for" => K::For, + "function" => K::Function, + "if" => K::If, + "import" => K::Import, + "in" => K::In, + "instanceof" => K::Instanceof, + "new" => K::New, + "null" => K::Null, + "return" => K::Return, + "super" => K::Super, + "switch" => K::Switch, + "this" => K::This, + "throw" => K::Throw, + "true" => K::True, + "try" => K::Try, + "typeof" => K::Typeof, + "var" => K::Var, + "void" => K::Void, + "while" => K::While, + "with" => K::With, + _ => return None, + }) + } + + pub fn as_str(self) -> &'static str { + use Keyword as K; + match self { + K::Break => "break", + K::Case => "case", + K::Catch => "catch", + K::Class => "class", + K::Const => "const", + K::Continue => "continue", + K::Debugger => "debugger", + K::Default => "default", + K::Delete => "delete", + K::Do => "do", + K::Else => "else", + K::Enum => "enum", + K::Export => "export", + K::Extends => "extends", + K::False => "false", + K::Finally => "finally", + K::For => "for", + K::Function => "function", + K::If => "if", + K::Import => "import", + K::In => "in", + K::Instanceof => "instanceof", + K::New => "new", + K::Null => "null", + K::Return => "return", + K::Super => "super", + K::Switch => "switch", + K::This => "this", + K::Throw => "throw", + K::True => "true", + K::Try => "try", + K::Typeof => "typeof", + K::Var => "var", + K::Void => "void", + K::While => "while", + K::With => "with", + } + } +} + +/// The words reserved only in strict mode code (ECMA-262 5.1 section 7.6.1.2). +pub fn strict_reserved(name: &str) -> bool { + matches!(name, "implements" | "interface" | "let" | "package" | "private" | "protected" | "public" | "static" | "yield") +} + +/// The punctuators of ECMA-262 5.1 sections 7.7 and 7.8.5 (division). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Punct { + LBrace, + RBrace, + LParen, + RParen, + LBracket, + RBracket, + Dot, + Semicolon, + Comma, + Lt, + Gt, + LtEq, + GtEq, + EqEq, + NotEq, + EqEqEq, + NotEqEq, + Plus, + Minus, + Star, + Percent, + PlusPlus, + MinusMinus, + Shl, + Shr, + UShr, + Amp, + Pipe, + Caret, + Bang, + Tilde, + AmpAmp, + PipePipe, + Question, + Colon, + Eq, + PlusEq, + MinusEq, + StarEq, + PercentEq, + ShlEq, + ShrEq, + UShrEq, + AmpEq, + PipeEq, + CaretEq, + Slash, + SlashEq, +} + +impl Punct { + /// Punctuators by spelling, longest first, so the first that matches is the longest. + pub(crate) const ALL: &'static [(&'static str, Punct)] = &[ + (">>>=", Punct::UShrEq), + ("===", Punct::EqEqEq), + ("!==", Punct::NotEqEq), + (">>>", Punct::UShr), + ("<<=", Punct::ShlEq), + (">>=", Punct::ShrEq), + ("<=", Punct::LtEq), + (">=", Punct::GtEq), + ("==", Punct::EqEq), + ("!=", Punct::NotEq), + ("++", Punct::PlusPlus), + ("--", Punct::MinusMinus), + ("<<", Punct::Shl), + (">>", Punct::Shr), + ("&&", Punct::AmpAmp), + ("||", Punct::PipePipe), + ("+=", Punct::PlusEq), + ("-=", Punct::MinusEq), + ("*=", Punct::StarEq), + ("%=", Punct::PercentEq), + ("&=", Punct::AmpEq), + ("|=", Punct::PipeEq), + ("^=", Punct::CaretEq), + ("/=", Punct::SlashEq), + ("{", Punct::LBrace), + ("}", Punct::RBrace), + ("(", Punct::LParen), + (")", Punct::RParen), + ("[", Punct::LBracket), + ("]", Punct::RBracket), + (".", Punct::Dot), + (";", Punct::Semicolon), + (",", Punct::Comma), + ("<", Punct::Lt), + (">", Punct::Gt), + ("+", Punct::Plus), + ("-", Punct::Minus), + ("*", Punct::Star), + ("%", Punct::Percent), + ("&", Punct::Amp), + ("|", Punct::Pipe), + ("^", Punct::Caret), + ("!", Punct::Bang), + ("~", Punct::Tilde), + ("?", Punct::Question), + (":", Punct::Colon), + ("=", Punct::Eq), + ("/", Punct::Slash), + ]; + + pub fn as_str(self) -> &'static str { + Punct::ALL.iter().find(|(_, p)| *p == self).map_or("", |(s, _)| s) + } +} + +/// A preprocessor line's directive: `#include`, `#includepath`, `#script`, `#strict`, `#target` +/// and `#targetengine`, as the public JavaScript Tools Guide lists them +/// (<https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/>). +/// Only the names are taken from it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum DirectiveName { + Include, + IncludePath, + Script, + Strict, + Target, + TargetEngine, +} + +impl DirectiveName { + /// The directive named `name`, compared without case. + pub fn from_name(name: &str) -> Option<DirectiveName> { + const NAMES: [(&str, DirectiveName); 6] = [ + ("include", DirectiveName::Include), + ("includepath", DirectiveName::IncludePath), + ("script", DirectiveName::Script), + ("strict", DirectiveName::Strict), + ("target", DirectiveName::Target), + ("targetengine", DirectiveName::TargetEngine), + ]; + NAMES.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, d)| *d) + } + + pub fn as_str(self) -> &'static str { + match self { + DirectiveName::Include => "include", + DirectiveName::IncludePath => "includepath", + DirectiveName::Script => "script", + DirectiveName::Strict => "strict", + DirectiveName::Target => "target", + DirectiveName::TargetEngine => "targetengine", + } + } +} + +/// A preprocessor line: `#name value` at the start of a line, or a `//@name value` comment there +/// that names a directive. It is read and kept, never run. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Directive { + /// The directive, when the name is one of the six (`#name` lines with another name are kept + /// with `None`). + pub known: Option<DirectiveName>, + /// The name as written. + pub name: String, + /// The text after the name, with blanks around it removed. + pub value: String, + /// `#` or `//@`. + pub form: &'static str, + pub span: Span, + pub line: u32, +} + +/// What a token is. +#[derive(Clone, Debug, PartialEq)] +pub enum TokenKind { + /// The end of the source. + Eof, + /// An identifier, the strict-mode reserved words included. `escaped` tokens held a `\u` escape. + Ident(String), + Keyword(Keyword), + Punct(Punct), + /// A numeric literal's value. + Number(f64), + /// A string literal's value in UTF-16 code units. + String(Vec<u16>), + /// A regular expression literal: its pattern and flags as written. + RegExp { + pattern: Vec<u16>, + flags: String, + }, + /// A preprocessor line. + Directive(Directive), +} + +/// A token, with where it is and the facts the grammar's rules need about it. +#[derive(Clone, Debug, PartialEq)] +pub struct Token { + pub kind: TokenKind, + pub span: Span, + /// The line (from 1) and column (from 1, in UTF-16 code units) the token starts at. + pub line: u32, + pub column: u32, + /// A line terminator comes between the previous token and this one (automatic semicolon + /// insertion and the restricted productions use it). + pub newline_before: bool, + /// An identifier written with a `\u` escape. + pub escaped: bool, + /// A legacy octal numeric literal (`017`, `08`) or a string with an octal escape (`"\1"`), which + /// strict mode code does not allow. + pub legacy_octal: bool, +} + +impl Token { + pub fn is_punct(&self, p: Punct) -> bool { + self.kind == TokenKind::Punct(p) + } + + pub fn is_keyword(&self, k: Keyword) -> bool { + self.kind == TokenKind::Keyword(k) + } + + /// The identifier's name, for an identifier token. + pub fn ident(&self) -> Option<&str> { + match &self.kind { + TokenKind::Ident(name) => Some(name), + _ => None, + } + } + + /// The name this token gives when it stands where any identifier name may, as a property's + /// name after `.` or in an object literal: identifiers, keywords and the literals `null`, + /// `true` and `false`. + pub fn identifier_name(&self) -> Option<&str> { + match &self.kind { + TokenKind::Ident(name) => Some(name), + TokenKind::Keyword(k) => Some(k.as_str()), + _ => None, + } + } +} diff --git a/crates/js/tests/test262.rs b/crates/js/tests/test262.rs new file mode 100644 index 000000000..c32045507 --- /dev/null +++ b/crates/js/tests/test262.rs @@ -0,0 +1,178 @@ +//! The ES5 parse subset of test262, the ECMAScript conformance suite, when `cargo xtask test262` +//! has fetched it (it sets `VECTORCRAFT_TEST262_DIR`; without it the test looks in +//! `corpus/test262/` and skips when the suite is absent). +//! +//! The subset is every test under `test/language/` that names the ES5 section it was written for +//! (`es5id:`) and has no `features:` tag (the tags name syntax and built-ins later than ES5) and no +//! `module` flag. The other tests without a feature tag (written for later editions, many of them +//! using later syntax) run too, and their counts are printed apart. A test whose metadata says +//! `negative: phase: parse` must give a `SyntaxError`; every other test must parse. Each test runs +//! as sloppy and as strict mode code (with `"use strict";` before it, as the suite's +//! `INTERPRETING.md` says), unless its `onlyStrict`, `noStrict` or `raw` flag says otherwise. The +//! test prints the counts and writes each failure to `failures.txt` beside the suite; it fails only +//! when the suite cannot be read. + +use std::path::{Path, PathBuf}; + +use vectorcraft_js::{Options, parse}; + +/// A test file's metadata, from its `/*--- … ---*/` block. +#[derive(Default)] +struct Meta { + features: Vec<String>, + flags: Vec<String>, + negative_phase: Option<String>, + /// The test names the ES5 section it was written for (`es5id:`). + es5: bool, +} + +fn suite_dir() -> Option<PathBuf> { + if let Ok(dir) = std::env::var("VECTORCRAFT_TEST262_DIR") { + return Some(PathBuf::from(dir)); + } + let corpus = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/test262"); + let mut dirs: Vec<PathBuf> = std::fs::read_dir(corpus).ok()?.filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.is_dir()).collect(); + dirs.sort(); + dirs.pop() +} + +/// The `.js` files under `dir`, sorted, with the `_FIXTURE` helper files left out. +fn test_files(dir: &Path) -> Vec<PathBuf> { + let mut out = Vec::new(); + let mut stack = vec![dir.to_path_buf()]; + while let Some(d) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&d) else { continue }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if path.extension().is_some_and(|e| e == "js") && !path.to_string_lossy().contains("_FIXTURE") { + out.push(path); + } + } + } + out.sort(); + out +} + +/// A YAML flow list (`[a, b]`) or the block list (`- a` lines) after a key. +fn list(value: &str, lines: &mut std::iter::Peekable<std::str::Lines<'_>>) -> Vec<String> { + let value = value.trim(); + if let Some(inner) = value.strip_prefix('[').and_then(|v| v.strip_suffix(']')) { + return inner.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(); + } + let mut out = Vec::new(); + while let Some(item) = lines.peek().and_then(|l| l.trim().strip_prefix("- ")) { + out.push(item.trim().to_string()); + lines.next(); + } + out +} + +fn meta(source: &str) -> Meta { + let mut meta = Meta::default(); + let Some(block) = source.split_once("/*---").and_then(|(_, rest)| rest.split_once("---*/")).map(|(block, _)| block) else { + return meta; + }; + let mut lines = block.lines().peekable(); + let mut in_negative = false; + while let Some(line) = lines.next() { + let indented = line.starts_with(' '); + let trimmed = line.trim(); + if !indented { + in_negative = false; + } + if let Some(v) = trimmed.strip_prefix("features:").filter(|_| !indented) { + meta.features = list(v, &mut lines); + } else if let Some(v) = trimmed.strip_prefix("flags:").filter(|_| !indented) { + meta.flags = list(v, &mut lines); + } else if trimmed.starts_with("es5id:") && !indented { + meta.es5 = true; + } else if trimmed == "negative:" && !indented { + in_negative = true; + } else if let Some(v) = trimmed.strip_prefix("phase:").filter(|_| in_negative) { + meta.negative_phase = Some(v.trim().to_string()); + } + } + meta +} + +#[test] +fn es5_parse_subset() { + let Some(dir) = suite_dir() else { + println!("test262: SKIPPED (no suite; run cargo xtask test262)"); + return; + }; + let language = dir.join("test").join("language"); + assert!(language.is_dir(), "{} is not a test262 checkout", dir.display()); + let files = test_files(&language); + assert!(!files.is_empty(), "no tests under {}", language.display()); + // VECTORCRAFT_TEST262_VERBOSE=1 prints each test before it is parsed. + let verbose = std::env::var_os("VECTORCRAFT_TEST262_VERBOSE").is_some(); + + let (mut positive, mut negative, mut with_features, mut modules) = (0usize, 0usize, 0usize, 0usize); + let (mut passed, mut failed) = (0usize, 0usize); + // The tests without a feature tag that name no ES5 section: files, runs passed and failed. + let (mut later, mut later_passed, mut later_failed) = (0usize, 0usize, 0usize); + let mut failures = String::new(); + let mut later_failures = String::new(); + for path in &files { + let source = String::from_utf8_lossy(&std::fs::read(path).unwrap()).into_owned(); + let meta = meta(&source); + if !meta.features.is_empty() { + with_features += 1; + continue; + } + if meta.flags.iter().any(|f| f == "module") || meta.negative_phase.as_deref() == Some("resolution") { + modules += 1; + continue; + } + let expect_error = meta.negative_phase.as_deref() == Some("parse"); + if !meta.es5 { + later += 1; + } else if expect_error { + negative += 1; + } else { + positive += 1; + } + let has = |flag: &str| meta.flags.iter().any(|f| f == flag); + let modes: &[bool] = if has("raw") || has("noStrict") { + &[false] + } else if has("onlyStrict") { + &[true] + } else { + &[false, true] + }; + for &strict in modes { + if verbose { + eprintln!("{} ({})", path.display(), if strict { "strict" } else { "sloppy" }); + } + let text = if strict { format!("\"use strict\";\n{source}") } else { source.clone() }; + let result = parse(&text, &Options::default()); + let (pass, fail, list) = + if meta.es5 { (&mut passed, &mut failed, &mut failures) } else { (&mut later_passed, &mut later_failed, &mut later_failures) }; + if result.is_err() == expect_error { + *pass += 1; + } else { + *fail += 1; + let rel = path.strip_prefix(&dir).unwrap_or(path).display().to_string().replace('\\', "/"); + let mode = if strict { "strict" } else { "sloppy" }; + let outcome = match &result { + Ok(_) => "parsed; a SyntaxError was expected".to_string(), + Err(e) => e.to_string(), + }; + list.push_str(&format!("{rel} ({mode}): {outcome}\n")); + } + } + } + let report = dir.join("failures.txt"); + std::fs::write(&report, format!("# ES5 subset\n{failures}\n# Other tests without a feature tag\n{later_failures}")).unwrap(); + println!( + "test262 ES5 parse subset ({}): {} tests ({positive} that must parse, {negative} that must give a SyntaxError), \ + {passed} runs passed, {failed} failed. Other tests without a feature tag: {later} tests, {later_passed} runs passed, \ + {later_failed} failed. Skipped {with_features} tests with feature tags and {modules} module tests. Failures: {}", + dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), + positive + negative, + report.display() + ); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0af2f0670..41e4e7a78 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -653,6 +653,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads). - Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785); `run --commands FILE` (or `-` for standard input) reads the steps from a JSON array or JSON Lines, past the Windows command-line limit (#3147, `apps/vectorcraft-cli/tests/it/cli.rs` `run_reads_*`, `run_refuses_malformed_commands_naming_the_step`). - File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files. + - `vectorcraft-js` parses JavaScript as ECMAScript 5.1 defines it, with the legacy octal forms, block functions and lenient regular expressions of Annex B, into a syntax tree with source spans; it reads the `#target`, `#include` and other preprocessor lines as directives without running them, and a script that breaks the grammar or an early-error rule gives a `SyntaxError` with its line and column. Nesting past the parser's depth limit (`Options::max_depth`, 200 by default) gives a `SyntaxError` instead of exhausting the stack. `cargo xtask test262` fetches the conformance suite at a pinned commit and parses its ES5 tests: 5,411 of 5,418 runs pass (the 7 others use `let` and `const`). Nothing runs scripts yet. Tests: `crates/js/src/tests.rs`, `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs`. - **UI:** Illustrator 2026 layout restyled to measured values: - Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs. - Hint bar, contextual task bar, 19 dock panels with ≡ menus. diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 3117a7af1..6b8bda8d1 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -63,6 +63,7 @@ pub const TABLE: &[(&str, Class)] = &[ ("plugins", Class::Layer(2)), ("affinity", Class::Standalone), ("scriptcheck", Class::Standalone), + ("js", Class::Standalone), ]; /// Explicit orderings *within* a layer (earlier may be used by later). diff --git a/xtask/src/main.rs b/xtask/src/main.rs index de6ff5815..334910732 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -14,6 +14,7 @@ mod pdf_deps; mod pngsuite; mod sha256; mod stats; +mod test262; mod version; mod web_fonts; @@ -41,6 +42,8 @@ commands: ico <out.ico> <png>... pack PNGs into a Windows .ico (used by packaging/icons.sh) stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) + test262 fetch the pinned ECMAScript conformance suite into corpus/test262 (sha256 + verified) and parse its ES5 subset with vectorcraft-js, printing the counts web-fonts copy the craft-fonts faces in crates/text/web-fonts.txt into the web site (the Trunk post_build hook; a no-op without CRAFT_FONTS_DIR) "; @@ -64,6 +67,7 @@ fn main() -> ExitCode { Some("ico") => ico::run(&rest), Some("version") => version::run(&root(), &rest), Some("stats") => stats::run(&root(), rest.contains(&"--exact")), + Some("test262") => test262::run_suite(), Some("web-fonts") => web_fonts::run(&root()), Some("-h" | "--help" | "help") | None => { print!("{USAGE}"); @@ -333,6 +337,8 @@ Tests that use a corpus skip cleanly when it is absent. corpus/raw/ raw.pixls.us samples, CC0 (optional) corpus/affinity/ public Affinity documents (CC0/MIT/Apache-2.0) at pinned commits, sha256 verified: oracles for the Affinity reader. Fetch: cargo xtask corpus --affinity + corpus/test262/ the ECMAScript conformance suite (BSD-3-Clause) at a pinned commit, sha256 + verified: the JavaScript parser's ES5 subset. Fetch and run: cargo xtask test262 ", corpus.display() ); diff --git a/xtask/src/test262.rs b/xtask/src/test262.rs new file mode 100644 index 000000000..ab390538b --- /dev/null +++ b/xtask/src/test262.rs @@ -0,0 +1,73 @@ +//! `cargo xtask test262`: the official ECMAScript conformance suite (https://github.com/tc39/test262, +//! BSD-3-Clause) at a pinned commit, fetched as GitHub's archive of that commit, checked against +//! its SHA-256 and size, and unpacked into `corpus/test262/` (gitignored, never committed). It then +//! runs `vectorcraft-js`'s `test262` test, which parses the ES5 parse subset and prints its counts. +//! +//! Moving the pin: change [`COMMIT`], run the command once with the new commit to see the +//! archive's SHA-256 in the error, check the archive, and update [`SHA256`]. + +use std::path::Path; +use std::process::Command; + +use crate::{cargo, root, run, sha256}; + +/// The tc39/test262 commit the suite is read at. +const COMMIT: &str = "d53ebc8b1931d9f1541e43d59650776e4b766248"; + +/// The SHA-256 of GitHub's `tar.gz` archive of [`COMMIT`]. +const SHA256: &str = "6db8566044d1a3f5a68aac87806e09b2fe5d21f5869f3a74cfd8b7236ebaf5c8"; + +/// The largest archive accepted (the pinned one is 9.6 MB). +const MAX_BYTES: u64 = 64 << 20; + +pub fn run_suite() -> Result<(), String> { + let dir = fetch()?; + let mut c = cargo(); + c.env("VECTORCRAFT_TEST262_DIR", &dir).args(["test", "-p", "vectorcraft-js", "--release", "--test", "test262", "--", "--nocapture"]); + run(c, &format!("cargo test -p vectorcraft-js --release --test test262 -- --nocapture (VECTORCRAFT_TEST262_DIR={})", dir.display())) +} + +/// Fetches and unpacks the suite unless it is there already, and returns its folder. +fn fetch() -> Result<std::path::PathBuf, String> { + let base = root().join("corpus").join("test262"); + let dir = base.join(format!("test262-{COMMIT}")); + let marker = dir.join(".vectorcraft-sha256"); + if std::fs::read_to_string(&marker).is_ok_and(|s| s.trim() == SHA256) { + println!("test262 {COMMIT} is in {}", dir.display()); + return Ok(dir); + } + std::fs::create_dir_all(&base).map_err(|e| format!("create {}: {e}", base.display()))?; + let archive = base.join(format!("test262-{COMMIT}.tar.gz")); + if !verified(&archive)? { + let url = format!("https://codeload.github.com/tc39/test262/tar.gz/{COMMIT}"); + let mut curl = Command::new("curl"); + curl.args(["-fsSL", "--retry", "3", "-A", "VectorCraft-dev", "--max-filesize", &MAX_BYTES.to_string(), "-o"]).arg(&archive).arg(&url); + run(curl, &format!("curl {url}"))?; + if !verified(&archive)? { + let got = std::fs::read(&archive).map(|b| sha256::hex(&b)).unwrap_or_default(); + let _ = std::fs::remove_file(&archive); + return Err(format!("test262 archive: sha256 {got} does not match the pinned {SHA256}")); + } + } + if dir.exists() { + std::fs::remove_dir_all(&dir).map_err(|e| format!("remove {}: {e}", dir.display()))?; + } + let mut tar = Command::new("tar"); + tar.arg("-xzf").arg(&archive).arg("-C").arg(&base); + run(tar, &format!("tar -xzf {}", archive.display()))?; + if !dir.join("test").join("language").is_dir() { + return Err(format!("{} has no test/language folder after unpacking", dir.display())); + } + std::fs::write(&marker, SHA256).map_err(|e| format!("{}: {e}", marker.display()))?; + println!("test262 {COMMIT} unpacked into {}", dir.display()); + Ok(dir) +} + +/// The archive is there, within the size limit, with the pinned SHA-256. +fn verified(path: &Path) -> Result<bool, String> { + let Ok(meta) = std::fs::metadata(path) else { return Ok(false) }; + if meta.len() > MAX_BYTES { + return Err(format!("{} is {} bytes, over the {MAX_BYTES}-byte limit", path.display(), meta.len())); + } + Ok(std::fs::read(path).is_ok_and(|b| sha256::hex(&b) == SHA256)) +} From 6bb3d3dfbe1efdf845e8d80bc000820aa18980e8 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 10:51:53 -0800 Subject: [PATCH 070/160] JavaScript: a for-in variable initializer is a SyntaxError in strict mode code Current test262 (ECMA-262 2017 Annex B.3.6) accepts `for (var x = 1 in o)` only outside strict mode code. The parser now rejects the initializer in strict code, with the error at `in`. --- crates/js/src/parser.rs | 5 +++++ crates/js/src/tests.rs | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/crates/js/src/parser.rs b/crates/js/src/parser.rs index ebecab4ca..17424e42f 100644 --- a/crates/js/src/parser.rs +++ b/crates/js/src/parser.rs @@ -500,6 +500,11 @@ impl<'s> Parser<'s> { let mut decls = self.parse_var_decls(true)?; if decls.len() == 1 && self.at_keyword(Keyword::In) { let decl = decls.pop().ok_or_else(|| self.unexpected())?; + // The variable of a for-in loop takes an initializer only outside strict mode + // code (ECMA-262 2017 Annex B.3.6). + if self.ctx.strict && decl.init.is_some() { + return Err(self.error_at_token(&self.tok, "a for-in variable cannot have an initializer in strict mode code")); + } return self.parse_for_in(ForInTarget::Var(decl), start); } Some(ForInit::Var(decls)) diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index a4e73d275..fc4b5b1a1 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -466,6 +466,10 @@ fn strict_mode_early_errors() { } assert!(strict_err("function f(a, a) {}").message.contains("duplicate parameter")); ok("function f(a, a) {}"); + let e = strict_err("for (var x = 1 in o) {}"); + assert!(e.message.contains("initializer"), "{}", e.message); + assert_eq!((e.line, e.column), (2, 16)); + ok("'use strict'; for (var x in o) {}"); } #[test] From 1f6be41d8acd3d67d26d89af08c22f4d7c7c5bde Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 10:59:50 -0800 Subject: [PATCH 071/160] Docs: the .ai editing copy keeps live effects on simple objects only The fallback for objects that can't use an art style keeps their vector art with geometry effects applied and leaves out their other effects, such as Drop Shadow, the glows and Feather. README, ROADMAP, gaps, roadmap and target-app-parity no longer say every live effect is left out, and target-app-parity lists the round-trip tests. --- README.md | 2 +- ROADMAP.md | 2 +- docs/file-format-parity.md | 5 +++-- docs/gaps.md | 3 ++- docs/roadmap.md | 2 +- docs/target-app-parity.md | 8 +++++--- 6 files changed, 13 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 8f1113c4f..be5ed4958 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 live objects as their art); +- reliable exchange of .ai files (layer structure in real files; the editing copy a save writes keeps live effects only on paths, compound paths and groups with at most a fill and a stroke, leaves out other objects' effects other than geometry 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 ae8417530..108cd9207 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 live objects as their art, the interaction details have never been checked side by side, and some machines fail to start the +keeps live effects only on paths, compound paths and groups with at most a fill and a stroke 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/docs/file-format-parity.md b/docs/file-format-parity.md index 369127d3e..9984686f4 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -91,8 +91,9 @@ private editing data. VectorCraft: 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 + 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 with + live effects are written as their vector art with their geometry effects applied, and their other effects, such as + Drop Shadow, the glows and Feather, are left out 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 diff --git a/docs/gaps.md b/docs/gaps.md index fd7927481..e51ed0760 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -203,7 +203,8 @@ 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, type as text and symbols), with live effects left out and live objects written as their art + clipping, type as text and symbols), with live effects kept only on paths, compound paths and groups with at most a + fill and a stroke, other objects' effects other than geometry 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 002b3a525..3be081973 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, 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) | +| 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 kept only on paths, compound paths and groups with at most a fill and a stroke, other objects' effects other than geometry 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 766dcfd5e..22223fb9d 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 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 on objects other than paths, compound paths and groups with at most a fill and a stroke, 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,9 @@ 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, 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 + gradients, patterns, images, transparency, opacity masks, clipping, type as text and symbols), with live effects kept only + on paths, compound paths and groups with at most a fill and a stroke, other objects' effects other than geometry + 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 @@ -1219,6 +1220,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur SVG import reads each `<circle>` and `<ellipse>` 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 `<ellipse>` 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 paths, compound paths and groups with live effects with an art style, which VectorCraft's `.ai` reader opens as the same appearance with its effects live (`objects_with_live_effects_are_read_back` and `every_effect_setting_written_on_an_object_is_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the objects written without one and the effects they lose). 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 f9788771dbe3a1d6773069a1552686094d29bb08 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 11:28:29 -0800 Subject: [PATCH 072/160] Docs: art styles cover eligible objects only The .ai editing copy writes an art style for paths, compound paths and groups with live effects only when they have at most a fill and a stroke, fill by the non-zero rule, and are not on a large canvas. README, ROADMAP, gaps, roadmap and target-app-parity say so. --- README.md | 2 +- ROADMAP.md | 2 +- docs/gaps.md | 4 ++-- docs/roadmap.md | 2 +- docs/target-app-parity.md | 6 +++--- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index be5ed4958..3fdb4f3fc 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 keeps live effects only on paths, compound paths and groups with at most a fill and a stroke, leaves out other objects' effects other than geometry effects, and writes live objects as their art); +- reliable exchange of .ai files (layer structure in real files; the editing copy a save writes keeps live effects only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas), leaves out other objects' effects other than geometry 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 108cd9207..d9b11cfd2 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 -keeps live effects only on paths, compound paths and groups with at most a fill and a stroke and writes live objects as their art, the interaction details have never been checked side by side, and some machines fail to start the +keeps live effects only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas) 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/docs/gaps.md b/docs/gaps.md index e51ed0760..725319355 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -203,8 +203,8 @@ 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, type as text and symbols), with live effects kept only on paths, compound paths and groups with at most a - fill and a stroke, other objects' effects other than geometry effects left out, and live objects written as their art + clipping, type as text and symbols), with live effects kept only on paths, compound paths and groups that + qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas), other objects' effects other than geometry 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 3be081973..83056d59b 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, type as text and symbols, with live effects kept only on paths, compound paths and groups with at most a fill and a stroke, other objects' effects other than geometry 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) | +| 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 kept only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas), other objects' effects other than geometry 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 22223fb9d..d04777775 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 on objects other than paths, compound paths and groups with at most a fill and a stroke, 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. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas), 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 | @@ -283,7 +283,7 @@ The discounts, with the evidence: #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, type as text and symbols), with live effects kept only - on paths, compound paths and groups with at most a fill and a stroke, other objects' effects other than geometry + on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas), other objects' effects other than geometry 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. @@ -1220,7 +1220,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur SVG import reads each `<circle>` and `<ellipse>` 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 `<ellipse>` 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 paths, compound paths and groups with live effects with an art style, which VectorCraft's `.ai` reader opens as the same appearance with its effects live (`objects_with_live_effects_are_read_back` and `every_effect_setting_written_on_an_object_is_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the objects written without one and the effects they lose). + A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes eligible paths, compound paths and groups with live effects with an art style (not a filled path that uses the even-odd rule, and none on a large canvas), which VectorCraft's `.ai` reader opens as the same appearance with its effects live (`objects_with_live_effects_are_read_back` and `every_effect_setting_written_on_an_object_is_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the objects written without one and the effects they lose). 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 8374e694d8816fa228754ea79fbe3d8dc0edd46f Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 11:57:54 -0800 Subject: [PATCH 073/160] Docs: effects are kept only where the writer supports the appearance The editing copy keeps live effects on qualifying objects only when the art-style writer supports their appearance and effects. README, ROADMAP and the progress docs now say so and link to docs/file-format-parity.md for the limits. --- README.md | 2 +- ROADMAP.md | 2 +- docs/gaps.md | 2 +- docs/roadmap.md | 2 +- docs/target-app-parity.md | 6 +++--- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 3fdb4f3fc..3a34bb7f9 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 keeps live effects only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas), leaves out other objects' effects other than geometry effects, and writes live objects as their art); +- reliable exchange of .ai files (layer structure in real files; the editing copy a save writes keeps live effects only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas) and whose appearance and effects the art-style writer supports (see [docs/file-format-parity.md](docs/file-format-parity.md)), leaves out other objects' effects other than geometry 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 d9b11cfd2..3f2882bc4 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 -keeps live effects only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas) and writes live objects as their art, the interaction details have never been checked side by side, and some machines fail to start the +keeps live effects only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas) and whose appearance and effects the art-style writer supports (see [docs/file-format-parity.md](docs/file-format-parity.md)), 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/docs/gaps.md b/docs/gaps.md index 725319355..e09887bc8 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -204,7 +204,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall 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, type as text and symbols), with live effects kept only on paths, compound paths and groups that - qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas), other objects' effects other than geometry effects left out, and live objects written as their art + qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas) and whose appearance and effects the art-style writer supports, other objects' effects other than geometry 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 83056d59b..1c177d294 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, type as text and symbols, with live effects kept only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas), other objects' effects other than geometry 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) | +| 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 kept only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas) and whose appearance and effects the art-style writer supports ([file-format-parity.md](file-format-parity.md)), other objects' effects other than geometry 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 d04777775..04feefc13 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 on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas), 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. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); 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 | @@ -283,7 +283,7 @@ The discounts, with the evidence: #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, type as text and symbols), with live effects kept only - on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas), other objects' effects other than geometry + on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas) and whose appearance and effects the art-style writer supports, other objects' effects other than geometry 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. @@ -1220,7 +1220,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur SVG import reads each `<circle>` and `<ellipse>` 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 `<ellipse>` 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 eligible paths, compound paths and groups with live effects with an art style (not a filled path that uses the even-odd rule, and none on a large canvas), which VectorCraft's `.ai` reader opens as the same appearance with its effects live (`objects_with_live_effects_are_read_back` and `every_effect_setting_written_on_an_object_is_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the objects written without one and the effects they lose). + A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes eligible paths, compound paths and groups with live effects with an art style when the writer supports their appearance and effects (not a filled path that uses the even-odd rule, and none on a large canvas), which VectorCraft's `.ai` reader opens as the same appearance with its effects live (`objects_with_live_effects_are_read_back` and `every_effect_setting_written_on_an_object_is_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the objects written without one and the effects they lose). 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 9c0e7ccb808aceccc40c9172fb30bb1a10e315c8 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 6 Nov 2026 15:17:55 -0800 Subject: [PATCH 074/160] .ai save: the editing copy keeps graphic styles and links to them The editing copy of a PDF-compatible .ai left out the Graphic Styles panel's styles and objects' links to them, with a warning. They are now written as art styles: - The panel's styles come first in %AI9_BeginArtStyles and are listed in its order in %AI9_BeginArtStyleList after the palette, with their fills, strokes, transparency and effects. Fills and strokes may have transparency and effects of their own, and a style may also hold Warp, Transform, Free Distort, Pucker & Bloat, Twist, Zig Zag or Gaussian Blur. A style of an object's own holds the same effects as before. - A path, compound path or group linked to a graphic style names it when it has the style's appearance, the style is written, and the reader reads that style on that object. Objects linked to a new document's styles keep their link: the panel is written for them too. - The writer reads each style back with the reader before it writes it. A style with gradient or pattern fills, with anything the reader does not read back, with a name another style has, with a name over 1,024 bytes or past 4,096 styles is left out with a warning. Other links are left out with a warning, and a panel with no style written warns that it opens with a new document's styles. No graphic style is written on a large canvas, with a warning. Graphic styles: https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/apply-graphic-styles.html Tests: the round trip of the panel's styles and of links; each effect a style can hold; the forms written; the list's limits; duplicate names; an empty panel; links left out; a style's opacity at the written precision; a large canvas; Twist and Transform on an object with no link; a link to a style whose Transform moves the outline, with and without an opacity mask; the reader's reading of warp numbers; fuzz cases for damaged written styles. Docs: docs/file-format-parity.md. --- crates/engine/tests/import_fuzz.rs | 25 +- crates/eps/src/ai_editing.rs | 33 +- crates/eps/src/ai_editing/tests.rs | 497 +++++++++++++++++++-- crates/eps/src/ai_editing/writer.rs | 4 +- crates/eps/src/ai_editing/writer/styles.rs | 309 +++++++++++-- crates/eps/src/import.rs | 4 +- crates/eps/src/import/ai.rs | 3 +- crates/eps/src/import/ai/read.rs | 2 +- crates/eps/src/import/ai/read/ops.rs | 16 +- crates/eps/src/import/ai/style.rs | 10 +- crates/eps/src/import/ai/style/tests.rs | 10 + docs/file-format-parity.md | 9 +- 12 files changed, 810 insertions(+), 112 deletions(-) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 684cf3bfa..b7bc7c65e 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3708,13 +3708,13 @@ proptest! { 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. +/// and a width profile on others, a stroked even-odd path with an opacity mask, Pathfinder › Add on +/// a group and a graphic style of its own with effects on one of its fills. fn styles_text() -> &'static str { static TEXT: std::sync::OnceLock<String> = 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_doc::{Appearance, AppearanceItem, FillLayer, GraphicStyle, 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; @@ -3757,6 +3757,10 @@ fn styles_text() -> &'static str { 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(); + doc.graphic_styles.push(GraphicStyle::new("Shaded", ap)); + let chained = FillLayer { opacity: 0.5, effects: vec![fx("distort.twist")], ..FillLayer::new(Paint::solid(Color::WHITE)) }; + let chained = Appearance { items: vec![AppearanceItem::Fill(chained)], ..Appearance::default() }; + doc.graphic_styles.push(GraphicStyle::new("Chained", chained)); let out = vectorcraft_eps::export_ai_editing(&doc, &vectorcraft_eps::AiEditingOptions::default()).unwrap(); String::from_utf8(out.program).unwrap() }) @@ -3768,14 +3772,25 @@ fn styles_text() -> &'static str { 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}"); + for mark in [ + "%AI9_BeginArtStyles", + "%AI9_BeginArtStyleList", + "%AI5_BeginLayer", + "\n6 () XW", + "\n1 (Anon", + "(BSLeftWidthsArray)", + "0 /Bool (UseEvenOdd)", + "(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/BasicFilter", + "Bb", + ] { + assert!(text.replace("\r\n", "\n").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::<Vec<_>>(), [1, 1, 1, 1, 1]); + assert!(d.graphic_style("Shaded").is_some() && d.graphic_style("Chained").is_some()); } /// 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 06a3f2935..bffb2e2ad 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -101,16 +101,17 @@ //! 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 +//! group with live effects or linked to a graphic style 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. +//! stack of fills and strokes with the contents among them, the effects and the blend filter), the +//! Graphic Styles panel's first; the panel's order follows the palette (`%AI9_BeginArtStyleList`). +//! 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 (or the graphic style's). //! //! 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 @@ -145,8 +146,9 @@ //! 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, 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. +//! effects other than geometry effects left out; graphic styles the reader wouldn't read, links to +//! graphic styles on objects not written with 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`]). @@ -228,7 +230,8 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result<AiEditi // 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); + let own_styles = doc.graphic_styles != Document::new(1.0, 1.0).graphic_styles; + let links = writer::style_links(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); @@ -262,15 +265,16 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result<AiEditi w.reserve_swatch_gradients(); if scale == 1.0 { w.styled = styled; - } else if !styled.is_empty() { + w.panel_styles(); + } else if !styled.is_empty() || own_styles || links > 0 { w.warnings.push(writer::LARGE_CANVAS_STYLES.to_string()); } - if graphic_styles { - w.warnings.push(writer::GRAPHIC_STYLES.to_string()); - } for layer in &doc.layers { w.layer(layer); } + if scale == 1.0 { + w.lost_links(links); + } // The symbols no instance uses too; their art adds definitions. w.symbols(); // The palette first: its gradient and pattern swatches add definitions. @@ -293,6 +297,7 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result<AiEditi w.symbol_definitions(&mut s); w.art_styles(&mut s); s.push_str(&palette); + w.art_style_list(&mut s); w.symbol_list(&mut s); artboards(&mut s, doc, to_art, origin); if !w.texts.is_empty() { diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index fca9eef02..41a4a789b 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -5246,6 +5246,33 @@ fn styles_document() -> Document { 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")); + // A graphic style of its own, and an object linked to one. + let mut fill_top = FillLayer::new(solid(Color::rgb(0.2, 0.2, 0.9))); + fill_top.opacity = 0.5; + fill_top.blend = BlendMode::Screen; + fill_top.effects = vec![fx("distort.twist", serde_json::json!({"angle": 30.0}))]; + let style_ap = Appearance { + items: vec![ + AppearanceItem::Fill(FillLayer::new(solid(Color::rgb(0.9, 0.9, 0.1)))), + AppearanceItem::Fill(fill_top), + AppearanceItem::Stroke(StrokeLayer::new(solid(Color::BLACK), 3.0)), + ], + effects: vec![fx("warp.arc", serde_json::json!({"bend": 30.0})), shadow], + // The contents over every fill and stroke. + contents_index: Some(3), + }; + let mut layered = vectorcraft_doc::GraphicStyle::new("Layered", style_ap); + layered.id = 9; + layered.opacity = 0.8; + doc.graphic_styles.push(layered); + let sunshine = doc.graphic_style("Sunshine").unwrap().clone(); + let mut linked_obj = Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(170.0, 130.0, 220.0, 170.0)), + sunshine.appearance_on(&Node::group(vectorcraft_doc::NodeId(0), vec![])).into_owned(), + ); + linked_obj.extras_mut().graphic_style = Some(sunshine.id); + add(&mut doc, named(linked_obj, "Sunny")); doc } @@ -5369,12 +5396,24 @@ fn near_lines(got: &str, want: &str) { } #[test] -fn objects_with_live_effects_are_read_back() { +fn objects_with_live_effects_and_graphic_styles_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); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS || w == writer::PANEL_STYLES), "{:?}", out.warnings); let back = read_back(&doc); same_styles(&back, &doc); + // The panel's styles, in its order, with their appearance and transparency. + let names = |d: &Document| d.graphic_styles.iter().map(|g| g.name.clone()).collect::<Vec<_>>(); + assert_eq!(names(&back), names(&doc)); + for (g, w) in back.graphic_styles.iter().zip(doc.graphic_styles.iter()) { + assert!(writer::same_appearance(&g.appearance, &w.appearance), "{}:\n got {:?}\n want {:?}", w.name, g.appearance, w.appearance); + assert_eq!( + (r3(f64::from(g.opacity)), g.blend, g.isolate, g.knockout), + (r3(f64::from(w.opacity)), w.blend, w.isolate, w.knockout), + "{}", + w.name + ); + } // A group's members come back as they were. let members = |d: &Document, name: &str| { let mut out = vec![]; @@ -5386,13 +5425,39 @@ fn objects_with_live_effects_are_read_back() { same_lines(&members(&back, "Merged"), &members(&doc, "Merged")); } +/// In a document with only a new document's graphic styles, an object linked to one of them keeps +/// its link: the panel's styles are written. +#[test] +fn a_link_to_a_built_in_graphic_style_is_read_back() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let sunshine = doc.graphic_style("Sunshine").unwrap().clone(); + let ap = sunshine.appearance_on(&Node::group(vectorcraft_doc::NodeId(0), vec![])).into_owned(); + // Without a link, a new document's styles are not written. + let mut plain = doc.clone(); + let unlinked = Node::path(plain.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 50.0)), ap.clone()); + plain.insert(Some(layer), usize::MAX, unlinked).unwrap(); + assert!(!program(&plain).contains("%AI9_BeginArtStyleList")); + let mut sunny = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 50.0)), ap); + sunny.extras_mut().graphic_style = Some(sunshine.id); + doc.insert(Some(layer), usize::MAX, named(sunny, "Sunny")).unwrap(); + assert_eq!(doc.graphic_styles, Document::new(1.0, 1.0).graphic_styles); + let back = read_back(&doc); + same_styles(&back, &doc); + let n = &back.layers[0].children().unwrap()[0]; + assert_eq!(n.extras().graphic_style.and_then(|id| back.graphic_style_by_id(id)).map(|g| g.name.as_str()), Some("Sunshine")); +} + #[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(); + // The styles in a non-printing block after the symbols' and before the palette; the panel's in + // its order first, then the objects' own. + let styles = p.find("%AI5_Begin_NonPrinting\nNp\n%AI9_BeginArtStyles\n/KnownStyle :\n(Default Graphic Style) /Name ,\n/ActiveStyle :\n").unwrap(); assert!(styles < p.find("%AI5_BeginPalette").unwrap(), "{p}"); - assert!(!p.contains("%AI9_BeginArtStyleList"), "{p}"); + let list = "%AI5_Begin_NonPrinting\nNp\n%AI9_BeginArtStyleList\n(Default Graphic Style)\n(Black Outline)\n(Heavy Ink)\n(Sunshine)\n(Layered)\n%AI9_EndArtStyleList\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 drop shadow on the object, after its stack and before its transparency. assert!( p.contains( @@ -5406,6 +5471,8 @@ fn art_styles_are_written_in_their_forms() { 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}"); + // The object linked to a graphic style names it. + assert!(p.contains("%_/XMLUID : (Sunny) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Sunshine) XW\n"), "{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}"); @@ -5425,8 +5492,9 @@ fn art_styles_are_written_in_their_forms() { } /// 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. +/// drawn look, with the warnings they were written with before; graphic styles it wouldn't read +/// are left out with a warning; and a document without effects or styles of its own has no art +/// styles. #[test] fn appearances_the_reader_wouldnt_read_are_their_drawn_look() { let mut doc = Document::new(200.0, 100.0); @@ -5454,10 +5522,41 @@ fn appearances_the_reader_wouldnt_read_are_their_drawn_look() { 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())); + // A graphic style with a gradient: left out, the others listed. + let ramp = Gradient { stops: vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)], ..Gradient::default() }; + let graded = Appearance::basic(Paint::Gradient(Box::new(GradientPaint::new(ramp))), Paint::None, 0.0); + doc.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Graded", graded)); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings); + let p = String::from_utf8(out.program).unwrap().replace("\r\n", "\n"); + assert!(p.contains("(Sunshine)\n%AI9_EndArtStyleList") && !p.contains("(Graded)"), "{p}"); // Nothing styled: no art styles. assert!(!program(&sample()).contains("ArtStyle")); } +/// A graphic style's opacity is read back within the written number's precision: the style and an +/// object's link to it stay. +#[test] +fn a_graphic_styles_opacity_is_compared_at_the_written_precision() { + let mut doc = Document::new(200.0, 100.0); + let ap = Appearance::basic(solid(Color::BLACK), Paint::None, 1.0); + let mut g = vectorcraft_doc::GraphicStyle::new("Fine", ap.clone()); + g.id = 1; + g.opacity = 0.123456; + *doc.graphic_styles = vec![g]; + let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 50.0)), ap); + n.opacity = 0.123456; + n.extras_mut().graphic_style = Some(1); + 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::PANEL_STYLES), "{:?}", out.warnings); + let back = read_back(&doc); + assert_eq!(back.graphic_styles.len(), 1); + assert!((back.graphic_styles[0].opacity - 0.123456).abs() < 1e-3); + assert_eq!(back.layers[0].children().unwrap()[0].extras().graphic_style, Some(back.graphic_styles[0].id)); +} + /// 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] @@ -5488,6 +5587,45 @@ fn compound_shapes_are_not_written_with_an_art_style() { } } +/// Graphic styles the reader's style list wouldn't hold (a name over its limit, styles past its +/// count) are left out with a warning, and an object linked to one has no link. +#[test] +fn graphic_styles_past_the_lists_limits_are_left_out() { + let plain = || Appearance::basic(solid(Color::BLACK), Paint::None, 1.0); + let styled = |name: String, id: u32| { + let mut g = vectorcraft_doc::GraphicStyle::new(name, plain()); + g.id = id; + g + }; + // A name one byte over the limit, among others. + let mut doc = Document::new(200.0, 100.0); + let long = "n".repeat(1025); + *doc.graphic_styles = vec![styled("Before".into(), 1), styled(long.clone(), 2), styled("After".into(), 3)]; + let mut linked = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 50.0)), plain()); + linked.extras_mut().graphic_style = Some(2); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, linked).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings); + assert!(!shown(&out.program).contains(&long)); + let back = read_back(&doc); + let names: Vec<&str> = back.graphic_styles.iter().map(|g| g.name.as_str()).collect(); + assert_eq!(names, ["Before", "After"]); + assert_eq!(back.layers[0].children().unwrap()[0].extras().graphic_style, None); + // A name at the limit is kept. + let at_limit = "n".repeat(1024); + doc.graphic_styles[1].name = at_limit.clone(); + assert!(read_back(&doc).graphic_styles.iter().any(|g| g.name == at_limit)); + // Styles past the count. + let mut many = Document::new(200.0, 100.0); + *many.graphic_styles = (1..=4097).map(|i| styled(format!("Style {i}"), i)).collect(); + let out = export_ai_editing(&many, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings); + let back = read_back(&many); + assert_eq!(back.graphic_styles.len(), 4096); + assert_eq!(back.graphic_styles.last().map(|g| g.name.as_str()), Some("Style 4096")); +} + /// 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] @@ -5620,7 +5758,8 @@ fn read_one(make: impl FnOnce(&mut Document) -> Node) -> (Arc<Node>, Vec<String> /// 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. +/// keeps its style and its own fill rule. A filled even-odd object linked to a graphic style isn't +/// written with that style, and its link is left out with a warning. #[test] fn a_filled_even_odd_object_is_written_as_its_drawn_look() { let mut doc = Document::new(300.0, 100.0); @@ -5649,9 +5788,28 @@ fn a_filled_even_odd_object_is_written_as_its_drawn_look() { let back = read_back(&doc); same_styles(&back, &doc); assert!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::Compound { rule: FillRule::EvenOdd, .. })); + + let mut doc = Document::new(200.0, 100.0); + let ap = Appearance::basic(black_fill(), Paint::None, 0.0); + let mut g = vectorcraft_doc::GraphicStyle::new("Solid", ap.clone()); + g.id = 7; + doc.graphic_styles.push(g); + let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap); + n.extras_mut().graphic_style = Some(7); + if let NodeKind::Path { rule, .. } = &mut n.kind { + *rule = FillRule::EvenOdd; + } + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{:?}", out.warnings); + let back = read_back(&doc); + let n = &back.layers[0].children().unwrap()[0]; + assert!(matches!(n.kind, NodeKind::Path { rule: FillRule::EvenOdd, .. }), "{n:?}"); + assert_eq!(n.extras().graphic_style, None); } -/// A compound path filled by the non-zero rule keeps its effects and its gradient fill. +/// A compound path filled by the non-zero rule keeps its effects, its gradient fill and its link to +/// a graphic style. #[test] fn a_compound_path_keeps_its_style() { let mut doc = Document::new(300.0, 100.0); @@ -5669,8 +5827,16 @@ fn a_compound_path_keeps_its_style() { 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 sunshine = doc.graphic_style("Sunshine").unwrap().clone(); + let ap = sunshine.appearance_on(&Node::group(vectorcraft_doc::NodeId(0), vec![])).into_owned(); + let mut linked_ring = named(ring(&mut doc, ap), "Linked"); + if let NodeKind::Compound { rule, .. } = &mut linked_ring.kind { + *rule = FillRule::NonZero; + } + linked_ring.extras_mut().graphic_style = Some(sunshine.id); + put(&mut doc, linked_ring); 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::EFFECTS || w == writer::STYLE_LINKS), "{:?}", 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 { .. }))); @@ -5696,36 +5862,136 @@ fn a_group_holding_an_image_is_written_as_its_drawn_look() { 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. +/// A link to a graphic style that isn't written warns; one that is written doesn't. #[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); +fn a_link_left_out_warns() { + let warns = |make: &dyn Fn(&mut Document, u32) -> Node| { + let mut doc = Document::new(200.0, 100.0); + let ap = Appearance { + items: vec![AppearanceItem::Fill(FillLayer::new(black_fill())), AppearanceItem::Fill(FillLayer::new(solid(Color::WHITE)))], + ..Appearance::default() + }; + let mut layered = vectorcraft_doc::GraphicStyle::new("Layered", ap); + layered.id = 7; + doc.graphic_styles.push(layered); + let mut plain = vectorcraft_doc::GraphicStyle::new("Plain", Appearance::basic(black_fill(), Paint::None, 0.0)); + plain.id = 8; + doc.graphic_styles.push(plain); + let n = make(&mut doc, 7); + put(&mut doc, n); + let warned = export_ai_editing(&doc, &opts()).unwrap().warnings.iter().any(|w| w == writer::STYLE_LINKS); + (warned, read_back(&doc)) + }; + let linked = |mut n: Node, id: u32| { + n.extras_mut().graphic_style = Some(id); + n + }; + // A style of two fills: a path isn't read with it. + let (warned, back) = warns(&|doc, id| { + let ap = doc.graphic_style_by_id(id).unwrap().appearance.clone(); + linked(Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap), id) + }); + assert!(warned); + assert_eq!(back.layers[0].children().unwrap()[0].extras().graphic_style, None); + assert!(back.graphic_style("Layered").is_some()); + // An image, a symbol instance and a knockout shape are never written with a style. + let (warned, _) = warns(&|doc, _| { + let art = square(doc, Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK); + doc.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); + linked(Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((50.0, 20.0)) }), 8) + }); + assert!(warned); + let (warned, _) = warns(&|doc, _| { + let mut n = plain_rect(doc, "Out", vec![]); + n.knockout_shape = true; + linked(n, 8) + }); + assert!(warned); + // A group with a member the style can't hold. + let (warned, _) = warns(&|doc, _| { + let member = plain_rect(doc, "Member", vec![drop_shadow()]); + linked(group(doc, vec![member], false), 8) + }); + assert!(warned); + // A group with an effect and a member linked to a style: the group keeps its effect, and the + // member's link is left out. + let (warned, back) = warns(&|doc, _| { + let member = linked(plain_rect(doc, "Member", vec![]), 8); + let mut g = group(doc, vec![member], false); + g.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::default() }.into(); + g + }); + assert!(warned); + let g = &back.layers[0].children().unwrap()[0]; + assert_eq!(g.appearance.effects.len(), 1, "{g:?}"); + assert_eq!(g.children().unwrap()[0].extras().graphic_style, None); + // A group whose link isn't written: its member keeps its own. + let (warned, back) = warns(&|doc, id| { + let plain = doc.graphic_style_by_id(id + 1).unwrap().appearance.clone(); + let member = linked(Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), plain), id + 1); + linked(group(doc, vec![member], false), id) + }); + assert!(warned); + let g = &back.layers[0].children().unwrap()[0]; + assert_eq!(g.extras().graphic_style, None); + let member = g.children().unwrap()[0].extras().graphic_style.unwrap(); + assert_eq!(back.graphic_style_by_id(member).map(|s| s.name.as_str()), Some("Plain")); // 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)); + let (warned, _) = warns(&|doc, _| { + let art = linked(square(doc, Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK), 8); + doc.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); + Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((50.0, 20.0)) }) + }); + assert!(warned); + // A link that is kept. + let (warned, back) = warns(&|doc, id| { + let plain = doc.graphic_style_by_id(id + 1).unwrap().appearance.clone(); + linked(Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), plain), id + 1) + }); + assert!(!warned); + let id = back.layers[0].children().unwrap()[0].extras().graphic_style.unwrap(); + assert_eq!(back.graphic_style_by_id(id).map(|g| g.name.as_str()), Some("Plain")); +} + +/// A panel with no style written warns that a new document's styles come back. +#[test] +fn an_empty_graphic_styles_panel_warns() { + let mut doc = Document::new(200.0, 100.0); + doc.graphic_styles.clear(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::PANEL_EMPTY), "{:?}", out.warnings); + assert!(!shown(&out.program).contains("ArtStyle")); + assert_eq!(read_back(&doc).graphic_styles, Document::new(1.0, 1.0).graphic_styles); + // Every style left out. + 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)))); + doc.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Graded", Appearance::basic(graded, Paint::None, 0.0))); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::PANEL_EMPTY) && out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings); + // A new document's styles aren't written and don't warn. + let out = export_ai_editing(&Document::new(200.0, 100.0), &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::PANEL_EMPTY), "{:?}", out.warnings); +} + +/// A second style of one name is left out with a warning, and an object linked to it has no link. +#[test] +fn a_graphic_style_of_a_name_another_has_is_left_out() { + let mut doc = Document::new(200.0, 100.0); + let style = |id: u32, c: Color| { + let mut g = vectorcraft_doc::GraphicStyle::new("Twin", Appearance::basic(solid(c), Paint::None, 0.0)); + g.id = id; + g + }; + *doc.graphic_styles = vec![style(1, Color::BLACK), style(2, Color::WHITE)]; + let mut n = styled_rect(&mut doc, "Second", Rect::new(10.0, 10.0, 50.0, 40.0), solid(Color::WHITE), Paint::None, vec![]); + n.extras_mut().graphic_style = Some(2); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::PANEL_STYLES) && out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{:?}", out.warnings); + let back = read_back(&doc); + assert_eq!(back.graphic_styles.len(), 1); + assert_eq!(back.graphic_styles[0].appearance.fill().and_then(|f| f.paint.color()), Some(Color::BLACK)); + assert_eq!(back.layers[0].children().unwrap()[0].extras().graphic_style, None); } /// A path or compound path with no points writes nothing, with or without a style; a group with no @@ -5850,6 +6116,93 @@ fn every_effect_setting_written_on_an_object_is_read_back() { assert!(matches!(&spot.appearance.fill().unwrap().paint, Paint::Solid { swatch: Some(s), .. } if s == "Ink"), "{spot:?}"); } +/// Each effect a graphic style can hold, in a style of its own, reads back with its settings, whatever +/// the writer's conversions to the file's units. +#[test] +fn every_effect_a_graphic_style_holds_is_read_back() { + use serde_json::json; + let mut cases = vec![ + ( + "distort.transform", + json!({"scaleH": 50.0, "scaleV": 150.0, "moveH": 12.5, "moveV": -7.5, "rotate": 30.0, "copies": 2, "reference": 8, "reflectX": true, "objects": false, "strokes": true, "patterns": true}), + ), + ("distort.freeDistort", json!({"corners": [[0.1, 0.05], [0.9, 0.1], [1.0, 1.0], [0.0, 0.8]]})), + ("distort.puckerBloat", json!({"amount": -40.0})), + ("distort.puckerBloat", json!({"amount": 60.0})), + ("distort.twist", json!({"angle": -45.0})), + ("distort.zigZag", json!({"size": 7.5, "relative": false, "ridges": 6, "points": "corner"})), + ("distort.zigZag", json!({"size": 25.0, "relative": true, "ridges": 3, "points": "smooth"})), + ("blur.gaussian", json!({"radius": 2.5})), + ]; + let warps = [ + "arc", + "arcLower", + "arcUpper", + "arch", + "bulge", + "shellLower", + "shellUpper", + "flag", + "wave", + "fish", + "rise", + "fisheye", + "inflate", + "squeeze", + "twist", + ]; + let ids: Vec<String> = warps.iter().map(|w| format!("warp.{w}")).collect(); + for id in &ids { + cases.push((id, json!({"bend": 30.0, "horizontal": -10.0, "vertical": 20.0, "orientation": "vertical"}))); + cases.push((id, json!({"bend": -30.0, "horizontal": 0.0, "vertical": 0.0, "orientation": "horizontal"}))); + } + for (id, params) in cases { + let effect = fx(id, params.clone()); + let mut doc = Document::new(200.0, 100.0); + let mut g = vectorcraft_doc::GraphicStyle::new( + "Held", + Appearance { effects: vec![effect.clone()], ..Appearance::basic(black_fill(), Paint::None, 0.0) }, + ); + g.id = 3; + *doc.graphic_styles = vec![g]; + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{id} {params}: {:?}", out.warnings); + let back = read_back(&doc); + let got = &back.graphic_styles[0].appearance.effects; + assert_eq!(got.len(), 1, "{id} {params}"); + assert_eq!(got[0].id, effect.id); + let (g, w) = (vectorcraft_effects::merged_params(&got[0].id, &got[0].params), vectorcraft_effects::merged_params(&effect.id, &effect.params)); + for (key, value) in w.as_object().unwrap() { + // Random isn't written, and a style with it isn't read; its seed is the default. + let read = g.get(key).unwrap_or(&serde_json::Value::Null); + match value { + serde_json::Value::Array(rows) => { + let got = read.as_array().unwrap(); + assert_eq!(got.len(), rows.len(), "{id}"); + for (r, w) in got.iter().zip(rows) { + for (r, w) in r.as_array().unwrap().iter().zip(w.as_array().unwrap()) { + assert!((r.as_f64().unwrap() - w.as_f64().unwrap()).abs() < 6e-3, "{id} {key}: {read} for {value}"); + } + } + } + serde_json::Value::Number(v) => assert!((read.as_f64().unwrap() - v.as_f64().unwrap()).abs() < 6e-3, "{id} {key}: {read} for {v}"), + serde_json::Value::String(v) => assert_eq!(read.as_str().map(str::to_ascii_lowercase), Some(v.to_ascii_lowercase()), "{id} {key}"), + _ => assert_eq!(read, value, "{id} {key}"), + } + } + } + // A Zig Zag with its 0 to 1 settings written under the size's own key, and a turn in radians. + let mut doc = Document::new(200.0, 100.0); + let effects = vec![ + fx("distort.zigZag", json!({"size": 7.5, "relative": false, "ridges": 6, "points": "corner"})), + fx("distort.transform", json!({"moveV": -7.5, "rotate": 30.0})), + ]; + doc.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Forms", Appearance { effects, ..Appearance::default() })); + let p = program_shown(&doc); + assert!(p.contains("7.5 /Real (amount) ,") && p.contains("0 /Real (roundness) ,"), "{p}"); + assert!(p.contains("7.5 /Real (moveV_Pts) ,") && p.contains("0.5236 /Real (rotate_Radians) ,"), "{p}"); +} + /// 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() { @@ -5991,6 +6344,60 @@ fn appearances_written_as_their_drawn_look() { } } +/// A link to a graphic style whose Transform moves the outline is kept on a path the reader reads +/// that style on, and left out with a warning on a path with an opacity mask, which keeps its mask. +#[test] +fn a_link_to_a_style_that_moves_the_outline_is_kept_where_the_reader_reads_it() { + let make = |masked: bool| { + let mut doc = Document::new(200.0, 100.0); + let moved = fx("distort.transform", serde_json::json!({"copies": 0, "scaleH": 50.0})); + let ap = Appearance { effects: vec![moved], ..Appearance::basic(black_fill(), Paint::None, 0.0) }; + let mut g = vectorcraft_doc::GraphicStyle::new("Moved", ap.clone()); + g.id = 9; + doc.graphic_styles.push(g); + let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap); + n.extras_mut().graphic_style = Some(9); + if masked { + let art = square(&mut doc, Rect::new(10.0, 10.0, 50.0, 25.0), Color::gray(0.5)); + n.extras_mut().mask = Some(OpacityMask { art: Arc::new(art), clip: true, invert: false, disabled: false, linked: true }.into()); + } + put(&mut doc, n); + doc + }; + let doc = make(false); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{:?}", out.warnings); + let back = read_back(&doc); + let n = &back.layers[0].children().unwrap()[0]; + assert_eq!(n.extras().graphic_style.and_then(|id| back.graphic_style_by_id(id)).map(|g| g.name.as_str()), Some("Moved")); + + let doc = make(true); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{:?}", out.warnings); + let back = read_back(&doc); + let n = &back.layers[0].children().unwrap()[0]; + assert_eq!(n.extras().graphic_style, None); + assert!(n.extras().mask.is_some(), "{n:?}"); +} + +/// Twist and Transform on an object that isn't linked to a graphic style are not written as a style +/// of its own: the object reads back without live effects. +#[test] +fn distort_effects_on_an_unlinked_object_write_no_style() { + let cases = [fx("distort.twist", serde_json::json!({"angle": 30.0})), fx("distort.transform", serde_json::json!({"copies": 0, "moveH": 5.0}))]; + for e in cases { + let what = e.id.clone(); + let mut doc = Document::new(200.0, 100.0); + let n = plain_rect(&mut doc, "D", vec![e]); + put(&mut doc, n); + let p = shown(&export_ai_editing(&doc, &opts()).unwrap().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] @@ -6289,14 +6696,26 @@ fn a_styled_objects_gradient_is_placed_as_a_plain_objects_is() { } } -/// On a large canvas an object with an effect warns and comes back as its drawn look at its place. +/// On a large canvas a document with only graphic styles, an object linked to one and an object with +/// an effect all warn, and the object 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 styles_only = Document::new(20000.0, 100.0); + styles_only.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Mine", Appearance::basic(black_fill(), Paint::None, 0.0))); + assert!(warns(&styles_only)); + assert_eq!(read_back(&styles_only).graphic_style("Mine"), None); 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 linked_doc = Document::new(20000.0, 100.0); + let id = linked_doc.graphic_style("Sunshine").unwrap().id; + let mut n = plain_rect(&mut linked_doc, "Linked", vec![]); + n.extras_mut().graphic_style = Some(id); + put(&mut linked_doc, n); + assert!(warns(&linked_doc)); + assert_eq!(read_back(&linked_doc).layers[0].children().unwrap()[0].extras().graphic_style, None); let mut shaded = Document::new(20000.0, 100.0); let n = plain_rect(&mut shaded, "Shaded", vec![drop_shadow()]); put(&mut shaded, n); diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 89fd5abb7..231b67ce8 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -21,9 +21,9 @@ mod styles; mod symbols; mod texts; +pub(super) use styles::{LARGE_CANVAS_STYLES, style_links, styled_objects}; #[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 styles::{PANEL_EMPTY, PANEL_STYLES, STYLE_LINKS, same_appearance}; pub(super) use images::PAYLOAD; #[cfg(test)] diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index de74bc99e..7dce5f6d8 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -1,17 +1,18 @@ -//! 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. +//! Art styles: objects whose appearance a plain path can't hold, written as their drawn look, the +//! object itself on `%_` lines and `1 (style) XW`; the styles they name and the Graphic Styles +//! panel's styles in `%AI9_BeginArtStyles`, and the panel's order in `%AI9_BeginArtStyleList` (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::collections::{HashMap, HashSet}; 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_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, GraphicStyle, Knockout, Node, NodeId, NodeKind, StrokeLayer}; use vectorcraft_geom::{BezPath, FillRule, Rect, Shape}; use super::{Writer, Xy, blend_code, compound_path, hide}; @@ -19,34 +20,36 @@ 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"; + "on a large canvas, objects with live effects are written as their drawn look and graphic styles are left out of the editing data for other apps"; +pub(in crate::ai_editing) const PANEL_STYLES: &str = "graphic styles with gradient or pattern fills, with fills, strokes or effects the editing data can't hold as a graphic style, with a name another style has, with names over 1,024 bytes, or past 4,096 styles, are left out of the editing data for other apps"; +pub(in crate::ai_editing) const PANEL_EMPTY: &str = + "a Graphic Styles panel with no style written opens from the editing data with a new document's styles"; +pub(in crate::ai_editing) const STYLE_LINKS: &str = "links to graphic styles are left out of the editing data for other apps on type, images, symbol instances, knockout shapes and other objects that aren't written with their graphic style"; -/// 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))) +/// How many objects of `doc`'s layers, opacity masks and symbols are linked to a graphic style. +pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize { + fn count(n: &Node) -> usize { + let own = usize::from(n.extras().graphic_style.is_some()); + let mask = n.extras().mask.as_ref().map_or(0, |m| count(&m.art)); + own + mask + n.children().map_or(0, |c| c.iter().map(|m| count(m)).sum()) } - 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)) + doc.layers.iter().map(|l| count(l)).sum::<usize>() + doc.symbols.iter().map(|s| count(&s.art)).sum::<usize>() } /// 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. +/// by id: paths and compound paths with points and groups, with live effects or linked to a graphic +/// style, 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<NodeId, Arc<Node>> { fn walk(nodes: &[Arc<Node>], out: &mut HashMap<NodeId, Arc<Node>>) { 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. + // A group's members are written with a style of their own only when the group is written + // without one. 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) { @@ -70,7 +73,8 @@ fn bakes_members(n: &Node) -> bool { /// 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 { + let styled = !n.appearance.effects.is_empty() || n.extras().graphic_style.is_some(); + if !styled || n.knockout_shape { return false; } match &n.kind { @@ -97,25 +101,39 @@ fn plain_member(n: &Node) -> bool { } } -/// The art styles objects name, each with its text after its name (`/ActiveStyle :` to `/Def ;`). +/// The art styles written: the Graphic Styles panel's (listed, in its order) and those 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. + /// The panel's styles written, by the graphic style's id. + panel: HashMap<u32, String>, + /// The panel's styles written, in its order. + listed: Vec<String>, + /// The names styles have, and the name of each object style by its text. + names: HashSet<String>, by_text: HashMap<String, String>, next_anon: u64, + /// How many objects were written with the graphic style they are linked to. + pub(super) links_written: usize, } 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. + /// new one named `Anon` and a number no other style has. 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); + let name = loop { + self.next_anon += 1; + let name = format!("Anon {}", self.next_anon); + if !self.names.contains(&name) { + break name; + } + }; + self.names.insert(name.clone()); self.by_text.insert(text.clone(), name.clone()); self.known.push((name.clone(), text)); name @@ -287,16 +305,30 @@ impl Writer<'_> { (!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. + /// An appearance item's filter: its basic filter, or a chain of it, its effects and its + /// transparency. 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; - } + let plain = item.opacity() >= 1.0 && item.blend() == BlendMode::Normal && item.effects().is_empty(); + let mut basic_text = String::new(); + // In a chain, the chain holds the item's visibility. + let visible = item.visible() || !plain; match item { - AppearanceItem::Fill(f) => self.fill_filter(s, f, item.visible(), on), - AppearanceItem::Stroke(st) => self.stroke_filter(s, st, item.visible()), + AppearanceItem::Fill(f) => self.fill_filter(&mut basic_text, f, visible, on)?, + AppearanceItem::Stroke(st) => self.stroke_filter(&mut basic_text, st, visible)?, } + if plain { + s.push_str(&basic_text); + return Some(()); + } + let _ = write!(s, "/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n{} /Visible ,\n", u8::from(item.visible())); + s.push_str(&basic_text); + for e in item.effects() { + s.push_str(&self.effect_filter(e)?); + } + let opacity = if item.opacity().is_finite() { item.opacity().clamp(0.0, 1.0) } else { 1.0 }; + blend_filter(s, Xy { blend: item.blend(), opacity, ..Xy::OPAQUE }); + s.push_str(";\n /Part ,\n"); + Some(()) } /// The text of a style of appearance `ap` and transparency `xy` (after its name): the chain @@ -338,7 +370,13 @@ impl Writer<'_> { 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 name = if id.starts_with("stylize.") { + self.stylize_entries(id, &p, &mut d)? + } else if id.starts_with("warp.") || id.starts_with("distort.") || id.starts_with("blur.") { + Self::distort_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"); @@ -461,9 +499,140 @@ impl Writer<'_> { }) } - /// 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. + /// A Warp, Distort & Transform or Gaussian Blur effect's entries ([`Self::effect_filter`]) and + /// its filter's name. + fn distort_entries(id: &str, p: &Value, d: &mut Entries) -> Option<&'static str> { + Some(match id { + id if id.starts_with("warp.") => { + let style = crate::import::warp_number(id.strip_prefix("warp.")?)?; + d.int("DeformStyle", style); + d.real("DeformValue", num(p, "bend")? / 100.0); + d.real("DeformHoriz", num(p, "horizontal")? / 100.0); + d.real("DeformVert", num(p, "vertical")? / 100.0); + d.bool("Rotate", word(p, "orientation").as_deref() == Some("vertical")); + "Adobe Deform" // brand-ok: the filter's name in the files + } + "distort.transform" => { + d.real("scaleH_Factor", num(p, "scaleH")? / 100.0); + d.real("scaleV_Factor", num(p, "scaleV")? / 100.0); + d.real("moveH_Pts", num(p, "moveH")?); + // Art space has y up. + d.real("moveV_Pts", 0.0 - num(p, "moveV")?); + d.real("rotate_Radians", num(p, "rotate")?.to_radians()); + d.int("numCopies", num(p, "copies")? as i64); + d.int("pinPoint", num(p, "reference")? as i64); + d.bool("reflectX", flag(p, "reflectX")?); + d.bool("reflectY", flag(p, "reflectY")?); + d.bool("transformObjects", flag(p, "objects")?); + d.bool("scaleLines", flag(p, "strokes")?); + d.bool("transformPatterns", flag(p, "patterns")?); + d.bool("randomize", false); + "Adobe Transform" // brand-ok: the filter's name in the files + } + "distort.freeDistort" => { + let corners = p.get("corners")?.as_array()?; + let corner = |i: usize| -> Option<(f64, f64)> { + let c = corners.get(i)?.as_array()?; + Some((c.first()?.as_f64()?, c.get(1)?.as_f64()?)) + }; + // The source frame is the unit square (y up); destination corners 0, 1, 3, 2 are + // the catalog's top left, top right, bottom right, bottom left (y down). + for (i, (h, v)) in [(0.0, 1.0), (1.0, 1.0), (0.0, 0.0), (1.0, 0.0)].into_iter().enumerate() { + d.real(&format!("src{i}h"), h); + d.real(&format!("src{i}v"), v); + } + for (i, from) in [0, 1, 3, 2].into_iter().enumerate() { + let (x, y) = corner(from)?; + d.real(&format!("dst{i}h"), x); + d.real(&format!("dst{i}v"), 1.0 - y); + } + "Adobe Free Distort" // brand-ok: the filter's name in the files + } + "distort.puckerBloat" => { + d.real("d_factor", num(p, "amount")?); + "Adobe Punk and Bloat" // brand-ok: the filter's name in the files + } + "distort.twist" => { + d.real("angle", num(p, "angle")?); + "Adobe Twirl" // brand-ok: the filter's name in the files + } + "distort.zigZag" => { + let relative = flag(p, "relative")?; + d.real("absoluteness", if relative { 0.0 } else { 1.0 }); + d.real(if relative { "relAmount" } else { "amount" }, num(p, "size")?); + d.real("ridges", num(p, "ridges")?); + d.real("roundness", if word(p, "points").as_deref() == Some("smooth") { 1.0 } else { 0.0 }); + "Adobe Zigzag" // brand-ok: the filter's name in the files + } + "blur.gaussian" => { + d.real("blur", num(p, "radius")?); + "Adobe PSL Gaussian Blur" // brand-ok: the filter's name in the files + } + _ => return None, + }) + } + + /// The Graphic Styles panel's styles that the reader reads as they are: each checked, and + /// the others left out with a warning. Their names are taken before the art's styles are. + pub(in crate::ai_editing) fn panel_styles(&mut self) { + let doc = self.doc; + // A new document's styles are what the reader gives a file without a style list; they are + // still written when an object is linked to one of them, so that it keeps its link. + let linked = self.styled.values().any(|n| n.extras().graphic_style.is_some()); + if !linked && doc.graphic_styles == Document::new(1.0, 1.0).graphic_styles { + return; + } + for g in doc.graphic_styles.iter() { + self.styles.names.insert(g.name.clone()); + } + let mut readable: Vec<(&GraphicStyle, String)> = vec![]; + let mut seen: HashSet<&str> = HashSet::new(); + for g in doc.graphic_styles.iter() { + if readable.len() >= crate::import::MAX_LISTED || g.name.len() > crate::import::MAX_LISTED_NAME || seen.contains(g.name.as_str()) { + self.warn(PANEL_STYLES); + continue; + } + let xy = Xy { blend: g.blend, opacity: g.opacity.clamp(0.0, 1.0), isolate: g.isolate, knockout: g.knockout, shape: false }; + let text = self.style_text(&g.appearance, xy, None).filter(|t| panel_reads(g, t)); + let Some(text) = text else { + self.warn(PANEL_STYLES); + continue; + }; + seen.insert(g.name.as_str()); + readable.push((g, text)); + } + // The list as the reader reads it: names too long, and styles past its count, aren't. + let list: String = readable.iter().map(|(g, _)| ps::bytes(g.name.as_bytes()) + "\n").collect(); + let listed: HashSet<String> = crate::import::read_style_list(list.as_bytes()).into_iter().collect(); + for (g, text) in readable { + if !listed.contains(&g.name) { + self.warn(PANEL_STYLES); + continue; + } + if g.id != 0 { + self.styles.panel.insert(g.id, g.name.clone()); + } + self.styles.listed.push(g.name.clone()); + self.styles.known.push((g.name.clone(), text)); + } + // The reader gives a file without a style list a new document's styles. + if self.styles.listed.is_empty() { + self.warn(PANEL_EMPTY); + } + } + + /// Warn when fewer objects were written with their graphic style than `links` are linked to one. + pub(in crate::ai_editing) fn lost_links(&mut self, links: usize) { + if self.styles.links_written < links { + self.warn(STYLE_LINKS); + } + } + + /// The style object `orig` (`n` before baking) is written with: the graphic style it is linked + /// to when it has that style's appearance and transparency and the panel's style is written, + /// else a style of its own; `None` when the reader wouldn't read the object as it is with + /// either. A gradient fill is placed on `n`'s outline, as the object draws it. A style of its own + /// that isn't written leaves no gradient definition or warning behind. pub(super) fn object_style(&mut self, n: &Node, orig: &Node) -> Option<String> { if let NodeKind::Group { children, .. } = &orig.kind && bakes_members(orig) @@ -471,6 +640,21 @@ impl Writer<'_> { { return None; } + let doc = self.doc; + if let Some(id) = orig.extras().graphic_style + && let Some(name) = self.styles.panel.get(&id).cloned() + && let Some(g) = doc.graphic_style_by_id(id) + && g.transparency_matches(orig) + && g.appearance_on(orig).approx_eq(&orig.appearance) + && let Some(text) = self.styles.known.iter().find(|(n, _)| *n == name).map(|(_, t)| t.clone()) + && object_reads(orig, &text) + { + self.styles.links_written += 1; + return Some(name); + } + if orig.appearance.effects.is_empty() || !own_style_holds(&orig.appearance) { + 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); @@ -583,6 +767,20 @@ impl Writer<'_> { } s.push_str("%AI9_EndArtStyles\n%AI5_End_NonPrinting--\n"); } + + /// The panel's styles written, in its order, in a non-printing block: + /// `%AI9_BeginArtStyleList`, a name on each line, `%AI9_EndArtStyleList`. + pub(in crate::ai_editing) fn art_style_list(&self, s: &mut String) { + if self.styles.listed.is_empty() { + return; + } + s.push_str("%AI5_Begin_NonPrinting\nNp\n%AI9_BeginArtStyleList\n"); + for name in &self.styles.listed { + s.push_str(&ps::bytes(name.as_bytes())); + s.push('\n'); + } + s.push_str("%AI9_EndArtStyleList\n%AI5_End_NonPrinting--\n"); + } } /// The outline a path's or compound path's style places a gradient fill on. @@ -609,15 +807,38 @@ fn object_rule(n: &Node) -> FillRule { } } +/// Can appearance `ap` be written as a style of an object's own: fills and strokes that paint without +/// transparency or effects of their own, and no Warp, Distort & Transform or Blur effect? Those +/// are written only in the Graphic Styles panel's styles. +fn own_style_holds(ap: &Appearance) -> bool { + let plain = |i: &AppearanceItem| i.opacity() >= 1.0 && i.blend() == BlendMode::Normal && i.effects().is_empty(); + let panel_only = |e: &Effect| ["warp.", "distort.", "blur."].iter().any(|p| e.id.starts_with(p)); + ap.items.iter().filter(|i| !i.paint().is_none()).all(plain) && !ap.effects.iter().any(panel_only) +} + +/// Does the reader read graphic style `g` from style text `text` as it is? +fn panel_reads(g: &GraphicStyle, text: &str) -> bool { + let section = known_style("x", text); + let Some(read) = crate::import::read_art_style(section.as_bytes(), "x", None) else { return false }; + // The opacity within the written number's precision. + read.transparency.is_some_and(|t| (t.0 - g.opacity).abs() <= 1e-4 && (t.1, t.2, t.3) == (g.blend, g.isolate, g.knockout)) + && same_appearance(&read.appearance, &g.appearance) +} + /// 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? +/// or a group with no fills or strokes of its own, with effects that move the outline only where the +/// reader reads them ([`crate::import::style_moves_with`])? 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) { + if !read.simple + || (group && !read.appearance.items.is_empty()) + || (!group && read.pathfinder) + || !crate::import::style_moves_with(section.as_bytes(), "x", n) + { return false; } if !group { diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index 4b5b47c28..32395447f 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -70,7 +70,9 @@ use graphics::{GState, Out}; use interp::{Fault, Interp}; use obj::PsError; -pub(crate) use ai::{MAX_GRADIENT_STOPS, MAX_SYMBOLS, read_art_style, symbol_art_member}; +pub(crate) use ai::{ + MAX_GRADIENT_STOPS, MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, read_art_style, read_style_list, style_moves_with, symbol_art_member, warp_number, +}; 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 a0720d3c1..93e9295e6 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -41,9 +41,10 @@ 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, read_art_style, slot_frame, slot_of, symbol_art_member}; +pub(crate) use read::{MAX_SYMBOLS, join_and, read_art_style, read_style_list, slot_frame, slot_of, style_moves_with, symbol_art_member}; pub(crate) use read::is_look_slot; +pub(crate) use style::{MAX_LISTED, MAX_LISTED_NAME, warp_number}; #[cfg(test)] mod tests; diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 3386f5098..dff6a9a2b 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -136,7 +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(crate) use ops::{read_art_style, read_style_list, style_moves_with}; 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 1be4f716e..0b23aea4b 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -1131,6 +1131,13 @@ pub(crate) struct StyleRead { pub simple: bool, /// It has Effect › Pathfinder effects, which only a group with no fills or strokes is read with. pub pathfinder: bool, + /// The opacity, blending mode, isolation and knockout its blend filter gives. + pub transparency: Option<(f32, BlendMode, bool, Knockout)>, +} + +/// The names a style list section gives graphic styles, as the reader reads them. +pub(crate) fn read_style_list(section: &[u8]) -> Vec<String> { + crate::import::ai::style::art_style_list(section).iter().map(|raw| obj::text(raw)).filter(|name| !name.is_empty()).collect() } /// Art style `name` of `section` as the reader reads it ([`StyleRead`]): [`appearance_from_art_style`], @@ -1149,7 +1156,14 @@ pub(crate) fn read_art_style(section: &[u8], name: &str, own_gradient: Option<&P } } let appearance = appearance_from_art_style(&st)?; - Some(StyleRead { appearance, simple: st.simple, pathfinder: st.pathfinder }) + Some(StyleRead { appearance, simple: st.simple, pathfinder: st.pathfinder, transparency: st.transparency }) +} + +/// Is art style `name` of `section` read on `object` as far as an effect that moves the outline +/// goes: the style has none, or `object` is one [`moves_with`] reads it on, as [`styled`] checks? +/// `false` when the section doesn't define it. +pub(crate) fn style_moves_with(section: &[u8], name: &str, object: &Node) -> bool { + crate::import::ai::style::art_styles(section).remove(name).is_some_and(|st| !st.moves_outline() || moves_with(object, &st)) } /// A style's stroke as an appearance item (`None` for a fill). diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 9699e3ac6..24fa7ea7d 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -113,6 +113,12 @@ pub(super) fn warp_style(n: i64) -> Option<&'static str> { id.strip_prefix("warp.") } +/// The number `DeformStyle` gives warp style `style` (`arc`, `arcLower`, …), from 1. +pub(crate) fn warp_number(style: &str) -> Option<i64> { + let at = WARPS.iter().position(|id| id.strip_prefix("warp.") == Some(style))?; + i64::try_from(at + 1).ok() +} + /// The Effect › Pathfinder effects by the `Command` number of their filter. `Command` 0 is Add /// (https://community.adobe.com/questions-652/liveeffect-xml-effect-is-added-under-the-object-795137); /// the numbers 1 to 11 are VectorCraft's own reader rules, learned from saved files. Trap (12) isn't @@ -312,9 +318,9 @@ impl Default for Filter { const MAX_FILTERS: usize = 4096; /// Most entries read from a style list. -const MAX_LISTED: usize = 4096; +pub(crate) const MAX_LISTED: usize = 4096; /// The longest style name read from a style list, in bytes. -const MAX_LISTED_NAME: usize = 1024; +pub(crate) const MAX_LISTED_NAME: usize = 1024; /// The style list (`%AI9_BeginArtStyleList`, between its markers): one literal string per style, /// the Graphic Styles panel's order. Each name is its string's bytes, escapes undone; other tokens, diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index da9a83ad7..55c21688f 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -1187,6 +1187,15 @@ fn convert_to_shape_settings_that_arent_read_keep_the_drawn_look() { assert!(!art_styles(data.as_bytes())["Odd"].plain); } +#[test] +fn warp_numbers_are_the_dialogs_order() { + for n in 1..=15 { + let style = warp_style(n).unwrap(); + assert_eq!(warp_number(style), Some(n), "{style}"); + } + assert_eq!((warp_style(0), warp_style(16), warp_number("arcs"), warp_number("")), (None, None, None, None)); +} + /// 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] @@ -1200,6 +1209,7 @@ fn a_style_is_read_as_a_writer_checks_it() { let got = read(&data, Some(&own)).unwrap(); assert_eq!(got.appearance.items.len(), 1); assert_eq!(got.appearance.items[0].paint(), &own); + assert_eq!(got.transparency.map(|t| (t.0, t.1)), Some((0.5, BlendMode::Multiply))); 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()); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 9984686f4..fc9a822e6 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -93,8 +93,13 @@ private editing data. VectorCraft: 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 with live effects are written as their vector art with their geometry effects applied, and their other effects, such as - Drop Shadow, the glows and Feather, are left out with a warning. Graphic styles and links to them are left out with a warning. No art style is written on a large - canvas. + Drop Shadow, the glows and Feather, are left out with a warning. A path, compound path or group linked to a graphic style names it when it has the style's + appearance and the style is written; other links to graphic styles, those on a styled group's members included, are + left out with a warning. The Graphic Styles panel's styles are written in its order, with their fills, strokes, transparency and effects (Warp, + Transform, Free Distort, Pucker & Bloat, Twist, Zig Zag and Gaussian Blur too). Styles with gradient or pattern fills, + with fills, strokes or effects the reader doesn't read back, with a name another style has, with names over 1,024 + bytes and past 4,096 styles are left out with a warning. A panel with no style written opens with a new document's + styles, 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, From 600ce68bc7d69481c588120cf3c883f04317d1f4 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 12:28:17 -0800 Subject: [PATCH 075/160] Docs: only filled even-odd paths lose their live effects in a saved .ai A stroke-only path or compound path that uses the even-odd rule keeps its live effects in the editing copy; the art-style writer leaves out only paths that fill by the even-odd rule. --- README.md | 2 +- ROADMAP.md | 2 +- docs/gaps.md | 2 +- docs/roadmap.md | 2 +- docs/target-app-parity.md | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3a34bb7f9..d71e5b23a 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 keeps live effects only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas) and whose appearance and effects the art-style writer supports (see [docs/file-format-parity.md](docs/file-format-parity.md)), leaves out other objects' effects other than geometry effects, and writes live objects as their art); +- reliable exchange of .ai files (layer structure in real files; the editing copy a save writes keeps live effects only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports (see [docs/file-format-parity.md](docs/file-format-parity.md)), leaves out other objects' effects other than geometry 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 3f2882bc4..0f9267e0e 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 -keeps live effects only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas) and whose appearance and effects the art-style writer supports (see [docs/file-format-parity.md](docs/file-format-parity.md)), and writes live objects as their art, the interaction details have never been checked side by side, and some machines fail to start the +keeps live effects only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports (see [docs/file-format-parity.md](docs/file-format-parity.md)), 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/docs/gaps.md b/docs/gaps.md index e09887bc8..e5e57ebd7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -204,7 +204,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall 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, type as text and symbols), with live effects kept only on paths, compound paths and groups that - qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas) and whose appearance and effects the art-style writer supports, other objects' effects other than geometry effects left out, and live objects written as their art + qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, other objects' effects other than geometry 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 1c177d294..d01c002ae 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, type as text and symbols, with live effects kept only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas) and whose appearance and effects the art-style writer supports ([file-format-parity.md](file-format-parity.md)), other objects' effects other than geometry 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) | +| 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 kept only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports ([file-format-parity.md](file-format-parity.md)), other objects' effects other than geometry 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 04feefc13..9710d9e16 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 on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); 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 | @@ -283,7 +283,7 @@ The discounts, with the evidence: #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, type as text and symbols), with live effects kept only - on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas) and whose appearance and effects the art-style writer supports, other objects' effects other than geometry + on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, other objects' effects other than geometry 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. From d026e6ae26f5245caef73cf322a9b5d1d572bc34 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 12:36:48 -0800 Subject: [PATCH 076/160] JavaScript: interpreter with a garbage-collected heap and execution limits vectorcraft-js gains a Realm that runs ECMAScript 5.1 scripts. The parsed script compiles to stack-machine bytecode, a verifier checks every jump target, handler, constant index and stack depth before the code runs, and the interpreter keeps JavaScript calls on its own frame stack. Closures, this, arguments, with, direct and indirect eval, new Function, try/catch/finally, labeled break and continue, and for-in in insertion order follow sections 10 to 14 of ECMA-262 5.1. Variable references are resolved before the right-hand side of an assignment runs (11.13). Objects and environments live in an arena with a mark-and-sweep collector and index handles. Built-ins are limited to Object and Object.prototype's basic methods, Function with call and apply, eval and the error constructors. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag another thread can set, with smaller defaults in the wasm build. A run that reaches a limit ends with a limit error that a script's try cannot catch. Source passed to eval and Function counts against Options::max_source_units. cargo xtask test262 also runs the suite's ES5 test/language tests that need only these built-ins and prints their counts apart from the parse counts. Nothing in the app calls the interpreter yet. --- crates/js/Cargo.toml | 2 +- crates/js/src/builtins.rs | 328 ++++++ crates/js/src/bytecode.rs | 359 ++++++ crates/js/src/compiler.rs | 1249 ++++++++++++++++++++ crates/js/src/heap.rs | 515 ++++++++ crates/js/src/interp.rs | 2064 +++++++++++++++++++++++++++++++++ crates/js/src/interp_tests.rs | 492 ++++++++ crates/js/src/lib.rs | 21 + crates/js/src/realm.rs | 218 ++++ crates/js/src/tests.rs | 2 +- crates/js/src/value.rs | 381 ++++++ crates/js/tests/test262.rs | 138 ++- docs/target-app-parity.md | 1 + xtask/src/main.rs | 6 +- xtask/src/test262.rs | 3 +- 15 files changed, 5773 insertions(+), 6 deletions(-) create mode 100644 crates/js/src/builtins.rs create mode 100644 crates/js/src/bytecode.rs create mode 100644 crates/js/src/compiler.rs create mode 100644 crates/js/src/heap.rs create mode 100644 crates/js/src/interp.rs create mode 100644 crates/js/src/interp_tests.rs create mode 100644 crates/js/src/realm.rs create mode 100644 crates/js/src/value.rs diff --git a/crates/js/Cargo.toml b/crates/js/Cargo.toml index f39ab33b6..a961fd7dd 100644 --- a/crates/js/Cargo.toml +++ b/crates/js/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vectorcraft-js" -description = "JavaScript for scripts: an ECMAScript 5.1 lexer and parser to a syntax tree with source positions" +description = "JavaScript for scripts: an ECMAScript 5.1 parser, and an interpreter with a garbage-collected heap and execution limits" version.workspace = true edition.workspace = true license.workspace = true diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs new file mode 100644 index 000000000..02f2364e9 --- /dev/null +++ b/crates/js/src/builtins.rs @@ -0,0 +1,328 @@ +//! The built-in objects the interpreter itself needs (ECMA-262 5.1 section 15): `Object` and the +//! basic methods of `Object.prototype`, `Function` with `Function.prototype.call`, `apply` and +//! `toString`, `eval`, the error constructors and `Error.prototype.toString`, and the prototypes +//! primitive values look properties up on. The rest of the standard library is not here yet. + +use std::rc::Rc; + +use crate::bytecode::{CodeKind, Source}; +use crate::compiler::{CompileFail, compile_constructed_function}; +use crate::heap::{Class, Func, HIDDEN, NativeFn, NativeKind, ObjId, Object, Prop, key}; +use crate::interp::{Abrupt, ErrorType, Interp, JsResult}; +use crate::value::{Str, Value}; + +fn alloc(interp: &mut Interp, class: Class, proto: Option<ObjId>) -> ObjId { + interp.heap.alloc_object(Object::new(class, proto)) +} + +/// A built-in function object. +fn native(interp: &mut Interp, name: &'static str, f: NativeFn, length: u32, kind: NativeKind, constructor: bool) -> JsResult<ObjId> { + let proto = interp.intrinsics.function_proto; + let o = alloc(interp, Class::Function(Func::Native { f, kind, constructor, name }), Some(proto)); + interp.define(o, key("length"), Prop::Data { value: Value::Number(f64::from(length)), flags: 0 })?; + Ok(o) +} + +fn method(interp: &mut Interp, on: ObjId, name: &'static str, f: NativeFn, length: u32) -> JsResult<()> { + let m = native(interp, name, f, length, NativeKind::Plain, false)?; + interp.define(on, key(name), Prop::Data { value: Value::Object(m), flags: HIDDEN }) +} + +fn data(interp: &mut Interp, on: ObjId, name: &str, value: Value, flags: u8) -> JsResult<()> { + interp.define(on, key(name), Prop::Data { value, flags }) +} + +/// Links a constructor and its prototype and makes the constructor a global. +fn constructor(interp: &mut Interp, name: &'static str, ctor: ObjId, proto: ObjId) -> JsResult<()> { + data(interp, ctor, "prototype", Value::Object(proto), 0)?; + data(interp, proto, "constructor", Value::Object(ctor), HIDDEN)?; + let global = interp.global; + data(interp, global, name, Value::Object(ctor), HIDDEN) +} + +/// Creates the built-ins and the global object's properties. +pub(crate) fn install(interp: &mut Interp) { + if let Err(e) = try_install(interp) { + // Installing touches only objects it has just made, so this does not happen; a realm + // that somehow lacks a built-in reports it when a script uses it. + let _ = e; + } +} + +fn try_install(interp: &mut Interp) -> JsResult<()> { + let object_proto = alloc(interp, Class::Ordinary, None); + interp.intrinsics.object_proto = object_proto; + let function_proto = alloc( + interp, + Class::Function(Func::Native { f: function_proto_call, kind: NativeKind::Plain, constructor: false, name: "" }), + Some(object_proto), + ); + interp.intrinsics.function_proto = function_proto; + data(interp, function_proto, "length", Value::Number(0.0), 0)?; + let global = interp.global; + if let Some(g) = interp.heap.object_mut(global) { + g.proto = Some(object_proto); + } + interp.intrinsics.array_proto = alloc(interp, Class::Array { length: 0, length_writable: true }, Some(object_proto)); + interp.intrinsics.string_proto = alloc(interp, Class::String(Str::from("")), Some(object_proto)); + interp.intrinsics.number_proto = alloc(interp, Class::Number, Some(object_proto)); + interp.intrinsics.boolean_proto = alloc(interp, Class::Boolean, Some(object_proto)); + interp.intrinsics.regexp_proto = alloc(interp, Class::Ordinary, Some(object_proto)); + let thrower = native(interp, "", throw_type_error, 0, NativeKind::Plain, false)?; + if let Some(t) = interp.heap.object_mut(thrower) { + t.extensible = false; + } + interp.intrinsics.throw_type_error = thrower; + + data(interp, global, "NaN", Value::Number(f64::NAN), 0)?; + data(interp, global, "Infinity", Value::Number(f64::INFINITY), 0)?; + data(interp, global, "undefined", Value::Undefined, 0)?; + let eval = native(interp, "eval", eval, 1, NativeKind::Eval, false)?; + interp.intrinsics.eval = eval; + data(interp, global, "eval", Value::Object(eval), HIDDEN)?; + + let object = native(interp, "Object", object_ctor, 1, NativeKind::Plain, true)?; + constructor(interp, "Object", object, object_proto)?; + method(interp, object_proto, "toString", object_to_string, 0)?; + method(interp, object_proto, "toLocaleString", object_to_locale_string, 0)?; + method(interp, object_proto, "valueOf", object_value_of, 0)?; + method(interp, object_proto, "hasOwnProperty", has_own_property, 1)?; + method(interp, object_proto, "isPrototypeOf", is_prototype_of, 1)?; + method(interp, object_proto, "propertyIsEnumerable", property_is_enumerable, 1)?; + + let function = native(interp, "Function", function_ctor, 1, NativeKind::Plain, true)?; + constructor(interp, "Function", function, function_proto)?; + let call = native(interp, "call", unreachable_native, 1, NativeKind::Call, false)?; + data(interp, function_proto, "call", Value::Object(call), HIDDEN)?; + let apply = native(interp, "apply", unreachable_native, 2, NativeKind::Apply, false)?; + data(interp, function_proto, "apply", Value::Object(apply), HIDDEN)?; + method(interp, function_proto, "toString", function_to_string, 0)?; + + install_errors(interp, object_proto) +} + +fn install_errors(interp: &mut Interp, object_proto: ObjId) -> JsResult<()> { + let kinds: [(&'static str, NativeFn); 7] = [ + ("Error", error_ctor), + ("EvalError", eval_error_ctor), + ("RangeError", range_error_ctor), + ("ReferenceError", reference_error_ctor), + ("SyntaxError", syntax_error_ctor), + ("TypeError", type_error_ctor), + ("URIError", uri_error_ctor), + ]; + let mut error_proto = object_proto; + for (i, (name, f)) in kinds.into_iter().enumerate() { + let proto = alloc(interp, Class::Ordinary, Some(if i == 0 { object_proto } else { error_proto })); + if i == 0 { + error_proto = proto; + if let Some(p) = interp.heap.object_mut(proto) { + p.class = Class::Error; + } + method(interp, proto, "toString", error_to_string, 0)?; + } + if let Some(slot) = interp.intrinsics.error_protos.get_mut(i) { + *slot = proto; + } + data(interp, proto, "name", Value::str(name), HIDDEN)?; + data(interp, proto, "message", Value::str(""), HIDDEN)?; + let ctor = native(interp, name, f, 1, NativeKind::Plain, true)?; + constructor(interp, name, ctor, proto)?; + } + Ok(()) +} + +fn arg(args: &[Value], i: usize) -> Value { + args.get(i).cloned().unwrap_or(Value::Undefined) +} + +fn unreachable_native(_: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult<Value> { + Err(Abrupt::Internal("call or apply reached its native body".into())) +} + +fn throw_type_error(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult<Value> { + Err(interp.type_error("'caller', 'callee' and 'arguments' cannot be used on strict mode functions")) +} + +fn function_proto_call(_: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult<Value> { + Ok(Value::Undefined) +} + +// ---- Object ---- + +/// `Object(value)` and `new Object(value)` (sections 15.2.1 and 15.2.2). +fn object_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> { + let v = arg(args, 0); + if v.is_nullish() { + return Ok(Value::Object(interp.new_object())); + } + interp.to_object(&v).map(Value::Object) +} + +/// `Object.prototype.toString` (section 15.2.4.2). +fn object_to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> { + let class = match &this { + Value::Undefined => "Undefined", + Value::Null => "Null", + _ => { + let o = interp.to_object(&this)?; + interp.heap.object(o).map_or("Object", |o| o.class.name()) + } + }; + Ok(Value::str(&format!("[object {class}]"))) +} + +fn object_to_locale_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> { + let o = interp.to_object(&this)?; + let f = interp.get_from(o, &key("toString"), &this)?; + if !interp.is_callable(&f) { + return Err(interp.type_error("toString is not a function")); + } + interp.call(&f, &this, &[]) +} + +fn object_value_of(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> { + interp.to_object(&this).map(Value::Object) +} + +fn has_own_property(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> { + let k = interp.to_key(&arg(args, 0))?; + let o = interp.to_object(&this)?; + Ok(Value::Bool(interp.get_own_property(o, &k)?.is_some())) +} + +fn is_prototype_of(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> { + let Value::Object(mut v) = arg(args, 0) else { return Ok(Value::Bool(false)) }; + let o = interp.to_object(&this)?; + while let Some(p) = interp.heap.object(v).and_then(|v| v.proto) { + if p == o { + return Ok(Value::Bool(true)); + } + v = p; + interp.use_fuel()?; + } + Ok(Value::Bool(false)) +} + +fn property_is_enumerable(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> { + let k = interp.to_key(&arg(args, 0))?; + let o = interp.to_object(&this)?; + Ok(Value::Bool(interp.get_own_property(o, &k)?.is_some_and(|p| p.enumerable()))) +} + +// ---- Function ---- + +/// `Function(p1, …, body)` and `new Function(…)` (section 15.3.2.1): the source is read as +/// `(function anonymous(p1,…\n) {\nbody\n})` and must be exactly that one function. +fn function_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> { + let mut parts = Vec::with_capacity(args.len()); + for a in args { + parts.push(interp.to_str(a)?.to_rust_string()); + } + let body = parts.pop().unwrap_or_default(); + let text = format!("(function anonymous({}\n) {{\n{body}\n}})", parts.join(",")); + let units: Vec<u16> = text.encode_utf16().collect(); + interp.source_units = interp.source_units.saturating_add(units.len()); + let remaining = interp.options.max_source_units.saturating_sub(interp.source_units.saturating_sub(units.len())); + let options = crate::Options { strict: false, max_source_units: remaining, ..interp.options.clone() }; + let program = match crate::parse_utf16(&units, &options) { + Ok(p) => p, + Err(e) => return Err(interp.throw(ErrorType::Syntax, &e.message)), + }; + let source = Rc::new(Source { lines: program.lines.clone(), text: units.into() }); + let code = match compile_constructed_function(&program, &source) { + Ok(Some(code)) => code, + Ok(None) => return Err(interp.throw(ErrorType::Syntax, "the parameters or body do not form one function")), + Err(CompileFail::Syntax(e)) => return Err(interp.throw(ErrorType::Syntax, &e.message)), + Err(CompileFail::Internal(m)) => return Err(Abrupt::Internal(m)), + }; + let env = interp.global_env; + interp.make_closure(code, env).map(Value::Object) +} + +/// `Function.prototype.toString` (section 15.3.4.2): a function's source text, or a stand-in +/// for a built-in. +fn function_to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> { + let func = this.as_object().and_then(|o| interp.heap.object(o)).and_then(|o| match &o.class { + Class::Function(f) => Some(f.clone()), + _ => None, + }); + let text = match func { + Some(Func::Closure { code, .. }) => { + let (start, end) = (code.span.0 as usize, code.span.1 as usize); + String::from_utf16_lossy(code.source.text.get(start..end).unwrap_or_default()) + } + Some(Func::Native { name, .. }) => format!("function {name}() {{ [native code] }}"), + Some(Func::Host { name, .. }) => format!("function {name}() {{ [native code] }}"), + None => return Err(interp.type_error("Function.prototype.toString was called on a value that is not a function")), + }; + Ok(Value::str(&text)) +} + +/// The global `eval` called indirectly (section 15.1.2.1.1): global code in the global +/// environment. +fn eval(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> { + let x = arg(args, 0); + let Value::String(s) = &x else { return Ok(x) }; + let units = s.flat(); + let code = interp.compile_source(&units, false, CodeKind::Eval).map_err(|e| interp.compile_error_to_abrupt(e))?; + interp.run_global(code, true) +} + +// ---- errors ---- + +fn make_error(interp: &mut Interp, t: ErrorType, args: &[Value]) -> JsResult<Value> { + let message = arg(args, 0); + let text = if matches!(message, Value::Undefined) { None } else { Some(interp.to_str(&message)?) }; + let o = interp.error_object(t, ""); + if let Some(text) = text { + interp.define(o, key("message"), Prop::Data { value: Value::String(text), flags: HIDDEN })?; + } + Ok(Value::Object(o)) +} + +fn error_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> { + make_error(interp, ErrorType::Error, args) +} + +fn eval_error_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> { + make_error(interp, ErrorType::Eval, args) +} + +fn range_error_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> { + make_error(interp, ErrorType::Range, args) +} + +fn reference_error_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> { + make_error(interp, ErrorType::Reference, args) +} + +fn syntax_error_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> { + make_error(interp, ErrorType::Syntax, args) +} + +fn type_error_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> { + make_error(interp, ErrorType::Type, args) +} + +fn uri_error_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> { + make_error(interp, ErrorType::Uri, args) +} + +/// `Error.prototype.toString` (section 15.11.4.4). +fn error_to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> { + let Value::Object(o) = this else { return Err(interp.type_error("Error.prototype.toString was called on a value that is not an object")) }; + let name = interp.get_from(o, &key("name"), &this)?; + let name = if matches!(name, Value::Undefined) { Str::from("Error") } else { interp.to_str(&name)? }; + let message = interp.get_from(o, &key("message"), &this)?; + let message = if matches!(message, Value::Undefined) { Str::from("") } else { interp.to_str(&message)? }; + if name.len() == 0 { + return Ok(Value::String(message)); + } + if message.len() == 0 { + return Ok(Value::String(name)); + } + let sep = Str::from(": "); + let head = interp.concat(&name, &sep)?; + interp.concat(&head, &message).map(Value::String) +} diff --git a/crates/js/src/bytecode.rs b/crates/js/src/bytecode.rs new file mode 100644 index 000000000..4ecb582d0 --- /dev/null +++ b/crates/js/src/bytecode.rs @@ -0,0 +1,359 @@ +//! Bytecode: the instructions a function, a script or eval code compiles to, and the verifier +//! that checks them before they run. +//! +//! The interpreter is a stack machine. Each instruction takes its operands from the top of the +//! frame's operand stack and pushes its result there; jumps name an instruction index. The +//! verifier walks every path through the code and checks that each jump target, exception handler +//! and `finally` dispatch target is an instruction, that each constant, name and function index is +//! in its table, that the stack never goes below the frame's base, that every path reaching an +//! instruction arrives with the same stack depth, and that no path runs past the last +//! instruction. Code that fails is reported as an internal error and never runs. + +use std::rc::Rc; + +use crate::LineTable; +use crate::ast::BinaryOp; +use crate::heap::Key; +use crate::value::Str; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum Op { + Undefined, + Null, + True, + False, + /// Pushes `numbers[i]`. + Number(u32), + /// Pushes `strings[i]`. + String(u32), + This, + Pop, + /// Removes the `n` values under the top one. + PopUnder(u32), + Dup, + /// `a b` → `a b a b`. + Dup2, + Swap, + /// `a b c` → `b c a`. + Rot3, + /// Pushes the value of the variable `names[i]`. + GetVar(u32), + /// Pushes the value of the variable and the `this` a call to it gets. + GetVarThis(u32), + /// `value` → `value`, assigned to the variable. + SetVar(u32), + /// Pushes the base of the reference to the variable `names[i]`, resolved before the + /// right-hand side of an assignment runs. + RefVar(u32), + /// `base` → `value`: the variable's value read through the resolved base. + GetRef(u32), + /// `base value` → `value`, assigned to the variable through the resolved base. + PutRef(u32), + TypeofVar(u32), + DeleteVar(u32), + IncVar { + name: u32, + prefix: bool, + dec: bool, + }, + /// `object` → `object.name`. + GetMember(u32), + /// `object` → `object.name object`. + GetMemberThis(u32), + /// `object value` → `value`. + SetMember(u32), + DeleteMember(u32), + IncMember { + name: u32, + prefix: bool, + dec: bool, + }, + /// `object key` → `object name`: checks that the object can have properties and turns the key + /// into a property name, before the right-hand side of an assignment runs. + ToKey, + /// `object` → `object`: throws a `TypeError` when it is `undefined` or `null`, before the + /// right-hand side of an assignment to one of its properties runs. + CheckObject, + /// `object key` → `object[key]`. + GetIndex, + /// `object key` → `object[key] object`. + GetIndexThis, + /// `object key value` → `value`. + SetIndex, + DeleteIndex, + IncIndex { + prefix: bool, + dec: bool, + }, + /// `function this arg…` → result. + Call(u32), + /// Like `Call`, but a direct eval when the function is the global `eval`. + CallEval(u32), + /// `function arg…` → the new object. + New(u32), + Binary(BinaryOp), + Neg, + Plus, + BitNot, + Not, + Typeof, + ToNumber, + Jump(u32), + /// Pops the top value and jumps when it is falsy. + JumpIfFalse(u32), + JumpIfTrue(u32), + /// Jumps, keeping the top value, when it is falsy; otherwise pops it. + JumpIfFalseKeep(u32), + JumpIfTrueKeep(u32), + Return, + /// Returns the value of the last expression statement (script and eval code). + ReturnCompletion, + Throw, + /// Throws a `ReferenceError` with the message `strings[i]` (an assignment to something that + /// cannot be assigned to). + ThrowReferenceError(u32), + /// Ends a `finally` block: `value kind` on the stack say how it was entered. Kind 0 (normal) + /// pops both and goes on, kind 1 throws the value, and kind `2 + k` jumps to entry `k` of + /// `finally_tables[i]` with the value left on the stack. + EndFinally(u32), + NewObject, + NewArray, + /// `array value` → `array`, the value appended. + ArrayPush, + /// `array` → `array`, one longer. + ArrayHole, + /// `object value` → `object`. + InitProp(u32), + /// `object function` → `object`. + InitGetter(u32), + InitSetter(u32), + /// Pushes a new function made from `functions[i]` and the current environment. + Closure(u32), + /// Pushes a new regular expression object for `regexps[i]`. + RegExp(u32), + /// `object` → nothing: enters a `with` statement's environment. + PushWith, + /// `exception` → nothing: enters a catch clause's environment, binding `names[i]`. + PushCatch(u32), + PopEnv, + /// `object` → `iterator`. + ForInStart, + /// `iterator` → `iterator name`, or jumps with `iterator` when the names are used up. + ForInNext(u32), + /// Pops the value of an expression statement into the completion value. + SetCompletion, +} + +/// What code is: a function body, a script, or eval code. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CodeKind { + Function, + Global, + Eval, +} + +/// A range of instructions an exception thrown in jumps out of: to `target`, with the operand +/// stack cut back to `depth` values and the environments to `env_depth`, and the exception pushed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Handler { + pub(crate) start: u32, + pub(crate) end: u32, + pub(crate) target: u32, + pub(crate) depth: u32, + pub(crate) env_depth: u32, +} + +/// The source a piece of code came from, for error positions and `Function.prototype.toString`. +#[derive(Debug)] +pub(crate) struct Source { + pub(crate) lines: LineTable, + pub(crate) text: Rc<[u16]>, +} + +#[derive(Debug)] +pub(crate) struct Code { + pub(crate) ops: Vec<Op>, + /// The source offset each instruction comes from. + pub(crate) positions: Vec<u32>, + pub(crate) numbers: Vec<f64>, + pub(crate) strings: Vec<Str>, + pub(crate) names: Vec<Key>, + pub(crate) functions: Vec<Rc<Code>>, + pub(crate) regexps: Vec<(Str, Str)>, + pub(crate) handlers: Vec<Handler>, + pub(crate) finally_tables: Vec<Vec<u32>>, + /// The most values the code keeps on the operand stack, set by the verifier. + pub(crate) max_stack: u32, + pub(crate) strict: bool, + /// A function expression's own name, bound inside it. + pub(crate) self_name: Option<Key>, + pub(crate) params: Vec<Key>, + pub(crate) vars: Vec<Key>, + /// Function declarations to instantiate on entry: their names and indices in `functions`. + pub(crate) fun_decls: Vec<(Key, u32)>, + /// The body refers to `arguments` or calls `eval`. + pub(crate) uses_arguments: bool, + pub(crate) source: Rc<Source>, + /// Where the function's text starts and ends in the source. + pub(crate) span: (u32, u32), +} + +impl Code { + /// The innermost handler around instruction `pc`. + pub(crate) fn handler(&self, pc: usize) -> Option<Handler> { + self.handlers.iter().copied().find(|h| (h.start as usize) <= pc && pc < h.end as usize) + } + + /// The line and column of instruction `pc`. + pub(crate) fn line_column(&self, pc: usize) -> (u32, u32) { + let offset = self.positions.get(pc).copied().unwrap_or(self.span.0); + self.source.lines.line_column(offset) + } +} + +/// What an instruction does to the stack on each way out of it. +struct Effect { + pops: u32, + /// Values pushed when it goes on to the next instruction; `None` when it never does. + next: Option<u32>, + /// Instruction indices it may jump to, each with the values it pushes on that path. + jumps: Vec<(u32, u32)>, +} + +/// The values an instruction pops, and pushes when it goes on to the next instruction (0 for one +/// that never does), for the compiler's count of the stack depth. +pub(crate) fn fallthrough_effect(op: Op) -> (u32, u32) { + match op { + Op::EndFinally(_) => (2, 0), + _ => effect(op, &[]).map_or((0, 0), |fx| (fx.pops, fx.next.unwrap_or(0))), + } +} + +fn effect(op: Op, finally_tables: &[Vec<u32>]) -> Result<Effect, String> { + let e = |pops, pushes| Effect { pops, next: Some(pushes), jumps: Vec::new() }; + let end = |pops| Effect { pops, next: None, jumps: Vec::new() }; + Ok(match op { + Op::Undefined | Op::Null | Op::True | Op::False | Op::Number(_) | Op::String(_) | Op::This => e(0, 1), + Op::GetVar(_) | Op::TypeofVar(_) | Op::DeleteVar(_) | Op::IncVar { .. } | Op::RefVar(_) => e(0, 1), + Op::GetRef(_) => e(1, 1), + Op::PutRef(_) => e(2, 1), + Op::NewObject | Op::NewArray | Op::Closure(_) | Op::RegExp(_) => e(0, 1), + Op::Pop | Op::PushWith | Op::PushCatch(_) | Op::SetCompletion => e(1, 0), + Op::PopUnder(n) => e(n.checked_add(1).ok_or("operand too large")?, 1), + Op::Dup => e(1, 2), + Op::Dup2 => e(2, 4), + Op::Swap | Op::ToKey => e(2, 2), + Op::Rot3 => e(3, 3), + Op::GetVarThis(_) => e(0, 2), + Op::SetVar(_) | Op::GetMember(_) | Op::DeleteMember(_) | Op::IncMember { .. } => e(1, 1), + Op::Neg | Op::Plus | Op::BitNot | Op::Not | Op::Typeof | Op::ToNumber | Op::ArrayHole | Op::ForInStart | Op::CheckObject => e(1, 1), + Op::GetMemberThis(_) => e(1, 2), + Op::SetMember(_) | Op::GetIndex | Op::DeleteIndex | Op::IncIndex { .. } | Op::Binary(_) => e(2, 1), + Op::ArrayPush | Op::InitProp(_) | Op::InitGetter(_) | Op::InitSetter(_) => e(2, 1), + Op::GetIndexThis => e(2, 2), + Op::SetIndex => e(3, 1), + Op::Call(n) | Op::CallEval(n) => e(n.checked_add(2).ok_or("operand too large")?, 1), + Op::New(n) => e(n.checked_add(1).ok_or("operand too large")?, 1), + Op::PopEnv => e(0, 0), + Op::Jump(t) => Effect { pops: 0, next: None, jumps: vec![(t, 0)] }, + Op::JumpIfFalse(t) | Op::JumpIfTrue(t) => Effect { pops: 1, next: Some(0), jumps: vec![(t, 0)] }, + Op::JumpIfFalseKeep(t) | Op::JumpIfTrueKeep(t) => Effect { pops: 1, next: Some(0), jumps: vec![(t, 1)] }, + Op::Return | Op::Throw => end(1), + Op::ReturnCompletion | Op::ThrowReferenceError(_) => end(0), + Op::EndFinally(t) => { + let table = finally_tables.get(t as usize).ok_or_else(|| format!("finally table {t} out of range"))?; + Effect { pops: 2, next: Some(0), jumps: table.iter().map(|&target| (target, 1)).collect() } + } + Op::ForInNext(t) => Effect { pops: 1, next: Some(2), jumps: vec![(t, 1)] }, + }) +} + +/// Checks the constant, name, function and regular expression indices an instruction uses. +fn check_indices(op: Op, code: &Code) -> Result<(), String> { + let check = |i: u32, len: usize, what: &str| if (i as usize) < len { Ok(()) } else { Err(format!("{what} {i} out of range")) }; + match op { + Op::Number(i) => check(i, code.numbers.len(), "number"), + Op::String(i) | Op::ThrowReferenceError(i) => check(i, code.strings.len(), "string"), + Op::GetVar(i) + | Op::GetVarThis(i) + | Op::SetVar(i) + | Op::RefVar(i) + | Op::GetRef(i) + | Op::PutRef(i) + | Op::TypeofVar(i) + | Op::DeleteVar(i) + | Op::IncVar { name: i, .. } + | Op::GetMember(i) + | Op::GetMemberThis(i) + | Op::SetMember(i) + | Op::DeleteMember(i) + | Op::IncMember { name: i, .. } + | Op::InitProp(i) + | Op::InitGetter(i) + | Op::InitSetter(i) + | Op::PushCatch(i) => check(i, code.names.len(), "name"), + Op::Closure(i) => check(i, code.functions.len(), "function"), + Op::RegExp(i) => check(i, code.regexps.len(), "regular expression"), + _ => Ok(()), + } +} + +/// Verifies `code` (not the functions inside it, which are verified when they are compiled) and +/// returns the most values it keeps on the stack. +pub(crate) fn verify(code: &Code) -> Result<u32, String> { + let len = code.ops.len(); + if len == 0 { + return Err("no instructions".into()); + } + if code.positions.len() != len { + return Err("the position table does not match the instructions".into()); + } + for (name, _) in &code.fun_decls { + if name.is_empty() { + return Err("a function declaration without a name".into()); + } + } + if code.fun_decls.iter().any(|(_, i)| *i as usize >= code.functions.len()) { + return Err("function declaration index out of range".into()); + } + let mut depth: Vec<Option<u32>> = vec![None; len]; + let mut work: Vec<(u32, u32)> = vec![(0, 0)]; + for h in &code.handlers { + if h.start > h.end || h.end as usize > len { + return Err(format!("handler range {}..{} out of range", h.start, h.end)); + } + work.push((h.target, h.depth.checked_add(1).ok_or("handler depth too large")?)); + } + let mut max = 0; + while let Some((pc, d)) = work.pop() { + let slot = depth.get_mut(pc as usize).ok_or_else(|| format!("jump to {pc}, past the end"))?; + match *slot { + Some(seen) if seen == d => continue, + Some(seen) => return Err(format!("instruction {pc} reached with stack depths {seen} and {d}")), + None => *slot = Some(d), + } + max = max.max(d); + let op = code.ops.get(pc as usize).copied().ok_or("instruction out of range")?; + check_indices(op, code)?; + let fx = effect(op, &code.finally_tables)?; + let base = d.checked_sub(fx.pops).ok_or_else(|| format!("instruction {pc} ({op:?}) pops below the stack"))?; + if let Some(pushes) = fx.next { + if pc as usize + 1 >= len { + return Err("the code runs past its last instruction".into()); + } + work.push((pc + 1, base + pushes)); + } + for (target, pushes) in fx.jumps { + work.push((target, base + pushes)); + } + } + for h in &code.handlers { + if let Some(Some(d)) = depth.get(h.start as usize) + && *d < h.depth + && h.start < h.end + { + return Err(format!("handler at {} expects {} values but its range starts with {d}", h.target, h.depth)); + } + } + Ok(max) +} diff --git a/crates/js/src/compiler.rs b/crates/js/src/compiler.rs new file mode 100644 index 000000000..75ee257af --- /dev/null +++ b/crates/js/src/compiler.rs @@ -0,0 +1,1249 @@ +//! The compiler: a syntax tree to bytecode, one [`Code`] per function, script or eval code. +//! +//! Variables are looked up by name at run time through the environment chain, as ECMA-262 5.1 +//! section 10 describes, so `with` and direct `eval` need no special analysis. Declarations are +//! hoisted: each code lists its `var` names and function declarations, and the interpreter binds +//! them when the code is entered (section 10.5). +//! +//! The compiler counts the operand stack depth as it emits, so `break`, `continue` and `return` +//! know how many values to drop. A jump out of a `try` with a `finally` block goes through the +//! block: it pushes a completion (a value and a kind number) and jumps to the block, whose +//! `EndFinally` instruction goes on with the jump afterwards. Long left-nested runs of binary +//! operators and member accesses, right-nested assignment chains, prefix operators, `else if` +//! chains and labels are compiled with loops; every other kind of nesting is counted, and code +//! nested deeper than [`MAX_DEPTH`] gives a `SyntaxError` instead of exhausting the stack. + +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; + +use crate::ast::{ + AssignOp, Ast, BinaryOp, Expr, ExprId, ForInTarget, ForInit, FunctionId, FunctionKind, LogicalOp, PropertyValue, Stmt, StmtId, UnaryOp, UpdateOp, + VarDecl, +}; +use crate::bytecode::{Code, CodeKind, Handler, Op, Source, fallthrough_effect, verify}; +use crate::error::{SyntaxError, TOO_DEEP}; +use crate::heap::Key; +use crate::value::Str; + +/// How deeply the compiler may recurse: past the parser's default nesting limit, and shallow +/// enough for a 1 MiB stack. +pub(crate) const MAX_DEPTH: u32 = 600; + +/// Why code could not be compiled. +#[derive(Debug)] +pub(crate) enum CompileFail { + Syntax(SyntaxError), + /// The compiler made code the verifier rejects. + Internal(String), +} + +type CResult<T> = Result<T, CompileFail>; + +fn internal<T>(message: impl Into<String>) -> CResult<T> { + Err(CompileFail::Internal(message.into())) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Label(usize); + +/// Where `break`, `continue` and `return` go. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Exit { + /// To the end of the jump context at this index. + Break(usize), + /// To the continue point of the loop at this index. + Continue(usize), + Return, +} + +enum Ctx { + /// A loop, a `switch` or a labeled statement. + Jump { labels: Vec<String>, is_loop: bool, plain_break: bool, brk: Label, cont: Option<Label>, depth: u32, env_depth: u32 }, + /// The protected part (and catch clause) of a `try` with a `finally` block. + Finally { try_depth: u32, env_depth: u32, entry: Label, exits: Vec<Exit> }, +} + +struct FnCompiler<'a> { + ast: &'a Ast, + source: &'a Rc<Source>, + kind: CodeKind, + strict: bool, + ops: Vec<Op>, + positions: Vec<u32>, + pos: u32, + numbers: Vec<f64>, + number_index: HashMap<u64, u32>, + strings: Vec<Str>, + string_index: HashMap<Vec<u16>, u32>, + names: Vec<Key>, + name_index: HashMap<Key, u32>, + functions: Vec<Rc<Code>>, + regexps: Vec<(Str, Str)>, + handlers: Vec<(u32, u32, Label, u32, u32)>, + finally_tables: Vec<Vec<Label>>, + labels: Vec<Option<u32>>, + patches: Vec<(usize, Label)>, + depth: u32, + env_depth: u32, + contexts: Vec<Ctx>, + uses_arguments: bool, + recursion: u32, +} + +/// The `var` names and function declarations a body hoists. +#[derive(Default)] +struct Hoisted { + vars: Vec<Key>, + fun_decls: Vec<(Key, u32)>, +} + +fn key_of(name: &str) -> Key { + crate::heap::key(name) +} + +impl<'a> FnCompiler<'a> { + fn new(ast: &'a Ast, source: &'a Rc<Source>, kind: CodeKind, strict: bool, recursion: u32) -> Self { + Self { + ast, + source, + kind, + strict, + ops: Vec::new(), + positions: Vec::new(), + pos: 0, + numbers: Vec::new(), + number_index: HashMap::new(), + strings: Vec::new(), + string_index: HashMap::new(), + names: Vec::new(), + name_index: HashMap::new(), + functions: Vec::new(), + regexps: Vec::new(), + handlers: Vec::new(), + finally_tables: Vec::new(), + labels: Vec::new(), + patches: Vec::new(), + depth: 0, + env_depth: 0, + contexts: Vec::new(), + uses_arguments: false, + recursion, + } + } + + // ---- emitting ---- + + fn emit(&mut self, op: Op) { + let (pops, pushes) = fallthrough_effect(op); + self.depth = self.depth.saturating_sub(pops) + pushes; + self.ops.push(op); + self.positions.push(self.pos); + } + + fn new_label(&mut self) -> Label { + self.labels.push(None); + Label(self.labels.len() - 1) + } + + fn bind(&mut self, label: Label) { + if let Some(slot) = self.labels.get_mut(label.0) { + *slot = Some(u32::try_from(self.ops.len()).unwrap_or(u32::MAX)); + } + } + + /// Emits a jump instruction (`make` builds it from its target) to `label`, patched when the + /// code is finished. + fn jump(&mut self, make: fn(u32) -> Op, label: Label) { + self.patches.push((self.ops.len(), label)); + self.emit(make(0)); + } + + fn at(&mut self, offset: u32) { + self.pos = offset; + } + + fn number(&mut self, n: f64) -> u32 { + if let Some(&i) = self.number_index.get(&n.to_bits()) { + return i; + } + let i = u32::try_from(self.numbers.len()).unwrap_or(u32::MAX); + self.numbers.push(n); + self.number_index.insert(n.to_bits(), i); + i + } + + fn string(&mut self, units: &[u16]) -> u32 { + if let Some(&i) = self.string_index.get(units) { + return i; + } + let i = u32::try_from(self.strings.len()).unwrap_or(u32::MAX); + self.strings.push(Str::from_units(units.to_vec())); + self.string_index.insert(units.to_vec(), i); + i + } + + fn name_key(&mut self, key: Key) -> u32 { + if let Some(&i) = self.name_index.get(&key) { + return i; + } + let i = u32::try_from(self.names.len()).unwrap_or(u32::MAX); + self.names.push(key.clone()); + self.name_index.insert(key, i); + i + } + + /// The index of a variable name, noting a use of `arguments`. + fn var_name(&mut self, name: &str) -> u32 { + if name == "arguments" { + self.uses_arguments = true; + } + self.name_key(key_of(name)) + } + + fn push_number(&mut self, n: f64) { + let i = self.number(n); + self.emit(Op::Number(i)); + } + + fn enter(&mut self) -> CResult<()> { + self.recursion += 1; + if self.recursion > MAX_DEPTH { + let (line, column) = self.source.lines.line_column(self.pos); + return Err(CompileFail::Syntax(SyntaxError { message: TOO_DEEP.into(), line, column, offset: self.pos })); + } + Ok(()) + } + + fn leave(&mut self) { + self.recursion -= 1; + } + + // ---- declarations ---- + + /// Collects the `var` names and function declarations of a body, outside nested functions, + /// in source order, and compiles the functions. + fn hoist(&mut self, body: &[StmtId]) -> CResult<Hoisted> { + let mut out = Hoisted::default(); + let mut seen = HashSet::new(); + let mut work: Vec<StmtId> = body.iter().rev().copied().collect(); + let mut add_var = |out: &mut Hoisted, d: &VarDecl| { + let k = key_of(&d.name.name); + if seen.insert(k.clone()) { + out.vars.push(k); + } + }; + while let Some(id) = work.pop() { + let Some(stmt) = self.ast.stmt(id) else { return internal("statement out of range") }; + match stmt { + Stmt::Var(decls) => decls.iter().for_each(|d| add_var(&mut out, d)), + Stmt::FunctionDecl(fid) => { + let Some(name) = self.ast.function(*fid).and_then(|f| f.name.as_ref()) else { + return internal("function declaration without a name"); + }; + let k = key_of(&name.name); + let i = self.function(*fid)?; + out.fun_decls.push((k, i)); + } + Stmt::Block(list) => work.extend(list.iter().rev()), + Stmt::If { consequent, alternate, .. } => { + work.extend(alternate.iter()); + work.push(*consequent); + } + Stmt::DoWhile { body, .. } | Stmt::While { body, .. } | Stmt::With { body, .. } | Stmt::Labeled { body, .. } => work.push(*body), + Stmt::For { init, body, .. } => { + work.push(*body); + if let Some(ForInit::Var(decls)) = init { + decls.iter().for_each(|d| add_var(&mut out, d)); + } + } + Stmt::ForIn { target, body, .. } => { + work.push(*body); + if let ForInTarget::Var(d) = target { + add_var(&mut out, d); + } + } + Stmt::Switch { cases, .. } => { + for case in cases.iter().rev() { + work.extend(case.body.iter().rev()); + } + } + Stmt::Try { block, handler, finalizer } => { + if let Some(f) = finalizer { + work.extend(f.iter().rev()); + } + if let Some(h) = handler { + work.extend(h.body.iter().rev()); + } + work.extend(block.iter().rev()); + } + Stmt::Expr(_) | Stmt::Empty | Stmt::Continue(_) | Stmt::Break(_) | Stmt::Return(_) | Stmt::Throw(_) | Stmt::Debugger => {} + } + } + Ok(out) + } + + /// Compiles a nested function and returns its index in the function table. + fn function(&mut self, fid: FunctionId) -> CResult<u32> { + let code = compile_function(self.ast, fid, self.source, self.recursion)?; + self.functions.push(code); + Ok(u32::try_from(self.functions.len() - 1).unwrap_or(u32::MAX)) + } + + // ---- statements ---- + + fn stmts(&mut self, list: &[StmtId]) -> CResult<()> { + for &s in list { + self.stmt(s)?; + } + Ok(()) + } + + fn stmt(&mut self, id: StmtId) -> CResult<()> { + self.enter()?; + let saved = self.depth; + let r = self.stmt_inner(id); + self.depth = saved; + self.leave(); + r + } + + fn stmt_inner(&mut self, id: StmtId) -> CResult<()> { + let Some(stmt) = self.ast.stmt(id) else { return internal("statement out of range") }; + self.at(self.ast.stmt_span(id).start); + match stmt { + Stmt::Var(decls) => self.var_decls(decls), + Stmt::FunctionDecl(_) | Stmt::Empty | Stmt::Debugger => Ok(()), + Stmt::Expr(e) => { + self.expr(*e)?; + self.emit(if self.kind == CodeKind::Function { Op::Pop } else { Op::SetCompletion }); + Ok(()) + } + Stmt::Block(list) => self.stmts(list), + Stmt::If { .. } => self.if_chain(id), + Stmt::DoWhile { .. } | Stmt::While { .. } | Stmt::For { .. } | Stmt::ForIn { .. } => self.loop_stmt(id, Vec::new()), + Stmt::Continue(label) => self.jump_stmt(label.as_ref().map(|l| l.name.as_str()), true), + Stmt::Break(label) => self.jump_stmt(label.as_ref().map(|l| l.name.as_str()), false), + Stmt::Return(value) => { + match value { + Some(e) => self.expr(*e)?, + None => self.emit(Op::Undefined), + } + self.emit_exit(Exit::Return) + } + Stmt::With { object, body } => { + self.expr(*object)?; + self.emit(Op::PushWith); + self.env_depth += 1; + self.stmt(*body)?; + self.emit(Op::PopEnv); + self.env_depth -= 1; + Ok(()) + } + Stmt::Switch { .. } => self.switch_stmt(id, Vec::new()), + Stmt::Labeled { .. } => self.labeled(id), + Stmt::Throw(e) => { + self.expr(*e)?; + self.at(self.ast.stmt_span(id).start); + self.emit(Op::Throw); + Ok(()) + } + Stmt::Try { block, handler, finalizer } => self.try_stmt(block, handler.as_ref(), finalizer.as_deref()), + } + } + + fn var_decls(&mut self, decls: &[VarDecl]) -> CResult<()> { + for d in decls { + if let Some(init) = d.init { + self.expr(init)?; + self.at(d.name.span.start); + let n = self.var_name(&d.name.name); + self.emit(Op::SetVar(n)); + self.emit(Op::Pop); + } + } + Ok(()) + } + + fn if_chain(&mut self, id: StmtId) -> CResult<()> { + let saved = self.depth; + let end = self.new_label(); + let mut cur = id; + while let Some(Stmt::If { test, consequent, alternate }) = self.ast.stmt(cur) { + self.at(self.ast.stmt_span(cur).start); + self.expr(*test)?; + let otherwise = self.new_label(); + self.jump(Op::JumpIfFalse, otherwise); + self.stmt(*consequent)?; + let Some(alt) = *alternate else { + self.bind(otherwise); + break; + }; + self.jump(Op::Jump, end); + self.bind(otherwise); + self.depth = saved; + if matches!(self.ast.stmt(alt), Some(Stmt::If { .. })) { + cur = alt; + } else { + self.stmt(alt)?; + break; + } + } + self.bind(end); + Ok(()) + } + + fn labeled(&mut self, id: StmtId) -> CResult<()> { + let mut labels = Vec::new(); + let mut cur = id; + while let Some(Stmt::Labeled { label, body }) = self.ast.stmt(cur) { + labels.push(label.name.clone()); + cur = *body; + } + match self.ast.stmt(cur) { + Some(Stmt::DoWhile { .. } | Stmt::While { .. } | Stmt::For { .. } | Stmt::ForIn { .. }) => self.loop_stmt(cur, labels), + Some(Stmt::Switch { .. }) => self.switch_stmt(cur, labels), + _ => { + let brk = self.new_label(); + self.push_jump_ctx(labels, false, false, brk, None); + self.stmt(cur)?; + self.contexts.pop(); + self.bind(brk); + Ok(()) + } + } + } + + fn push_jump_ctx(&mut self, labels: Vec<String>, is_loop: bool, plain_break: bool, brk: Label, cont: Option<Label>) { + self.contexts.push(Ctx::Jump { labels, is_loop, plain_break, brk, cont, depth: self.depth, env_depth: self.env_depth }); + } + + /// Compiles a loop body inside its jump context. + fn loop_body(&mut self, body: StmtId, labels: Vec<String>, brk: Label, cont: Label) -> CResult<()> { + self.push_jump_ctx(labels, true, true, brk, Some(cont)); + let r = self.stmt(body); + self.contexts.pop(); + r + } + + fn loop_stmt(&mut self, id: StmtId, labels: Vec<String>) -> CResult<()> { + let brk = self.new_label(); + let cont = self.new_label(); + match self.ast.stmt(id) { + Some(Stmt::While { test, body }) => { + self.bind(cont); + self.expr(*test)?; + self.jump(Op::JumpIfFalse, brk); + self.loop_body(*body, labels, brk, cont)?; + self.jump(Op::Jump, cont); + } + Some(Stmt::DoWhile { body, test }) => { + let top = self.new_label(); + self.bind(top); + self.loop_body(*body, labels, brk, cont)?; + self.bind(cont); + self.expr(*test)?; + self.jump(Op::JumpIfTrue, top); + } + Some(Stmt::For { init, test, update, body }) => { + match init { + Some(ForInit::Var(decls)) => self.var_decls(decls)?, + Some(ForInit::Expr(e)) => { + self.expr(*e)?; + self.emit(Op::Pop); + } + None => {} + } + let head = self.new_label(); + self.bind(head); + if let Some(t) = test { + self.expr(*t)?; + self.jump(Op::JumpIfFalse, brk); + } + self.loop_body(*body, labels, brk, cont)?; + self.bind(cont); + if let Some(u) = update { + self.expr(*u)?; + self.emit(Op::Pop); + } + self.jump(Op::Jump, head); + } + Some(Stmt::ForIn { target, object, body }) => return self.for_in(target, *object, *body, labels), + _ => return internal("not a loop"), + } + self.bind(brk); + Ok(()) + } + + fn for_in(&mut self, target: &ForInTarget, object: ExprId, body: StmtId, labels: Vec<String>) -> CResult<()> { + if let ForInTarget::Var(d) = target { + self.var_decls(std::slice::from_ref(d))?; + } + self.expr(object)?; + self.emit(Op::ForInStart); + let iter_depth = self.depth; + let head = self.new_label(); + let brk = self.new_label(); + self.bind(head); + self.jump(Op::ForInNext, brk); + match target { + ForInTarget::Var(d) => { + let n = self.var_name(&d.name.name); + self.emit(Op::SetVar(n)); + } + ForInTarget::Expr(e) => self.store_key_to(*e)?, + } + self.emit(Op::Pop); + self.loop_body(body, labels, brk, head)?; + self.jump(Op::Jump, head); + self.bind(brk); + self.depth = iter_depth; + self.emit(Op::Pop); + Ok(()) + } + + /// Assigns the name on top of the stack to a `for-in` target expression, leaving it on top. + fn store_key_to(&mut self, target: ExprId) -> CResult<()> { + let target = self.ast.unparenthesized(target); + self.at(self.ast.expr_span(target).start); + match self.ast.expr(target) { + Some(Expr::Ident(name)) => { + let n = self.var_name(name); + self.emit(Op::SetVar(n)); + } + Some(Expr::Member { object, property }) => { + self.expr(*object)?; + self.emit(Op::Swap); + let n = self.name_key(key_of(&property.name)); + self.emit(Op::SetMember(n)); + } + Some(Expr::Index { object, index }) => { + self.expr(*object)?; + self.expr(*index)?; + self.emit(Op::ToKey); + self.emit(Op::Rot3); + self.emit(Op::SetIndex); + } + _ => { + self.expr(target)?; + self.emit(Op::Pop); + self.throw_invalid_target(); + } + } + Ok(()) + } + + fn throw_invalid_target(&mut self) { + let s = self.string(&"invalid assignment target".encode_utf16().collect::<Vec<u16>>()); + let depth = self.depth; + self.emit(Op::ThrowReferenceError(s)); + self.depth = depth; + } + + fn switch_stmt(&mut self, id: StmtId, labels: Vec<String>) -> CResult<()> { + let Some(Stmt::Switch { discriminant, cases }) = self.ast.stmt(id) else { return internal("not a switch") }; + self.expr(*discriminant)?; + let d = self.depth; + let brk = self.new_label(); + let case_labels: Vec<Label> = cases.iter().map(|_| self.new_label()).collect(); + for (case, &label) in cases.iter().zip(&case_labels) { + if let Some(test) = case.test { + self.emit(Op::Dup); + self.expr(test)?; + self.at(case.span.start); + self.emit(Op::Binary(BinaryOp::StrictEq)); + self.jump(Op::JumpIfTrue, label); + } + } + match cases.iter().position(|c| c.test.is_none()).and_then(|i| case_labels.get(i)) { + Some(&default) => self.jump(Op::Jump, default), + None => self.jump(Op::Jump, brk), + } + self.push_jump_ctx(labels, false, true, brk, None); + for (case, &label) in cases.iter().zip(&case_labels) { + self.bind(label); + self.depth = d; + self.stmts(&case.body)?; + } + self.contexts.pop(); + self.bind(brk); + self.depth = d; + self.emit(Op::Pop); + Ok(()) + } + + fn jump_stmt(&mut self, label: Option<&str>, is_continue: bool) -> CResult<()> { + let target = self.contexts.iter().enumerate().rev().find_map(|(i, c)| match c { + Ctx::Jump { labels, is_loop, plain_break, .. } => { + let hit = match label { + Some(l) => labels.iter().any(|x| x == l) && (!is_continue || *is_loop), + None if is_continue => *is_loop, + None => *plain_break, + }; + hit.then_some(i) + } + Ctx::Finally { .. } => None, + }); + match target { + Some(i) => self.emit_exit(if is_continue { Exit::Continue(i) } else { Exit::Break(i) }), + None => internal("break or continue without a target"), + } + } + + /// Emits a jump out to `exit`, dropping the values and environments in between and going + /// through each `finally` block on the way. + fn emit_exit(&mut self, exit: Exit) -> CResult<()> { + let (saved, saved_env) = (self.depth, self.env_depth); + let mut i = self.contexts.len(); + loop { + let Some(next) = i.checked_sub(1) else { + if exit != Exit::Return { + return internal("jump target not found"); + } + self.emit(Op::Return); + break; + }; + i = next; + match self.contexts.get(i) { + Some(&Ctx::Jump { brk, cont, depth, env_depth, .. }) => { + let label = match exit { + Exit::Break(t) if t == i => Some(brk), + Exit::Continue(t) if t == i => cont, + _ => None, + }; + if let Some(label) = label { + self.unwind_to(depth, env_depth)?; + self.jump(Op::Jump, label); + break; + } + } + Some(&Ctx::Finally { try_depth, env_depth, entry, .. }) => { + self.route_through_finally(i, exit, try_depth, env_depth, entry)?; + break; + } + None => return internal("context out of range"), + } + } + self.depth = saved; + self.env_depth = saved_env; + Ok(()) + } + + fn route_through_finally(&mut self, ctx: usize, exit: Exit, try_depth: u32, env_depth: u32, entry: Label) -> CResult<()> { + let Some(Ctx::Finally { exits, .. }) = self.contexts.get_mut(ctx) else { return internal("not a finally context") }; + let k = match exits.iter().position(|e| *e == exit) { + Some(k) => k, + None => { + exits.push(exit); + exits.len() - 1 + } + }; + if exit == Exit::Return { + let Some(under) = self.depth.checked_sub(1 + try_depth) else { return internal("stack below a try block") }; + if under > 0 { + self.emit(Op::PopUnder(under)); + } + self.pop_envs(env_depth)?; + } else { + self.unwind_to(try_depth, env_depth)?; + self.emit(Op::Undefined); + } + self.push_number((k + 2) as f64); + self.jump(Op::Jump, entry); + Ok(()) + } + + fn unwind_to(&mut self, depth: u32, env_depth: u32) -> CResult<()> { + if self.depth < depth { + return internal("jump to a deeper stack"); + } + while self.depth > depth { + self.emit(Op::Pop); + } + self.pop_envs(env_depth) + } + + fn pop_envs(&mut self, env_depth: u32) -> CResult<()> { + if self.env_depth < env_depth { + return internal("jump into an environment"); + } + for _ in env_depth..self.env_depth { + self.ops.push(Op::PopEnv); + self.positions.push(self.pos); + } + Ok(()) + } + + fn try_stmt(&mut self, block: &[StmtId], handler: Option<&crate::ast::CatchClause>, finalizer: Option<&[StmtId]>) -> CResult<()> { + let try_depth = self.depth; + let env_depth = self.env_depth; + let fin_entry = self.new_label(); + if finalizer.is_some() { + self.contexts.push(Ctx::Finally { try_depth, env_depth, entry: fin_entry, exits: Vec::new() }); + } + let start = self.pc(); + self.stmts(block)?; + if let Some(h) = handler { + let after = self.new_label(); + let catch = self.new_label(); + self.jump(Op::Jump, after); + let end = self.pc(); + self.handlers.push((start, end, catch, try_depth, env_depth)); + self.bind(catch); + self.depth = try_depth + 1; + self.at(h.param.span.start); + let n = self.var_name(&h.param.name); + self.emit(Op::PushCatch(n)); + self.env_depth += 1; + self.stmts(&h.body)?; + self.emit(Op::PopEnv); + self.env_depth -= 1; + self.bind(after); + } + let Some(fin) = finalizer else { return Ok(()) }; + let protected_end = self.pc(); + let Some(Ctx::Finally { exits, .. }) = self.contexts.pop() else { return internal("finally context missing") }; + let after = self.new_label(); + let stub = self.new_label(); + self.handlers.push((start, protected_end, stub, try_depth, env_depth)); + self.emit(Op::Undefined); + self.push_number(0.0); + self.bind(fin_entry); + self.stmts(fin)?; + let dispatch: Vec<Label> = exits.iter().map(|_| self.new_label()).collect(); + let table = u32::try_from(self.finally_tables.len()).unwrap_or(u32::MAX); + self.finally_tables.push(dispatch.clone()); + self.emit(Op::EndFinally(table)); + self.jump(Op::Jump, after); + self.bind(stub); + self.depth = try_depth + 1; + self.push_number(1.0); + self.jump(Op::Jump, fin_entry); + for (exit, label) in exits.into_iter().zip(dispatch) { + self.bind(label); + self.depth = try_depth + 1; + if exit != Exit::Return { + self.emit(Op::Pop); + } + self.emit_exit(exit)?; + } + self.bind(after); + self.depth = try_depth; + Ok(()) + } + + fn pc(&self) -> u32 { + u32::try_from(self.ops.len()).unwrap_or(u32::MAX) + } + + // ---- expressions ---- + + fn expr(&mut self, id: ExprId) -> CResult<()> { + self.enter()?; + let r = self.expr_inner(self.ast.unparenthesized(id)); + self.leave(); + r + } + + fn expr_inner(&mut self, id: ExprId) -> CResult<()> { + let Some(e) = self.ast.expr(id) else { return internal("expression out of range") }; + self.at(self.ast.expr_span(id).start); + match e { + Expr::This => self.emit(Op::This), + Expr::Ident(name) => { + let n = self.var_name(name); + self.emit(Op::GetVar(n)); + } + Expr::Null => self.emit(Op::Null), + Expr::Bool(b) => self.emit(if *b { Op::True } else { Op::False }), + Expr::Number(n) => self.push_number(*n), + Expr::String(s) => { + let i = self.string(s); + self.emit(Op::String(i)); + } + Expr::RegExp { pattern, flags } => { + self.regexps.push((Str::from_units(pattern.clone()), Str::from(flags.as_str()))); + let i = u32::try_from(self.regexps.len() - 1).unwrap_or(u32::MAX); + self.emit(Op::RegExp(i)); + } + Expr::Array(elements) => { + self.emit(Op::NewArray); + for el in elements { + match el { + Some(e) => { + self.expr(*e)?; + self.emit(Op::ArrayPush); + } + None => self.emit(Op::ArrayHole), + } + } + } + Expr::Object(props) => self.object_literal(props)?, + Expr::Function(fid) => { + let i = self.function(*fid)?; + self.emit(Op::Closure(i)); + } + Expr::Member { .. } | Expr::Index { .. } | Expr::Call { .. } => self.chain(id)?, + Expr::New { callee, args } => { + self.expr(*callee)?; + self.args(args)?; + self.at(self.ast.expr_span(id).start); + self.emit(Op::New(len_u32(args.len()))); + } + Expr::Update { op, prefix, target } => self.update(*op, *prefix, *target)?, + Expr::Unary { .. } => self.unary(id)?, + Expr::Binary { .. } | Expr::Logical { .. } => self.binary_chain(id)?, + Expr::Conditional { test, consequent, alternate } => { + self.expr(*test)?; + let d = self.depth; + let otherwise = self.new_label(); + let end = self.new_label(); + self.jump(Op::JumpIfFalse, otherwise); + self.expr(*consequent)?; + self.jump(Op::Jump, end); + self.bind(otherwise); + self.depth = d.saturating_sub(1); + self.expr(*alternate)?; + self.bind(end); + } + Expr::Assign { .. } => self.assign_chain(id)?, + Expr::Sequence(list) => { + for (i, e) in list.iter().enumerate() { + if i > 0 { + self.emit(Op::Pop); + } + self.expr(*e)?; + } + if list.is_empty() { + self.emit(Op::Undefined); + } + } + Expr::Paren(inner) => self.expr(*inner)?, + } + Ok(()) + } + + fn args(&mut self, args: &[ExprId]) -> CResult<()> { + for a in args { + self.expr(*a)?; + } + Ok(()) + } + + fn object_literal(&mut self, props: &[crate::ast::Property]) -> CResult<()> { + self.emit(Op::NewObject); + for p in props { + let n = self.name_key(p.key.to_utf16().into()); + match &p.value { + PropertyValue::Init(e) => { + self.expr(*e)?; + self.emit(Op::InitProp(n)); + } + PropertyValue::Get(f) => { + let i = self.function(*f)?; + self.emit(Op::Closure(i)); + self.emit(Op::InitGetter(n)); + } + PropertyValue::Set(f) => { + let i = self.function(*f)?; + self.emit(Op::Closure(i)); + self.emit(Op::InitSetter(n)); + } + } + } + Ok(()) + } + + /// A run of member accesses and calls (`a.b[c](d).e`), compiled from the innermost object out + /// with a loop. + fn chain(&mut self, id: ExprId) -> CResult<()> { + let mut suffixes = Vec::new(); + let mut cur = id; + loop { + match self.ast.expr(cur) { + Some(Expr::Member { object, .. } | Expr::Index { object, .. }) => { + suffixes.push(cur); + cur = self.ast.unparenthesized(*object); + } + Some(Expr::Call { callee, .. }) => { + suffixes.push(cur); + cur = self.ast.unparenthesized(*callee); + } + _ => break, + } + } + let is_call = |c: &Self, e: Option<&ExprId>| e.is_some_and(|&e| matches!(c.ast.expr(e), Some(Expr::Call { .. }))); + let first_is_call = is_call(self, suffixes.last()); + let mut base_has_this = false; + let mut eval_call = false; + match self.ast.expr(cur) { + Some(Expr::Ident(name)) if first_is_call => { + self.at(self.ast.expr_span(cur).start); + let n = self.var_name(name); + self.emit(Op::GetVarThis(n)); + base_has_this = true; + eval_call = name == "eval"; + } + _ => self.expr(cur)?, + } + let count = suffixes.len(); + for j in (0..count).rev() { + let Some(&s) = suffixes.get(j) else { return internal("suffix out of range") }; + let next_is_call = j > 0 && is_call(self, suffixes.get(j - 1)); + let span = self.ast.expr_span(s); + match self.ast.expr(s) { + Some(Expr::Member { property, .. }) => { + self.at(span.start); + let n = self.name_key(key_of(&property.name)); + self.emit(if next_is_call { Op::GetMemberThis(n) } else { Op::GetMember(n) }); + } + Some(Expr::Index { index, .. }) => { + self.expr(*index)?; + self.at(span.start); + self.emit(if next_is_call { Op::GetIndexThis } else { Op::GetIndex }); + } + Some(Expr::Call { args, .. }) => { + let innermost = j + 1 == count; + let has_this = if innermost { + base_has_this + } else { + matches!(suffixes.get(j + 1).and_then(|&e| self.ast.expr(e)), Some(Expr::Member { .. } | Expr::Index { .. })) + }; + if !has_this { + self.emit(Op::Undefined); + } + self.args(args)?; + self.at(span.start); + let n = len_u32(args.len()); + if innermost && eval_call { + self.uses_arguments = true; + self.emit(Op::CallEval(n)); + } else { + self.emit(Op::Call(n)); + } + } + _ => return internal("not a member or call"), + } + } + Ok(()) + } + + fn unary(&mut self, id: ExprId) -> CResult<()> { + let mut ops = Vec::new(); + let mut cur = id; + while let Some(Expr::Unary { op, arg }) = self.ast.expr(cur) { + if matches!(op, UnaryOp::Delete | UnaryOp::Typeof) { + break; + } + ops.push(*op); + cur = self.ast.unparenthesized(*arg); + } + match self.ast.expr(cur) { + Some(Expr::Unary { op: UnaryOp::Typeof, arg }) => self.typeof_expr(*arg)?, + Some(Expr::Unary { op: UnaryOp::Delete, arg }) => self.delete_expr(*arg)?, + _ => self.expr(cur)?, + } + self.at(self.ast.expr_span(id).start); + for op in ops.into_iter().rev() { + match op { + UnaryOp::Plus => self.emit(Op::Plus), + UnaryOp::Minus => self.emit(Op::Neg), + UnaryOp::BitNot => self.emit(Op::BitNot), + UnaryOp::Not => self.emit(Op::Not), + UnaryOp::Void => { + self.emit(Op::Pop); + self.emit(Op::Undefined); + } + UnaryOp::Delete | UnaryOp::Typeof => return internal("unexpected operator"), + } + } + Ok(()) + } + + fn typeof_expr(&mut self, arg: ExprId) -> CResult<()> { + let arg = self.ast.unparenthesized(arg); + if let Some(Expr::Ident(name)) = self.ast.expr(arg) { + let n = self.var_name(name); + self.emit(Op::TypeofVar(n)); + } else { + self.expr(arg)?; + self.emit(Op::Typeof); + } + Ok(()) + } + + fn delete_expr(&mut self, arg: ExprId) -> CResult<()> { + let arg = self.ast.unparenthesized(arg); + match self.ast.expr(arg) { + Some(Expr::Ident(name)) => { + let n = self.var_name(name); + self.emit(Op::DeleteVar(n)); + } + Some(Expr::Member { object, property }) => { + self.expr(*object)?; + let n = self.name_key(key_of(&property.name)); + self.emit(Op::DeleteMember(n)); + } + Some(Expr::Index { object, index }) => { + self.expr(*object)?; + self.expr(*index)?; + self.emit(Op::DeleteIndex); + } + _ => { + self.expr(arg)?; + self.emit(Op::Pop); + self.emit(Op::True); + } + } + Ok(()) + } + + /// A run of binary and logical operators nested on the left (`a + b + c`), compiled with a + /// loop. + fn binary_chain(&mut self, id: ExprId) -> CResult<()> { + enum Step { + Binary(BinaryOp), + Logical(LogicalOp), + } + let mut spine = Vec::new(); + let mut cur = id; + loop { + match self.ast.expr(cur) { + Some(Expr::Binary { op, left, right }) => { + spine.push((Step::Binary(*op), *right, cur)); + cur = self.ast.unparenthesized(*left); + } + Some(Expr::Logical { op, left, right }) => { + spine.push((Step::Logical(*op), *right, cur)); + cur = self.ast.unparenthesized(*left); + } + _ => break, + } + } + self.expr(cur)?; + for (step, right, node) in spine.into_iter().rev() { + match step { + Step::Binary(op) => { + self.expr(right)?; + self.at(self.ast.expr_span(node).start); + self.emit(Op::Binary(op)); + } + Step::Logical(op) => { + let end = self.new_label(); + self.jump(if op == LogicalOp::And { Op::JumpIfFalseKeep } else { Op::JumpIfTrueKeep }, end); + self.expr(right)?; + self.bind(end); + } + } + } + Ok(()) + } + + /// A chain of assignments nested on the right (`a = b.c = d[e] = 1`), compiled with a loop: + /// each target's object and key first, from the left, then the value, then the stores from + /// the right. + fn assign_chain(&mut self, id: ExprId) -> CResult<()> { + let mut chain: Vec<(AssignOp, ExprId, ExprId)> = Vec::new(); + let mut cur = id; + while let Some(Expr::Assign { op, target, value }) = self.ast.expr(cur) { + chain.push((*op, self.ast.unparenthesized(*target), cur)); + cur = self.ast.unparenthesized(*value); + } + for &(op, target, node) in &chain { + self.at(self.ast.expr_span(node).start); + self.assign_target(op, target)?; + } + self.expr(cur)?; + for &(op, target, node) in chain.iter().rev() { + self.at(self.ast.expr_span(node).start); + if let Some(b) = op { + self.emit(Op::Binary(b)); + } + self.store(target)?; + } + Ok(()) + } + + /// Pushes what an assignment to `target` needs before its value: the object and key, and the + /// current value for a compound assignment. + fn assign_target(&mut self, op: AssignOp, target: ExprId) -> CResult<()> { + match self.ast.expr(target) { + Some(Expr::Ident(name)) => { + let n = self.var_name(name); + self.emit(Op::RefVar(n)); + if op.is_some() { + self.emit(Op::Dup); + self.emit(Op::GetRef(n)); + } + } + Some(Expr::Member { object, property }) => { + self.expr(*object)?; + if op.is_some() { + self.emit(Op::Dup); + let n = self.name_key(key_of(&property.name)); + self.emit(Op::GetMember(n)); + } else { + self.emit(Op::CheckObject); + } + } + Some(Expr::Index { object, index }) => { + self.expr(*object)?; + self.expr(*index)?; + self.emit(Op::ToKey); + if op.is_some() { + self.emit(Op::Dup2); + self.emit(Op::GetIndex); + } + } + _ => { + self.expr(target)?; + if op.is_none() { + self.emit(Op::Pop); + } + } + } + Ok(()) + } + + fn store(&mut self, target: ExprId) -> CResult<()> { + match self.ast.expr(target) { + Some(Expr::Ident(name)) => { + let n = self.var_name(name); + self.emit(Op::PutRef(n)); + } + Some(Expr::Member { property, .. }) => { + let n = self.name_key(key_of(&property.name)); + self.emit(Op::SetMember(n)); + } + Some(Expr::Index { .. }) => self.emit(Op::SetIndex), + _ => self.throw_invalid_target(), + } + Ok(()) + } + + fn update(&mut self, op: UpdateOp, prefix: bool, target: ExprId) -> CResult<()> { + let target = self.ast.unparenthesized(target); + let dec = op == UpdateOp::Decrement; + match self.ast.expr(target) { + Some(Expr::Ident(name)) => { + let n = self.var_name(name); + self.emit(Op::IncVar { name: n, prefix, dec }); + } + Some(Expr::Member { object, property }) => { + self.expr(*object)?; + let n = self.name_key(key_of(&property.name)); + self.emit(Op::IncMember { name: n, prefix, dec }); + } + Some(Expr::Index { object, index }) => { + self.expr(*object)?; + self.expr(*index)?; + self.emit(Op::IncIndex { prefix, dec }); + } + _ => { + self.expr(target)?; + self.emit(Op::ToNumber); + self.throw_invalid_target(); + } + } + Ok(()) + } + + // ---- finishing ---- + + fn finish(mut self, hoisted: Hoisted, meta: Meta) -> CResult<Rc<Code>> { + let resolve = |labels: &[Option<u32>], l: Label| labels.get(l.0).copied().flatten(); + for &(i, label) in &self.patches { + let Some(target) = resolve(&self.labels, label) else { return internal("jump to an unplaced label") }; + match self.ops.get_mut(i) { + Some(Op::Jump(t) | Op::JumpIfFalse(t) | Op::JumpIfTrue(t) | Op::JumpIfFalseKeep(t) | Op::JumpIfTrueKeep(t) | Op::ForInNext(t)) => { + *t = target + } + _ => return internal("patching an instruction that is not a jump"), + } + } + let mut handlers = Vec::new(); + for &(start, end, label, depth, env_depth) in &self.handlers { + let Some(target) = resolve(&self.labels, label) else { return internal("handler at an unplaced label") }; + handlers.push(Handler { start, end, target, depth, env_depth }); + } + let mut finally_tables = Vec::new(); + for table in &self.finally_tables { + let resolved: Option<Vec<u32>> = table.iter().map(|&l| resolve(&self.labels, l)).collect(); + let Some(resolved) = resolved else { return internal("finally exit at an unplaced label") }; + finally_tables.push(resolved); + } + let mut code = Code { + ops: std::mem::take(&mut self.ops), + positions: std::mem::take(&mut self.positions), + numbers: std::mem::take(&mut self.numbers), + strings: std::mem::take(&mut self.strings), + names: std::mem::take(&mut self.names), + functions: std::mem::take(&mut self.functions), + regexps: std::mem::take(&mut self.regexps), + handlers, + finally_tables, + max_stack: 0, + strict: self.strict, + self_name: meta.self_name, + params: meta.params, + vars: hoisted.vars, + fun_decls: hoisted.fun_decls, + uses_arguments: self.uses_arguments, + source: self.source.clone(), + span: meta.span, + }; + code.max_stack = verify(&code).map_err(CompileFail::Internal)?; + Ok(Rc::new(code)) + } +} + +/// What a code knows about the function it is, if it is one. +#[derive(Default)] +struct Meta { + self_name: Option<Key>, + params: Vec<Key>, + span: (u32, u32), +} + +fn len_u32(n: usize) -> u32 { + u32::try_from(n).unwrap_or(u32::MAX) +} + +/// Compiles function `fid` of `ast`. +pub(crate) fn compile_function(ast: &Ast, fid: FunctionId, source: &Rc<Source>, recursion: u32) -> CResult<Rc<Code>> { + let Some(f) = ast.function(fid) else { return internal("function out of range") }; + let mut c = FnCompiler::new(ast, source, CodeKind::Function, f.strict, recursion); + c.at(f.span.start); + c.enter()?; + let hoisted = c.hoist(&f.body)?; + c.stmts(&f.body)?; + c.at(f.span.end.saturating_sub(1)); + c.emit(Op::Undefined); + c.emit(Op::Return); + let name = f.name.as_ref().map(|n| key_of(&n.name)); + let meta = Meta { + self_name: if f.kind == FunctionKind::Expression { name } else { None }, + params: f.params.iter().map(|p| key_of(&p.name)).collect(), + span: (f.span.start, f.span.end), + }; + c.finish(hoisted, meta) +} + +/// Compiles a script (`kind` [`CodeKind::Global`]) or eval code ([`CodeKind::Eval`]). +pub(crate) fn compile_program(program: &crate::Program, source: &Rc<Source>, kind: CodeKind) -> CResult<Rc<Code>> { + let mut c = FnCompiler::new(&program.ast, source, kind, program.strict, 0); + let hoisted = c.hoist(&program.body)?; + c.stmts(&program.body)?; + c.emit(Op::ReturnCompletion); + let end = u32::try_from(source.text.len()).unwrap_or(u32::MAX); + c.finish(hoisted, Meta { span: (0, end), ..Meta::default() }) +} + +/// The function of a `Function` constructor's source, `(function anonymous(…) {…})`: the program +/// must be that one parenthesized function expression and nothing else. +pub(crate) fn compile_constructed_function(program: &crate::Program, source: &Rc<Source>) -> CResult<Option<Rc<Code>>> { + let [stmt] = program.body.as_slice() else { return Ok(None) }; + let Some(Stmt::Expr(e)) = program.ast.stmt(*stmt) else { return Ok(None) }; + let Some(Expr::Paren(inner)) = program.ast.expr(*e) else { return Ok(None) }; + let Some(Expr::Function(fid)) = program.ast.expr(*inner) else { return Ok(None) }; + compile_function(&program.ast, *fid, source, 0).map(Some) +} diff --git a/crates/js/src/heap.rs b/crates/js/src/heap.rs new file mode 100644 index 000000000..1e590301e --- /dev/null +++ b/crates/js/src/heap.rs @@ -0,0 +1,515 @@ +//! The heap: objects and environments in one arena, referred to by index, and a mark-sweep +//! collector over it. +//! +//! A cell is either free or holds an [`Object`] or an [`Env`]. The collector marks every cell +//! reachable from the roots the interpreter passes in, with a worklist rather than recursion, and +//! frees the rest; freed indices are reused by later allocations. Cycles between objects are freed +//! like anything else that is unreachable. [`Heap::bytes`] is an estimate of the memory the heap +//! holds, strings reachable from it included; the interpreter compares it with its heap limit. + +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; + +use crate::bytecode::Code; +use crate::value::{Str, Value}; + +/// A property name: its UTF-16 code units. +pub(crate) type Key = Rc<[u16]>; + +pub(crate) fn key(s: &str) -> Key { + s.encode_utf16().collect::<Vec<u16>>().into() +} + +/// An object's index in the heap. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct ObjId(pub(crate) u32); + +/// An environment's index in the heap. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct EnvId(pub(crate) u32); + +/// A map from property names to values that keeps insertion order (the order `for-in` lists own +/// properties in). Small maps are searched in order; larger ones keep a hash index. +#[derive(Clone, Debug)] +pub(crate) struct PropMap<T> { + entries: Vec<Option<(Key, T)>>, + index: Option<HashMap<Key, usize>>, + live: usize, +} + +/// Maps with more entries than this keep a hash index. +const INDEX_AFTER: usize = 8; + +impl<T> Default for PropMap<T> { + fn default() -> Self { + Self { entries: Vec::new(), index: None, live: 0 } + } +} + +impl<T> PropMap<T> { + fn position(&self, k: &[u16]) -> Option<usize> { + match &self.index { + Some(index) => index.get(k).copied(), + None => self.entries.iter().position(|e| e.as_ref().is_some_and(|(ek, _)| &**ek == k)), + } + } + + pub(crate) fn get(&self, k: &[u16]) -> Option<&T> { + let i = self.position(k)?; + self.entries.get(i)?.as_ref().map(|(_, v)| v) + } + + pub(crate) fn get_mut(&mut self, k: &[u16]) -> Option<&mut T> { + let i = self.position(k)?; + self.entries.get_mut(i)?.as_mut().map(|(_, v)| v) + } + + pub(crate) fn contains(&self, k: &[u16]) -> bool { + self.position(k).is_some() + } + + /// Sets the value under `k`, keeping its place when it is there already. + pub(crate) fn insert(&mut self, k: Key, v: T) { + if let Some(slot) = self.get_mut(&k) { + *slot = v; + return; + } + let i = self.entries.len(); + if let Some(index) = &mut self.index { + index.insert(k.clone(), i); + } + self.entries.push(Some((k, v))); + self.live += 1; + if self.index.is_none() && self.entries.len() > INDEX_AFTER { + self.rebuild_index(); + } + } + + pub(crate) fn remove(&mut self, k: &[u16]) -> Option<T> { + let i = self.position(k)?; + let (_, v) = self.entries.get_mut(i)?.take()?; + if let Some(index) = &mut self.index { + index.remove(k); + } + self.live -= 1; + if self.entries.len() > 2 * INDEX_AFTER && self.live * 2 < self.entries.len() { + self.entries.retain(Option::is_some); + self.rebuild_index(); + } + Some(v) + } + + fn rebuild_index(&mut self) { + if self.entries.len() <= INDEX_AFTER { + self.index = None; + return; + } + let index = self.entries.iter().enumerate().filter_map(|(i, e)| e.as_ref().map(|(k, _)| (k.clone(), i))).collect(); + self.index = Some(index); + } + + pub(crate) fn len(&self) -> usize { + self.live + } + + pub(crate) fn iter(&self) -> impl Iterator<Item = (&Key, &T)> { + self.entries.iter().filter_map(|e| e.as_ref().map(|(k, v)| (k, v))) + } + + pub(crate) fn keys(&self) -> Vec<Key> { + self.iter().map(|(k, _)| k.clone()).collect() + } +} + +pub(crate) const WRITABLE: u8 = 1; +pub(crate) const ENUMERABLE: u8 = 2; +pub(crate) const CONFIGURABLE: u8 = 4; +/// Writable, enumerable and configurable: a property made by assignment. +pub(crate) const OPEN: u8 = WRITABLE | ENUMERABLE | CONFIGURABLE; +/// Writable and configurable but not enumerable: the built-in methods. +pub(crate) const HIDDEN: u8 = WRITABLE | CONFIGURABLE; + +/// A property (section 8.6.1). +#[derive(Clone, Debug)] +pub(crate) enum Prop { + Data { value: Value, flags: u8 }, + Accessor { get: Option<ObjId>, set: Option<ObjId>, flags: u8 }, +} + +impl Prop { + pub(crate) fn flags(&self) -> u8 { + match self { + Prop::Data { flags, .. } | Prop::Accessor { flags, .. } => *flags, + } + } + + pub(crate) fn enumerable(&self) -> bool { + self.flags() & ENUMERABLE != 0 + } + + pub(crate) fn configurable(&self) -> bool { + self.flags() & CONFIGURABLE != 0 + } +} + +/// A built-in function implemented in Rust: `(interp, this, arguments, called with new)`. +pub(crate) type NativeFn = fn(&mut crate::interp::Interp, Value, &[Value], bool) -> Result<Value, crate::interp::Abrupt>; + +/// A function the embedding defines. +pub(crate) type HostFn = dyn Fn(&[crate::JsValue]) -> Result<crate::JsValue, String>; + +/// The built-ins the interpreter treats specially when it calls them. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum NativeKind { + Plain, + /// `Function.prototype.call`. + Call, + /// `Function.prototype.apply`. + Apply, + /// The global `eval`: a call to it by that name is a direct eval. + Eval, +} + +#[derive(Clone)] +pub(crate) enum Func { + Closure { code: Rc<Code>, env: EnvId }, + Native { f: NativeFn, kind: NativeKind, constructor: bool, name: &'static str }, + Host { f: Rc<HostFn>, name: Rc<str> }, +} + +/// A sloppy mode `arguments` object's link to its function's parameters (section 10.6): index +/// `i` reads and writes the binding `names[i]` while that is `Some`. +#[derive(Clone, Debug)] +pub(crate) struct ArgMap { + pub(crate) env: EnvId, + pub(crate) names: Vec<Option<Key>>, +} + +/// A `for-in` loop's state: the names listed when it started, and the object they came from. +#[derive(Clone, Debug)] +pub(crate) struct ForIn { + pub(crate) keys: Vec<Key>, + pub(crate) pos: usize, + pub(crate) object: Option<ObjId>, +} + +/// What kind of object it is, with the internal state that kind has. +#[derive(Clone)] +pub(crate) enum Class { + Ordinary, + Array { + length: u32, + length_writable: bool, + }, + Function(Func), + Error, + Arguments(Box<ArgMap>), + /// A `Boolean` object made by ToObject. + Boolean, + /// A `Number` object made by ToObject. + Number, + String(Str), + RegExp, + ForIn(Box<ForIn>), +} + +impl Class { + /// The `[[Class]]` name `Object.prototype.toString` reports. + pub(crate) fn name(&self) -> &'static str { + match self { + Class::Ordinary | Class::ForIn(_) => "Object", + Class::Array { .. } => "Array", + Class::Function(_) => "Function", + Class::Error => "Error", + Class::Arguments(_) => "Arguments", + Class::Boolean => "Boolean", + Class::Number => "Number", + Class::String(_) => "String", + Class::RegExp => "RegExp", + } + } +} + +pub(crate) struct Object { + pub(crate) class: Class, + pub(crate) proto: Option<ObjId>, + pub(crate) extensible: bool, + pub(crate) props: PropMap<Prop>, +} + +impl Object { + pub(crate) fn new(class: Class, proto: Option<ObjId>) -> Self { + Self { class, proto, extensible: true, props: PropMap::default() } + } +} + +/// A binding in a declarative environment. +#[derive(Clone, Debug)] +pub(crate) struct Binding { + pub(crate) value: Value, + pub(crate) mutable: bool, + pub(crate) deletable: bool, +} + +/// An environment record (section 10.2.1) with its outer environment. +pub(crate) enum Env { + Decl { + bindings: PropMap<Binding>, + outer: Option<EnvId>, + }, + /// The global object, or a `with` statement's object (`with_this`: calls to its functions get + /// it as `this`). + Obj { + object: ObjId, + with_this: bool, + outer: Option<EnvId>, + }, +} + +impl Env { + pub(crate) fn outer(&self) -> Option<EnvId> { + match self { + Env::Decl { outer, .. } | Env::Obj { outer, .. } => *outer, + } + } +} + +enum Cell { + Free, + Object(Box<Object>), + Env(Box<Env>), +} + +/// The estimated bytes of a property or a binding, its name included. +pub(crate) const PROP_BYTES: usize = 64; +const OBJECT_BYTES: usize = 96; +const ENV_BYTES: usize = 64; +/// The estimated bytes of a rope node. +pub(crate) const ROPE_BYTES: usize = 48; + +pub(crate) fn string_bytes(len: usize) -> usize { + 32 + 2 * len +} + +#[derive(Default)] +pub(crate) struct Heap { + cells: Vec<Cell>, + free: Vec<u32>, + /// An estimate of the bytes the heap holds: the live total from the last collection plus what + /// was allocated since. + pub(crate) bytes: usize, + /// How many collections have run. + pub(crate) collections: usize, +} + +impl Heap { + fn alloc(&mut self, cell: Cell, bytes: usize) -> u32 { + self.bytes += bytes; + if let Some(i) = self.free.pop() + && let Some(slot) = self.cells.get_mut(i as usize) + { + *slot = cell; + return i; + } + self.cells.push(cell); + u32::try_from(self.cells.len() - 1).unwrap_or(u32::MAX) + } + + pub(crate) fn alloc_object(&mut self, object: Object) -> ObjId { + let bytes = OBJECT_BYTES + PROP_BYTES * object.props.len(); + ObjId(self.alloc(Cell::Object(Box::new(object)), bytes)) + } + + pub(crate) fn alloc_env(&mut self, env: Env) -> EnvId { + let bytes = ENV_BYTES + if let Env::Decl { bindings, .. } = &env { PROP_BYTES * bindings.len() } else { 0 }; + EnvId(self.alloc(Cell::Env(Box::new(env)), bytes)) + } + + pub(crate) fn object(&self, id: ObjId) -> Option<&Object> { + match self.cells.get(id.0 as usize) { + Some(Cell::Object(o)) => Some(o), + _ => None, + } + } + + pub(crate) fn object_mut(&mut self, id: ObjId) -> Option<&mut Object> { + match self.cells.get_mut(id.0 as usize) { + Some(Cell::Object(o)) => Some(o), + _ => None, + } + } + + pub(crate) fn env(&self, id: EnvId) -> Option<&Env> { + match self.cells.get(id.0 as usize) { + Some(Cell::Env(e)) => Some(e), + _ => None, + } + } + + pub(crate) fn env_mut(&mut self, id: EnvId) -> Option<&mut Env> { + match self.cells.get_mut(id.0 as usize) { + Some(Cell::Env(e)) => Some(e), + _ => None, + } + } + + /// Live cells, for tests. + #[cfg(test)] + pub(crate) fn live_cells(&self) -> usize { + self.cells.iter().filter(|c| !matches!(c, Cell::Free)).count() + } + + /// Marks everything reachable from `roots` and frees the rest. Afterwards [`Heap::bytes`] is + /// the estimated size of what is left, strings reachable from it included. + pub(crate) fn collect(&mut self, roots: &Roots<'_>) { + let mut marks = vec![false; self.cells.len()]; + let mut work: Vec<u32> = Vec::new(); + let mut strings = StringSizes::default(); + let mut live = 0usize; + for v in &roots.values { + mark_value(v, &mut work, &mut strings); + } + work.extend(roots.objects.iter().map(|o| o.0)); + work.extend(roots.envs.iter().map(|e| e.0)); + for code in &roots.codes { + mark_code(code, &mut strings); + } + while let Some(i) = work.pop() { + let Some(mark) = marks.get_mut(i as usize) else { continue }; + if *mark { + continue; + } + *mark = true; + match self.cells.get(i as usize) { + Some(Cell::Object(o)) => live += trace_object(o, &mut work, &mut strings), + Some(Cell::Env(e)) => live += trace_env(e, &mut work, &mut strings), + _ => {} + } + } + for (i, (cell, marked)) in self.cells.iter_mut().zip(&marks).enumerate() { + if !marked && !matches!(cell, Cell::Free) { + *cell = Cell::Free; + self.free.push(u32::try_from(i).unwrap_or(u32::MAX)); + } + } + self.bytes = live + strings.bytes; + self.collections += 1; + } +} + +/// What the collector starts from. +#[derive(Default)] +pub(crate) struct Roots<'a> { + pub(crate) values: Vec<&'a Value>, + pub(crate) objects: Vec<ObjId>, + pub(crate) envs: Vec<EnvId>, + pub(crate) codes: Vec<&'a Code>, +} + +/// Sums the sizes of the strings reached, counting each node once. +#[derive(Default)] +struct StringSizes { + seen: HashSet<usize>, + bytes: usize, +} + +impl StringSizes { + fn add(&mut self, s: &Str) { + let mut stack = vec![s.clone()]; + while let Some(s) = stack.pop() { + if !self.seen.insert(s.node_addr()) { + continue; + } + match s.halves() { + Some((l, r)) => { + self.bytes += ROPE_BYTES; + stack.push(l); + stack.push(r); + } + None => self.bytes += string_bytes(s.len()), + } + } + } +} + +fn mark_value(v: &Value, work: &mut Vec<u32>, strings: &mut StringSizes) { + match v { + Value::Object(o) => work.push(o.0), + Value::String(s) => strings.add(s), + _ => {} + } +} + +fn mark_code(code: &Code, strings: &mut StringSizes) { + for s in &code.strings { + strings.add(s); + } +} + +fn trace_object(o: &Object, work: &mut Vec<u32>, strings: &mut StringSizes) -> usize { + if let Some(p) = o.proto { + work.push(p.0); + } + for (_, prop) in o.props.iter() { + match prop { + Prop::Data { value, .. } => mark_value(value, work, strings), + Prop::Accessor { get, set, .. } => work.extend(get.iter().chain(set.iter()).map(|f| f.0)), + } + } + match &o.class { + Class::Function(Func::Closure { env, code }) => { + work.push(env.0); + mark_code(code, strings); + } + Class::Arguments(map) => work.push(map.env.0), + Class::String(s) => strings.add(s), + Class::ForIn(state) => { + work.extend(state.object.iter().map(|o| o.0)); + strings.bytes += state.keys.iter().map(|k| string_bytes(k.len())).sum::<usize>(); + } + _ => {} + } + OBJECT_BYTES + PROP_BYTES * o.props.len() +} + +fn trace_env(e: &Env, work: &mut Vec<u32>, strings: &mut StringSizes) -> usize { + match e { + Env::Decl { bindings, outer } => { + work.extend(outer.iter().map(|o| o.0)); + for (_, b) in bindings.iter() { + mark_value(&b.value, work, strings); + } + ENV_BYTES + PROP_BYTES * bindings.len() + } + Env::Obj { object, outer, .. } => { + work.push(object.0); + work.extend(outer.iter().map(|o| o.0)); + ENV_BYTES + } + } +} + +#[cfg(test)] +mod tests { + use super::{PropMap, key}; + + #[test] + fn prop_maps_keep_insertion_order_through_removals_and_growth() { + let mut m = PropMap::default(); + for i in 0..40 { + m.insert(key(&format!("k{i}")), i); + } + for i in (0..40).step_by(2) { + assert_eq!(m.remove(&key(&format!("k{i}"))), Some(i)); + } + m.insert(key("k1"), 100); + m.insert(key("new"), 7); + let keys: Vec<String> = m.iter().map(|(k, _)| String::from_utf16_lossy(k)).collect(); + let mut want: Vec<String> = (1..40).step_by(2).map(|i| format!("k{i}")).collect(); + want.push("new".into()); + assert_eq!(keys, want); + assert_eq!(m.get(&key("k1")), Some(&100)); + assert_eq!(m.len(), 21); + assert!(!m.contains(&key("k0"))); + } +} diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs new file mode 100644 index 000000000..5f1f2890a --- /dev/null +++ b/crates/js/src/interp.rs @@ -0,0 +1,2064 @@ +//! The interpreter: runs bytecode on an explicit stack of frames. +//! +//! A call to a function written in JavaScript pushes a frame and goes on in the same loop, so +//! JavaScript recursion uses the heap, not the Rust stack. The loop runs again inside a Rust call +//! only where the language needs a function's result in the middle of an operation (a getter or +//! setter, `valueOf` and `toString` during a conversion, indirect `eval`); those nested runs are +//! counted against [`crate::Limits::reentry`]. +//! +//! Before each instruction the interpreter takes one unit of fuel, checks the interrupt flag and, +//! when the heap has grown past its threshold, collects garbage. Running out of fuel, the heap +//! limit, the frame limit, the re-entry limit and the interrupt flag each end the run with an +//! [`Abrupt::Limit`] that unwinds every frame without running catch or finally blocks. +//! +//! Collection runs only between instructions. An operation that may run JavaScript in the middle +//! keeps its operands on the operand stack until it is done, so everything it holds is reachable +//! from the roots when a nested run collects. + +use std::rc::Rc; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use crate::ast::BinaryOp; +use crate::bytecode::{Code, CodeKind, Op, Source}; +use crate::compiler::{CompileFail, compile_program}; +use crate::heap::{ + ArgMap, Binding, CONFIGURABLE, Class, ENUMERABLE, Env, EnvId, ForIn, Func, HIDDEN, Heap, Key, NativeKind, OPEN, ObjId, Object, PROP_BYTES, Prop, + PropMap, ROPE_BYTES, Roots, WRITABLE, key, string_bytes, +}; +use crate::value::{Str, Value, array_index, index_key, number_to_str, string_to_number, to_int32, to_uint32}; +use crate::{LimitKind, Limits, Options}; + +/// How an operation ended other than normally. +#[derive(Debug)] +pub(crate) enum Abrupt { + /// A JavaScript exception: `try` and `catch` can handle it. + Throw(Value), + /// A limit was reached; nothing handles it. + Limit(LimitKind), + /// The interpreter found itself in a state it never makes (a bug); nothing handles it. + Internal(String), +} + +pub(crate) type JsResult<T> = Result<T, Abrupt>; + +fn internal<T>(message: &str) -> JsResult<T> { + Err(Abrupt::Internal(message.into())) +} + +/// The kinds of error object the engine throws. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ErrorType { + Error, + Eval, + Range, + Reference, + Syntax, + Type, + Uri, +} + +/// The objects the interpreter itself refers to. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Intrinsics { + pub(crate) object_proto: ObjId, + pub(crate) function_proto: ObjId, + pub(crate) array_proto: ObjId, + pub(crate) string_proto: ObjId, + pub(crate) number_proto: ObjId, + pub(crate) boolean_proto: ObjId, + pub(crate) regexp_proto: ObjId, + pub(crate) error_protos: [ObjId; 7], + pub(crate) eval: ObjId, + pub(crate) throw_type_error: ObjId, +} + +impl Intrinsics { + fn placeholder() -> Self { + let o = ObjId(0); + Self { + object_proto: o, + function_proto: o, + array_proto: o, + string_proto: o, + number_proto: o, + boolean_proto: o, + regexp_proto: o, + error_protos: [o; 7], + eval: o, + throw_type_error: o, + } + } + + pub(crate) fn error_proto(&self, t: ErrorType) -> ObjId { + let i = match t { + ErrorType::Error => 0, + ErrorType::Eval => 1, + ErrorType::Range => 2, + ErrorType::Reference => 3, + ErrorType::Syntax => 4, + ErrorType::Type => 5, + ErrorType::Uri => 6, + }; + self.error_protos.get(i).copied().unwrap_or(self.object_proto) + } + + fn all(&self) -> Vec<ObjId> { + let mut v = vec![ + self.object_proto, + self.function_proto, + self.array_proto, + self.string_proto, + self.number_proto, + self.boolean_proto, + self.regexp_proto, + self.eval, + self.throw_type_error, + ]; + v.extend(self.error_protos); + v + } +} + +pub(crate) struct Frame { + pub(crate) code: Rc<Code>, + pub(crate) pc: usize, + /// Where this frame's operands start on the operand stack. + base: usize, + env: EnvId, + var_env: EnvId, + /// `with` and catch environments entered and not yet left. + env_depth: u32, + this: Value, + callee: Option<ObjId>, + /// The object `new` made, returned unless the function returns an object. + construct: Option<ObjId>, + completion: Value, + /// Returning from this frame returns from the Rust call that pushed it. + boundary: bool, +} + +/// Where the exception being thrown was raised: the code and the instruction. +type ThrowSite = (Rc<Code>, usize); + +pub(crate) struct Interp { + pub(crate) heap: Heap, + pub(crate) stack: Vec<Value>, + pub(crate) frames: Vec<Frame>, + /// Values native code holds while it may run JavaScript. + pub(crate) temp_roots: Vec<Value>, + pub(crate) global: ObjId, + pub(crate) global_env: EnvId, + pub(crate) intrinsics: Intrinsics, + pub(crate) limits: Limits, + pub(crate) options: Options, + pub(crate) fuel: u64, + pub(crate) interrupt: Arc<AtomicBool>, + pub(crate) reentry: usize, + gc_threshold: usize, + /// UTF-16 code units of source parsed in this run, for [`Options::max_source_units`]. + pub(crate) source_units: usize, + pub(crate) throw_site: Option<ThrowSite>, + /// The last exception a run did not catch. + pub(crate) last_thrown: Value, +} + +/// The least heap size a collection waits for. +const MIN_GC_BYTES: usize = 1 << 20; + +/// The most arguments `Function.prototype.apply` passes. +pub(crate) const MAX_APPLY_ARGS: usize = 1 << 16; + +/// A call to a function written in JavaScript, ready to enter. +struct Entry { + callee: ObjId, + code: Rc<Code>, + env: EnvId, + this: Value, + args: Vec<Value>, +} + +/// What a call resolved to. +enum Prepared { + Closure(Entry), + Done(Value), +} + +/// A variable found in the environment chain. +enum Resolved { + Decl(EnvId), + Obj { object: ObjId, with_this: bool }, + Unresolved, +} + +/// A reference base as an operand: a declarative environment's index as a number, a binding +/// object as itself, and an unresolvable reference as `undefined`. The environment stays +/// reachable through the frame's environment chain while the operand is on the stack. +fn resolved_to_value(r: &Resolved) -> Value { + match r { + Resolved::Decl(e) => Value::Number(f64::from(e.0)), + Resolved::Obj { object, .. } => Value::Object(*object), + Resolved::Unresolved => Value::Undefined, + } +} + +fn value_to_resolved(v: &Value) -> JsResult<Resolved> { + match v { + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + Value::Number(n) if *n >= 0.0 && *n <= f64::from(u32::MAX) && n.fract() == 0.0 => Ok(Resolved::Decl(EnvId(*n as u32))), + Value::Object(object) => Ok(Resolved::Obj { object: *object, with_this: false }), + Value::Undefined => Ok(Resolved::Unresolved), + _ => internal("not a reference base"), + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Hint { + Default, + Number, + String, +} + +fn is(k: &[u16], s: &str) -> bool { + k.len() == s.len() && k.iter().zip(s.bytes()).all(|(&a, b)| a == u16::from(b)) +} + +impl Interp { + pub(crate) fn new(options: Options, limits: Limits) -> Self { + let mut heap = Heap::default(); + let global = heap.alloc_object(Object::new(Class::Ordinary, None)); + let global_env = heap.alloc_env(Env::Obj { object: global, with_this: false, outer: None }); + let mut interp = Self { + heap, + stack: Vec::new(), + frames: Vec::new(), + temp_roots: Vec::new(), + global, + global_env, + intrinsics: Intrinsics::placeholder(), + fuel: limits.fuel, + limits, + options, + interrupt: Arc::new(AtomicBool::new(false)), + reentry: 0, + gc_threshold: MIN_GC_BYTES, + source_units: 0, + throw_site: None, + last_thrown: Value::Undefined, + }; + crate::builtins::install(&mut interp); + interp.gc_threshold = interp.next_threshold(); + interp + } + + fn next_threshold(&self) -> usize { + (self.heap.bytes.saturating_mul(2)).max(MIN_GC_BYTES).min(self.limits.heap_bytes) + } + + // ---- errors ---- + + pub(crate) fn error_object(&mut self, t: ErrorType, message: &str) -> ObjId { + let proto = self.intrinsics.error_proto(t); + let mut o = Object::new(Class::Error, Some(proto)); + if !message.is_empty() { + o.props.insert(key("message"), Prop::Data { value: Value::str(message), flags: HIDDEN }); + } + self.heap.alloc_object(o) + } + + pub(crate) fn throw(&mut self, t: ErrorType, message: &str) -> Abrupt { + Abrupt::Throw(Value::Object(self.error_object(t, message))) + } + + pub(crate) fn type_error(&mut self, message: &str) -> Abrupt { + self.throw(ErrorType::Type, message) + } + + // ---- the operand stack ---- + + fn push(&mut self, v: Value) -> JsResult<()> { + self.stack.push(v); + Ok(()) + } + + fn frame(&self) -> JsResult<&Frame> { + self.frames.last().ok_or_else(|| Abrupt::Internal("no frame".into())) + } + + fn frame_mut(&mut self) -> JsResult<&mut Frame> { + self.frames.last_mut().ok_or_else(|| Abrupt::Internal("no frame".into())) + } + + fn base(&self) -> usize { + self.frames.last().map_or(0, |f| f.base) + } + + fn pop(&mut self) -> JsResult<Value> { + if self.stack.len() <= self.base() { + return internal("operand stack underflow"); + } + self.stack.pop().ok_or_else(|| Abrupt::Internal("operand stack underflow".into())) + } + + /// The value `n` places below the top (0 is the top). + fn peek(&self, n: usize) -> JsResult<Value> { + let len = self.stack.len(); + match len.checked_sub(n + 1).filter(|&i| i >= self.base()) { + Some(i) => self.stack.get(i).cloned().ok_or_else(|| Abrupt::Internal("operand stack underflow".into())), + None => internal("operand stack underflow"), + } + } + + fn drop_n(&mut self, n: usize) -> JsResult<()> { + let len = self.stack.len(); + match len.checked_sub(n).filter(|&i| i >= self.base()) { + Some(i) => { + self.stack.truncate(i); + Ok(()) + } + None => internal("operand stack underflow"), + } + } + + /// Replaces the top `n` values with `v`. + fn replace(&mut self, n: usize, v: Value) -> JsResult<()> { + self.drop_n(n)?; + self.push(v) + } + + // ---- running ---- + + /// Takes a unit of fuel and checks the interrupt flag. + pub(crate) fn use_fuel(&mut self) -> JsResult<()> { + if self.fuel == 0 { + return Err(Abrupt::Limit(LimitKind::Fuel)); + } + self.fuel -= 1; + if self.interrupt.load(Ordering::Relaxed) { + return Err(Abrupt::Limit(LimitKind::Interrupted)); + } + Ok(()) + } + + fn tick(&mut self) -> JsResult<()> { + self.use_fuel()?; + if self.heap.bytes > self.gc_threshold { + self.collect_garbage(); + if self.heap.bytes > self.limits.heap_bytes { + return Err(Abrupt::Limit(LimitKind::Heap)); + } + self.gc_threshold = self.next_threshold(); + } + Ok(()) + } + + pub(crate) fn collect_garbage(&mut self) { + let mut roots = Roots::default(); + roots.values.extend(self.stack.iter()); + roots.values.extend(self.temp_roots.iter()); + roots.values.push(&self.last_thrown); + for f in &self.frames { + roots.values.push(&f.this); + roots.values.push(&f.completion); + roots.objects.extend(f.callee.iter().chain(f.construct.iter())); + roots.envs.push(f.env); + roots.envs.push(f.var_env); + roots.codes.push(&f.code); + } + roots.objects.push(self.global); + roots.objects.extend(self.intrinsics.all()); + roots.envs.push(self.global_env); + self.heap.collect(&roots); + } + + /// Runs frames until the boundary frame on top returns, and returns its value. + pub(crate) fn execute(&mut self) -> JsResult<Value> { + loop { + match self.step() { + Ok(Some(v)) => return Ok(v), + Ok(None) => {} + Err(Abrupt::Throw(v)) => self.unwind(v)?, + Err(other) => { + self.note_site(); + self.abort_to_boundary(); + return Err(other); + } + } + } + } + + fn step(&mut self) -> JsResult<Option<Value>> { + self.tick()?; + let frame = self.frame_mut()?; + let Some(&op) = frame.code.ops.get(frame.pc) else { return internal("ran past the end of the code") }; + frame.pc += 1; + match op { + Op::Return => { + let v = self.pop()?; + self.return_value(v) + } + Op::ReturnCompletion => { + let v = self.frame()?.completion.clone(); + self.return_value(v) + } + _ => self.exec(op).map(|()| None), + } + } + + fn note_site(&mut self) { + if self.throw_site.is_none() + && let Some(f) = self.frames.last() + { + self.throw_site = Some((f.code.clone(), f.pc.saturating_sub(1))); + } + } + + /// Finds the handler for an exception: in the top frame, or in the frames below it up to the + /// boundary frame. Returns the exception when no handler takes it before the boundary frame. + fn unwind(&mut self, v: Value) -> JsResult<()> { + self.note_site(); + loop { + let Some(frame) = self.frames.last_mut() else { return Err(Abrupt::Throw(v)) }; + if let Some(h) = frame.code.handler(frame.pc.saturating_sub(1)) { + let target_len = frame.base + h.depth as usize; + while frame.env_depth > h.env_depth { + let outer = self.heap.env(frame.env).and_then(Env::outer); + let Some(outer) = outer else { return internal("environment chain ended early") }; + frame.env = outer; + frame.env_depth -= 1; + } + frame.pc = h.target as usize; + if self.stack.len() < target_len { + return internal("handler stack deeper than the stack"); + } + self.stack.truncate(target_len); + self.stack.push(v); + self.throw_site = None; + return Ok(()); + } + let Some(frame) = self.frames.pop() else { return Err(Abrupt::Throw(v)) }; + self.stack.truncate(frame.base); + if frame.boundary { + return Err(Abrupt::Throw(v)); + } + } + } + + fn abort_to_boundary(&mut self) { + while let Some(frame) = self.frames.pop() { + self.stack.truncate(frame.base); + if frame.boundary { + break; + } + } + } + + fn return_value(&mut self, v: Value) -> JsResult<Option<Value>> { + let Some(frame) = self.frames.pop() else { return internal("return without a frame") }; + self.stack.truncate(frame.base); + let v = match frame.construct { + Some(o) if !matches!(v, Value::Object(_)) => Value::Object(o), + _ => v, + }; + if frame.boundary { + return Ok(Some(v)); + } + self.push(v)?; + Ok(None) + } + + fn jump_to(&mut self, target: u32) -> JsResult<()> { + self.frame_mut()?.pc = target as usize; + Ok(()) + } + + fn name(&self, i: u32) -> JsResult<Key> { + self.frame()?.code.names.get(i as usize).cloned().ok_or_else(|| Abrupt::Internal("name out of range".into())) + } + + fn strict(&self) -> bool { + self.frames.last().is_some_and(|f| f.code.strict) + } + + fn exec(&mut self, op: Op) -> JsResult<()> { + match op { + Op::Undefined => self.push(Value::Undefined), + Op::Null => self.push(Value::Null), + Op::True => self.push(Value::Bool(true)), + Op::False => self.push(Value::Bool(false)), + Op::Number(i) => { + let n = self.frame()?.code.numbers.get(i as usize).copied(); + self.push(Value::Number(n.unwrap_or(f64::NAN))) + } + Op::String(i) => { + let s = self.frame()?.code.strings.get(i as usize).cloned(); + self.push(s.map_or(Value::Undefined, Value::String)) + } + Op::This => { + let this = self.frame()?.this.clone(); + self.push(this) + } + Op::Pop => self.pop().map(drop), + Op::PopUnder(n) => self.op_pop_under(n), + Op::Dup => self.op_dup(1), + Op::Dup2 => self.op_dup(2), + Op::Swap => self.op_swap(), + Op::Rot3 => self.op_rot3(), + Op::GetVar(n) => self.op_get_var(n, false), + Op::GetVarThis(n) => self.op_get_var(n, true), + Op::SetVar(n) => self.op_set_var(n), + Op::RefVar(n) => self.op_ref_var(n), + Op::GetRef(n) => self.op_get_ref(n), + Op::PutRef(n) => self.op_put_ref(n), + Op::TypeofVar(n) => self.op_typeof_var(n), + Op::DeleteVar(n) => self.op_delete_var(n), + Op::IncVar { name, prefix, dec } => self.op_inc_var(name, prefix, dec), + Op::GetMember(n) => self.op_get_member(n, false), + Op::GetMemberThis(n) => self.op_get_member(n, true), + Op::SetMember(n) => self.op_set_member(n), + Op::DeleteMember(n) => self.op_delete_member(n), + Op::IncMember { name, prefix, dec } => self.op_inc_member(name, prefix, dec), + Op::ToKey => self.op_to_key(), + Op::CheckObject => self.op_check_object(), + Op::GetIndex => self.op_get_index(false), + Op::GetIndexThis => self.op_get_index(true), + Op::SetIndex => self.op_set_index(), + Op::DeleteIndex => self.op_delete_index(), + Op::IncIndex { prefix, dec } => self.op_inc_index(prefix, dec), + Op::Call(n) => self.op_call(n, false), + Op::CallEval(n) => self.op_call(n, true), + Op::New(n) => self.op_new(n), + Op::Binary(b) => self.op_binary(b), + Op::Neg | Op::Plus | Op::BitNot | Op::Not | Op::Typeof | Op::ToNumber => self.op_unary(op), + Op::Jump(t) => self.jump_to(t), + Op::JumpIfFalse(t) | Op::JumpIfTrue(t) => self.op_branch(t, matches!(op, Op::JumpIfTrue(_)), false), + Op::JumpIfFalseKeep(t) | Op::JumpIfTrueKeep(t) => self.op_branch(t, matches!(op, Op::JumpIfTrueKeep(_)), true), + Op::Throw => Err(Abrupt::Throw(self.pop()?)), + Op::ThrowReferenceError(i) => self.op_throw_reference_error(i), + Op::EndFinally(t) => self.op_end_finally(t), + Op::NewObject => self.op_new_object(), + Op::NewArray => self.op_new_array(), + Op::ArrayPush => self.op_array_push(true), + Op::ArrayHole => self.op_array_push(false), + Op::InitProp(n) => self.op_init_prop(n), + Op::InitGetter(n) => self.op_init_accessor(n, true), + Op::InitSetter(n) => self.op_init_accessor(n, false), + Op::Closure(i) => self.op_closure(i), + Op::RegExp(i) => self.op_regexp(i), + Op::PushWith => self.op_push_with(), + Op::PushCatch(n) => self.op_push_catch(n), + Op::PopEnv => self.op_pop_env(), + Op::ForInStart => self.op_for_in_start(), + Op::ForInNext(t) => self.op_for_in_next(t), + Op::SetCompletion => { + let v = self.pop()?; + self.frame_mut()?.completion = v; + Ok(()) + } + Op::Return | Op::ReturnCompletion => internal("return reached the dispatcher"), + } + } + + // ---- stack operations ---- + + fn op_pop_under(&mut self, n: u32) -> JsResult<()> { + let top = self.pop()?; + self.drop_n(n as usize)?; + self.push(top) + } + + fn op_dup(&mut self, n: usize) -> JsResult<()> { + for _ in 0..n { + let v = self.peek(n - 1)?; + self.push(v)?; + } + Ok(()) + } + + fn op_swap(&mut self) -> JsResult<()> { + let b = self.pop()?; + let a = self.pop()?; + self.push(b)?; + self.push(a) + } + + fn op_rot3(&mut self) -> JsResult<()> { + let c = self.pop()?; + let b = self.pop()?; + let a = self.pop()?; + self.push(b)?; + self.push(c)?; + self.push(a) + } + + fn op_branch(&mut self, target: u32, when: bool, keep: bool) -> JsResult<()> { + let v = self.peek(0)?; + let jump = v.truthy() == when; + if !(keep && jump) { + self.pop()?; + } + if jump { self.jump_to(target) } else { Ok(()) } + } + + fn op_throw_reference_error(&mut self, i: u32) -> JsResult<()> { + let message = self.frame()?.code.strings.get(i as usize).map(Str::to_rust_string).unwrap_or_default(); + Err(self.throw(ErrorType::Reference, &message)) + } + + fn op_end_finally(&mut self, table: u32) -> JsResult<()> { + let Value::Number(kind) = self.pop()? else { return internal("finally kind is not a number") }; + if kind == 0.0 { + return self.pop().map(drop); + } + if kind == 1.0 { + return Err(Abrupt::Throw(self.pop()?)); + } + let k = (kind - 2.0) as usize; + let target = self.frame()?.code.finally_tables.get(table as usize).and_then(|t| t.get(k)).copied(); + match target { + Some(t) => self.jump_to(t), + None => internal("finally exit out of range"), + } + } + + // ---- variables ---- + + fn resolve(&self, name: &[u16]) -> JsResult<Resolved> { + let mut cur = Some(self.frame()?.env); + while let Some(e) = cur { + match self.heap.env(e) { + Some(Env::Decl { bindings, outer }) => { + if bindings.contains(name) { + return Ok(Resolved::Decl(e)); + } + cur = *outer; + } + Some(Env::Obj { object, with_this, outer }) => { + if self.has_property(*object, name)? { + return Ok(Resolved::Obj { object: *object, with_this: *with_this }); + } + cur = *outer; + } + None => return internal("environment out of range"), + } + } + Ok(Resolved::Unresolved) + } + + fn binding_value(&self, env: EnvId, name: &[u16]) -> JsResult<Value> { + match self.heap.env(env) { + Some(Env::Decl { bindings, .. }) => Ok(bindings.get(name).map_or(Value::Undefined, |b| b.value.clone())), + _ => internal("not a declarative environment"), + } + } + + fn not_defined(&mut self, name: &[u16]) -> Abrupt { + let message = format!("{} is not defined", String::from_utf16_lossy(name)); + self.throw(ErrorType::Reference, &message) + } + + fn get_var(&mut self, name: &Key) -> JsResult<(Value, Value)> { + let r = self.resolve(name)?; + self.get_resolved(r, name) + } + + /// GetValue of a reference whose base was resolved earlier (section 8.7.1). + fn get_resolved(&mut self, r: Resolved, name: &Key) -> JsResult<(Value, Value)> { + match r { + Resolved::Decl(e) => Ok((self.binding_value(e, name)?, Value::Undefined)), + Resolved::Obj { object, with_this } => { + let v = self.get_from(object, name, &Value::Object(object))?; + Ok((v, if with_this { Value::Object(object) } else { Value::Undefined })) + } + Resolved::Unresolved => Err(self.not_defined(name)), + } + } + + fn set_var(&mut self, name: &Key, v: Value) -> JsResult<()> { + let r = self.resolve(name)?; + self.set_resolved(r, name, v) + } + + /// PutValue of a reference whose base was resolved earlier (section 8.7.2). + fn set_resolved(&mut self, r: Resolved, name: &Key, v: Value) -> JsResult<()> { + let strict = self.strict(); + match r { + Resolved::Decl(e) => { + let Some(Env::Decl { bindings, .. }) = self.heap.env_mut(e) else { return internal("not a declarative environment") }; + match bindings.get_mut(name) { + Some(b) if b.mutable => { + b.value = v; + Ok(()) + } + _ if strict => Err(self.type_error("assignment to a constant binding")), + _ => Ok(()), + } + } + Resolved::Obj { object, .. } => self.put(object, name.clone(), v, strict), + Resolved::Unresolved if strict => Err(self.not_defined(name)), + Resolved::Unresolved => { + let global = self.global; + self.put(global, name.clone(), v, false) + } + } + } + + fn op_get_var(&mut self, n: u32, with_this: bool) -> JsResult<()> { + let name = self.name(n)?; + let (v, this) = self.get_var(&name)?; + self.push(v)?; + if with_this { self.push(this) } else { Ok(()) } + } + + fn op_set_var(&mut self, n: u32) -> JsResult<()> { + let name = self.name(n)?; + let v = self.peek(0)?; + self.set_var(&name, v) + } + + fn op_typeof_var(&mut self, n: u32) -> JsResult<()> { + let name = self.name(n)?; + let v = match self.resolve(&name)? { + Resolved::Unresolved => Value::Undefined, + _ => self.get_var(&name)?.0, + }; + let t = self.type_of(&v); + self.push(Value::str(t)) + } + + fn op_delete_var(&mut self, n: u32) -> JsResult<()> { + let name = self.name(n)?; + let result = match self.resolve(&name)? { + Resolved::Unresolved => true, + Resolved::Decl(e) => match self.heap.env_mut(e) { + Some(Env::Decl { bindings, .. }) => { + if bindings.get(&name).is_some_and(|b| b.deletable) { + bindings.remove(&name); + true + } else { + false + } + } + _ => return internal("not a declarative environment"), + }, + Resolved::Obj { object, .. } => self.delete(object, &name, false)?, + }; + self.push(Value::Bool(result)) + } + + fn op_inc_var(&mut self, n: u32, prefix: bool, dec: bool) -> JsResult<()> { + let name = self.name(n)?; + let r = self.resolve(&name)?; + let base = resolved_to_value(&r); + let (v, _) = self.get_resolved(r, &name)?; + // The base stays on the stack, where the collector sees it, while ToNumber runs script. + self.push(base)?; + let old = self.to_number(&v)?; + let base = self.pop()?; + let new = if dec { old - 1.0 } else { old + 1.0 }; + self.set_resolved(value_to_resolved(&base)?, &name, Value::Number(new))?; + self.push(Value::Number(if prefix { new } else { old })) + } + + /// Resolves a variable before the right-hand side of an assignment runs, and pushes the + /// reference's base. + fn op_ref_var(&mut self, n: u32) -> JsResult<()> { + let name = self.name(n)?; + let r = self.resolve(&name)?; + self.push(resolved_to_value(&r)) + } + + /// Replaces the reference base on top of the stack with the variable's value. + fn op_get_ref(&mut self, n: u32) -> JsResult<()> { + let name = self.name(n)?; + let base = self.peek(0)?; + let (v, _) = self.get_resolved(value_to_resolved(&base)?, &name)?; + self.pop()?; + self.push(v) + } + + /// Stores the value on top of the stack through the reference base under it, and leaves the + /// value. + fn op_put_ref(&mut self, n: u32) -> JsResult<()> { + let name = self.name(n)?; + let v = self.peek(0)?; + let base = self.peek(1)?; + self.set_resolved(value_to_resolved(&base)?, &name, v)?; + let v = self.pop()?; + self.pop()?; + self.push(v) + } + + // ---- properties ---- + + fn object(&self, id: ObjId) -> JsResult<&Object> { + self.heap.object(id).ok_or_else(|| Abrupt::Internal("object out of range".into())) + } + + fn object_mut(&mut self, id: ObjId) -> JsResult<&mut Object> { + self.heap.object_mut(id).ok_or_else(|| Abrupt::Internal("object out of range".into())) + } + + /// [[GetOwnProperty]] (section 8.12.1, with the special cases of arrays, strings and + /// arguments objects). + pub(crate) fn get_own_property(&self, id: ObjId, k: &[u16]) -> JsResult<Option<Prop>> { + let o = self.object(id)?; + match &o.class { + Class::Array { length, length_writable } if is(k, "length") => { + return Ok(Some(Prop::Data { value: Value::Number(f64::from(*length)), flags: if *length_writable { WRITABLE } else { 0 } })); + } + Class::String(s) => { + if is(k, "length") { + return Ok(Some(Prop::Data { value: Value::Number(s.len() as f64), flags: 0 })); + } + if let Some(i) = array_index(k) + && (i as usize) < s.len() + { + let unit = s.flat().get(i as usize).copied().unwrap_or(0); + return Ok(Some(Prop::Data { value: Value::String(Str::from_units(vec![unit])), flags: ENUMERABLE })); + } + } + Class::Arguments(map) => { + if let Some(name) = array_index(k).and_then(|i| map.names.get(i as usize)).and_then(Option::as_ref) + && let Some(Prop::Data { flags, .. }) = o.props.get(k) + { + let flags = *flags; + return Ok(Some(Prop::Data { value: self.binding_value(map.env, name)?, flags })); + } + } + _ => {} + } + Ok(o.props.get(k).cloned()) + } + + /// [[GetProperty]]: the property found on the object or its prototype chain. + pub(crate) fn find_property(&self, id: ObjId, k: &[u16]) -> JsResult<Option<Prop>> { + let mut cur = Some(id); + let mut steps = 0usize; + while let Some(o) = cur { + if let Some(p) = self.get_own_property(o, k)? { + return Ok(Some(p)); + } + cur = self.object(o)?.proto; + steps += 1; + if steps > 1_000_000 { + return internal("prototype chain too long"); + } + } + Ok(None) + } + + pub(crate) fn has_property(&self, id: ObjId, k: &[u16]) -> JsResult<bool> { + Ok(self.find_property(id, k)?.is_some()) + } + + /// [[Get]] starting at `start`, with `receiver` as `this` for a getter. + pub(crate) fn get_from(&mut self, start: ObjId, k: &[u16], receiver: &Value) -> JsResult<Value> { + match self.find_property(start, k)? { + None | Some(Prop::Accessor { get: None, .. }) => Ok(Value::Undefined), + Some(Prop::Data { value, .. }) => Ok(value), + Some(Prop::Accessor { get: Some(g), .. }) => self.call(&Value::Object(g), receiver, &[]), + } + } + + /// The prototype a primitive's properties are looked up on. + fn primitive_proto(&mut self, v: &Value, k: &[u16]) -> JsResult<ObjId> { + match v { + Value::Object(o) => Ok(*o), + Value::String(_) => Ok(self.intrinsics.string_proto), + Value::Number(_) => Ok(self.intrinsics.number_proto), + Value::Bool(_) => Ok(self.intrinsics.boolean_proto), + Value::Undefined | Value::Null => { + let message = format!( + "cannot read property '{}' of {}", + String::from_utf16_lossy(k), + if matches!(v, Value::Null) { "null" } else { "undefined" } + ); + Err(self.type_error(&message)) + } + } + } + + /// `base[k]` for any value (GetValue, section 8.7.1). + pub(crate) fn get_value(&mut self, base: &Value, k: &[u16]) -> JsResult<Value> { + if let Value::String(s) = base { + if is(k, "length") { + return Ok(Value::Number(s.len() as f64)); + } + if let Some(i) = array_index(k) + && (i as usize) < s.len() + { + let unit = s.flat().get(i as usize).copied().unwrap_or(0); + return Ok(Value::String(Str::from_units(vec![unit]))); + } + } + let start = self.primitive_proto(base, k)?; + self.get_from(start, k, base) + } + + /// Adds or replaces an own property directly, keeping the heap estimate. + pub(crate) fn define(&mut self, id: ObjId, k: Key, prop: Prop) -> JsResult<()> { + let o = self.object_mut(id)?; + let new = !o.props.contains(&k); + if let Class::Array { length, .. } = &mut o.class + && let Some(i) = array_index(&k) + && i >= *length + { + *length = i + 1; + } + o.props.insert(k, prop); + if new { + self.heap.bytes += PROP_BYTES; + } + Ok(()) + } + + fn reject(&mut self, throw: bool, k: &[u16], why: &str) -> JsResult<()> { + if throw { + let message = format!("cannot assign to property '{}': {why}", String::from_utf16_lossy(k)); + return Err(self.type_error(&message)); + } + Ok(()) + } + + /// [[Put]] (section 8.12.5). + pub(crate) fn put(&mut self, id: ObjId, k: Key, v: Value, throw: bool) -> JsResult<()> { + match self.get_own_property(id, &k)? { + Some(Prop::Data { flags, .. }) => { + if flags & WRITABLE == 0 { + return self.reject(throw, &k, "it is read-only"); + } + return self.write_own(id, k, v, throw); + } + Some(Prop::Accessor { set: Some(s), .. }) => return self.call(&Value::Object(s), &Value::Object(id), &[v]).map(drop), + Some(Prop::Accessor { set: None, .. }) => return self.reject(throw, &k, "it has only a getter"), + None => {} + } + let proto = self.object(id)?.proto; + match proto.map(|p| self.find_property(p, &k)).transpose()?.flatten() { + Some(Prop::Accessor { set: Some(s), .. }) => return self.call(&Value::Object(s), &Value::Object(id), &[v]).map(drop), + Some(Prop::Accessor { set: None, .. }) => return self.reject(throw, &k, "it has only a getter"), + Some(Prop::Data { flags, .. }) if flags & WRITABLE == 0 => return self.reject(throw, &k, "it is read-only"), + _ => {} + } + let o = self.object(id)?; + if !o.extensible { + return self.reject(throw, &k, "the object is not extensible"); + } + if let Class::Array { length, length_writable: false } = o.class + && array_index(&k).is_some_and(|i| i >= length) + { + return self.reject(throw, &k, "the array's length is read-only"); + } + self.define(id, k, Prop::Data { value: v, flags: OPEN }) + } + + /// Sets the value of an existing writable own data property. + fn write_own(&mut self, id: ObjId, k: Key, v: Value, throw: bool) -> JsResult<()> { + let o = self.object(id)?; + match &o.class { + Class::Array { .. } if is(&k, "length") => return self.set_array_length(id, &v, throw), + Class::Arguments(map) => { + if let Some(name) = array_index(&k).and_then(|i| map.names.get(i as usize)).and_then(Option::as_ref) { + let (env, name) = (map.env, name.clone()); + if let Some(Env::Decl { bindings, .. }) = self.heap.env_mut(env) + && let Some(b) = bindings.get_mut(&name) + { + b.value = v.clone(); + } + } + } + _ => {} + } + match self.object_mut(id)?.props.get_mut(&k) { + Some(Prop::Data { value, .. }) => { + *value = v; + Ok(()) + } + _ => internal("own data property vanished"), + } + } + + /// Assigning to an array's `length` (section 15.4.5.1). + fn set_array_length(&mut self, id: ObjId, v: &Value, throw: bool) -> JsResult<()> { + let n = self.to_number(v)?; + let new = to_uint32(n); + if f64::from(new) != n { + return Err(self.throw(ErrorType::Range, "invalid array length")); + } + let o = self.object_mut(id)?; + let Class::Array { length, length_writable } = &mut o.class else { return internal("not an array") }; + if !*length_writable { + return self.reject(throw, &key("length"), "it is read-only"); + } + let old = *length; + *length = new; + if new < old { + let doomed: Vec<Key> = o.props.keys().into_iter().filter(|k| array_index(k).is_some_and(|i| i >= new)).collect(); + for k in doomed { + o.props.remove(&k); + } + } + Ok(()) + } + + /// [[Delete]] (section 8.12.7). + pub(crate) fn delete(&mut self, id: ObjId, k: &[u16], throw: bool) -> JsResult<bool> { + match self.get_own_property(id, k)? { + None => Ok(true), + Some(p) if p.configurable() => { + let o = self.object_mut(id)?; + o.props.remove(k); + if let Class::Arguments(map) = &mut o.class + && let Some(slot) = array_index(k).and_then(|i| map.names.get_mut(i as usize)) + { + *slot = None; + } + Ok(true) + } + Some(_) if throw => { + let message = format!("cannot delete property '{}'", String::from_utf16_lossy(k)); + Err(self.type_error(&message)) + } + Some(_) => Ok(false), + } + } + + /// PutValue on a property reference (section 8.7.2), whose base may be a primitive. + fn put_value(&mut self, base: &Value, k: Key, v: Value) -> JsResult<()> { + let strict = self.strict(); + match base { + Value::Object(o) => self.put(*o, k, v, strict), + Value::Undefined | Value::Null => { + let message = format!( + "cannot set property '{}' of {}", + String::from_utf16_lossy(&k), + if matches!(base, Value::Null) { "null" } else { "undefined" } + ); + Err(self.type_error(&message)) + } + _ => { + let proto = self.primitive_proto(base, &k)?; + let own = matches!(base, Value::String(s) if is(&k, "length") || array_index(&k).is_some_and(|i| (i as usize) < s.len())); + if !own && let Some(Prop::Accessor { set: Some(s), .. }) = self.find_property(proto, &k)? { + return self.call(&Value::Object(s), base, &[v]).map(drop); + } + self.reject(strict, &k, "the value is a primitive") + } + } + } + + fn op_get_member(&mut self, n: u32, with_this: bool) -> JsResult<()> { + let name = self.name(n)?; + let base = self.peek(0)?; + let v = self.get_value(&base, &name)?; + self.replace(1, v)?; + if with_this { self.push(base) } else { Ok(()) } + } + + fn op_set_member(&mut self, n: u32) -> JsResult<()> { + let name = self.name(n)?; + let v = self.peek(0)?; + let base = self.peek(1)?; + self.put_value(&base, name, v.clone())?; + self.replace(2, v) + } + + fn check_coercible(&mut self, base: &Value, k: &[u16]) -> JsResult<()> { + if base.is_nullish() { + self.primitive_proto(base, k)?; + } + Ok(()) + } + + fn op_check_object(&mut self) -> JsResult<()> { + let base = self.peek(0)?; + self.check_coercible(&base, &[]) + } + + fn op_to_key(&mut self) -> JsResult<()> { + let k = self.peek(0)?; + let base = self.peek(1)?; + self.check_coercible(&base, &[])?; + let name = self.to_key(&k)?; + self.replace(1, Value::String(Str::from_rc(name))) + } + + fn op_get_index(&mut self, with_this: bool) -> JsResult<()> { + let k = self.peek(0)?; + let base = self.peek(1)?; + self.check_coercible(&base, &[])?; + let name = self.to_key(&k)?; + let v = self.get_value(&base, &name)?; + self.replace(2, v)?; + if with_this { self.push(base) } else { Ok(()) } + } + + fn op_set_index(&mut self) -> JsResult<()> { + let v = self.peek(0)?; + let k = self.peek(1)?; + let base = self.peek(2)?; + let name = self.to_key(&k)?; + self.put_value(&base, name, v.clone())?; + self.replace(3, v) + } + + fn op_delete_member(&mut self, n: u32) -> JsResult<()> { + let name = self.name(n)?; + let base = self.peek(0)?; + let o = self.to_object(&base)?; + let strict = self.strict(); + let r = self.delete(o, &name, strict)?; + self.replace(1, Value::Bool(r)) + } + + fn op_delete_index(&mut self) -> JsResult<()> { + let k = self.peek(0)?; + let base = self.peek(1)?; + self.check_coercible(&base, &[])?; + let name = self.to_key(&k)?; + let o = self.to_object(&base)?; + let strict = self.strict(); + let r = self.delete(o, &name, strict)?; + self.replace(2, Value::Bool(r)) + } + + fn inc_property(&mut self, base: &Value, name: Key, prefix: bool, dec: bool) -> JsResult<Value> { + let v = self.get_value(base, &name)?; + let old = self.to_number(&v)?; + let new = if dec { old - 1.0 } else { old + 1.0 }; + self.put_value(base, name, Value::Number(new))?; + Ok(Value::Number(if prefix { new } else { old })) + } + + fn op_inc_member(&mut self, n: u32, prefix: bool, dec: bool) -> JsResult<()> { + let name = self.name(n)?; + let base = self.peek(0)?; + let r = self.inc_property(&base, name, prefix, dec)?; + self.replace(1, r) + } + + fn op_inc_index(&mut self, prefix: bool, dec: bool) -> JsResult<()> { + let k = self.peek(0)?; + let base = self.peek(1)?; + self.check_coercible(&base, &[])?; + let name = self.to_key(&k)?; + let r = self.inc_property(&base, name, prefix, dec)?; + self.replace(2, r) + } + + // ---- object literals and functions ---- + + fn op_new_object(&mut self) -> JsResult<()> { + let o = self.new_object(); + self.push(Value::Object(o)) + } + + pub(crate) fn new_object(&mut self) -> ObjId { + let proto = self.intrinsics.object_proto; + self.heap.alloc_object(Object::new(Class::Ordinary, Some(proto))) + } + + fn op_new_array(&mut self) -> JsResult<()> { + let proto = self.intrinsics.array_proto; + let a = self.heap.alloc_object(Object::new(Class::Array { length: 0, length_writable: true }, Some(proto))); + self.push(Value::Object(a)) + } + + fn op_array_push(&mut self, with_value: bool) -> JsResult<()> { + let v = if with_value { Some(self.pop()?) } else { None }; + let Value::Object(a) = self.peek(0)? else { return internal("array literal is not an object") }; + let o = self.object_mut(a)?; + let Class::Array { length, .. } = &mut o.class else { return internal("array literal is not an array") }; + let i = *length; + if i == u32::MAX - 1 { + return Err(self.throw(ErrorType::Range, "array literal too long")); + } + *length = i + 1; + match v { + Some(v) => self.define(a, index_key(i), Prop::Data { value: v, flags: OPEN }), + None => Ok(()), + } + } + + fn op_init_prop(&mut self, n: u32) -> JsResult<()> { + let name = self.name(n)?; + let v = self.pop()?; + let Value::Object(o) = self.peek(0)? else { return internal("object literal is not an object") }; + self.define(o, name, Prop::Data { value: v, flags: OPEN }) + } + + fn op_init_accessor(&mut self, n: u32, getter: bool) -> JsResult<()> { + let name = self.name(n)?; + let Value::Object(f) = self.pop()? else { return internal("accessor is not a function") }; + let Value::Object(o) = self.peek(0)? else { return internal("object literal is not an object") }; + let (mut get, mut set) = match self.object(o)?.props.get(&name) { + Some(Prop::Accessor { get, set, .. }) => (*get, *set), + _ => (None, None), + }; + if getter { + get = Some(f); + } else { + set = Some(f); + } + self.define(o, name, Prop::Accessor { get, set, flags: ENUMERABLE | CONFIGURABLE }) + } + + fn op_closure(&mut self, i: u32) -> JsResult<()> { + let frame = self.frame()?; + let env = frame.env; + let Some(code) = frame.code.functions.get(i as usize).cloned() else { return internal("function out of range") }; + let f = self.make_closure(code, env)?; + self.push(Value::Object(f)) + } + + /// A function object for `code` closing over `env` (section 13.2). + pub(crate) fn make_closure(&mut self, code: Rc<Code>, env: EnvId) -> JsResult<ObjId> { + let env = match &code.self_name { + Some(_) => self.heap.alloc_env(Env::Decl { bindings: PropMap::default(), outer: Some(env) }), + None => env, + }; + let proto = self.intrinsics.function_proto; + let params = code.params.len(); + let strict = code.strict; + let self_name = code.self_name.clone(); + let f = self.heap.alloc_object(Object::new(Class::Function(Func::Closure { code, env }), Some(proto))); + self.define(f, key("length"), Prop::Data { value: Value::Number(params as f64), flags: 0 })?; + let p = self.new_object(); + self.define(p, key("constructor"), Prop::Data { value: Value::Object(f), flags: HIDDEN })?; + self.define(f, key("prototype"), Prop::Data { value: Value::Object(p), flags: WRITABLE })?; + if strict { + let thrower = Some(self.intrinsics.throw_type_error); + for name in ["caller", "arguments"] { + self.define(f, key(name), Prop::Accessor { get: thrower, set: thrower, flags: 0 })?; + } + } + if let Some(name) = self_name { + self.env_insert(env, name, Binding { value: Value::Object(f), mutable: false, deletable: false })?; + } + Ok(f) + } + + fn env_insert(&mut self, env: EnvId, name: Key, b: Binding) -> JsResult<()> { + let Some(Env::Decl { bindings, .. }) = self.heap.env_mut(env) else { return internal("not a declarative environment") }; + let new = !bindings.contains(&name); + bindings.insert(name, b); + if new { + self.heap.bytes += PROP_BYTES; + } + Ok(()) + } + + fn op_regexp(&mut self, i: u32) -> JsResult<()> { + let Some((source, flags)) = self.frame()?.code.regexps.get(i as usize).cloned() else { return internal("regular expression out of range") }; + let proto = self.intrinsics.regexp_proto; + let r = self.heap.alloc_object(Object::new(Class::RegExp, Some(proto))); + let flag_units = flags.flat(); + let has = |c: u8| flag_units.contains(&u16::from(c)); + let (g, i_, m) = (has(b'g'), has(b'i'), has(b'm')); + self.define(r, key("source"), Prop::Data { value: Value::String(source), flags: 0 })?; + self.define(r, key("global"), Prop::Data { value: Value::Bool(g), flags: 0 })?; + self.define(r, key("ignoreCase"), Prop::Data { value: Value::Bool(i_), flags: 0 })?; + self.define(r, key("multiline"), Prop::Data { value: Value::Bool(m), flags: 0 })?; + self.define(r, key("lastIndex"), Prop::Data { value: Value::Number(0.0), flags: WRITABLE })?; + self.push(Value::Object(r)) + } + + // ---- environments ---- + + fn op_push_with(&mut self) -> JsResult<()> { + let v = self.peek(0)?; + let object = self.to_object(&v)?; + self.pop()?; + let outer = self.frame()?.env; + let env = self.heap.alloc_env(Env::Obj { object, with_this: true, outer: Some(outer) }); + let frame = self.frame_mut()?; + frame.env = env; + frame.env_depth += 1; + Ok(()) + } + + fn op_push_catch(&mut self, n: u32) -> JsResult<()> { + let name = self.name(n)?; + let v = self.pop()?; + let outer = self.frame()?.env; + let mut bindings = PropMap::default(); + bindings.insert(name, Binding { value: v, mutable: true, deletable: false }); + let env = self.heap.alloc_env(Env::Decl { bindings, outer: Some(outer) }); + self.heap.bytes += PROP_BYTES; + let frame = self.frame_mut()?; + frame.env = env; + frame.env_depth += 1; + Ok(()) + } + + fn op_pop_env(&mut self) -> JsResult<()> { + let env = self.frame()?.env; + let Some(outer) = self.heap.env(env).and_then(Env::outer) else { return internal("left the outermost environment") }; + let frame = self.frame_mut()?; + if frame.env_depth == 0 { + return internal("left more environments than were entered"); + } + frame.env = outer; + frame.env_depth -= 1; + Ok(()) + } + + // ---- for-in ---- + + /// The names `for-in` visits (section 12.6.4): the enumerable properties of the object and + /// its prototypes, own properties in the order they were added, each name once, and none + /// that an object nearer the start of the chain has (enumerable or not). + fn enumerable_keys(&self, id: ObjId) -> JsResult<Vec<Key>> { + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + let mut cur = Some(id); + while let Some(o) = cur { + let obj = self.object(o)?; + if let Class::String(s) = &obj.class { + for i in 0..s.len() { + let k = index_key(u32::try_from(i).unwrap_or(u32::MAX)); + if seen.insert(k.clone()) { + out.push(k); + } + } + seen.insert(key("length")); + } + if matches!(obj.class, Class::Array { .. }) { + seen.insert(key("length")); + } + for (k, p) in obj.props.iter() { + if seen.insert(k.clone()) && p.enumerable() { + out.push(k.clone()); + } + } + cur = obj.proto; + } + Ok(out) + } + + fn op_for_in_start(&mut self) -> JsResult<()> { + let v = self.peek(0)?; + let (keys, object) = if v.is_nullish() { + (Vec::new(), None) + } else { + let o = self.to_object(&v)?; + (self.enumerable_keys(o)?, Some(o)) + }; + self.heap.bytes += keys.iter().map(|k| string_bytes(k.len())).sum::<usize>(); + let iter = self.heap.alloc_object(Object::new(Class::ForIn(Box::new(ForIn { keys, pos: 0, object })), None)); + self.replace(1, Value::Object(iter)) + } + + fn op_for_in_next(&mut self, target: u32) -> JsResult<()> { + let Value::Object(iter) = self.peek(0)? else { return internal("for-in iterator is not an object") }; + loop { + let Class::ForIn(state) = &mut self.object_mut(iter)?.class else { return internal("not a for-in iterator") }; + let Some(k) = state.keys.get(state.pos).cloned() else { return self.jump_to(target) }; + state.pos += 1; + let object = state.object; + if let Some(o) = object + && self.has_property(o, &k)? + { + return self.push(Value::String(Str::from_rc(k))); + } + } + } + + // ---- calls ---- + + pub(crate) fn is_callable(&self, v: &Value) -> bool { + v.as_object().and_then(|o| self.heap.object(o)).is_some_and(|o| matches!(o.class, Class::Function(_))) + } + + fn func_of(&self, v: &Value) -> Option<Func> { + match &self.heap.object(v.as_object()?)?.class { + Class::Function(f) => Some(f.clone()), + _ => None, + } + } + + /// Resolves `call` and `apply` and runs native and host functions; a function written in + /// JavaScript comes back to be entered by the caller. + fn prepare_call(&mut self, mut f: Value, mut this: Value, mut args: Vec<Value>) -> JsResult<Prepared> { + loop { + let Some(func) = self.func_of(&f) else { return Err(self.type_error("the value is not a function")) }; + match func { + Func::Closure { code, env } => { + let callee = f.as_object().ok_or_else(|| Abrupt::Internal("callee is not an object".into()))?; + return Ok(Prepared::Closure(Entry { callee, code, env, this, args })); + } + Func::Native { kind: NativeKind::Call, .. } => { + self.use_fuel()?; + f = this; + this = if args.is_empty() { Value::Undefined } else { args.remove(0) }; + } + Func::Native { kind: NativeKind::Apply, .. } => { + self.use_fuel()?; + if !self.is_callable(&this) { + return Err(self.type_error("apply was called on a value that is not a function")); + } + let list = args.get(1).cloned().unwrap_or(Value::Undefined); + f = this; + this = args.first().cloned().unwrap_or(Value::Undefined); + args = self.list_from_array_like(&list)?; + } + Func::Native { f: native, .. } => return Ok(Prepared::Done(native(self, this, &args, false)?)), + Func::Host { f: host, .. } => { + let js_args: Vec<crate::JsValue> = args.iter().map(|v| self.to_js_value(v)).collect(); + return match host(&js_args) { + Ok(v) => Ok(Prepared::Done(self.from_js_value(v))), + Err(message) => Err(self.throw(ErrorType::Error, &message)), + }; + } + } + } + } + + /// The elements of an array-like `apply` argument (section 15.3.4.3). + fn list_from_array_like(&mut self, list: &Value) -> JsResult<Vec<Value>> { + let Value::Object(o) = list else { + if list.is_nullish() { + return Ok(Vec::new()); + } + return Err(self.type_error("the argument list is not an object")); + }; + let len = self.get_from(*o, &key("length"), list)?; + let n = to_uint32(self.to_number(&len)?) as usize; + if n > MAX_APPLY_ARGS { + return Err(self.throw(ErrorType::Range, "too many arguments")); + } + let mut out = Vec::with_capacity(n); + for i in 0..n { + let v = self.get_from(*o, &index_key(u32::try_from(i).unwrap_or(u32::MAX)), list)?; + self.temp_roots.push(v.clone()); + out.push(v); + } + let keep = self.temp_roots.len() - n; + self.temp_roots.truncate(keep); + Ok(out) + } + + fn op_call(&mut self, argc: u32, eval: bool) -> JsResult<()> { + let n = argc as usize; + let start = + self.stack.len().checked_sub(n + 2).filter(|&s| s >= self.base()).ok_or_else(|| Abrupt::Internal("call below the stack".into()))?; + let f = self.stack.get(start).cloned().unwrap_or(Value::Undefined); + let this = self.stack.get(start + 1).cloned().unwrap_or(Value::Undefined); + let args: Vec<Value> = self.stack.get(start + 2..).map(<[Value]>::to_vec).unwrap_or_default(); + if eval && f.as_object() == Some(self.intrinsics.eval) { + return self.direct_eval(start, args); + } + match self.prepare_call(f, this, args)? { + Prepared::Done(v) => { + self.stack.truncate(start); + self.push(v) + } + Prepared::Closure(entry) => { + self.stack.truncate(start); + self.enter_function(entry, None, false) + } + } + } + + fn op_new(&mut self, argc: u32) -> JsResult<()> { + let n = argc as usize; + let start = + self.stack.len().checked_sub(n + 1).filter(|&s| s >= self.base()).ok_or_else(|| Abrupt::Internal("new below the stack".into()))?; + let f = self.stack.get(start).cloned().unwrap_or(Value::Undefined); + let args: Vec<Value> = self.stack.get(start + 1..).map(<[Value]>::to_vec).unwrap_or_default(); + match self.func_of(&f) { + Some(Func::Closure { code, env }) => { + let Some(callee) = f.as_object() else { return internal("callee is not an object") }; + let proto = match self.get_from(callee, &key("prototype"), &f)? { + Value::Object(p) => p, + _ => self.intrinsics.object_proto, + }; + let obj = self.heap.alloc_object(Object::new(Class::Ordinary, Some(proto))); + self.stack.truncate(start); + self.enter_function(Entry { callee, code, env, this: Value::Object(obj), args }, Some(obj), false) + } + Some(Func::Native { f: native, constructor: true, .. }) => { + let v = native(self, Value::Undefined, &args, true)?; + self.stack.truncate(start); + self.push(v) + } + _ => Err(self.type_error("the value is not a constructor")), + } + } + + /// Pushes a frame for a call to a function written in JavaScript, its operands already off + /// the stack. + fn enter_function(&mut self, entry: Entry, construct: Option<ObjId>, boundary: bool) -> JsResult<()> { + let Entry { callee, code, env, this, args } = entry; + if self.frames.len() >= self.limits.frames { + return Err(Abrupt::Limit(LimitKind::Frames)); + } + let this = if code.strict { + this + } else { + match this { + Value::Undefined | Value::Null => Value::Object(self.global), + Value::Object(_) => this, + prim => Value::Object(self.to_object(&prim)?), + } + }; + let fenv = self.instantiate_function(&code, env, &args, callee)?; + let base = self.stack.len(); + self.frames.push(Frame { + code, + pc: 0, + base, + env: fenv, + var_env: fenv, + env_depth: 0, + this, + callee: Some(callee), + construct, + completion: Value::Undefined, + boundary, + }); + Ok(()) + } + + /// Declaration binding instantiation for function code (section 10.5). + fn instantiate_function(&mut self, code: &Rc<Code>, outer: EnvId, args: &[Value], callee: ObjId) -> JsResult<EnvId> { + let mut bindings = PropMap::default(); + for (i, p) in code.params.iter().enumerate() { + bindings.insert(p.clone(), Binding { value: args.get(i).cloned().unwrap_or(Value::Undefined), mutable: true, deletable: false }); + } + let count = bindings.len(); + let env = self.heap.alloc_env(Env::Decl { bindings, outer: Some(outer) }); + self.heap.bytes += PROP_BYTES * count; + for (name, fi) in &code.fun_decls { + let Some(fcode) = code.functions.get(*fi as usize).cloned() else { return internal("function out of range") }; + let f = self.make_closure(fcode, env)?; + self.env_insert(env, name.clone(), Binding { value: Value::Object(f), mutable: true, deletable: false })?; + } + let has = |interp: &Self, name: &[u16]| matches!(interp.heap.env(env), Some(Env::Decl { bindings, .. }) if bindings.contains(name)); + if code.uses_arguments && !has(self, &key("arguments")) { + let a = self.create_arguments(code, env, args, callee)?; + self.env_insert(env, key("arguments"), Binding { value: Value::Object(a), mutable: !code.strict, deletable: false })?; + } + for v in &code.vars { + if !has(self, v) { + self.env_insert(env, v.clone(), Binding { value: Value::Undefined, mutable: true, deletable: false })?; + } + } + Ok(env) + } + + /// The `arguments` object (section 10.6). + fn create_arguments(&mut self, code: &Code, env: EnvId, args: &[Value], callee: ObjId) -> JsResult<ObjId> { + let mut names = vec![None; args.len()]; + if !code.strict { + let mut mapped = std::collections::HashSet::new(); + for (i, p) in code.params.iter().enumerate().take(args.len()).rev() { + if mapped.insert(p.clone()) + && let Some(slot) = names.get_mut(i) + { + *slot = Some(p.clone()); + } + } + } + let proto = self.intrinsics.object_proto; + let a = self.heap.alloc_object(Object::new(Class::Arguments(Box::new(ArgMap { env, names })), Some(proto))); + for (i, v) in args.iter().enumerate() { + self.define(a, index_key(u32::try_from(i).unwrap_or(u32::MAX)), Prop::Data { value: v.clone(), flags: OPEN })?; + } + self.define(a, key("length"), Prop::Data { value: Value::Number(args.len() as f64), flags: HIDDEN })?; + if code.strict { + let thrower = Some(self.intrinsics.throw_type_error); + for name in ["callee", "caller"] { + self.define(a, key(name), Prop::Accessor { get: thrower, set: thrower, flags: 0 })?; + } + } else { + self.define(a, key("callee"), Prop::Data { value: Value::Object(callee), flags: HIDDEN })?; + } + Ok(a) + } + + /// Calls `f` from Rust and returns its result, running the loop again for a function written + /// in JavaScript. + pub(crate) fn call(&mut self, f: &Value, this: &Value, args: &[Value]) -> JsResult<Value> { + if self.reentry >= self.limits.reentry { + return Err(Abrupt::Limit(LimitKind::Reentry)); + } + let saved = self.stack.len(); + self.stack.push(f.clone()); + self.stack.push(this.clone()); + self.stack.extend(args.iter().cloned()); + self.reentry += 1; + let r = self.call_rooted(f.clone(), this.clone(), args.to_vec(), saved); + self.reentry -= 1; + self.stack.truncate(saved); + r + } + + fn call_rooted(&mut self, f: Value, this: Value, args: Vec<Value>, saved: usize) -> JsResult<Value> { + match self.prepare_call(f, this, args)? { + Prepared::Done(v) => Ok(v), + Prepared::Closure(entry) => { + self.stack.truncate(saved); + self.enter_function(entry, None, true)?; + self.execute() + } + } + } + + // ---- eval and scripts ---- + + /// Parses and compiles source for eval code or a script, counting it against the source + /// limit. + pub(crate) fn compile_source(&mut self, units: &[u16], strict: bool, kind: CodeKind) -> Result<Rc<Code>, CompileError> { + self.source_units = self.source_units.saturating_add(units.len()); + let remaining = self.options.max_source_units.saturating_sub(self.source_units.saturating_sub(units.len())); + let options = Options { strict, max_source_units: remaining, ..self.options.clone() }; + let program = crate::parse_utf16(units, &options).map_err(CompileError::Syntax)?; + let source = Rc::new(Source { lines: program.lines.clone(), text: units.into() }); + compile_program(&program, &source, kind).map_err(|e| match e { + CompileFail::Syntax(s) => CompileError::Syntax(s), + CompileFail::Internal(m) => CompileError::Internal(m), + }) + } + + /// Turns a compile failure inside a running script into what it throws. + pub(crate) fn compile_error_to_abrupt(&mut self, e: CompileError) -> Abrupt { + match e { + CompileError::Syntax(s) => self.throw(ErrorType::Syntax, &s.message), + CompileError::Internal(m) => Abrupt::Internal(m), + } + } + + fn direct_eval(&mut self, start: usize, args: Vec<Value>) -> JsResult<()> { + let x = args.into_iter().next().unwrap_or(Value::Undefined); + let Value::String(s) = &x else { + self.stack.truncate(start); + return self.push(x); + }; + let strict = self.strict(); + let units = s.flat(); + let code = self.compile_source(&units, strict, CodeKind::Eval).map_err(|e| self.compile_error_to_abrupt(e))?; + let frame = self.frame()?; + let (caller_env, caller_var_env, this) = (frame.env, frame.var_env, frame.this.clone()); + let (env, var_env) = if code.strict { + let e = self.heap.alloc_env(Env::Decl { bindings: PropMap::default(), outer: Some(caller_env) }); + (e, e) + } else { + (caller_env, caller_var_env) + }; + self.stack.truncate(start); + self.instantiate_global(&code, var_env, true)?; + self.push_code_frame(code, env, var_env, this, false) + } + + /// Indirect eval and scripts: runs `code` in the global environment and returns its + /// completion value. + pub(crate) fn run_global(&mut self, code: Rc<Code>, is_eval: bool) -> JsResult<Value> { + if self.reentry >= self.limits.reentry { + return Err(Abrupt::Limit(LimitKind::Reentry)); + } + let (env, global) = (self.global_env, self.global); + let (env, var_env) = if code.strict && is_eval { + let e = self.heap.alloc_env(Env::Decl { bindings: PropMap::default(), outer: Some(env) }); + (e, e) + } else { + (env, env) + }; + self.instantiate_global(&code, var_env, is_eval)?; + self.push_code_frame(code, env, var_env, Value::Object(global), true)?; + self.reentry += 1; + let r = self.execute(); + self.reentry -= 1; + r + } + + fn push_code_frame(&mut self, code: Rc<Code>, env: EnvId, var_env: EnvId, this: Value, boundary: bool) -> JsResult<()> { + if self.frames.len() >= self.limits.frames { + return Err(Abrupt::Limit(LimitKind::Frames)); + } + let base = self.stack.len(); + self.frames.push(Frame { + code, + pc: 0, + base, + env, + var_env, + env_depth: 0, + this, + callee: None, + construct: None, + completion: Value::Undefined, + boundary, + }); + Ok(()) + } + + /// Declaration binding instantiation for global and eval code (section 10.5). + fn instantiate_global(&mut self, code: &Rc<Code>, var_env: EnvId, is_eval: bool) -> JsResult<()> { + let configurable = if is_eval { CONFIGURABLE } else { 0 }; + for (name, fi) in &code.fun_decls { + let Some(fcode) = code.functions.get(*fi as usize).cloned() else { return internal("function out of range") }; + let f = self.make_closure(fcode, var_env)?; + self.declare(var_env, name, Some(Value::Object(f)), configurable)?; + } + for v in &code.vars { + self.declare(var_env, v, None, configurable)?; + } + Ok(()) + } + + /// Declares `name` in a variable environment, with `value` for a function declaration. + fn declare(&mut self, env: EnvId, name: &Key, value: Option<Value>, configurable: u8) -> JsResult<()> { + match self.heap.env(env) { + Some(Env::Decl { bindings, .. }) => { + let exists = bindings.contains(name); + match value { + Some(v) => self.env_insert(env, name.clone(), Binding { value: v, mutable: true, deletable: configurable != 0 }), + None if !exists => { + self.env_insert(env, name.clone(), Binding { value: Value::Undefined, mutable: true, deletable: configurable != 0 }) + } + None => Ok(()), + } + } + Some(Env::Obj { object, .. }) => { + let object = *object; + let declared = self.has_property(object, name)?; + let flags = WRITABLE | ENUMERABLE | configurable; + match value { + None if declared => Ok(()), + None => self.define(object, name.clone(), Prop::Data { value: Value::Undefined, flags }), + Some(v) => { + let existing = self.find_property(object, name)?; + match existing { + None => self.define(object, name.clone(), Prop::Data { value: v, flags }), + Some(p) if p.configurable() => self.define(object, name.clone(), Prop::Data { value: v, flags }), + Some(Prop::Data { flags: f, .. }) if f & (WRITABLE | ENUMERABLE) == WRITABLE | ENUMERABLE => { + self.put(object, name.clone(), v, true) + } + Some(_) => { + let message = format!("cannot declare function {}", String::from_utf16_lossy(name)); + Err(self.type_error(&message)) + } + } + } + } + } + None => internal("environment out of range"), + } + } + + // ---- conversions ---- + + pub(crate) fn type_of(&self, v: &Value) -> &'static str { + match v { + Value::Undefined => "undefined", + Value::Null => "object", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Object(_) if self.is_callable(v) => "function", + Value::Object(_) => "object", + } + } + + /// ToPrimitive (section 9.1) through [[DefaultValue]] (section 8.12.8). + #[allow(clippy::wrong_self_convention)] + pub(crate) fn to_primitive(&mut self, v: &Value, hint: Hint) -> JsResult<Value> { + let Value::Object(o) = v else { return Ok(v.clone()) }; + let order = if hint == Hint::String { ["toString", "valueOf"] } else { ["valueOf", "toString"] }; + for name in order { + let f = self.get_from(*o, &key(name), v)?; + if self.is_callable(&f) { + let r = self.call(&f, v, &[])?; + if !matches!(r, Value::Object(_)) { + return Ok(r); + } + } + } + Err(self.type_error("cannot convert the object to a primitive value")) + } + + #[allow(clippy::wrong_self_convention)] + pub(crate) fn to_number(&mut self, v: &Value) -> JsResult<f64> { + Ok(match v { + Value::Undefined => f64::NAN, + Value::Null => 0.0, + Value::Bool(b) => f64::from(u8::from(*b)), + Value::Number(n) => *n, + Value::String(s) => string_to_number(&s.flat()), + Value::Object(_) => { + let p = self.to_primitive(v, Hint::Number)?; + return self.to_number(&p); + } + }) + } + + /// ToString applied to a primitive. + pub(crate) fn primitive_to_str(&mut self, v: &Value) -> Str { + match v { + Value::Undefined => Str::from("undefined"), + Value::Null => Str::from("null"), + Value::Bool(b) => Str::from(if *b { "true" } else { "false" }), + Value::Number(n) => { + let s = number_to_str(*n); + self.heap.bytes += string_bytes(s.len()); + s + } + Value::String(s) => s.clone(), + Value::Object(_) => Str::from("[object]"), + } + } + + #[allow(clippy::wrong_self_convention)] + pub(crate) fn to_str(&mut self, v: &Value) -> JsResult<Str> { + let p = self.to_primitive(v, Hint::String)?; + Ok(self.primitive_to_str(&p)) + } + + /// A property name from a value (ToString, with a short way for array indices). + #[allow(clippy::wrong_self_convention)] + pub(crate) fn to_key(&mut self, v: &Value) -> JsResult<Key> { + match v { + Value::String(s) => { + let rope = s.is_rope(); + let flat = s.flat(); + if rope { + self.heap.bytes += string_bytes(flat.len()); + } + Ok(flat) + } + Value::Number(n) if *n >= 0.0 && *n < 4_294_967_295.0 && n.fract() == 0.0 => Ok(index_key(*n as u32)), + _ => Ok(self.to_str(v)?.flat()), + } + } + + /// ToObject (section 9.9). + #[allow(clippy::wrong_self_convention)] + pub(crate) fn to_object(&mut self, v: &Value) -> JsResult<ObjId> { + let (class, proto) = match v { + Value::Object(o) => return Ok(*o), + Value::Undefined | Value::Null => return Err(self.type_error("cannot convert undefined or null to an object")), + Value::Bool(_) => (Class::Boolean, self.intrinsics.boolean_proto), + Value::Number(_) => (Class::Number, self.intrinsics.number_proto), + Value::String(s) => (Class::String(s.clone()), self.intrinsics.string_proto), + }; + Ok(self.heap.alloc_object(Object::new(class, Some(proto)))) + } + + /// Concatenates two strings, ending the run with the heap limit when the result could not + /// be flattened within it. + pub(crate) fn concat(&mut self, a: &Str, b: &Str) -> JsResult<Str> { + let len = a.len() + b.len(); + if len > self.limits.heap_bytes / 4 { + return Err(Abrupt::Limit(LimitKind::Heap)); + } + self.heap.bytes += ROPE_BYTES; + Ok(Str::concat(a, b)) + } + + // ---- operators ---- + + fn op_unary(&mut self, op: Op) -> JsResult<()> { + let v = self.peek(0)?; + let r = match op { + Op::Neg => Value::Number(-self.to_number(&v)?), + Op::Plus | Op::ToNumber => Value::Number(self.to_number(&v)?), + Op::BitNot => Value::Number(f64::from(!to_int32(self.to_number(&v)?))), + Op::Not => Value::Bool(!v.truthy()), + Op::Typeof => Value::str(self.type_of(&v)), + _ => return internal("not a unary operator"), + }; + self.replace(1, r) + } + + fn op_binary(&mut self, op: BinaryOp) -> JsResult<()> { + let b = self.peek(0)?; + let a = self.peek(1)?; + let r = self.binary(op, &a, &b)?; + self.replace(2, r) + } + + pub(crate) fn binary(&mut self, op: BinaryOp, a: &Value, b: &Value) -> JsResult<Value> { + let num = |interp: &mut Self, f: fn(f64, f64) -> f64| -> JsResult<Value> { + let x = interp.to_number(a)?; + let y = interp.to_number(b)?; + Ok(Value::Number(f(x, y))) + }; + let int = |interp: &mut Self, f: fn(i32, i32) -> i32| -> JsResult<Value> { + let x = to_int32(interp.to_number(a)?); + let y = to_int32(interp.to_number(b)?); + Ok(Value::Number(f64::from(f(x, y)))) + }; + match op { + BinaryOp::Add => self.add(a, b), + BinaryOp::Sub => num(self, |x, y| x - y), + BinaryOp::Mul => num(self, |x, y| x * y), + BinaryOp::Div => num(self, |x, y| x / y), + BinaryOp::Mod => num(self, |x, y| x % y), + BinaryOp::Shl => int(self, |x, y| x.wrapping_shl((y & 31) as u32)), + BinaryOp::Shr => int(self, |x, y| x >> (y & 31)), + BinaryOp::UShr => { + let x = to_uint32(self.to_number(a)?); + let y = to_uint32(self.to_number(b)?); + Ok(Value::Number(f64::from(x >> (y & 31)))) + } + BinaryOp::BitAnd => int(self, |x, y| x & y), + BinaryOp::BitOr => int(self, |x, y| x | y), + BinaryOp::BitXor => int(self, |x, y| x ^ y), + BinaryOp::Lt => Ok(Value::Bool(self.less_than(a, b, true)? == Some(true))), + BinaryOp::Gt => Ok(Value::Bool(self.less_than(b, a, false)? == Some(true))), + BinaryOp::LtEq => Ok(Value::Bool(self.less_than(b, a, false)? == Some(false))), + BinaryOp::GtEq => Ok(Value::Bool(self.less_than(a, b, true)? == Some(false))), + BinaryOp::Eq => Ok(Value::Bool(self.loose_eq(a, b)?)), + BinaryOp::NotEq => Ok(Value::Bool(!self.loose_eq(a, b)?)), + BinaryOp::StrictEq => Ok(Value::Bool(strict_eq(a, b))), + BinaryOp::StrictNotEq => Ok(Value::Bool(!strict_eq(a, b))), + BinaryOp::Instanceof => self.instance_of(a, b).map(Value::Bool), + BinaryOp::In => { + let Value::Object(o) = b else { return Err(self.type_error("the right side of 'in' is not an object")) }; + let k = self.to_key(a)?; + self.has_property(*o, &k).map(Value::Bool) + } + } + } + + /// The addition operator (section 11.6.1). + fn add(&mut self, a: &Value, b: &Value) -> JsResult<Value> { + if let (Value::Number(x), Value::Number(y)) = (a, b) { + return Ok(Value::Number(x + y)); + } + let pa = self.to_primitive(a, Hint::Default)?; + let pb = self.to_primitive(b, Hint::Default)?; + if matches!(pa, Value::String(_)) || matches!(pb, Value::String(_)) { + let sa = self.primitive_to_str(&pa); + let sb = self.primitive_to_str(&pb); + return self.concat(&sa, &sb).map(Value::String); + } + Ok(Value::Number(self.to_number(&pa)? + self.to_number(&pb)?)) + } + + /// The abstract relational comparison `x < y` (section 11.8.5): `None` when either side is + /// NaN. + fn less_than(&mut self, x: &Value, y: &Value, left_first: bool) -> JsResult<Option<bool>> { + let (px, py) = if left_first { + let px = self.to_primitive(x, Hint::Number)?; + (px, self.to_primitive(y, Hint::Number)?) + } else { + let py = self.to_primitive(y, Hint::Number)?; + (self.to_primitive(x, Hint::Number)?, py) + }; + if let (Value::String(a), Value::String(b)) = (&px, &py) { + return Ok(Some(*a.flat() < *b.flat())); + } + let nx = self.to_number(&px)?; + let ny = self.to_number(&py)?; + if nx.is_nan() || ny.is_nan() { + return Ok(None); + } + Ok(Some(nx < ny)) + } + + /// The abstract equality comparison (section 11.9.3). + fn loose_eq(&mut self, a: &Value, b: &Value) -> JsResult<bool> { + let (mut x, mut y) = (a.clone(), b.clone()); + loop { + match (&x, &y) { + (Value::Undefined | Value::Null, Value::Undefined | Value::Null) => return Ok(true), + (Value::Number(_), Value::Number(_)) + | (Value::String(_), Value::String(_)) + | (Value::Bool(_), Value::Bool(_)) + | (Value::Object(_), Value::Object(_)) => return Ok(strict_eq(&x, &y)), + (Value::Number(n), Value::String(s)) | (Value::String(s), Value::Number(n)) => return Ok(*n == string_to_number(&s.flat())), + (Value::Bool(v), _) => x = Value::Number(f64::from(u8::from(*v))), + (_, Value::Bool(v)) => y = Value::Number(f64::from(u8::from(*v))), + (Value::Number(_) | Value::String(_), Value::Object(_)) => y = self.to_primitive(&y, Hint::Default)?, + (Value::Object(_), Value::Number(_) | Value::String(_)) => x = self.to_primitive(&x, Hint::Default)?, + _ => return Ok(false), + } + } + } + + /// `instanceof` (sections 11.8.6 and 15.3.5.3). + fn instance_of(&mut self, v: &Value, f: &Value) -> JsResult<bool> { + if !self.is_callable(f) { + return Err(self.type_error("the right side of 'instanceof' is not a function")); + } + let Some(fid) = f.as_object() else { return Ok(false) }; + let Value::Object(mut o) = v.clone() else { return Ok(false) }; + let Value::Object(proto) = self.get_from(fid, &key("prototype"), f)? else { + return Err(self.type_error("the function's prototype is not an object")); + }; + let mut steps = 0usize; + while let Some(p) = self.object(o)?.proto { + if p == proto { + return Ok(true); + } + o = p; + steps += 1; + if steps > 1_000_000 { + return internal("prototype chain too long"); + } + } + Ok(false) + } + + // ---- host values ---- + + pub(crate) fn to_js_value(&self, v: &Value) -> crate::JsValue { + match v { + Value::Undefined => crate::JsValue::Undefined, + Value::Null => crate::JsValue::Null, + Value::Bool(b) => crate::JsValue::Bool(*b), + Value::Number(n) => crate::JsValue::Number(*n), + Value::String(s) => crate::JsValue::String(s.to_rust_string()), + Value::Object(o) => crate::JsValue::Object(self.heap.object(*o).map_or("Object", |o| o.class.name()).to_string()), + } + } + + #[allow(clippy::wrong_self_convention)] + pub(crate) fn from_js_value(&mut self, v: crate::JsValue) -> Value { + match v { + crate::JsValue::Undefined => Value::Undefined, + crate::JsValue::Null => Value::Null, + crate::JsValue::Bool(b) => Value::Bool(b), + crate::JsValue::Number(n) => Value::Number(n), + crate::JsValue::String(s) => Value::str(&s), + crate::JsValue::Object(_) => Value::Object(self.new_object()), + } + } + + /// A description of a thrown value that runs no JavaScript: `Name: message` for an error, + /// the text of a primitive. + pub(crate) fn describe(&mut self, v: &Value) -> String { + let Value::Object(o) = v else { return self.primitive_to_str(v).to_rust_string() }; + let data = |interp: &Self, k: &str| match interp.find_property(*o, &key(k)) { + Ok(Some(Prop::Data { value: Value::String(s), .. })) => Some(s.to_rust_string()), + _ => None, + }; + match (data(self, "name"), data(self, "message")) { + (Some(n), Some(m)) if !m.is_empty() => format!("{n}: {m}"), + (Some(n), _) => n, + (None, Some(m)) => m, + (None, None) => format!("[object {}]", self.heap.object(*o).map_or("Object", |o| o.class.name())), + } + } +} + +/// A compile failure, before it becomes an exception or a run error. +pub(crate) enum CompileError { + Syntax(crate::SyntaxError), + Internal(String), +} + +/// The strict equality comparison (section 11.9.6). +pub(crate) fn strict_eq(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Undefined, Value::Undefined) | (Value::Null, Value::Null) => true, + (Value::Number(x), Value::Number(y)) => x == y, + (Value::String(x), Value::String(y)) => x == y, + (Value::Bool(x), Value::Bool(y)) => x == y, + (Value::Object(x), Value::Object(y)) => x == y, + _ => false, + } +} diff --git a/crates/js/src/interp_tests.rs b/crates/js/src/interp_tests.rs new file mode 100644 index 000000000..bbb3a2cb7 --- /dev/null +++ b/crates/js/src/interp_tests.rs @@ -0,0 +1,492 @@ +use std::rc::Rc; + +use proptest::prelude::*; + +use crate::bytecode::{Code, Handler, Op, Source, verify}; +use crate::{JsValue, LimitKind, Limits, Options, Realm, RunError, RunErrorKind}; + +fn run(src: &str) -> JsValue { + match Realm::new().run(src) { + Ok(v) => v, + Err(e) => panic!("{src}\n{e:?}"), + } +} + +fn num(src: &str) -> f64 { + match run(src) { + JsValue::Number(n) => n, + other => panic!("{src}: {other:?}"), + } +} + +fn string(src: &str) -> String { + match run(src) { + JsValue::String(s) => s, + other => panic!("{src}: {other:?}"), + } +} + +fn truth(src: &str) { + assert_eq!(run(src), JsValue::Bool(true), "{src}"); +} + +fn fail(src: &str) -> RunError { + match Realm::new().run(src) { + Ok(v) => panic!("{src} completed with {v:?}"), + Err(e) => e, + } +} + +fn small(limits: Limits) -> Realm { + Realm::with_options(Options::default(), limits) +} + +// ---- language ---- + +#[test] +fn assignments_resolve_the_variable_before_the_right_hand_side() { + // Section 11.13.1: the reference is resolved first, so a binding the right-hand side adds + // does not catch the assignment. + truth("var x = 0; var scope = {}; with (scope) { x = (scope.x = 2, 1); } scope.x === 2 && x === 1"); + truth("var x = 0; var scope = {}; with (scope) { x += (scope.x = 2, 1); } scope.x === 2 && x === 1"); + truth("var x = 5; function f() { var r = (x = (eval('var x;'), 1)); return [x, r]; } var a = f(); a[0] === undefined && a[1] === 1 && x === 1"); + // Section 11.3.1: the operand's reference is resolved before ToNumber runs. + truth( + "var x = 0; var scope = {x: {valueOf: function(){ delete scope.x; return 2; }}}; \ + with (scope) { x++; } scope.x === 3 && x === 0", + ); +} + +#[test] +fn arithmetic_comparison_and_conversions() { + assert_eq!(num("1 + 2 * 3 - 4 / 2"), 5.0); + assert_eq!(num("7 % 3 + (-7 % 3)"), 0.0); + assert_eq!(string("'a' + 1 + 2"), "a12"); + assert_eq!(num("1 + 2 + '3' - 0"), 33.0); + assert_eq!(num("(1 << 31) >> 31"), -1.0); + assert_eq!(num("-1 >>> 28"), 15.0); + assert_eq!(num("~5 ^ 3 | 8 & 12"), -7.0); + truth("'10' == 10 && null == undefined && !(null == 0) && '' == 0 && '1e1' == 10"); + truth("NaN !== NaN && 0 === -0 && 1/-0 === -Infinity"); + truth("'b' > 'a' && 'B' < 'a' && !(NaN < 1) && !(NaN >= 1) && 2 >= 2 && '10' < '9' && 10 > '9'"); + truth( + "typeof 1 === 'number' && typeof 'x' === 'string' && typeof null === 'object' && typeof undefined_name === 'undefined' && typeof function(){} === 'function'", + ); + truth("({valueOf: function(){ return 4; }}) * 2 === 8 && ({toString: function(){ return 'x'; }}) + 'y' === 'xy'"); + truth("+'0x10' === 16 && +' 12 ' === 12 && isNaNish(+'1x'); function isNaNish(n) { return n !== n; }"); +} + +#[test] +fn variables_functions_and_closures() { + assert_eq!(num("var x = 1; function f() { return x + 1; } f()"), 2.0); + assert_eq!(num("function counter() { var n = 0; return function() { return ++n; }; } var c = counter(); c(); c(); c()"), 3.0); + assert_eq!(num("f(); function f() { return 5; }"), 5.0); + assert_eq!(num("var g = function fact(n) { return n <= 1 ? 1 : n * fact(n - 1); }; g(10)"), 3_628_800.0); + assert_eq!(num("function f(a, b) { return arguments.length * 10 + (b === undefined ? 1 : 0); } f(1)"), 11.0); + assert_eq!(num("function f(a) { arguments[0] = 9; return a; } f(1)"), 9.0); + assert_eq!(num("function f(a) { 'use strict'; arguments[0] = 9; return a; } f(1)"), 1.0); + assert_eq!(num("function f(a) { a = 4; return arguments[0]; } f(1)"), 4.0); + truth("var o = { m: function() { return this; } }; o.m() === o"); + truth("function f() { return this; } f() === this"); + truth("function f() { 'use strict'; return this; } f() === undefined"); + assert_eq!(num("function P(x) { this.x = x; } P.prototype.get = function() { return this.x; }; new P(7).get()"), 7.0); + truth("function P() { return {a: 1}; } new P().a === 1 && new P() instanceof Object"); + truth("function P() {} var p = new P(); p instanceof P && p.constructor === P && P.prototype.isPrototypeOf(p)"); + assert_eq!(num("(function(a, b, c) {}).length"), 3.0); +} + +#[test] +fn call_and_apply_set_this_and_arguments() { + assert_eq!(num("function f(a, b) { return this.k + a + b; } f.call({k: 1}, 2, 3)"), 6.0); + assert_eq!(num("function f(a, b) { return this.k + a + b; } f.apply({k: 1}, [2, 3])"), 6.0); + assert_eq!(num("function f() { return arguments.length; } f.apply(null, undefined)"), 0.0); + assert_eq!(num("Function.prototype.call.call(function(a) { return a; }, null, 8)"), 8.0); + let e = fail("Function.prototype.call.call(1)"); + assert_eq!(e.kind, RunErrorKind::Thrown); + assert!(e.message.starts_with("TypeError"), "{}", e.message); +} + +#[test] +fn objects_arrays_and_accessors() { + assert_eq!(num("var o = {a: 1, 'b': 2, 3: 4}; o.a + o['b'] + o[3]"), 7.0); + assert_eq!(num("var a = [1, , 3]; a.length"), 3.0); + assert_eq!(num("var a = []; a[5] = 1; a.length"), 6.0); + assert_eq!(num("var a = [1, 2, 3, 4]; a.length = 1; a[2] === undefined ? a.length : -1"), 1.0); + assert_eq!(num("var o = { get x() { return this.y * 2; }, set x(v) { this.y = v; }, y: 1 }; o.x = 5; o.x"), 10.0); + truth("var o = {a: 1}; delete o.a && !('a' in o) && o.a === undefined"); + truth("'length' in [] && !delete [].length"); + truth("'abc'.length === 3 && 'abc'[1] === 'b' && 'abc'.x === undefined"); + truth("var o = {}; o.hasOwnProperty('x') === false && ({x: 1}).hasOwnProperty('x')"); + truth("Object.prototype.toString.call([]) === '[object Array]' && Object.prototype.toString.call(null) === '[object Null]'"); + let e = fail("var a = []; a.length = -1"); + assert!(e.message.starts_with("RangeError"), "{}", e.message); + let e = fail("'use strict'; var o = { get x() { return 1; } }; o.x = 2;"); + assert!(e.message.starts_with("TypeError"), "{}", e.message); + let e = fail("null.x"); + assert!(e.message.starts_with("TypeError"), "{}", e.message); +} + +#[test] +fn for_in_lists_own_properties_in_insertion_order_then_inherited_ones() { + assert_eq!( + string("function P() { this.z = 1; this.a = 2; } P.prototype.m = 3; P.prototype.a = 9; var s = ''; for (var k in new P()) s += k; s"), + "zam" + ); + assert_eq!(string("var o = {a: 1, b: 2, c: 3}, s = ''; for (var k in o) { delete o.b; s += k; } s"), "ac"); + assert_eq!(string("var s = ''; for (var k in 'xyz') s += k; s"), "012"); + assert_eq!(num("var n = 0; for (var k in null) n++; for (k in undefined) n++; n"), 0.0); + assert_eq!(string("var o = {p: 1}, t = {}, s = ''; for (t.k in o) s += t.k; s"), "p"); + assert_eq!(string("var a = ['x', 'y'], s = ''; for (var i in a) s += i + a[i]; s"), "0x1y"); +} + +#[test] +fn control_flow_with_labels_switch_and_finally() { + assert_eq!( + num( + "var n = 0; outer: for (var i = 0; i < 5; i++) { for (var j = 0; j < 5; j++) { if (j == 2) continue outer; if (i == 3) break outer; n++; } } n" + ), + 6.0 + ); + assert_eq!(num("var r = 0; switch (3) { case 1: r = 1; case 3: r += 3; case 4: r += 4; break; default: r = 100; } r"), 7.0); + assert_eq!(num("var r = 0; switch ('z') { case 'a': r = 1; break; default: r = 2; case 'b': r += 10; } r"), 12.0); + assert_eq!(num("var log = 0; function f() { try { return 1; } finally { log = 5; } } f() + log"), 6.0); + assert_eq!(num("function f() { try { return 1; } finally { return 2; } } f()"), 2.0); + assert_eq!(num("var n = 0; for (var i = 0; i < 3; i++) { try { if (i == 1) continue; n += 10; } finally { n += 1; } } n"), 23.0); + assert_eq!(num("var n = 0; while (true) { try { try { break; } finally { n += 1; } } finally { n += 10; } } n"), 11.0); + assert_eq!(num("var n = 0; try { throw 3; } catch (e) { n = e; } finally { n *= 2; } n"), 6.0); + assert_eq!(num("var n = 0; try { try { throw 1; } finally { n = 7; } } catch (e) { n += e; } n"), 8.0); + assert_eq!(num("function f() { for (var k in {a: 1}) { try { return 4; } finally { } } } f()"), 4.0); + assert_eq!(num("var x = 0; lbl: { x = 1; break lbl; x = 2; } x"), 1.0); + assert_eq!(num("var i = 0; do { i++; } while (i < 10); i"), 10.0); + assert_eq!(num("var e = 1; try { throw 2; } catch (e) { e = 3; } e"), 1.0); + assert_eq!(num("function f() { var r = 0; with ({a: 1}) { try { return a; } finally { r = 1; } } } f()"), 1.0); +} + +#[test] +fn with_eval_and_new_function() { + assert_eq!(num("var o = {a: 2}; with (o) { a = 3; } o.a"), 3.0); + truth("var o = {f: function() { return this; }}; with (o) { f() === o }"); + assert_eq!(num("var x = 1; function f() { var x = 2; return eval('x'); } f()"), 2.0); + assert_eq!(num("var x = 1; function f() { var x = 2; return (0, eval)('x'); } f()"), 1.0); + assert_eq!(num("function f() { eval('var y = 4'); return y; } f()"), 4.0); + truth("function f() { 'use strict'; eval('var y = 4'); return typeof y; } f() === 'undefined'"); + assert_eq!(num("eval('1; 2; if (false) {}')"), 2.0); + assert_eq!(num("new Function('a', 'b', 'return a * b;')(6, 7)"), 42.0); + assert_eq!(num("Function('return 3')()"), 3.0); + let e = fail("new Function('a){}; (function(', 'return 1')"); + assert!(e.message.starts_with("SyntaxError"), "{}", e.message); + let e = fail("new Function('}); (function() {')"); + assert!(e.message.starts_with("SyntaxError"), "{}", e.message); + let e = fail("eval('var = 1')"); + assert!(e.message.starts_with("SyntaxError"), "{}", e.message); + truth("try { eval('a b') } catch (e) { e instanceof SyntaxError }"); + assert_eq!(string("(function f(a) { return a; }).toString()"), "function f(a) { return a; }"); +} + +#[test] +fn strict_mode_assignments_and_errors() { + let e = fail("'use strict'; undeclared = 1;"); + assert!(e.message.starts_with("ReferenceError"), "{}", e.message); + assert_eq!(num("undeclared = 1; undeclared"), 1.0); + let e = fail("'use strict'; (function f() { f = 1; })()"); + assert!(e.message.starts_with("TypeError"), "{}", e.message); + assert_eq!(num("var g = function f() { f = 1; return typeof f === 'function' ? 1 : 0; }; g()"), 1.0); + let e = fail("'use strict'; NaN = 1;"); + assert!(e.message.starts_with("TypeError"), "{}", e.message); + let e = fail("'use strict'; (function() {}).caller"); + assert!(e.message.starts_with("TypeError"), "{}", e.message); + truth("var o = {}; try { o.x.y; false } catch (e) { e instanceof TypeError && e.constructor === TypeError }"); + truth( + "try { nope; } catch (e) { e instanceof ReferenceError && e.name === 'ReferenceError' && String_(e) === 'ReferenceError: nope is not defined' } function String_(v) { return '' + v; }", + ); + truth( + "new Error('m').message === 'm' && Error('m') instanceof Error && new TypeError().message === '' && new RangeError('r').toString() === 'RangeError: r'", + ); +} + +#[test] +fn errors_carry_line_and_column() { + let e = fail("var a = 1;\nvar b = 2;\n nope();"); + assert_eq!((e.kind, e.line, e.column), (RunErrorKind::Thrown, 3, 3)); + assert_eq!(e.message, "ReferenceError: nope is not defined"); + let e = fail("function f() {\n throw new TypeError('bad');\n}\nf();"); + assert_eq!((e.line, e.column), (2, 3)); + assert_eq!(e.message, "TypeError: bad"); + let e = fail("var x = ;"); + assert_eq!((e.kind, e.line), (RunErrorKind::Syntax, 1)); + let e = fail("throw 42"); + assert_eq!(e.message, "42"); +} + +#[test] +fn host_functions_receive_arguments_and_return_values() { + let mut realm = Realm::new(); + realm.define_function("add", |args| match args { + [JsValue::Number(a), JsValue::Number(b)] => Ok(JsValue::Number(a + b)), + _ => Err("add takes two numbers".into()), + }); + assert_eq!(realm.run("add(2, 3)"), Ok(JsValue::Number(5.0))); + let e = realm.run("add('x')").unwrap_err(); + assert_eq!(e.message, "Error: add takes two numbers"); + assert_eq!(realm.run("try { add(); } catch (e) { e.message }"), Ok(JsValue::String("add takes two numbers".into()))); + assert_eq!(realm.run("var kept = 1; 'next'"), Ok(JsValue::String("next".into()))); + assert_eq!(realm.run("kept"), Ok(JsValue::Number(1.0)), "a realm keeps its globals between runs"); +} + +// ---- limits ---- + +fn limit(realm: &mut Realm, src: &str) -> LimitKind { + match realm.run(src) { + Err(RunError { kind: RunErrorKind::Limit(k), .. }) => k, + other => panic!("{src}: {other:?}"), + } +} + +#[test] +fn an_infinite_loop_ends_with_the_fuel_limit() { + let mut realm = small(Limits { fuel: 100_000, ..Limits::default() }); + assert_eq!(limit(&mut realm, "for (;;) {}"), LimitKind::Fuel); + assert_eq!(limit(&mut realm, "while (true) { try { for (;;) {} } catch (e) {} }"), LimitKind::Fuel); + assert_eq!(limit(&mut realm, "var n = 0; try { for (;;) {} } finally { n = 1; } "), LimitKind::Fuel); + assert_eq!( + limit(&mut realm, "function f() { try { for (;;) {} } finally { for (;;) {} } } while (true) { try { f(); } catch (e) {} }"), + LimitKind::Fuel + ); + assert_eq!(realm.run("n"), Ok(JsValue::Number(0.0)), "the finally block did not run"); + assert_eq!(realm.run("1 + 1"), Ok(JsValue::Number(2.0)), "each run gets fresh fuel"); +} + +#[test] +fn deep_recursion_ends_with_the_frame_limit_on_a_small_stack() { + let handle = std::thread::Builder::new() + .stack_size(256 << 10) + .spawn(|| { + let mut realm = Realm::new(); + let r = realm.run("function f(n) { return n === 0 ? 0 : 1 + f(n - 1); } f(1000000)"); + let ok = realm.run("function g(n) { return n === 0 ? 0 : 1 + g(n - 1); } g(5000)"); + let caught = realm.run("function h(n) { try { return h(n + 1); } catch (e) { return -1; } } h(0)"); + (r, ok, caught) + }) + .unwrap(); + let (r, ok, caught) = handle.join().unwrap(); + assert_eq!(r.map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Frames))); + assert_eq!(ok, Ok(JsValue::Number(5000.0))); + assert_eq!(caught.map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Frames)), "catch cannot swallow the frame limit"); +} + +#[test] +fn reentry_through_getters_ends_with_the_reentry_limit() { + let handle = std::thread::Builder::new() + .stack_size(8 << 20) + .spawn(|| { + let mut realm = Realm::new(); + let getter = realm.run("var o = { get x() { return this.x; } }; o.x"); + let conversion = realm.run("var v = { valueOf: function() { return v * 1; } }; v * 1"); + let eval = realm.run("function e() { return (0, eval)('e()'); } e()"); + (getter, conversion, eval) + }) + .unwrap(); + let (getter, conversion, eval) = handle.join().unwrap(); + for r in [getter, conversion, eval] { + assert_eq!(r.map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Reentry))); + } +} + +#[test] +fn memory_bombs_end_with_the_heap_limit() { + let limits = Limits { heap_bytes: 4 << 20, ..Limits::default() }; + for src in [ + "var a = null; for (;;) a = {next: a, v: [a, a]};", + "var s = 'x'; for (;;) s = s + s;", + "var a = []; for (var i = 0; ; i++) a[i] = 'item' + i;", + "var s = 'x'; for (;;) { s = s + s; var o = {}; o[s] = 1; }", + ] { + let mut realm = small(limits.clone()); + assert_eq!(limit(&mut realm, src), LimitKind::Heap, "{src}"); + // What the bomb built is still reachable from the globals; raising the limit lets the + // realm drop it. + realm.set_limits(Limits::default()); + assert_eq!(realm.run("a = s = null; 1"), Ok(JsValue::Number(1.0))); + realm.set_limits(limits.clone()); + assert_eq!(realm.run("2"), Ok(JsValue::Number(2.0))); + } +} + +#[test] +fn garbage_is_collected_cycles_included() { + let mut realm = small(Limits { heap_bytes: 2 << 20, ..Limits::default() }); + let src = "for (var i = 0; i < 200000; i++) { var a = {}, b = {a: a, s: 'x' + i}; a.b = b; } i"; + assert_eq!(realm.run(src), Ok(JsValue::Number(200_000.0))); + assert!(realm.interp().heap.collections > 0); + let before = realm.collect_garbage(); + realm.run("var keep = []; for (var j = 0; j < 1000; j++) keep[j] = {j: j};").unwrap(); + let live = realm.collect_garbage(); + realm.run("keep = null;").unwrap(); + let after = realm.collect_garbage(); + assert!(live > before && after < live, "{before} {live} {after}"); + let cells = realm.interp().heap.live_cells(); + assert!(cells < 200, "{cells} cells are still live"); +} + +#[test] +fn a_million_appends_flatten_without_recursion() { + let handle = std::thread::Builder::new() + .stack_size(1 << 20) + .spawn(|| { + let mut realm = Realm::new(); + realm.run("var s = '', t = ''; for (var i = 0; i < 1000000; i++) { s += 'ab'; t = t + 'ab'; } s.length === 2000000 && s === t && s[1999999] === 'b'") + }) + .unwrap(); + assert_eq!(handle.join().unwrap(), Ok(JsValue::Bool(true))); +} + +#[test] +fn the_interrupt_flag_stops_a_run() { + let mut realm = Realm::new(); + let flag = realm.interrupt(); + flag.set(); + assert_eq!(limit(&mut realm, "1 + 1"), LimitKind::Interrupted); + flag.clear(); + assert_eq!(realm.run("1 + 1"), Ok(JsValue::Number(2.0))); + let from_host = realm.interrupt(); + realm.define_function("stop", move |_| { + from_host.set(); + Ok(JsValue::Undefined) + }); + assert_eq!(limit(&mut realm, "for (var i = 0; ; i++) { if (i === 1000) stop(); }"), LimitKind::Interrupted); + assert!(flag.is_set()); + assert_eq!(limit(&mut realm, "i"), LimitKind::Interrupted, "the flag stays set"); + flag.clear(); + assert_eq!(realm.run("i"), Ok(JsValue::Number(1000.0))); +} + +#[test] +fn eval_and_function_source_counts_against_the_source_limit() { + let mut realm = Realm::with_options(Options { max_source_units: 200, ..Options::default() }, Limits::default()); + assert_eq!(realm.run("eval('1 + 1')"), Ok(JsValue::Number(2.0))); + let e = realm.run("var s = 'x'; for (var i = 0; i < 8; i++) s += s; eval(s)").unwrap_err(); + assert!(e.message.contains(crate::TOO_LONG), "{e:?}"); + let e = realm.run("for (var i = 0; i < 20; i++) eval('1 + 1 + 1 + 1 + 1 + 1 + 1 + 1');").unwrap_err(); + assert!(e.message.contains(crate::TOO_LONG), "the sources of one run count together: {e:?}"); + let e = realm.run("Function('return 1;' + ' ')").unwrap_err(); + assert!(e.message.contains(crate::TOO_LONG), "{e:?}"); +} + +#[test] +fn wasm_builds_have_smaller_default_limits() { + let limits = Limits::default(); + #[cfg(target_arch = "wasm32")] + assert_eq!(limits, Limits { fuel: 250_000_000, heap_bytes: 128 << 20, frames: 2_000, reentry: 16 }); + #[cfg(not(target_arch = "wasm32"))] + assert_eq!(limits, Limits { fuel: 1_000_000_000, heap_bytes: 512 << 20, frames: 10_000, reentry: 32 }); +} + +#[test] +fn deep_nesting_compiles_on_a_small_stack() { + let n = crate::DEFAULT_MAX_DEPTH - 5; + let cases = [ + format!("x = {}1{};", "[".repeat(n), "]".repeat(n)), + format!("x = {}1{};", "{a:".repeat(n), "}".repeat(n)), + format!("{}{}", "function f(){".repeat(n), "}".repeat(n)), + format!("{}x = 1;", "if (1) ".repeat(n)), + format!("x = {}1;", "1 ? 1 : ".repeat(n)), + format!("x = 0; {}x++;{}", "while (x < 1) { try { ".repeat(n / 2), " } finally { } }".repeat(n / 2)), + ]; + let handle = std::thread::Builder::new() + .stack_size(1 << 20) + .spawn(move || cases.iter().map(|src| Realm::new().run(src).map(|_| ()).map_err(|e| e.kind)).collect::<Vec<_>>()) + .unwrap(); + for r in handle.join().unwrap() { + assert!(r.is_ok() || r == Err(RunErrorKind::Syntax), "{r:?}"); + } + let long = [ + format!("x = 1{};", " + 1".repeat(100_000)), + format!("{}x = 1;", "a = ".repeat(100_000)), + format!("x = {{}}; x.y = x; x{};", ".y".repeat(100_000)), + ]; + for src in &long { + Realm::new().run(src).unwrap(); + } + assert_eq!(num(&format!("var a; {}1; a", "a = ".repeat(100_000))), 1.0); + assert_eq!(num(&format!("x = 1{}", " + 1".repeat(100_000))), 100_001.0); +} + +// ---- the verifier ---- + +fn code(ops: Vec<Op>) -> Code { + let source = Rc::new(Source { lines: crate::LineTable::new(&[]), text: Rc::from(Vec::<u16>::new()) }); + Code { + positions: vec![0; ops.len()], + ops, + numbers: vec![1.0], + strings: Vec::new(), + names: Vec::new(), + functions: Vec::new(), + regexps: Vec::new(), + handlers: Vec::new(), + finally_tables: Vec::new(), + max_stack: 0, + strict: false, + self_name: None, + params: Vec::new(), + vars: Vec::new(), + fun_decls: Vec::new(), + uses_arguments: false, + source, + span: (0, 0), + } +} + +#[test] +fn the_verifier_rejects_bad_bytecode() { + assert_eq!(verify(&code(vec![Op::Number(0), Op::Return])), Ok(1)); + let bad: Vec<(Vec<Op>, &str)> = vec![ + (vec![Op::Jump(9)], "past the end"), + (vec![Op::Pop, Op::Undefined, Op::Return], "pops below"), + (vec![Op::Number(5), Op::Return], "number 5 out of range"), + (vec![Op::GetVar(0), Op::Return], "name 0 out of range"), + (vec![Op::Closure(0), Op::Return], "function 0 out of range"), + (vec![Op::Undefined], "past its last instruction"), + (vec![Op::True, Op::JumpIfFalse(3), Op::Undefined, Op::Undefined, Op::Return], "depths"), + (vec![Op::Undefined, Op::Undefined, Op::EndFinally(0)], "finally table 0"), + (vec![], "no instructions"), + ]; + for (ops, want) in bad { + let e = verify(&code(ops.clone())).unwrap_err(); + assert!(e.contains(want), "{ops:?}: {e}"); + } + let mut c = code(vec![Op::Undefined, Op::Return]); + c.handlers.push(Handler { start: 0, end: 5, target: 0, depth: 0, env_depth: 0 }); + assert!(verify(&c).unwrap_err().contains("handler range")); +} + +#[test] +fn every_compiled_function_passes_the_verifier() { + // Each code is verified as it is compiled; a failure would surface as an internal error. + let src = "function f(a) { try { for (var k in a) { switch (k) { case 'x': continue; default: break; } } } catch (e) { return e; } finally { a = 0; } \ + with (a) { lbl: do { if (b) break lbl; } while (0); } return function() { return arguments; }; } 1"; + assert_eq!(run(src), JsValue::Number(1.0)); +} + +// ---- robustness ---- + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + #[test] + fn arbitrary_fragment_programs_never_panic(parts in proptest::collection::vec(0..crate::tests::FRAGMENTS.len(), 0..60)) { + let src: String = parts.iter().map(|&i| crate::tests::FRAGMENTS[i]).collect(); + let mut realm = small(Limits { fuel: 20_000, heap_bytes: 1 << 20, frames: 200, reentry: 8 }); + if let Err(e) = realm.run(&src) { + prop_assert!(e.kind != RunErrorKind::Internal, "{src:?}: {e:?}"); + } + } + + #[test] + fn arbitrary_text_never_panics(src in ".{0,120}") { + let mut realm = small(Limits { fuel: 20_000, heap_bytes: 1 << 20, frames: 200, reentry: 8 }); + if let Err(e) = realm.run(&src) { + prop_assert!(e.kind != RunErrorKind::Internal, "{src:?}: {e:?}"); + } + } +} diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 4d8d32271..f17577fe6 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -18,16 +18,34 @@ //! `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. +//! +//! A [`Realm`] runs scripts. It compiles the syntax tree to bytecode, checks the bytecode with a +//! verifier, and runs it on an interpreter that keeps JavaScript calls on its own frame stack, so +//! deep recursion in a script does not use the native stack. Objects and environments live in an +//! arena with a mark-and-sweep collector; strings built by repeated `+` are flattened and dropped +//! with loops. The realm provides the language (sections 10 to 14 of ECMA-262 5.1) and a minimal +//! set of built-ins: `Object` and `Object.prototype`'s basic methods, `Function` with +//! `Function.prototype.call` and `apply`, `eval`, and the error constructors. Every run is bounded +//! by [`Limits`]: instructions run (fuel), heap bytes, frames, nested host calls, and an +//! [`Interrupt`] flag another thread can set. A run that reaches a limit ends with +//! [`RunErrorKind::Limit`], which a script's `try` cannot catch. #![forbid(unsafe_code)] pub mod ast; +mod builtins; +mod bytecode; mod chars; +mod compiler; mod error; +mod heap; +mod interp; mod lexer; mod number; mod parser; +mod realm; mod regexp; mod token; +mod value; pub use ast::{ AssignOp, Ast, BinaryOp, CatchClause, Expr, ExprId, ForInTarget, ForInit, Function, FunctionId, FunctionKind, LogicalOp, Name, Program, Property, @@ -36,6 +54,7 @@ pub use ast::{ pub use error::{SyntaxError, TOO_DEEP, TOO_LONG}; pub use lexer::{Goal, Lexer}; pub use number::to_string as number_to_string; +pub use realm::{Interrupt, JsValue, LimitKind, Limits, Realm, RunError, RunErrorKind}; pub use token::{Directive, DirectiveName, Keyword, Punct, Span, Token, TokenKind}; /// How a script is read. @@ -145,5 +164,7 @@ impl LineTable { } } +#[cfg(test)] +mod interp_tests; #[cfg(test)] mod tests; diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs new file mode 100644 index 000000000..955383b18 --- /dev/null +++ b/crates/js/src/realm.rs @@ -0,0 +1,218 @@ +//! The embedding API: a [`Realm`] holds a global object and its built-ins, runs scripts in them, +//! and enforces [`Limits`]. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use crate::bytecode::CodeKind; +use crate::heap::{Func, HIDDEN, Object, Prop, key}; +use crate::interp::{Abrupt, CompileError, Interp}; +use crate::value::Value; +use crate::{Options, SyntaxError}; + +/// The limits a run is held to. Reaching one ends the run with [`RunErrorKind::Limit`]; scripts +/// cannot catch it, and their `finally` blocks do not run. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Limits { + /// Instructions a run may execute. + pub fuel: u64, + /// Estimated bytes the heap (objects, environments and the strings they hold) may reach + /// after a collection. A string longer than a quarter of this many code units cannot be + /// made. + pub heap_bytes: usize, + /// Call frames that may be active at once. + pub frames: usize, + /// How deeply the interpreter may run again inside an operation (getters and setters, + /// conversions that call `valueOf` or `toString`, indirect `eval`). + pub reentry: usize, +} + +#[cfg(not(target_arch = "wasm32"))] +impl Default for Limits { + fn default() -> Self { + Self { fuel: 1_000_000_000, heap_bytes: 512 << 20, frames: 10_000, reentry: 32 } + } +} + +/// A wasm build has less memory and a smaller stack. +#[cfg(target_arch = "wasm32")] +impl Default for Limits { + fn default() -> Self { + Self { fuel: 250_000_000, heap_bytes: 128 << 20, frames: 2_000, reentry: 16 } + } +} + +/// Which limit a run reached. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LimitKind { + Fuel, + Heap, + Frames, + Reentry, + /// The host set the interrupt flag. + Interrupted, +} + +/// A flag the host sets, from any thread, to stop the running script at its next instruction. +/// It stays set until [`Interrupt::clear`]. +#[derive(Clone, Debug)] +pub struct Interrupt(Arc<AtomicBool>); + +impl Interrupt { + pub fn set(&self) { + self.0.store(true, Ordering::Relaxed); + } + + pub fn clear(&self) { + self.0.store(false, Ordering::Relaxed); + } + + pub fn is_set(&self) -> bool { + self.0.load(Ordering::Relaxed) + } +} + +/// A value passed between a script and the host. An object is given by its class name. +#[derive(Clone, Debug, PartialEq)] +pub enum JsValue { + Undefined, + Null, + Bool(bool), + Number(f64), + String(String), + Object(String), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RunErrorKind { + /// The source does not parse. + Syntax, + /// The script threw an exception it did not catch. + Thrown, + Limit(LimitKind), + /// The interpreter reached a state it never makes. + Internal, +} + +/// Why a run did not complete. `line` and `column` count from 1 (the column in UTF-16 code units) +/// and give where the error was raised; they are 0 when that is not known. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RunError { + pub kind: RunErrorKind, + pub message: String, + pub line: u32, + pub column: u32, +} + +impl std::fmt::Display for RunError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} (line {}, column {})", self.message, self.line, self.column) + } +} + +impl std::error::Error for RunError {} + +impl From<SyntaxError> for RunError { + fn from(e: SyntaxError) -> Self { + RunError { kind: RunErrorKind::Syntax, message: e.to_string(), line: e.line, column: e.column } + } +} + +/// A global environment with the built-ins, in which scripts run one after another. +pub struct Realm { + interp: Interp, +} + +impl Default for Realm { + fn default() -> Self { + Self::new() + } +} + +impl Realm { + pub fn new() -> Self { + Self::with_options(Options::default(), Limits::default()) + } + + /// A realm whose scripts (and the source given to `eval` and `Function`) are read with + /// `options`; [`Options::max_source_units`] caps the source parsed in one run, all of it + /// counted together. + pub fn with_options(options: Options, limits: Limits) -> Self { + Self { interp: Interp::new(options, limits) } + } + + pub fn limits(&self) -> &Limits { + &self.interp.limits + } + + /// Sets the limits for the next run. + pub fn set_limits(&mut self, limits: Limits) { + self.interp.limits = limits; + } + + /// The realm's interrupt flag. Setting it stops a running script, or the next one to start. + pub fn interrupt(&self) -> Interrupt { + Interrupt(self.interp.interrupt.clone()) + } + + /// Defines a global function that calls `f` with its arguments. An `Err` from `f` is thrown + /// to the script as an `Error` with that message. + pub fn define_function(&mut self, name: &str, f: impl Fn(&[JsValue]) -> Result<JsValue, String> + 'static) { + let interp = &mut self.interp; + let proto = interp.intrinsics.function_proto; + let func = Func::Host { f: std::rc::Rc::new(f), name: name.into() }; + let o = interp.heap.alloc_object(Object::new(crate::heap::Class::Function(func), Some(proto))); + let global = interp.global; + let _ = interp.define(global, key(name), Prop::Data { value: Value::Object(o), flags: HIDDEN }); + } + + /// Runs `source` as a script in the global environment and returns its completion value (the + /// value of the last expression statement that ran). + pub fn run(&mut self, source: &str) -> Result<JsValue, RunError> { + let interp = &mut self.interp; + interp.fuel = interp.limits.fuel; + interp.source_units = 0; + interp.throw_site = None; + let units: Vec<u16> = source.encode_utf16().collect(); + let code = match interp.compile_source(&units, false, CodeKind::Global) { + Ok(code) => code, + Err(CompileError::Syntax(e)) => return Err(e.into()), + Err(CompileError::Internal(m)) => return Err(RunError { kind: RunErrorKind::Internal, message: m, line: 0, column: 0 }), + }; + let result = interp.run_global(code, false); + let (line, column) = interp.throw_site.take().map_or((0, 0), |(code, pc)| code.line_column(pc)); + interp.stack.clear(); + interp.frames.clear(); + interp.temp_roots.clear(); + match result { + Ok(v) => Ok(interp.to_js_value(&v)), + Err(Abrupt::Throw(v)) => { + let message = interp.describe(&v); + interp.last_thrown = v; + Err(RunError { kind: RunErrorKind::Thrown, message, line, column }) + } + Err(Abrupt::Limit(kind)) => Err(RunError { kind: RunErrorKind::Limit(kind), message: format!("limit reached: {kind:?}"), line, column }), + Err(Abrupt::Internal(message)) => Err(RunError { kind: RunErrorKind::Internal, message, line, column }), + } + } + + /// Defines the global variable `name` holding the exception the last run did not catch + /// (`undefined` if there was none). + pub fn bind_last_thrown(&mut self, name: &str) { + let interp = &mut self.interp; + let v = interp.last_thrown.clone(); + let global = interp.global; + let _ = interp.define(global, key(name), Prop::Data { value: v, flags: HIDDEN }); + } + + /// Collects garbage now and returns the heap's estimated size in bytes afterwards. + pub fn collect_garbage(&mut self) -> usize { + self.interp.collect_garbage(); + self.interp.heap.bytes + } + + #[cfg(test)] + pub(crate) fn interp(&mut self) -> &mut Interp { + &mut self.interp + } +} diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index fc4b5b1a1..548b20ab0 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -663,7 +663,7 @@ fn sources_over_the_length_limit_are_refused() { // ---- robustness ---- /// Fragments of the language, put together at random so the parser reaches its deeper states. -const FRAGMENTS: &[&str] = &[ +pub(crate) const FRAGMENTS: &[&str] = &[ "var ", "function ", "f", diff --git a/crates/js/src/value.rs b/crates/js/src/value.rs new file mode 100644 index 000000000..7d1691464 --- /dev/null +++ b/crates/js/src/value.rs @@ -0,0 +1,381 @@ +//! Values (ECMA-262 5.1 section 8) and the conversions between primitives (section 9). +//! +//! A string is a rope: concatenation makes a node that points at its two halves, and the first +//! read of its characters flattens it, with a loop and an explicit stack, into one buffer that +//! replaces the node. Strings hold no objects, so the reference counts that share them cannot form +//! a cycle; dropping a long rope also uses a loop, not recursion. + +use std::cell::RefCell; +use std::rc::Rc; + +use crate::heap::ObjId; + +/// A JavaScript value. Objects are handles into the interpreter's heap. +#[derive(Clone, Debug)] +pub(crate) enum Value { + Undefined, + Null, + Bool(bool), + Number(f64), + String(Str), + Object(ObjId), +} + +impl Value { + pub(crate) fn str(s: &str) -> Value { + Value::String(Str::from(s)) + } + + pub(crate) fn as_object(&self) -> Option<ObjId> { + match self { + Value::Object(id) => Some(*id), + _ => None, + } + } + + pub(crate) fn is_nullish(&self) -> bool { + matches!(self, Value::Undefined | Value::Null) + } + + /// ToBoolean (section 9.2). + pub(crate) fn truthy(&self) -> bool { + match self { + Value::Undefined | Value::Null => false, + Value::Bool(b) => *b, + Value::Number(n) => !(n.is_nan() || *n == 0.0), + Value::String(s) => s.len() > 0, + Value::Object(_) => true, + } + } +} + +/// The text of a string value, in UTF-16 code units: a flat buffer or a concatenation. +#[derive(Clone)] +pub(crate) struct Str(Rc<RefCell<Node>>); + +enum Node { + Flat(Rc<[u16]>), + Concat { + left: Str, + right: Str, + len: usize, + }, + /// A node whose halves were moved out while it was dropped. + Taken, +} + +impl std::fmt::Debug for Str { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self.to_rust_string()) + } +} + +impl From<&str> for Str { + fn from(s: &str) -> Self { + Str::from_units(s.encode_utf16().collect()) + } +} + +/// Strings at most this long are copied when concatenated instead of becoming a rope node. +const FLAT_CONCAT: usize = 32; + +impl Str { + pub(crate) fn from_units(units: Vec<u16>) -> Self { + Str::from_rc(units.into()) + } + + pub(crate) fn from_rc(units: Rc<[u16]>) -> Self { + Str(Rc::new(RefCell::new(Node::Flat(units)))) + } + + pub(crate) fn len(&self) -> usize { + match &*self.0.borrow() { + Node::Flat(f) => f.len(), + Node::Concat { len, .. } => *len, + Node::Taken => 0, + } + } + + /// The node is not yet flattened. + pub(crate) fn is_rope(&self) -> bool { + matches!(&*self.0.borrow(), Node::Concat { .. }) + } + + /// An address that identifies the node, for counting shared nodes once. + pub(crate) fn node_addr(&self) -> usize { + Rc::as_ptr(&self.0) as *const () as usize + } + + /// The two halves of a concatenation node. + pub(crate) fn halves(&self) -> Option<(Str, Str)> { + match &*self.0.borrow() { + Node::Concat { left, right, .. } => Some((left.clone(), right.clone())), + _ => None, + } + } + + /// `a + b`. The caller checks the length limit first. + pub(crate) fn concat(a: &Str, b: &Str) -> Str { + let (la, lb) = (a.len(), b.len()); + if la == 0 { + return b.clone(); + } + if lb == 0 { + return a.clone(); + } + if la + lb <= FLAT_CONCAT { + let mut units = Vec::with_capacity(la + lb); + units.extend_from_slice(&a.flat()); + units.extend_from_slice(&b.flat()); + return Str::from_units(units); + } + Str(Rc::new(RefCell::new(Node::Concat { left: a.clone(), right: b.clone(), len: la + lb }))) + } + + /// The code units, flattening the rope first if it is one. + pub(crate) fn flat(&self) -> Rc<[u16]> { + if let Node::Flat(f) = &*self.0.borrow() { + return f.clone(); + } + let mut units = Vec::with_capacity(self.len()); + let mut stack = vec![self.clone()]; + while let Some(s) = stack.pop() { + match &*s.0.borrow() { + Node::Flat(f) => units.extend_from_slice(f), + Node::Concat { left, right, .. } => { + stack.push(right.clone()); + stack.push(left.clone()); + } + Node::Taken => {} + } + } + let flat: Rc<[u16]> = units.into(); + let old = std::mem::replace(&mut *self.0.borrow_mut(), Node::Flat(flat.clone())); + drop_node(old); + flat + } + + pub(crate) fn to_rust_string(&self) -> String { + String::from_utf16_lossy(&self.flat()) + } +} + +impl PartialEq for Str { + fn eq(&self, other: &Self) -> bool { + Rc::ptr_eq(&self.0, &other.0) || (self.len() == other.len() && self.flat() == other.flat()) + } +} + +/// Drops a node's halves with a loop, so a rope of a million appends does not recurse a million +/// levels deep. +fn drop_node(node: Node) { + let mut stack = Vec::new(); + if let Node::Concat { left, right, .. } = node { + stack.push(left); + stack.push(right); + } + while let Some(s) = stack.pop() { + if Rc::strong_count(&s.0) == 1 { + let taken = std::mem::replace(&mut *s.0.borrow_mut(), Node::Taken); + if let Node::Concat { left, right, .. } = taken { + stack.push(left); + stack.push(right); + } + } + } +} + +impl Drop for Str { + fn drop(&mut self) { + if Rc::strong_count(&self.0) == 1 && self.is_rope() { + let node = std::mem::replace(&mut *self.0.borrow_mut(), Node::Taken); + drop_node(node); + } + } +} + +/// White space and line terminators as StrWhiteSpaceChar (section 9.3.1) counts them. +pub(crate) fn is_str_space(u: u16) -> bool { + matches!(u, 0x09 | 0x0A | 0x0B | 0x0C | 0x0D | 0x20 | 0xA0 | 0x1680 | 0x180E | 0x2028 | 0x2029 | 0x202F | 0x205F | 0x3000 | 0xFEFF) + || (0x2000..=0x200A).contains(&u) +} + +/// ToNumber applied to a string (section 9.3.1): the StringNumericLiteral grammar, NaN for +/// anything else. +pub(crate) fn string_to_number(units: &[u16]) -> f64 { + let start = units.iter().position(|&u| !is_str_space(u)).unwrap_or(units.len()); + let end = units.iter().rposition(|&u| !is_str_space(u)).map_or(start, |i| i + 1); + let Some(text) = units.get(start..end) else { return f64::NAN }; + if text.is_empty() { + return 0.0; + } + let Ok(s) = String::from_utf16(text) else { return f64::NAN }; + if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) { + if hex.is_empty() || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return f64::NAN; + } + return hex.bytes().fold(0.0, |acc, b| acc * 16.0 + f64::from((b as char).to_digit(16).unwrap_or(0))); + } + let (sign, body) = match s.as_bytes().first() { + Some(b'+') => (1.0, &s[1..]), + Some(b'-') => (-1.0, &s[1..]), + _ => (1.0, s.as_str()), + }; + if body == "Infinity" { + return sign * f64::INFINITY; + } + if !is_decimal_literal(body) { + return f64::NAN; + } + body.parse::<f64>().map_or(f64::NAN, |n| sign * n) +} + +/// `body` is a StrUnsignedDecimalLiteral without `Infinity`: digits with an optional fraction and +/// exponent, at least one digit in the mantissa. +fn is_decimal_literal(body: &str) -> bool { + let b = body.as_bytes(); + let mut i = 0; + let digits = |i: &mut usize| { + let s = *i; + while b.get(*i).is_some_and(u8::is_ascii_digit) { + *i += 1; + } + *i - s + }; + let mut mantissa = digits(&mut i); + if b.get(i) == Some(&b'.') { + i += 1; + mantissa += digits(&mut i); + } + if mantissa == 0 { + return false; + } + if matches!(b.get(i), Some(b'e' | b'E')) { + i += 1; + if matches!(b.get(i), Some(b'+' | b'-')) { + i += 1; + } + if digits(&mut i) == 0 { + return false; + } + } + i == b.len() +} + +/// ToString applied to a number (section 9.8.1). +pub(crate) fn number_to_str(n: f64) -> Str { + Str::from(crate::number::to_string(n).as_str()) +} + +/// ToInt32 (section 9.5). +pub(crate) fn to_int32(n: f64) -> i32 { + to_uint32(n) as i32 +} + +/// ToUint32 (section 9.6). +pub(crate) fn to_uint32(n: f64) -> u32 { + if !n.is_finite() || n == 0.0 { + return 0; + } + let int = n.trunc(); + let m = int.rem_euclid(4_294_967_296.0); + m as u32 +} + +/// The array index a property name stands for (section 15.4: a canonical numeric string below +/// 2^32 − 1). +pub(crate) fn array_index(key: &[u16]) -> Option<u32> { + if key.is_empty() || key.len() > 10 || (key.len() > 1 && key.first() == Some(&u16::from(b'0'))) { + return None; + } + let mut n: u64 = 0; + for &u in key { + let d = u.checked_sub(u16::from(b'0')).filter(|d| *d <= 9)?; + n = n * 10 + u64::from(d); + } + u32::try_from(n).ok().filter(|&n| n != u32::MAX) +} + +/// The decimal digits of an array index. +pub(crate) fn index_key(i: u32) -> Rc<[u16]> { + i.to_string().encode_utf16().collect::<Vec<u16>>().into() +} + +#[cfg(test)] +mod tests { + use super::{Str, array_index, string_to_number, to_int32, to_uint32}; + + fn u(s: &str) -> Vec<u16> { + s.encode_utf16().collect() + } + + #[test] + fn strings_convert_to_numbers_by_the_string_numeric_literal_grammar() { + let cases: &[(&str, f64)] = &[ + ("", 0.0), + (" \n ", 0.0), + ("12", 12.0), + (" -1.5e2 ", -150.0), + ("0x1F", 31.0), + (".5", 0.5), + ("5.", 5.0), + ("+Infinity", f64::INFINITY), + ("-Infinity", f64::NEG_INFINITY), + ]; + for (s, want) in cases { + assert_eq!(string_to_number(&u(s)), *want, "{s:?}"); + } + for s in ["inf", "nan", "1_0", "0x", "1e", ".", "--1", "0x-1", "infinity", "1 2"] { + assert!(string_to_number(&u(s)).is_nan(), "{s:?}"); + } + } + + #[test] + fn int32_and_uint32_wrap() { + assert_eq!(to_int32(4_294_967_296.0 + 5.0), 5); + assert_eq!(to_int32(2_147_483_648.0), i32::MIN); + assert_eq!(to_uint32(-1.0), u32::MAX); + assert_eq!(to_int32(f64::NAN), 0); + assert_eq!(to_int32(-1.9), -1); + } + + #[test] + fn array_indices_are_canonical_and_below_the_largest_u32() { + assert_eq!(array_index(&u("0")), Some(0)); + assert_eq!(array_index(&u("4294967294")), Some(4_294_967_294)); + assert_eq!(array_index(&u("4294967295")), None); + assert_eq!(array_index(&u("01")), None); + assert_eq!(array_index(&u("1.0")), None); + assert_eq!(array_index(&u("")), None); + } + + #[test] + fn a_million_appends_flatten_and_drop_without_recursion() { + let handle = std::thread::Builder::new() + .stack_size(256 << 10) + .spawn(|| { + let x = Str::from("abcdefghijklmnopqrstuvwxyz0123456789"); + let mut s = Str::from(""); + for _ in 0..1_000_000 { + s = Str::concat(&s, &x); + } + let len = s.len(); + let flat = s.flat(); + (len, flat.len()) + }) + .unwrap(); + assert_eq!(handle.join().unwrap(), (36_000_000, 36_000_000)); + let handle = std::thread::Builder::new() + .stack_size(256 << 10) + .spawn(|| { + let x = Str::from("abcdefghijklmnopqrstuvwxyz0123456789"); + let mut s = Str::from(""); + for _ in 0..1_000_000 { + s = Str::concat(&x, &s); + } + s.len() + }) + .unwrap(); + assert_eq!(handle.join().unwrap(), 36_000_000); + } +} diff --git a/crates/js/tests/test262.rs b/crates/js/tests/test262.rs index c32045507..81ef399df 100644 --- a/crates/js/tests/test262.rs +++ b/crates/js/tests/test262.rs @@ -11,17 +11,29 @@ //! `INTERPRETING.md` says), unless its `onlyStrict`, `noStrict` or `raw` flag says otherwise. The //! test prints the counts and writes each failure to `failures.txt` beside the suite; it fails only //! when the suite cannot be read. +//! +//! A second test runs the same ES5 tests through the interpreter, each in a new realm after the +//! suite's `assert.js`, `sta.js` and the files its `includes:` names: every test that must parse +//! and uses only the built-ins the interpreter has (those that name a missing global or method, +//! in their own source or an included file, are counted as skipped, as are `async` tests). A test +//! passes when it completes, or, for `negative: phase: runtime`, when it throws an object whose +//! `constructor` is the global its `type:` names. Its counts are printed apart from the parse +//! counts, and its failures go to `run-failures.txt`. +use std::collections::HashSet; use std::path::{Path, PathBuf}; -use vectorcraft_js::{Options, parse}; +use vectorcraft_js::{JsValue, Limits, Options, Realm, RunErrorKind, TokenKind, parse, tokenize}; /// A test file's metadata, from its `/*--- … ---*/` block. #[derive(Default)] struct Meta { features: Vec<String>, flags: Vec<String>, + includes: Vec<String>, negative_phase: Option<String>, + /// The error constructor a negative test expects (`negative: type:`). + negative_type: Option<String>, /// The test names the ES5 section it was written for (`es5id:`). es5: bool, } @@ -86,6 +98,10 @@ fn meta(source: &str) -> Meta { meta.features = list(v, &mut lines); } else if let Some(v) = trimmed.strip_prefix("flags:").filter(|_| !indented) { meta.flags = list(v, &mut lines); + } else if let Some(v) = trimmed.strip_prefix("includes:").filter(|_| !indented) { + meta.includes = list(v, &mut lines); + } else if let Some(v) = trimmed.strip_prefix("type:").filter(|_| in_negative) { + meta.negative_type = Some(v.trim().to_string()); } else if trimmed.starts_with("es5id:") && !indented { meta.es5 = true; } else if trimmed == "negative:" && !indented { @@ -176,3 +192,123 @@ fn es5_parse_subset() { report.display() ); } + +/// Globals and method names the interpreter does not provide yet: a test that names one is +/// skipped. +const MISSING_BUILTINS: &str = "Array String Number Boolean Math JSON Date RegExp parseInt parseFloat isNaN isFinite encodeURI \ + decodeURI encodeURIComponent decodeURIComponent escape unescape print $262 $DONE Symbol Proxy Reflect Promise Map Set WeakMap \ + WeakSet ArrayBuffer DataView Int8Array Uint8Array Float64Array defineProperty defineProperties getOwnPropertyDescriptor \ + getOwnPropertyNames getPrototypeOf create keys freeze isFrozen seal isSealed preventExtensions isExtensible push pop shift unshift \ + slice splice concat join reverse sort indexOf lastIndexOf every some forEach map filter reduce reduceRight charAt charCodeAt \ + fromCharCode substring substr split replace match search exec test toUpperCase toLowerCase trim toFixed toPrecision toExponential \ + bind now isArray compareArray verifyProperty fnGlobalObject"; + +/// The source names a missing built-in, as an identifier, a property name or a word inside a +/// string literal (code passed to `eval` or `Function`), or does not parse as ES5 (the tests that +/// use `let` and `const`). +fn needs_missing_builtin(source: &str, missing: &HashSet<&str>) -> bool { + let Ok(tokens) = tokenize(source, &Options::default()) else { return true }; + tokens.iter().any(|t| match &t.kind { + TokenKind::Ident(name) => missing.contains(name.as_str()), + TokenKind::String(units) => { + String::from_utf16_lossy(units).split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '$')).any(|word| missing.contains(word)) + } + _ => false, + }) +} + +/// Runs one test in a new realm; `Err` describes a failure. +fn run_one(script: &str, negative_type: Option<&str>) -> Result<(), String> { + let mut realm = Realm::with_options(Options::default(), Limits { fuel: 20_000_000, heap_bytes: 256 << 20, ..Limits::default() }); + match (realm.run(script), negative_type) { + (Ok(_), None) => Ok(()), + (Ok(_), Some(t)) => Err(format!("completed; a {t} was expected")), + (Err(e), Some(t)) if e.kind == RunErrorKind::Thrown => { + realm.bind_last_thrown("__thrown"); + match realm.run(&format!("typeof __thrown === 'object' && __thrown !== null && __thrown.constructor === {t}")) { + Ok(JsValue::Bool(true)) => Ok(()), + _ => Err(format!("threw {} (line {}); a {t} was expected", e.message, e.line)), + } + } + (Err(e), _) => Err(format!("{:?}: {} (line {}, column {})", e.kind, e.message, e.line, e.column)), + } +} + +#[test] +fn es5_language_subset_runs() { + let Some(dir) = suite_dir() else { + println!("test262 interpreter: SKIPPED (no suite; run cargo xtask test262)"); + return; + }; + // Each test runs on a thread with a stack large enough for the nested runs the re-entry limit + // allows in an unoptimized build. + let handle = std::thread::Builder::new().stack_size(64 << 20).spawn(move || run_language_subset(&dir)).unwrap(); + handle.join().unwrap(); +} + +fn run_language_subset(dir: &Path) { + let language = dir.join("test").join("language"); + let harness_dir = dir.join("harness"); + // A file that cannot be read gives an empty source. + let read = |p: &Path| String::from_utf8_lossy(&std::fs::read(p).unwrap_or_default()).into_owned(); + let prelude = format!("{}\n{}\n", read(&harness_dir.join("assert.js")), read(&harness_dir.join("sta.js"))); + let missing: HashSet<&str> = MISSING_BUILTINS.split_whitespace().collect(); + let verbose = std::env::var_os("VECTORCRAFT_TEST262_VERBOSE").is_some(); + let (mut tests, mut skipped, mut passed, mut failed, mut panicked) = (0usize, 0usize, 0usize, 0usize, 0usize); + let mut failures = String::new(); + for path in test_files(&language) { + let source = read(&path); + let meta = meta(&source); + let negative_parse = matches!(meta.negative_phase.as_deref(), Some("parse" | "resolution")); + if !meta.es5 || !meta.features.is_empty() || negative_parse || meta.flags.iter().any(|f| f == "module") { + continue; + } + let includes: String = meta.includes.iter().map(|i| read(&harness_dir.join(i)) + "\n").collect(); + if meta.flags.iter().any(|f| f == "async") || needs_missing_builtin(&source, &missing) || needs_missing_builtin(&includes, &missing) { + skipped += 1; + continue; + } + tests += 1; + let has = |flag: &str| meta.flags.iter().any(|f| f == flag); + let modes: &[bool] = if has("raw") || has("noStrict") { + &[false] + } else if has("onlyStrict") { + &[true] + } else { + &[false, true] + }; + let rel = path.strip_prefix(dir).unwrap_or(&path).display().to_string().replace('\\', "/"); + for &strict in modes { + let mode = if strict { "strict" } else { "sloppy" }; + if verbose { + eprintln!("{rel} ({mode})"); + } + let body = if has("raw") { source.clone() } else { format!("{prelude}{includes}{source}") }; + let script = if strict { format!("\"use strict\";\n{body}") } else { body }; + let negative_type = meta.negative_type.clone().filter(|_| meta.negative_phase.as_deref() == Some("runtime")); + let outcome = std::panic::catch_unwind(|| run_one(&script, negative_type.as_deref())); + match outcome { + Ok(Ok(())) => passed += 1, + Ok(Err(why)) => { + failed += 1; + failures.push_str(&format!("{rel} ({mode}): {why}\n")); + } + Err(_) => { + failed += 1; + panicked += 1; + failures.push_str(&format!("{rel} ({mode}): PANIC\n")); + } + } + } + } + let report = dir.join("run-failures.txt"); + if let Err(e) = std::fs::write(&report, &failures) { + println!("could not write {}: {e}", report.display()); + } + println!( + "test262 ES5 language subset through the interpreter: {tests} tests that need only the built-ins present, {passed} runs passed, \ + {failed} failed ({panicked} panicked). Skipped {skipped} tests that need other built-ins, use syntax after ES5 or are asynchronous. Failures: {}", + report.display() + ); + assert_eq!(panicked, 0, "the interpreter panicked; see {}", report.display()); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 41e4e7a78..27e8b3d79 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -654,6 +654,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785); `run --commands FILE` (or `-` for standard input) reads the steps from a JSON array or JSON Lines, past the Windows command-line limit (#3147, `apps/vectorcraft-cli/tests/it/cli.rs` `run_reads_*`, `run_refuses_malformed_commands_naming_the_step`). - File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files. - `vectorcraft-js` parses JavaScript as ECMAScript 5.1 defines it, with the legacy octal forms, block functions and lenient regular expressions of Annex B, into a syntax tree with source spans; it reads the `#target`, `#include` and other preprocessor lines as directives without running them, and a script that breaks the grammar or an early-error rule gives a `SyntaxError` with its line and column. Nesting past the parser's depth limit (`Options::max_depth`, 200 by default) gives a `SyntaxError` instead of exhausting the stack. `cargo xtask test262` fetches the conformance suite at a pinned commit and parses its ES5 tests: 5,411 of 5,418 runs pass (the 7 others use `let` and `const`). Nothing runs scripts yet. Tests: `crates/js/src/tests.rs`, `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs`. + - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap. Built-ins are limited to `Object`, `Function` with `call` and `apply`, `eval` and the error types. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only these built-ins: 3,074 of 3,078 runs pass (the 4 others expect the completion values of later editions). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. - **UI:** Illustrator 2026 layout restyled to measured values: - Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs. - Hint bar, contextual task bar, 19 dock panels with ≡ menus. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 334910732..bfd6172ac 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -43,7 +43,8 @@ commands: pack PNGs into a Windows .ico (used by packaging/icons.sh) stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) test262 fetch the pinned ECMAScript conformance suite into corpus/test262 (sha256 - verified) and parse its ES5 subset with vectorcraft-js, printing the counts + verified), parse its ES5 subset with vectorcraft-js and run the ES5 language + tests its interpreter supports, printing the counts web-fonts copy the craft-fonts faces in crates/text/web-fonts.txt into the web site (the Trunk post_build hook; a no-op without CRAFT_FONTS_DIR) "; @@ -338,7 +339,8 @@ Tests that use a corpus skip cleanly when it is absent. corpus/affinity/ public Affinity documents (CC0/MIT/Apache-2.0) at pinned commits, sha256 verified: oracles for the Affinity reader. Fetch: cargo xtask corpus --affinity corpus/test262/ the ECMAScript conformance suite (BSD-3-Clause) at a pinned commit, sha256 - verified: the JavaScript parser's ES5 subset. Fetch and run: cargo xtask test262 + verified: the JavaScript parser's and interpreter's ES5 subsets. Fetch and run: + cargo xtask test262 ", corpus.display() ); diff --git a/xtask/src/test262.rs b/xtask/src/test262.rs index ab390538b..30f6f2472 100644 --- a/xtask/src/test262.rs +++ b/xtask/src/test262.rs @@ -1,7 +1,8 @@ //! `cargo xtask test262`: the official ECMAScript conformance suite (https://github.com/tc39/test262, //! BSD-3-Clause) at a pinned commit, fetched as GitHub's archive of that commit, checked against //! its SHA-256 and size, and unpacked into `corpus/test262/` (gitignored, never committed). It then -//! runs `vectorcraft-js`'s `test262` test, which parses the ES5 parse subset and prints its counts. +//! runs `vectorcraft-js`'s `test262` tests, which parse the ES5 parse subset, run the ES5 +//! `test/language` tests that need only the interpreter's built-ins, and print each one's counts. //! //! Moving the pin: change [`COMMIT`], run the command once with the new commit to see the //! archive's SHA-256 in the error, check the archive, and update [`SHA256`]. From ba9e78ebfdb01fa8fcd8e5a3a7beb8f11ef74d69 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 12:45:02 -0800 Subject: [PATCH 077/160] JavaScript: a run clears the last uncaught exception and checks source length before encoding Realm::run now resets the exception that bind_last_thrown binds, so a run that completes, reaches a limit or does not parse leaves it undefined. The script source, the text built by Function and the strings given to eval are checked against the run's remaining max_source_units before they are encoded or flattened, so an oversized source is rejected without being copied. --- crates/js/src/builtins.rs | 3 ++- crates/js/src/interp.rs | 19 +++++++++++++++++++ crates/js/src/interp_tests.rs | 33 +++++++++++++++++++++++++++++++++ crates/js/src/realm.rs | 4 ++-- 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index 02f2364e9..3274e2873 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -221,7 +221,7 @@ fn function_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsRe } let body = parts.pop().unwrap_or_default(); let text = format!("(function anonymous({}\n) {{\n{body}\n}})", parts.join(",")); - let units: Vec<u16> = text.encode_utf16().collect(); + let units = interp.encode_source(&text).map_err(|e| interp.compile_error_to_abrupt(e))?; interp.source_units = interp.source_units.saturating_add(units.len()); let remaining = interp.options.max_source_units.saturating_sub(interp.source_units.saturating_sub(units.len())); let options = crate::Options { strict: false, max_source_units: remaining, ..interp.options.clone() }; @@ -264,6 +264,7 @@ fn function_to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> fn eval(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> { let x = arg(args, 0); let Value::String(s) = &x else { return Ok(x) }; + interp.check_source_units(s.len()).map_err(|e| interp.compile_error_to_abrupt(e))?; let units = s.flat(); let code = interp.compile_source(&units, false, CodeKind::Eval).map_err(|e| interp.compile_error_to_abrupt(e))?; interp.run_global(code, true) diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 5f1f2890a..4d4b3d039 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -1608,6 +1608,24 @@ impl Interp { // ---- eval and scripts ---- + /// The options for source parsed next in this run: [`Options::max_source_units`] is what + /// the run may still parse. + fn remaining_source_options(&self) -> Options { + Options { max_source_units: self.options.max_source_units.saturating_sub(self.source_units), ..self.options.clone() } + } + + /// Checks that `units` more code units of source fit in what this run may still parse, + /// before the source is flattened or encoded. + pub(crate) fn check_source_units(&self, units: usize) -> Result<(), CompileError> { + crate::check_length(units, &self.remaining_source_options()).map_err(CompileError::Syntax) + } + + /// Encodes `text` as UTF-16 for parsing, after checking that it fits in what this run may + /// still parse. + pub(crate) fn encode_source(&self, text: &str) -> Result<Vec<u16>, CompileError> { + crate::to_utf16(text, &self.remaining_source_options()).map_err(CompileError::Syntax) + } + /// Parses and compiles source for eval code or a script, counting it against the source /// limit. pub(crate) fn compile_source(&mut self, units: &[u16], strict: bool, kind: CodeKind) -> Result<Rc<Code>, CompileError> { @@ -1637,6 +1655,7 @@ impl Interp { return self.push(x); }; let strict = self.strict(); + self.check_source_units(s.len()).map_err(|e| self.compile_error_to_abrupt(e))?; let units = s.flat(); let code = self.compile_source(&units, strict, CodeKind::Eval).map_err(|e| self.compile_error_to_abrupt(e))?; let frame = self.frame()?; diff --git a/crates/js/src/interp_tests.rs b/crates/js/src/interp_tests.rs index bbb3a2cb7..1b2a192a7 100644 --- a/crates/js/src/interp_tests.rs +++ b/crates/js/src/interp_tests.rs @@ -372,6 +372,39 @@ fn eval_and_function_source_counts_against_the_source_limit() { assert!(e.message.contains(crate::TOO_LONG), "{e:?}"); } +#[test] +fn a_script_longer_than_the_source_limit_is_a_syntax_error() { + let mut realm = Realm::with_options(Options { max_source_units: 10, ..Options::default() }, Limits::default()); + let e = realm.run("1 + 2 + 3 + 4").unwrap_err(); + assert_eq!((&e.kind, e.message.contains(crate::TOO_LONG)), (&RunErrorKind::Syntax, true), "{e:?}"); + // Ten UTF-16 code units in 18 bytes of UTF-8 fit. + assert_eq!(realm.run("'\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}'"), Ok(JsValue::String("\u{e9}".repeat(8)))); + let mut realm = Realm::with_options(Options { max_source_units: 40, ..Options::default() }, Limits::default()); + let e = realm.run("Function('return 1;')").unwrap_err(); + assert_eq!((&e.kind, e.message.contains(crate::TOO_LONG)), (&RunErrorKind::Thrown, true), "{e:?}"); +} + +#[test] +fn bind_last_thrown_binds_undefined_after_a_run_that_did_not_throw() { + let mut realm = Realm::new(); + assert_eq!(realm.run("throw 'first'").map_err(|e| e.kind), Err(RunErrorKind::Thrown)); + realm.bind_last_thrown("caught"); + assert_eq!(realm.run("caught"), Ok(JsValue::String("first".into()))); + assert_eq!(realm.run("1 + 1"), Ok(JsValue::Number(2.0))); + realm.bind_last_thrown("caught"); + assert_eq!(realm.run("caught"), Ok(JsValue::Undefined)); + let _ = realm.run("throw 'second'"); + assert_eq!(realm.run("var").map_err(|e| e.kind), Err(RunErrorKind::Syntax)); + realm.bind_last_thrown("caught"); + assert_eq!(realm.run("caught"), Ok(JsValue::Undefined), "a run that does not parse throws nothing"); + realm.set_limits(Limits { fuel: 100, ..Limits::default() }); + let _ = realm.run("throw 'third'"); + assert_eq!(limit(&mut realm, "for (;;) {}"), LimitKind::Fuel); + realm.bind_last_thrown("caught"); + realm.set_limits(Limits::default()); + assert_eq!(realm.run("caught"), Ok(JsValue::Undefined), "a run that reached a limit threw nothing"); +} + #[test] fn wasm_builds_have_smaller_default_limits() { let limits = Limits::default(); diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index 955383b18..2cfa19231 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -173,8 +173,8 @@ impl Realm { interp.fuel = interp.limits.fuel; interp.source_units = 0; interp.throw_site = None; - let units: Vec<u16> = source.encode_utf16().collect(); - let code = match interp.compile_source(&units, false, CodeKind::Global) { + interp.last_thrown = Value::Undefined; + let code = match interp.encode_source(source).and_then(|units| interp.compile_source(&units, false, CodeKind::Global)) { Ok(code) => code, Err(CompileError::Syntax(e)) => return Err(e.into()), Err(CompileError::Internal(m)) => return Err(RunError { kind: RunErrorKind::Internal, message: m, line: 0, column: 0 }), From 84341cc1cf4ba21047daf638883ff7746e138d03 Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:16:49 +0530 Subject: [PATCH 078/160] 5949: keep panel wheel turns out of canvas zoom glides --- crates/ui-egui/src/canvas/input.rs | 4 +- crates/ui-egui/src/canvas/tests.rs | 74 +++++++++++++++++++++++++++ crates/ui-egui/src/tests/numfields.rs | 2 + docs/target-app-parity.md | 7 +-- 4 files changed, 83 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index e0bd9f029..c4b8239b2 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -202,7 +202,9 @@ fn wheel_view( ) -> (egui::PointerState, egui::Modifiers, bool, Option<Pos2>) { let line = ui.ctx().options(|o| o.input_options.line_scroll_speed); let wheel_zooms = app.session.prefs.zoom_with_mouse_wheel; - let animate = animated_zoom(&app.session.prefs); + // A docked field may be drawn later and has not consumed its wheel event yet. Only canvas + // hover may start a zoom glide; existing glides still decay outside the canvas in `wheel`. + let animate = animated_zoom(&app.session.prefs) && resp.hovered(); let turn_id = egui::Id::unique("canvas-wheel-turn"); let turn_before: WheelTurn = ui.data(|d| d.get_temp(turn_id)).unwrap_or_default(); let mut turn = turn_before; diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 84788bb70..f90234860 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -4355,6 +4355,80 @@ fn zoom_with_mouse_wheel_preference() { assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "on: Cmd-wheel scrolls sideways ({zoom}, {scroll:?})"); } +/// A numeric panel drawn after the canvas owns its wheel turn even when the pointer enters the +/// canvas on the next frame (#5949). Only a subsequent turn over the canvas starts a zoom glide. +#[test] +fn wheel_over_a_numeric_panel_field_does_not_start_a_canvas_zoom_glide() { + use std::cell::Cell; + for focused in [false, true] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + for key in ["zoomWithMouseWheel", "animatedZoom", "gpuPerformance"] { + app.run("prefs.set", json!({"key": key, "value": true})).unwrap(); + } + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let value = Cell::new(10.0); + let field_rect = Cell::new(egui::Rect::NOTHING); + let field_id = Cell::new(egui::Id::NULL); + let mut time = 0.0; + let mut run = |app: &mut VectorcraftApp, events| { + time += 0.016; + let raw = egui::RawInput { + time: Some(time), + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + // Match dock rendering order: canvas first, the numeric panel afterward. + let mut canvas_ui = ui.new_child(egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(Pos2::ZERO, vec2(550.0, 500.0)))); + show(app, &mut canvas_ui); + let mut panel_ui = ui.new_child(egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(pos2(600.0, 20.0), vec2(150.0, 100.0)))); + field_id.set(panel_ui.scope_id().with("number")); + if let Some(v) = crate::widgets::plain_field(&mut panel_ui, "number", value.get(), "", 0, 80.0) { + value.set(v); + } + field_rect.set(panel_ui.min_rect()); + }); + out.textures_delta.clear(); + }; + run(&mut app, vec![]); + if focused { + ctx.memory_mut(|m| m.request_focus(field_id.get())); + } + let over_field = field_rect.get().center(); + run(&mut app, vec![egui::Event::PointerMoved(over_field)]); + let before = *app.view().unwrap(); + let turn = || egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, 1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }; + run(&mut app, vec![turn()]); + assert_eq!(value.get(), 11.0, "the hovered field steps, focused: {focused}"); + assert_eq!(ctx.memory(|m| m.has_focus(field_id.get())), focused, "hover does not take focus"); + assert_eq!(app.view().unwrap().zoom, before.zoom); + let over_canvas = app.canvas_rect.unwrap().center(); + run(&mut app, vec![egui::Event::PointerMoved(over_canvas)]); + for _ in 0..20 { + run(&mut app, vec![]); + assert_eq!(app.view().unwrap().zoom, before.zoom, "a panel turn cannot leave a canvas glide, focused: {focused}"); + assert_eq!(app.view().unwrap().center, before.center); + } + run(&mut app, vec![turn()]); + let first = app.view().unwrap().zoom; + assert!(first > before.zoom, "a turn over the canvas still zooms"); + for _ in 0..20 { + run(&mut app, vec![]); + } + assert!(app.view().unwrap().zoom > first, "the canvas turn still glides over subsequent frames"); + assert_eq!(value.get(), 11.0, "the canvas turn does not step the field"); + } +} + /// #888: with Zoom with Mouse Wheel, a wheel notch's zoom glides in over a few frames, as a /// scroll does, instead of jumping on the frame it arrives; it ends at the same zoom. #[test] diff --git a/crates/ui-egui/src/tests/numfields.rs b/crates/ui-egui/src/tests/numfields.rs index 4ba4b4df1..f9ad5764e 100644 --- a/crates/ui-egui/src/tests/numfields.rs +++ b/crates/ui-egui/src/tests/numfields.rs @@ -732,6 +732,7 @@ fn the_wheel_scrolls_the_panel_unless_over_a_numeric_field() { } } +/// A wheel turn over an unfocused field steps it without consuming an arrow key in the same frame. #[test] fn hover_wheel_keeps_arrow_keys_for_the_keyboard_focus() { let mut f = Field::new(); @@ -752,6 +753,7 @@ fn hover_wheel_keeps_arrow_keys_for_the_keyboard_focus() { assert!(!f.ctx.memory(|m| m.has_focus(f.id.get()))); } +/// Hover steps stop at both bounds, leave mixed values blank, and never edit disabled fields. #[test] fn hover_wheel_preserves_limits_mixed_values_and_disabled_fields() { for (value, enabled, dy, want) in [ diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d2bd2b15b..ac98575ab 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,10 +1,10 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (numeric wheel stepping on hover, #5949; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (panel wheel turns cannot seed a later canvas zoom glide, #5949; numeric wheel stepping on hover, #5949; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence -Numeric fields step on wheel hover without taking keyboard focus (#5949). Arrow keys still require focus; modifiers, units, limits, mixed values and existing rounding stay in the shared stepping path. Headless regressions in `tests/numfields.rs` cover hovered unfocused fields, focused fields, trackpad accumulation and panel scroll exclusion. Scores and estimates unchanged. +Numeric fields step on wheel hover without taking keyboard focus (#5949). Arrow keys still require focus; modifiers, units, limits, mixed values and existing rounding stay in the shared stepping path. Headless regressions in `tests/numfields.rs` cover hovered unfocused fields, focused fields, trackpad accumulation and panel scroll exclusion. A canvas-first/panel-second regression also checks that a field turn cannot start a canvas zoom glide when the pointer enters the canvas on the next frame. Scores and estimates unchanged. Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. @@ -725,7 +725,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Performance › Real-time Drawing and Editing off defers a drag's edits to its release, but a ruler guide dragged out of a ruler or moved still shows as it is dragged, where only its position label showed (#4646; `a_guide_dragged_out_of_a_ruler_shows_while_dragged`, `real_time_drawing_off_still_shows_a_dragged_guide`). - With Real-time Drawing and Editing off, a dragged selection whose outlines have more than 100,000 points shows its moved bounds instead, as the selection at rest does (#525, #2460; `real_time_drawing_off_draws_the_bounds_of_a_selection_too_big_to_outline`). - Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423). - - Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). A dialog opened from a menu takes the keyboard focus every time it opens, not only the first (#1367), and one whose first field starts greyed out (Blend Options on Smooth Color, Layers Panel Options) gives the focus to that field once it is enabled (#1395). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the hovered field without requiring focus (#485: a notch a step; the panel under it then stays put, and an unfocused field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once. + - Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). A dialog opened from a menu takes the keyboard focus every time it opens, not only the first (#1367), and one whose first field starts greyed out (Blend Options on Smooth Color, Layers Panel Options) gives the focus to that field once it is enabled (#1395). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the hovered field without requiring focus (#485, #5949: a notch a step; the panel under it then stays put, and a disabled field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once. - While Alt (Option on macOS) is held, the Flare Tool Options' Cancel button reads Reset, which puts every option back to the Flare tool's default and leaves the dialog open (`ui.dialogReset`). - A plain step of a number field (Up/Down or a wheel notch) goes to the next whole unit in its direction and a Ctrl/Cmd step goes to the next tenth, so 5.1 mm steps up to 6 mm and down to 5 mm (#2881). A Shift step adds or takes ten and keeps the fraction. - In the number, text and color fields of the panels and the Control bar, Shift+Enter (Shift+Return) applies the value and keeps the field active with its value selected; a text box of several lines takes it as a line break. In a dialog, Shift+Enter applies the value and confirms the dialog, as Enter does. @@ -1727,6 +1727,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-08 | minor | Panel numeric wheel turns do not seed canvas zoom glides; corrected the disabled-field scroll description (#5949). Scores and estimates unchanged. | | 2026-11-07 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. | | 2026-11-06 | minor | Direct Selection: with Snap to Grid, a dragged straight segment lands its anchors on the grid (#3835); evidence added; scores unchanged. | | 2026-11-06 | minor | Snap to Glyph › Glyph Bounds: the corners of each glyph's box are points a drawn point lands on, also on a curved side (`B`), and the box turns with rotated type, its edges then Angular Guides (#5112; [Snap to Glyph options](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/snap-to-glyph-options.html)); scores and estimates unchanged. | From 188142a792e5c2e1e7495210d28ea21d56402053 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 12:49:12 -0800 Subject: [PATCH 079/160] .ai save: a symbol first defined inside a drawn look warns of its art's effects A symbol whose first instance is written in the drawn look of an object with a style (through a member's scatter brush) was defined while the look's flag was set, so the warning for the effects its art loses was left out, and the cached definition kept it out for later instances. The definition now writes its art with the flag cleared and restores it afterwards. --- crates/eps/src/ai_editing/tests.rs | 42 +++++++++++++++++++++ crates/eps/src/ai_editing/writer/symbols.rs | 4 ++ 2 files changed, 46 insertions(+) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 41a4a789b..6cdf6da64 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -4573,6 +4573,48 @@ fn symbols_painting_patterns_through_brush_art_are_placed_as_art() { assert_eq!(instances(&back.layers[0]), 0, "the instance is its art"); } +/// A symbol first defined from the drawn look of a group with a style, through a member's scatter +/// brush, still warns that its art's own effects are left out, and so does a later instance of it. +#[test] +fn a_symbol_defined_inside_a_drawn_look_warns_of_its_own_effects() { + let mut doc = Document::new(200.0, 100.0); + let mut shadowed = square(&mut doc, Rect::new(0.0, 0.0, 6.0, 6.0), Color::BLACK); + shadowed.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(black_fill(), Paint::None, 0.0) }.into(); + doc.symbols.push(Symbol { dynamic: false, name: "Shadowed".into(), art: Arc::new(shadowed) }); + let dot = square(&mut doc, Rect::new(0.0, 0.0, 6.0, 6.0), Color::BLACK); + let placed = instance(&mut doc, "Shadowed", Affine::translate((8.0, 0.0))); + let art = group(&mut doc, vec![dot, placed], false); + let brush = vectorcraft_brush::Brush { + name: "Scattered".into(), + kind: vectorcraft_brush::BrushKind::Scatter(vectorcraft_brush::Scatter { + art, + size: (100.0, 100.0), + spacing: (100.0, 100.0), + scatter: (0.0, 0.0), + rotation: (0.0, 0.0), + colorization: vectorcraft_brush::Colorization::None, + ..Default::default() + }), + }; + vectorcraft_brush::store(&mut doc, &[brush]); + let mut line = + Node::path(doc.alloc_id(), shapes::line((10.0, 50.0).into(), (90.0, 50.0).into()), Appearance::basic(Paint::None, solid(Color::BLACK), 1.0)); + line.appearance.stroke_mut().unwrap().brush = Some("Scattered".into()); + let edge = square(&mut doc, Rect::new(10.0, 60.0, 30.0, 80.0), LEAF); + let mut styled = group(&mut doc, vec![line, edge], false); + styled.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::default() }.into(); + put(&mut doc, named(styled, "Styled")); + let out = export_ai_editing(&doc, &opts()).unwrap(); + let p = shown(&out.program); + assert!(p.contains("%AI14_BeginSymbol") && p.contains("1 (Anon 1) XW"), "{p}"); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + + let later = instance(&mut doc, "Shadowed", Affine::translate((150.0, 20.0))); + put(&mut doc, later); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); +} + /// 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. diff --git a/crates/eps/src/ai_editing/writer/symbols.rs b/crates/eps/src/ai_editing/writer/symbols.rs index ccec95d42..bfc1ccc3c 100644 --- a/crates/eps/src/ai_editing/writer/symbols.rs +++ b/crates/eps/src/ai_editing/writer/symbols.rs @@ -203,6 +203,8 @@ impl Writer<'_> { if sym.dynamic || doc.symbol_export_type(name) != SymbolExportType::default() || doc.symbol_nine_slice(name).is_some() { self.warn(SYMBOL_OPTIONS); } + // The art's effects are its own, not those of a drawn look that first places it. + let in_look = std::mem::take(&mut self.in_look); self.defining_symbols.push(name.to_string()); let mut used = vec![]; vectorcraft_doc::swatches::walk_with_masks(&sym.art, &mut |m| { @@ -216,6 +218,7 @@ impl Writer<'_> { if self.symbol_defs.len() + self.defining_symbols.len() > MAX_SYMBOLS { self.defining_symbols.pop(); self.warn(SYMBOL_COUNT); + self.in_look = in_look; return false; } // The art is written in the symbol's space, from the state `0 A` and `0 Xw` set, from the @@ -291,6 +294,7 @@ impl Writer<'_> { (self.wrote_fixed, self.inner_scale), ) = saved; self.doc = page_doc; + self.in_look = in_look; 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())); From 5d61122b7791e1e23c0ac50d708bea46d4dcce16 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 12:54:06 -0800 Subject: [PATCH 080/160] JavaScript: a strict realm runs scripts as strict mode code, and apply unroots its arguments when one throws Realm::run compiles the script with the realm's Options::strict instead of always as sloppy code, so a realm made with strict: true rejects with statements and calls functions with an undefined this. Indirect eval and Function keep their own strictness (ES5.1 10.4.2, 15.3.2.1). Function.prototype.apply drops the temporary GC roots it holds for the argument values when an index getter throws, as it already did when reading succeeds. Before, a caught throw left them rooted until the run ended, so a loop of such calls could reach the heap limit. --- crates/js/src/interp.rs | 17 ++++++++++++----- crates/js/src/interp_tests.rs | 22 ++++++++++++++++++++++ crates/js/src/realm.rs | 3 ++- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 4d4b3d039..795241829 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -1428,14 +1428,21 @@ impl Interp { if n > MAX_APPLY_ARGS { return Err(self.throw(ErrorType::Range, "too many arguments")); } + let saved = self.temp_roots.len(); let mut out = Vec::with_capacity(n); for i in 0..n { - let v = self.get_from(*o, &index_key(u32::try_from(i).unwrap_or(u32::MAX)), list)?; - self.temp_roots.push(v.clone()); - out.push(v); + match self.get_from(*o, &index_key(u32::try_from(i).unwrap_or(u32::MAX)), list) { + Ok(v) => { + self.temp_roots.push(v.clone()); + out.push(v); + } + Err(e) => { + self.temp_roots.truncate(saved); + return Err(e); + } + } } - let keep = self.temp_roots.len() - n; - self.temp_roots.truncate(keep); + self.temp_roots.truncate(saved); Ok(out) } diff --git a/crates/js/src/interp_tests.rs b/crates/js/src/interp_tests.rs index 1b2a192a7..c838d1b1b 100644 --- a/crates/js/src/interp_tests.rs +++ b/crates/js/src/interp_tests.rs @@ -405,6 +405,28 @@ fn bind_last_thrown_binds_undefined_after_a_run_that_did_not_throw() { assert_eq!(realm.run("caught"), Ok(JsValue::Undefined), "a run that reached a limit threw nothing"); } +#[test] +fn a_strict_realm_runs_scripts_as_strict_mode_code() { + let mut realm = Realm::with_options(Options { strict: true, ..Options::default() }, Limits::default()); + assert_eq!(realm.run("with ({}) {}").map_err(|e| e.kind), Err(RunErrorKind::Syntax)); + assert_eq!(realm.run("(function () { return this; })()"), Ok(JsValue::Undefined)); + assert_eq!(realm.run("(function () { return typeof this; })()"), Ok(JsValue::String("undefined".into()))); + let mut realm = Realm::new(); + assert_eq!(realm.run("with ({}) {} 1"), Ok(JsValue::Number(1.0))); + assert_eq!(realm.run("(function () { return typeof this; })()"), Ok(JsValue::String("object".into()))); +} + +#[test] +fn an_apply_that_throws_while_reading_its_arguments_keeps_nothing_alive() { + // Each call reads a fresh array at index 0 before index 1 throws; the arrays must not stay + // reachable after the call ends. + let mut realm = small(Limits { heap_bytes: 2 << 20, ..Limits::default() }); + let src = "function f() {} var caught = 0; \ + var list = {length: 2, get 0() { var a = []; for (var k = 0; k < 100; k++) a[k] = k; return a; }, get 1() { throw 'stop'; }}; \ + for (var i = 0; i < 5000; i++) { try { f.apply(null, list); } catch (e) { caught++; } } caught"; + assert_eq!(realm.run(src), Ok(JsValue::Number(5000.0))); +} + #[test] fn wasm_builds_have_smaller_default_limits() { let limits = Limits::default(); diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index 2cfa19231..d13bb0481 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -174,7 +174,8 @@ impl Realm { interp.source_units = 0; interp.throw_site = None; interp.last_thrown = Value::Undefined; - let code = match interp.encode_source(source).and_then(|units| interp.compile_source(&units, false, CodeKind::Global)) { + let strict = interp.options.strict; + let code = match interp.encode_source(source).and_then(|units| interp.compile_source(&units, strict, CodeKind::Global)) { Ok(code) => code, Err(CompileError::Syntax(e)) => return Err(e.into()), Err(CompileError::Internal(m)) => return Err(RunError { kind: RunErrorKind::Internal, message: m, line: 0, column: 0 }), From e85cecfa99574b140296a94471fffaee92686fbc Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 13:02:26 -0800 Subject: [PATCH 081/160] JavaScript: for-in over a String object checks the heap before listing its indices Listing a String object's index names for for-in built one name per UTF-16 unit before any limit check, so a string within the heap limit could make the loop allocate many times that limit. The heap budget for all the names is now checked first, after a collection if needed, and each name takes a unit of fuel. A wrapper made by ToObject stays rooted while the names are listed. --- crates/js/src/interp.rs | 55 +++++++++++++++++++++++++++++++---- crates/js/src/interp_tests.rs | 15 ++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 795241829..581eae290 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -169,6 +169,23 @@ const MIN_GC_BYTES: usize = 1 << 20; /// The most arguments `Function.prototype.apply` passes. pub(crate) const MAX_APPLY_ARGS: usize = 1 << 16; +/// The estimated bytes of a name's entries in the list and the set `for-in` builds. +const KEY_ENTRY_BYTES: usize = 32; + +/// The estimated bytes of the index names `0` to `len - 1` while `for-in` lists them. +fn index_keys_bytes(len: usize) -> usize { + let mut total = 0usize; + let (mut lo, mut hi, mut digits) = (0usize, 10usize, 1usize); + while lo < len { + let count = hi.min(len) - lo; + total = total.saturating_add(count.saturating_mul(string_bytes(digits) + KEY_ENTRY_BYTES)); + lo = hi; + hi = hi.saturating_mul(10); + digits += 1; + } + total +} + /// A call to a function written in JavaScript, ready to enter. struct Entry { callee: ObjId, @@ -352,6 +369,20 @@ impl Interp { Ok(()) } + /// Ends the run with the heap limit when `bytes` more would not fit in it after a + /// collection. Everything the caller still needs must be reachable from the roots. + fn reserve_heap(&mut self, bytes: usize) -> JsResult<()> { + if self.heap.bytes.saturating_add(bytes) <= self.limits.heap_bytes { + return Ok(()); + } + self.collect_garbage(); + self.gc_threshold = self.next_threshold(); + if self.heap.bytes.saturating_add(bytes) > self.limits.heap_bytes { + return Err(Abrupt::Limit(LimitKind::Heap)); + } + Ok(()) + } + pub(crate) fn collect_garbage(&mut self) { let mut roots = Roots::default(); roots.values.extend(self.stack.iter()); @@ -1309,14 +1340,21 @@ impl Interp { /// The names `for-in` visits (section 12.6.4): the enumerable properties of the object and /// its prototypes, own properties in the order they were added, each name once, and none /// that an object nearer the start of the chain has (enumerable or not). - fn enumerable_keys(&self, id: ObjId) -> JsResult<Vec<Key>> { + /// + /// For a String object, the heap budget for all its index names is checked before any is + /// built, and each name takes a unit of fuel. + fn enumerable_keys(&mut self, id: ObjId) -> JsResult<Vec<Key>> { let mut seen = std::collections::HashSet::new(); let mut out = Vec::new(); let mut cur = Some(id); while let Some(o) = cur { let obj = self.object(o)?; - if let Class::String(s) = &obj.class { - for i in 0..s.len() { + let string_len = if let Class::String(s) = &obj.class { Some(s.len()) } else { None }; + let is_array = matches!(obj.class, Class::Array { .. }); + if let Some(len) = string_len { + self.reserve_heap(index_keys_bytes(len))?; + for i in 0..len { + self.use_fuel()?; let k = index_key(u32::try_from(i).unwrap_or(u32::MAX)); if seen.insert(k.clone()) { out.push(k); @@ -1324,9 +1362,10 @@ impl Interp { } seen.insert(key("length")); } - if matches!(obj.class, Class::Array { .. }) { + if is_array { seen.insert(key("length")); } + let obj = self.object(o)?; for (k, p) in obj.props.iter() { if seen.insert(k.clone()) && p.enumerable() { out.push(k.clone()); @@ -1343,7 +1382,13 @@ impl Interp { (Vec::new(), None) } else { let o = self.to_object(&v)?; - (self.enumerable_keys(o)?, Some(o)) + // A wrapper made by ToObject is not on the stack; a collection while the names are + // listed must not free it. + let saved = self.temp_roots.len(); + self.temp_roots.push(Value::Object(o)); + let keys = self.enumerable_keys(o); + self.temp_roots.truncate(saved); + (keys?, Some(o)) }; self.heap.bytes += keys.iter().map(|k| string_bytes(k.len())).sum::<usize>(); let iter = self.heap.alloc_object(Object::new(Class::ForIn(Box::new(ForIn { keys, pos: 0, object })), None)); diff --git a/crates/js/src/interp_tests.rs b/crates/js/src/interp_tests.rs index c838d1b1b..e4904a1a7 100644 --- a/crates/js/src/interp_tests.rs +++ b/crates/js/src/interp_tests.rs @@ -427,6 +427,21 @@ fn an_apply_that_throws_while_reading_its_arguments_keeps_nothing_alive() { assert_eq!(realm.run(src), Ok(JsValue::Number(5000.0))); } +#[test] +fn for_in_over_a_long_string_checks_the_heap_before_listing_its_indices() { + // The string fits in the heap and its 131,072 index names do not. The fuel runs out partway + // through listing them, so only the check before the listing ends the run with the heap limit. + let mut realm = small(Limits { heap_bytes: 1 << 20, fuel: 50_000, ..Limits::default() }); + assert_eq!(limit(&mut realm, "var s = 'x'; while (s.length < 100000) s += s; for (var k in s) {}"), LimitKind::Heap); + assert_eq!(realm.run("var n = ''; for (var k in 'abc') n += k; n"), Ok(JsValue::String("012".into()))); +} + +#[test] +fn for_in_over_a_long_string_takes_fuel_for_each_index() { + let mut realm = small(Limits { fuel: 10_000, ..Limits::default() }); + assert_eq!(limit(&mut realm, "var s = 'x'; while (s.length < 100000) s += s; for (var k in s) break;"), LimitKind::Fuel); +} + #[test] fn wasm_builds_have_smaller_default_limits() { let limits = Limits::default(); From f045adba2bd884961200b62943672febe142a3d8 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 13:09:41 -0800 Subject: [PATCH 082/160] JavaScript: a function made by the Function constructor does not bind the name anonymous The constructor compiles its source as (function anonymous(...) {...}), and the compiled function bound anonymous to itself as a named function expression does. That binding shadowed a global variable of the same name. ES5.1 section 15.3.2.1 creates the function with the global environment as its scope and no name binding, so the constructed function now leaves the name unbound. --- crates/js/src/compiler.rs | 14 ++++++++------ crates/js/src/interp_tests.rs | 1 + 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/js/src/compiler.rs b/crates/js/src/compiler.rs index 75ee257af..6e16437a5 100644 --- a/crates/js/src/compiler.rs +++ b/crates/js/src/compiler.rs @@ -284,7 +284,7 @@ impl<'a> FnCompiler<'a> { /// Compiles a nested function and returns its index in the function table. fn function(&mut self, fid: FunctionId) -> CResult<u32> { - let code = compile_function(self.ast, fid, self.source, self.recursion)?; + let code = compile_function(self.ast, fid, self.source, self.recursion, true)?; self.functions.push(code); Ok(u32::try_from(self.functions.len() - 1).unwrap_or(u32::MAX)) } @@ -1208,8 +1208,9 @@ fn len_u32(n: usize) -> u32 { u32::try_from(n).unwrap_or(u32::MAX) } -/// Compiles function `fid` of `ast`. -pub(crate) fn compile_function(ast: &Ast, fid: FunctionId, source: &Rc<Source>, recursion: u32) -> CResult<Rc<Code>> { +/// Compiles function `fid` of `ast`. With `bind_name`, a function expression's name is bound +/// inside it. +pub(crate) fn compile_function(ast: &Ast, fid: FunctionId, source: &Rc<Source>, recursion: u32, bind_name: bool) -> CResult<Rc<Code>> { let Some(f) = ast.function(fid) else { return internal("function out of range") }; let mut c = FnCompiler::new(ast, source, CodeKind::Function, f.strict, recursion); c.at(f.span.start); @@ -1221,7 +1222,7 @@ pub(crate) fn compile_function(ast: &Ast, fid: FunctionId, source: &Rc<Source>, c.emit(Op::Return); let name = f.name.as_ref().map(|n| key_of(&n.name)); let meta = Meta { - self_name: if f.kind == FunctionKind::Expression { name } else { None }, + self_name: if bind_name && f.kind == FunctionKind::Expression { name } else { None }, params: f.params.iter().map(|p| key_of(&p.name)).collect(), span: (f.span.start, f.span.end), }; @@ -1239,11 +1240,12 @@ pub(crate) fn compile_program(program: &crate::Program, source: &Rc<Source>, kin } /// The function of a `Function` constructor's source, `(function anonymous(…) {…})`: the program -/// must be that one parenthesized function expression and nothing else. +/// must be that one parenthesized function expression and nothing else. The name `anonymous` is +/// not bound inside the function (section 15.3.2.1). pub(crate) fn compile_constructed_function(program: &crate::Program, source: &Rc<Source>) -> CResult<Option<Rc<Code>>> { let [stmt] = program.body.as_slice() else { return Ok(None) }; let Some(Stmt::Expr(e)) = program.ast.stmt(*stmt) else { return Ok(None) }; let Some(Expr::Paren(inner)) = program.ast.expr(*e) else { return Ok(None) }; let Some(Expr::Function(fid)) = program.ast.expr(*inner) else { return Ok(None) }; - compile_function(&program.ast, *fid, source, 0).map(Some) + compile_function(&program.ast, *fid, source, 0, false).map(Some) } diff --git a/crates/js/src/interp_tests.rs b/crates/js/src/interp_tests.rs index e4904a1a7..f94597d60 100644 --- a/crates/js/src/interp_tests.rs +++ b/crates/js/src/interp_tests.rs @@ -173,6 +173,7 @@ fn with_eval_and_new_function() { assert_eq!(num("eval('1; 2; if (false) {}')"), 2.0); assert_eq!(num("new Function('a', 'b', 'return a * b;')(6, 7)"), 42.0); assert_eq!(num("Function('return 3')()"), 3.0); + assert_eq!(num("var anonymous = 42; Function('return anonymous')()"), 42.0); let e = fail("new Function('a){}; (function(', 'return 1')"); assert!(e.message.starts_with("SyntaxError"), "{}", e.message); let e = fail("new Function('}); (function() {')"); From d16ff70fd3a3e9d6294b46f9fd18f6ab694ed3e6 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 13:19:19 -0800 Subject: [PATCH 083/160] JavaScript: a collection counts the code units of property and binding names A collection counted each property or binding at a fixed 64 bytes and never counted the name's own buffer. Once the string that produced a long name became garbage, the name kept its buffer alive while the estimate dropped it, so a script could hold megabytes of names past the heap limit. trace_object and trace_env now add each name's buffer, counting a buffer that a flat string value or another name shares once. --- crates/js/src/heap.rs | 40 +++++++++++++++++++++++++++++------ crates/js/src/interp_tests.rs | 15 +++++++++++++ crates/js/src/value.rs | 8 +++++++ 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/crates/js/src/heap.rs b/crates/js/src/heap.rs index 1e590301e..a3938aa83 100644 --- a/crates/js/src/heap.rs +++ b/crates/js/src/heap.rs @@ -280,7 +280,8 @@ enum Cell { Env(Box<Env>), } -/// The estimated bytes of a property or a binding, its name included. +/// The estimated bytes of a property or a binding entry. A collection adds its name's code units +/// separately, counting a buffer that several names or strings share once. pub(crate) const PROP_BYTES: usize = 64; const OBJECT_BYTES: usize = 96; const ENV_BYTES: usize = 64; @@ -406,7 +407,8 @@ pub(crate) struct Roots<'a> { pub(crate) codes: Vec<&'a Code>, } -/// Sums the sizes of the strings reached, counting each node once. +/// Sums the sizes of the strings and property names reached, counting each node and each +/// code-unit buffer once. #[derive(Default)] struct StringSizes { seen: HashSet<usize>, @@ -426,10 +428,21 @@ impl StringSizes { stack.push(l); stack.push(r); } - None => self.bytes += string_bytes(s.len()), + None => { + if s.buffer_addr().is_none_or(|a| self.seen.insert(a)) { + self.bytes += string_bytes(s.len()); + } + } } } } + + /// Adds a property or binding name, unless a string or another name shares its buffer. + fn add_key(&mut self, k: &Key) { + if self.seen.insert(Rc::as_ptr(k) as *const () as usize) { + self.bytes += string_bytes(k.len()); + } + } } fn mark_value(v: &Value, work: &mut Vec<u32>, strings: &mut StringSizes) { @@ -450,7 +463,8 @@ fn trace_object(o: &Object, work: &mut Vec<u32>, strings: &mut StringSizes) -> u if let Some(p) = o.proto { work.push(p.0); } - for (_, prop) in o.props.iter() { + for (k, prop) in o.props.iter() { + strings.add_key(k); match prop { Prop::Data { value, .. } => mark_value(value, work, strings), Prop::Accessor { get, set, .. } => work.extend(get.iter().chain(set.iter()).map(|f| f.0)), @@ -476,7 +490,8 @@ fn trace_env(e: &Env, work: &mut Vec<u32>, strings: &mut StringSizes) -> usize { match e { Env::Decl { bindings, outer } => { work.extend(outer.iter().map(|o| o.0)); - for (_, b) in bindings.iter() { + for (k, b) in bindings.iter() { + strings.add_key(k); mark_value(&b.value, work, strings); } ENV_BYTES + PROP_BYTES * bindings.len() @@ -491,7 +506,20 @@ fn trace_env(e: &Env, work: &mut Vec<u32>, strings: &mut StringSizes) -> usize { #[cfg(test)] mod tests { - use super::{PropMap, key}; + use super::{Class, Heap, OBJECT_BYTES, Object, PROP_BYTES, Prop, PropMap, Roots, key, string_bytes}; + use crate::value::{Str, Value}; + + #[test] + fn a_collection_counts_a_name_and_a_value_that_share_a_buffer_once() { + let mut heap = Heap::default(); + let name = key(&"n".repeat(1000)); + let mut object = Object::new(Class::Ordinary, None); + object.props.insert(name.clone(), Prop::Data { value: Value::String(Str::from_rc(name.clone())), flags: 0 }); + object.props.insert(key("other"), Prop::Data { value: Value::Undefined, flags: 0 }); + let id = heap.alloc_object(object); + heap.collect(&Roots { objects: vec![id], ..Roots::default() }); + assert_eq!(heap.bytes, OBJECT_BYTES + 2 * PROP_BYTES + string_bytes(1000) + string_bytes(5)); + } #[test] fn prop_maps_keep_insertion_order_through_removals_and_growth() { diff --git a/crates/js/src/interp_tests.rs b/crates/js/src/interp_tests.rs index f94597d60..37c654701 100644 --- a/crates/js/src/interp_tests.rs +++ b/crates/js/src/interp_tests.rs @@ -437,6 +437,21 @@ fn for_in_over_a_long_string_checks_the_heap_before_listing_its_indices() { assert_eq!(realm.run("var n = ''; for (var k in 'abc') n += k; n"), Ok(JsValue::String("012".into()))); } +#[test] +fn long_property_names_count_toward_the_heap_after_their_strings_are_collected() { + // Each name is a 16,385-unit buffer that only the object's key or the function's binding + // holds once the string that made it is garbage, so about 32 of them fill the heap well before + // the fuel runs out. In the second script the property lookup flattens a long string each + // time, so collections run. + let mut realm = small(Limits { heap_bytes: 1 << 20, fuel: 20_000, ..Limits::default() }); + let src = "var s = 'x'; while (s.length < 10000) s += s; var o = {}; for (var i = 0; ; i++) o[s + i] = 1;"; + assert_eq!(limit(&mut realm, src), LimitKind::Heap); + let src = "var s = 'x'; while (s.length < 10000) s += s; \ + (function () { for (var i = 0; ; i++) { eval('var ' + s + i + ' = 1'); ({})[s + i]; } })();"; + let mut realm = small(Limits { heap_bytes: 1 << 20, fuel: 20_000, ..Limits::default() }); + assert_eq!(limit(&mut realm, src), LimitKind::Heap); +} + #[test] fn for_in_over_a_long_string_takes_fuel_for_each_index() { let mut realm = small(Limits { fuel: 10_000, ..Limits::default() }); diff --git a/crates/js/src/value.rs b/crates/js/src/value.rs index 7d1691464..10a34a8fd 100644 --- a/crates/js/src/value.rs +++ b/crates/js/src/value.rs @@ -106,6 +106,14 @@ impl Str { Rc::as_ptr(&self.0) as *const () as usize } + /// The address of the code-unit buffer of a flat node, which a property name can share. + pub(crate) fn buffer_addr(&self) -> Option<usize> { + match &*self.0.borrow() { + Node::Flat(f) => Some(Rc::as_ptr(f) as *const () as usize), + _ => None, + } + } + /// The two halves of a concatenation node. pub(crate) fn halves(&self) -> Option<(Str, Str)> { match &*self.0.borrow() { From dfd0b1c9492371dd56b0fa039e9f0d0e209570c9 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 13:32:53 -0800 Subject: [PATCH 084/160] JavaScript: a finally block keeps the try block's completion value, and host strings count toward the heap In script and eval code a finally block that ends normally leaves the completion value of its try or catch block (ECMA-262 5.1 section 12.14), so try { 1; } finally { 2; } completes with 1. A new GetCompletion instruction saves the value when the finally block starts, and SetCompletion sets it back when the block ends normally; a break, continue or throw out of the finally block still replaces it. A string returned by a host function is charged to the heap, and one that does not fit after a collection ends the run with the heap limit. --- crates/js/src/bytecode.rs | 5 ++++- crates/js/src/compiler.rs | 9 +++++++++ crates/js/src/interp.rs | 22 +++++++++++++++++----- crates/js/src/interp_tests.rs | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/crates/js/src/bytecode.rs b/crates/js/src/bytecode.rs index 4ecb582d0..b23d9a525 100644 --- a/crates/js/src/bytecode.rs +++ b/crates/js/src/bytecode.rs @@ -142,6 +142,9 @@ pub(crate) enum Op { ForInNext(u32), /// Pops the value of an expression statement into the completion value. SetCompletion, + /// Pushes the completion value, which a `finally` block in script and eval code keeps and + /// sets back with `SetCompletion` when it ends normally. + GetCompletion, } /// What code is: a function body, a script, or eval code. @@ -237,7 +240,7 @@ fn effect(op: Op, finally_tables: &[Vec<u32>]) -> Result<Effect, String> { Op::GetVar(_) | Op::TypeofVar(_) | Op::DeleteVar(_) | Op::IncVar { .. } | Op::RefVar(_) => e(0, 1), Op::GetRef(_) => e(1, 1), Op::PutRef(_) => e(2, 1), - Op::NewObject | Op::NewArray | Op::Closure(_) | Op::RegExp(_) => e(0, 1), + Op::NewObject | Op::NewArray | Op::Closure(_) | Op::RegExp(_) | Op::GetCompletion => e(0, 1), Op::Pop | Op::PushWith | Op::PushCatch(_) | Op::SetCompletion => e(1, 0), Op::PopUnder(n) => e(n.checked_add(1).ok_or("operand too large")?, 1), Op::Dup => e(1, 2), diff --git a/crates/js/src/compiler.rs b/crates/js/src/compiler.rs index 6e16437a5..ae470be44 100644 --- a/crates/js/src/compiler.rs +++ b/crates/js/src/compiler.rs @@ -708,7 +708,16 @@ impl<'a> FnCompiler<'a> { self.emit(Op::Undefined); self.push_number(0.0); self.bind(fin_entry); + // In script and eval code the finally block keeps the completion value of the try or + // catch block when it ends normally (ECMA-262 5.1 section 12.14). + let keeps_completion = self.kind != CodeKind::Function; + if keeps_completion { + self.emit(Op::GetCompletion); + } self.stmts(fin)?; + if keeps_completion { + self.emit(Op::SetCompletion); + } let dispatch: Vec<Label> = exits.iter().map(|_| self.new_label()).collect(); let table = u32::try_from(self.finally_tables.len()).unwrap_or(u32::MAX); self.finally_tables.push(dispatch.clone()); diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 581eae290..67e6da2a7 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -586,6 +586,10 @@ impl Interp { self.frame_mut()?.completion = v; Ok(()) } + Op::GetCompletion => { + let v = self.frame()?.completion.clone(); + self.push(v) + } Op::Return | Op::ReturnCompletion => internal("return reached the dispatcher"), } } @@ -1452,7 +1456,7 @@ impl Interp { Func::Host { f: host, .. } => { let js_args: Vec<crate::JsValue> = args.iter().map(|v| self.to_js_value(v)).collect(); return match host(&js_args) { - Ok(v) => Ok(Prepared::Done(self.from_js_value(v))), + Ok(v) => Ok(Prepared::Done(self.from_js_value(v)?)), Err(message) => Err(self.throw(ErrorType::Error, &message)), }; } @@ -2087,16 +2091,24 @@ impl Interp { } } + /// A host value as a JavaScript value. A string is charged to the heap, and one that does not + /// fit in it ends the run with the heap limit. Everything the caller still needs must be + /// reachable from the roots. #[allow(clippy::wrong_self_convention)] - pub(crate) fn from_js_value(&mut self, v: crate::JsValue) -> Value { - match v { + pub(crate) fn from_js_value(&mut self, v: crate::JsValue) -> JsResult<Value> { + Ok(match v { crate::JsValue::Undefined => Value::Undefined, crate::JsValue::Null => Value::Null, crate::JsValue::Bool(b) => Value::Bool(b), crate::JsValue::Number(n) => Value::Number(n), - crate::JsValue::String(s) => Value::str(&s), + crate::JsValue::String(s) => { + let bytes = string_bytes(s.encode_utf16().count()); + self.reserve_heap(bytes)?; + self.heap.bytes += bytes; + Value::str(&s) + } crate::JsValue::Object(_) => Value::Object(self.new_object()), - } + }) } /// A description of a thrown value that runs no JavaScript: `Name: message` for an error, diff --git a/crates/js/src/interp_tests.rs b/crates/js/src/interp_tests.rs index 37c654701..9845d8eea 100644 --- a/crates/js/src/interp_tests.rs +++ b/crates/js/src/interp_tests.rs @@ -162,6 +162,21 @@ fn control_flow_with_labels_switch_and_finally() { assert_eq!(num("function f() { var r = 0; with ({a: 1}) { try { return a; } finally { r = 1; } } } f()"), 1.0); } +#[test] +fn a_finally_block_keeps_the_completion_value_unless_it_ends_abruptly() { + // Section 12.14: a finally block that ends normally leaves the try or catch block's value. + let cases = [ + ("try { 1; } finally { 2; }", 1.0), + ("try { throw 0; } catch (e) { 1; } finally { 2; }", 1.0), + ("eval('try { 1; } finally { 2; }')", 1.0), + ("try { try { 1; } finally { 2; } } finally { 3; }", 1.0), + ("do { try { 1; } finally { 2; break; } } while (false)", 2.0), + ("do { try { 1; } finally { 2; continue; } } while (false)", 2.0), + ]; + let wrong: Vec<(&str, f64, f64)> = cases.iter().map(|&(src, want)| (src, want, num(src))).filter(|&(_, want, got)| want != got).collect(); + assert!(wrong.is_empty(), "{wrong:?}"); +} + #[test] fn with_eval_and_new_function() { assert_eq!(num("var o = {a: 2}; with (o) { a = 3; } o.a"), 3.0); @@ -243,6 +258,25 @@ fn limit(realm: &mut Realm, src: &str) -> LimitKind { } } +fn with_big(limits: Limits) -> Realm { + let mut realm = small(limits); + realm.define_function("big", |args| match args { + [JsValue::Number(n)] => Ok(JsValue::String("x".repeat(*n as usize))), + _ => Err("big takes a length".into()), + }); + realm +} + +#[test] +fn strings_from_host_functions_count_toward_the_heap() { + let limits = Limits { heap_bytes: 1 << 20, fuel: 100_000, ..Limits::default() }; + let mut realm = with_big(limits.clone()); + assert_eq!(realm.run("big(1000).length"), Ok(JsValue::Number(1000.0))); + assert_eq!(realm.run("var a = []; for (var i = 0; i < 4; i++) a[i] = big(50000); a[3].length"), Ok(JsValue::Number(50000.0))); + assert_eq!(limit(&mut with_big(limits.clone()), "var a = []; for (var i = 0; ; i++) a[i] = big(100000);"), LimitKind::Heap); + assert_eq!(limit(&mut with_big(limits), "big(1000000).length"), LimitKind::Heap); +} + #[test] fn an_infinite_loop_ends_with_the_fuel_limit() { let mut realm = small(Limits { fuel: 100_000, ..Limits::default() }); From e1cc8d5de65c56157c6b79c00efa0d24af48daf2 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 13:49:13 -0800 Subject: [PATCH 085/160] JavaScript: names and host error messages count toward the heap when they are made A new property or binding now adds its name's code units to the heap estimate when it is inserted, so names declared by eval or Function source move the estimate toward a collection. Function parameter and catch bindings no longer count their entries twice. A host function's error message reserves its size in the heap before the error object is made, so a message larger than the heap ends the run with the heap limit. The Function constructor sums its arguments' lengths and checks the source limit before flattening or copying any of them, and builds the source from their code units, so a lone surrogate in an argument is kept. --- crates/js/src/builtins.rs | 27 ++++++++++++++--- crates/js/src/interp.rs | 20 +++++++++---- crates/js/src/interp_tests.rs | 55 +++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 10 deletions(-) diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index 3274e2873..2dbb9b31a 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -215,13 +215,32 @@ fn property_is_enumerable(interp: &mut Interp, this: Value, args: &[Value], _: b /// `Function(p1, …, body)` and `new Function(…)` (section 15.3.2.1): the source is read as /// `(function anonymous(p1,…\n) {\nbody\n})` and must be exactly that one function. fn function_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> { + const HEAD: &str = "(function anonymous("; + const MID: &str = "\n) {\n"; + const TAIL: &str = "\n})"; let mut parts = Vec::with_capacity(args.len()); for a in args { - parts.push(interp.to_str(a)?.to_rust_string()); + parts.push(interp.to_str(a)?); } - let body = parts.pop().unwrap_or_default(); - let text = format!("(function anonymous({}\n) {{\n{body}\n}})", parts.join(",")); - let units = interp.encode_source(&text).map_err(|e| interp.compile_error_to_abrupt(e))?; + let body = parts.pop().unwrap_or_else(|| Str::from("")); + // The length is checked against the source limit before any argument is flattened or copied. + let total = parts + .iter() + .map(Str::len) + .chain([parts.len().saturating_sub(1), HEAD.len() + MID.len() + TAIL.len(), body.len()]) + .fold(0usize, usize::saturating_add); + interp.check_source_units(total).map_err(|e| interp.compile_error_to_abrupt(e))?; + let mut units = Vec::with_capacity(total); + units.extend(HEAD.encode_utf16()); + for (i, p) in parts.iter().enumerate() { + if i > 0 { + units.push(u16::from(b',')); + } + units.extend_from_slice(&p.flat()); + } + units.extend(MID.encode_utf16()); + units.extend_from_slice(&body.flat()); + units.extend(TAIL.encode_utf16()); interp.source_units = interp.source_units.saturating_add(units.len()); let remaining = interp.options.max_source_units.saturating_sub(interp.source_units.saturating_sub(units.len())); let options = crate::Options { strict: false, max_source_units: remaining, ..interp.options.clone() }; diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 67e6da2a7..517d62bca 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -941,9 +941,10 @@ impl Interp { { *length = i + 1; } + let name_len = k.len(); o.props.insert(k, prop); if new { - self.heap.bytes += PROP_BYTES; + self.heap.bytes += PROP_BYTES + string_bytes(name_len); } Ok(()) } @@ -1277,9 +1278,10 @@ impl Interp { fn env_insert(&mut self, env: EnvId, name: Key, b: Binding) -> JsResult<()> { let Some(Env::Decl { bindings, .. }) = self.heap.env_mut(env) else { return internal("not a declarative environment") }; let new = !bindings.contains(&name); + let name_len = name.len(); bindings.insert(name, b); if new { - self.heap.bytes += PROP_BYTES; + self.heap.bytes += PROP_BYTES + string_bytes(name_len); } Ok(()) } @@ -1318,9 +1320,10 @@ impl Interp { let v = self.pop()?; let outer = self.frame()?.env; let mut bindings = PropMap::default(); + let name_bytes = string_bytes(name.len()); bindings.insert(name, Binding { value: v, mutable: true, deletable: false }); let env = self.heap.alloc_env(Env::Decl { bindings, outer: Some(outer) }); - self.heap.bytes += PROP_BYTES; + self.heap.bytes += name_bytes; let frame = self.frame_mut()?; frame.env = env; frame.env_depth += 1; @@ -1457,7 +1460,12 @@ impl Interp { let js_args: Vec<crate::JsValue> = args.iter().map(|v| self.to_js_value(v)).collect(); return match host(&js_args) { Ok(v) => Ok(Prepared::Done(self.from_js_value(v)?)), - Err(message) => Err(self.throw(ErrorType::Error, &message)), + Err(message) => { + let bytes = string_bytes(message.encode_utf16().count()); + self.reserve_heap(bytes)?; + self.heap.bytes += bytes; + Err(self.throw(ErrorType::Error, &message)) + } }; } } @@ -1583,9 +1591,9 @@ impl Interp { for (i, p) in code.params.iter().enumerate() { bindings.insert(p.clone(), Binding { value: args.get(i).cloned().unwrap_or(Value::Undefined), mutable: true, deletable: false }); } - let count = bindings.len(); + let name_bytes = bindings.iter().map(|(k, _)| string_bytes(k.len())).sum::<usize>(); let env = self.heap.alloc_env(Env::Decl { bindings, outer: Some(outer) }); - self.heap.bytes += PROP_BYTES * count; + self.heap.bytes += name_bytes; for (name, fi) in &code.fun_decls { let Some(fcode) = code.functions.get(*fi as usize).cloned() else { return internal("function out of range") }; let f = self.make_closure(fcode, env)?; diff --git a/crates/js/src/interp_tests.rs b/crates/js/src/interp_tests.rs index 9845d8eea..aa258a8e8 100644 --- a/crates/js/src/interp_tests.rs +++ b/crates/js/src/interp_tests.rs @@ -264,9 +264,48 @@ fn with_big(limits: Limits) -> Realm { [JsValue::Number(n)] => Ok(JsValue::String("x".repeat(*n as usize))), _ => Err("big takes a length".into()), }); + realm.define_function("fail", |args| match args { + [JsValue::Number(n)] => Err("x".repeat(*n as usize)), + _ => Err("fail takes a length".into()), + }); realm } +#[test] +fn error_messages_from_host_functions_count_toward_the_heap() { + let limits = Limits { heap_bytes: 1 << 20, fuel: 100_000, ..Limits::default() }; + let mut realm = with_big(limits.clone()); + assert_eq!(realm.run("try { fail(1000); } catch (e) { e.message.length }"), Ok(JsValue::Number(1000.0))); + assert_eq!(limit(&mut with_big(limits.clone()), "try { fail(1000000); } catch (e) {}"), LimitKind::Heap); + assert_eq!(limit(&mut with_big(limits), "var a = []; for (var i = 0; ; i++) try { fail(100000); } catch (e) { a[i] = e; }"), LimitKind::Heap); +} + +#[test] +fn names_from_source_count_toward_the_heap_when_they_are_bound() { + // Without a collection in between, the estimate grows by at least each new name's code units: + // a global variable (an object property), a binding made by direct eval, and a parameter of a + // function made by the Function constructor. + let mut realm = small(Limits::default()); + let name = "n".repeat(100_000); + let cases = [ + format!("var {name} = 1;"), + format!("(function () {{ eval('var {name}2 = 1; {name}2'); }})();"), + format!("Function('{name}3', 'return {name}3')(1);"), + ]; + let mut short = Vec::new(); + for (i, src) in cases.iter().enumerate() { + let collections = realm.interp().heap.collections; + let before = realm.interp().heap.bytes; + realm.run(src).unwrap(); + assert_eq!(realm.interp().heap.collections, collections, "a collection ran in case {i}"); + let grown = realm.interp().heap.bytes - before; + if grown < crate::heap::string_bytes(100_000) { + short.push((i, grown)); + } + } + assert!(short.is_empty(), "cases that grew by less than the name: {short:?}"); +} + #[test] fn strings_from_host_functions_count_toward_the_heap() { let limits = Limits { heap_bytes: 1 << 20, fuel: 100_000, ..Limits::default() }; @@ -407,6 +446,22 @@ fn eval_and_function_source_counts_against_the_source_limit() { assert!(e.message.contains(crate::TOO_LONG), "{e:?}"); } +#[test] +fn the_function_constructor_checks_the_source_limit_before_reading_its_arguments() { + // A rope argument that is too long is refused before it is flattened. + let mut realm = Realm::with_options(Options { max_source_units: 1000, ..Options::default() }, Limits::default()); + let e = realm.run("var s = 'return 1;'; for (var i = 0; i < 12; i++) s += s; Function('a', s)").unwrap_err(); + assert!(e.message.contains(crate::TOO_LONG), "{e:?}"); + let interp = realm.interp(); + match interp.find_property(interp.global, &crate::heap::key("s")) { + Ok(Some(crate::heap::Prop::Data { value: crate::value::Value::String(s), .. })) => assert!(s.is_rope(), "the argument was flattened"), + other => panic!("{other:?}"), + } + // The source is read as code units, so a lone surrogate in an argument is kept. + let mut realm = Realm::new(); + assert_eq!(realm.run("Function('return \"\\ud800\"')() === '\\ud800'"), Ok(JsValue::Bool(true))); +} + #[test] fn a_script_longer_than_the_source_limit_is_a_syntax_error() { let mut realm = Realm::with_options(Options { max_source_units: 10, ..Options::default() }, Limits::default()); From 47db0b7a8a569eede76023f85e3ac21ff3212ca9 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 14:07:07 -0800 Subject: [PATCH 086/160] JavaScript: declarations check the heap before they are bound Declaration binding instantiation reserves an upper bound of what a script's, eval's or function's variables and function declarations add to the heap before making them, and each declaration and parameter takes a unit of fuel. A long declaration list now ends with the heap limit without growing the estimate past it. Realm::set_limits recomputes when the next collection runs, so a lowered heap limit applies to the next run instead of after the old threshold. ToNumber skips the same white space and line terminators as the lexer, so U+180E is no longer white space in a numeric string. --- crates/js/src/heap.rs | 6 ++- crates/js/src/interp.rs | 60 ++++++++++++++++++++++++-- crates/js/src/interp_tests.rs | 79 +++++++++++++++++++++++++++++++++++ crates/js/src/realm.rs | 5 ++- crates/js/src/value.rs | 6 +-- 5 files changed, 146 insertions(+), 10 deletions(-) diff --git a/crates/js/src/heap.rs b/crates/js/src/heap.rs index a3938aa83..bf8a4c5ae 100644 --- a/crates/js/src/heap.rs +++ b/crates/js/src/heap.rs @@ -283,8 +283,10 @@ enum Cell { /// The estimated bytes of a property or a binding entry. A collection adds its name's code units /// separately, counting a buffer that several names or strings share once. pub(crate) const PROP_BYTES: usize = 64; -const OBJECT_BYTES: usize = 96; -const ENV_BYTES: usize = 64; +/// The estimated bytes of an object without its properties. +pub(crate) const OBJECT_BYTES: usize = 96; +/// The estimated bytes of an environment without its bindings. +pub(crate) const ENV_BYTES: usize = 64; /// The estimated bytes of a rope node. pub(crate) const ROPE_BYTES: usize = 48; diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 517d62bca..90cd81f75 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -23,8 +23,8 @@ use crate::ast::BinaryOp; use crate::bytecode::{Code, CodeKind, Op, Source}; use crate::compiler::{CompileFail, compile_program}; use crate::heap::{ - ArgMap, Binding, CONFIGURABLE, Class, ENUMERABLE, Env, EnvId, ForIn, Func, HIDDEN, Heap, Key, NativeKind, OPEN, ObjId, Object, PROP_BYTES, Prop, - PropMap, ROPE_BYTES, Roots, WRITABLE, key, string_bytes, + ArgMap, Binding, CONFIGURABLE, Class, ENUMERABLE, ENV_BYTES, Env, EnvId, ForIn, Func, HIDDEN, Heap, Key, NativeKind, OBJECT_BYTES, OPEN, ObjId, + Object, PROP_BYTES, Prop, PropMap, ROPE_BYTES, Roots, WRITABLE, key, string_bytes, }; use crate::value::{Str, Value, array_index, index_key, number_to_str, string_to_number, to_int32, to_uint32}; use crate::{LimitKind, Limits, Options}; @@ -186,6 +186,30 @@ fn index_keys_bytes(len: usize) -> usize { total } +/// The estimated bytes of a binding or property named by `len` code units. +fn binding_bytes(len: usize) -> usize { + PROP_BYTES + string_bytes(len) +} + +/// At least the bytes that a function object made from `code` adds to the heap: the function, +/// its prototype object, their properties and the environment holding a function expression's +/// own name. +fn closure_bytes(code: &Code) -> usize { + let props = if code.strict { 5 } else { 3 }; + let named = code.self_name.as_ref().map_or(0, |n| ENV_BYTES + binding_bytes(n.len())); + 2 * OBJECT_BYTES + props * binding_bytes("constructor".len()) + named +} + +/// At least the bytes that declaration binding instantiation (section 10.5) adds to the heap for +/// the function declarations and variables of `code`. +fn declaration_bytes(code: &Code) -> usize { + let funs = code.fun_decls.iter().fold(0usize, |total, (name, fi)| { + let closure = code.functions.get(*fi as usize).map_or(0, |f| closure_bytes(f)); + total.saturating_add(binding_bytes(name.len()) + closure) + }); + code.vars.iter().fold(funs, |total, v| total.saturating_add(binding_bytes(v.len()))) +} + /// A call to a function written in JavaScript, ready to enter. struct Entry { callee: ObjId, @@ -272,6 +296,11 @@ impl Interp { (self.heap.bytes.saturating_mul(2)).max(MIN_GC_BYTES).min(self.limits.heap_bytes) } + /// Recomputes when the next collection runs, after the limits change. + pub(crate) fn reset_gc_threshold(&mut self) { + self.gc_threshold = self.next_threshold(); + } + // ---- errors ---- pub(crate) fn error_object(&mut self, t: ErrorType, message: &str) -> ObjId { @@ -1558,6 +1587,20 @@ impl Interp { if self.frames.len() >= self.limits.frames { return Err(Abrupt::Limit(LimitKind::Frames)); } + let params = code.params.iter().fold(0usize, |total, p| total.saturating_add(binding_bytes(p.len()))); + let arguments = + if code.uses_arguments { OBJECT_BYTES.saturating_add(args.len().saturating_add(4).saturating_mul(binding_bytes(10))) } else { 0 }; + let need = declaration_bytes(&code).saturating_add(ENV_BYTES).saturating_add(params).saturating_add(arguments); + if self.heap.bytes.saturating_add(need) > self.limits.heap_bytes { + let mark = self.temp_roots.len(); + self.temp_roots.push(Value::Object(callee)); + self.temp_roots.push(this.clone()); + self.temp_roots.extend(construct.map(Value::Object)); + self.temp_roots.extend(args.iter().cloned()); + let reserved = self.reserve_heap(need); + self.temp_roots.truncate(mark); + reserved?; + } let this = if code.strict { this } else { @@ -1585,16 +1628,19 @@ impl Interp { Ok(()) } - /// Declaration binding instantiation for function code (section 10.5). + /// Declaration binding instantiation for function code (section 10.5). Each parameter and + /// declaration takes a unit of fuel. fn instantiate_function(&mut self, code: &Rc<Code>, outer: EnvId, args: &[Value], callee: ObjId) -> JsResult<EnvId> { let mut bindings = PropMap::default(); for (i, p) in code.params.iter().enumerate() { + self.use_fuel()?; bindings.insert(p.clone(), Binding { value: args.get(i).cloned().unwrap_or(Value::Undefined), mutable: true, deletable: false }); } let name_bytes = bindings.iter().map(|(k, _)| string_bytes(k.len())).sum::<usize>(); let env = self.heap.alloc_env(Env::Decl { bindings, outer: Some(outer) }); self.heap.bytes += name_bytes; for (name, fi) in &code.fun_decls { + self.use_fuel()?; let Some(fcode) = code.functions.get(*fi as usize).cloned() else { return internal("function out of range") }; let f = self.make_closure(fcode, env)?; self.env_insert(env, name.clone(), Binding { value: Value::Object(f), mutable: true, deletable: false })?; @@ -1605,6 +1651,7 @@ impl Interp { self.env_insert(env, key("arguments"), Binding { value: Value::Object(a), mutable: !code.strict, deletable: false })?; } for v in &code.vars { + self.use_fuel()?; if !has(self, v) { self.env_insert(env, v.clone(), Binding { value: Value::Undefined, mutable: true, deletable: false })?; } @@ -1722,6 +1769,7 @@ impl Interp { self.check_source_units(s.len()).map_err(|e| self.compile_error_to_abrupt(e))?; let units = s.flat(); let code = self.compile_source(&units, strict, CodeKind::Eval).map_err(|e| self.compile_error_to_abrupt(e))?; + self.reserve_heap(declaration_bytes(&code).saturating_add(ENV_BYTES))?; let frame = self.frame()?; let (caller_env, caller_var_env, this) = (frame.env, frame.var_env, frame.this.clone()); let (env, var_env) = if code.strict { @@ -1741,6 +1789,7 @@ impl Interp { if self.reentry >= self.limits.reentry { return Err(Abrupt::Limit(LimitKind::Reentry)); } + self.reserve_heap(declaration_bytes(&code).saturating_add(ENV_BYTES))?; let (env, global) = (self.global_env, self.global); let (env, var_env) = if code.strict && is_eval { let e = self.heap.alloc_env(Env::Decl { bindings: PropMap::default(), outer: Some(env) }); @@ -1777,15 +1826,18 @@ impl Interp { Ok(()) } - /// Declaration binding instantiation for global and eval code (section 10.5). + /// Declaration binding instantiation for global and eval code (section 10.5). The caller has + /// reserved [`declaration_bytes`] of `code`; each declaration takes a unit of fuel. fn instantiate_global(&mut self, code: &Rc<Code>, var_env: EnvId, is_eval: bool) -> JsResult<()> { let configurable = if is_eval { CONFIGURABLE } else { 0 }; for (name, fi) in &code.fun_decls { + self.use_fuel()?; let Some(fcode) = code.functions.get(*fi as usize).cloned() else { return internal("function out of range") }; let f = self.make_closure(fcode, var_env)?; self.declare(var_env, name, Some(Value::Object(f)), configurable)?; } for v in &code.vars { + self.use_fuel()?; self.declare(var_env, v, None, configurable)?; } Ok(()) diff --git a/crates/js/src/interp_tests.rs b/crates/js/src/interp_tests.rs index aa258a8e8..292de041a 100644 --- a/crates/js/src/interp_tests.rs +++ b/crates/js/src/interp_tests.rs @@ -316,6 +316,85 @@ fn strings_from_host_functions_count_toward_the_heap() { assert_eq!(limit(&mut with_big(limits), "big(1000000).length"), LimitKind::Heap); } +#[test] +fn declarations_check_the_heap_before_they_are_bound() { + // Each script declares more than fits in the heap. The run ends with the heap limit before + // the bindings are made, so the estimate never goes past the limit. + // Short names keep the source strings well inside the heap while the bindings are not. + let limits = Limits { heap_bytes: 2 << 20, ..Limits::default() }; + let short = |mut i: usize| { + let mut name = String::from("_"); + loop { + name.push(char::from(b'a' + (i % 26) as u8)); + i /= 26; + if i == 0 { + return name; + } + } + }; + let vars = (0..40_000).map(short).collect::<Vec<_>>().join(","); + let funs = (0..20_000).map(|i| format!("function f{i}() {{}}")).collect::<String>(); + let cases = [ + format!("var {vars};"), + funs.clone(), + format!("(function () {{ eval('var {vars};'); }})();"), + format!("(function () {{ eval('{funs}'); }})();"), + format!("(0, eval)('\"use strict\"; var {vars};');"), + format!("Function('var {vars};')();"), + format!("Function('{funs}')();"), + ]; + let mut wrong = Vec::new(); + for (i, src) in cases.iter().enumerate() { + let mut realm = small(limits.clone()); + let r = realm.run(src).map_err(|e| e.kind); + let bytes = realm.interp().heap.bytes; + if r != Err(RunErrorKind::Limit(LimitKind::Heap)) || bytes > limits.heap_bytes { + wrong.push((i, r, bytes)); + } + } + assert!(wrong.is_empty(), "cases that bound past the limit: {wrong:?}"); + let few = (0..1000).map(|i| format!("b{i}")).collect::<Vec<_>>().join(","); + let mut realm = small(limits); + assert_eq!(realm.run(&format!("var {few}; b999 = 1; Function('var {few}; return 2')() + b999")), Ok(JsValue::Number(3.0))); +} + +#[test] +fn declarations_take_fuel() { + let vars = (0..10_000).map(|i| format!("a{i}")).collect::<Vec<_>>().join(","); + let mut realm = small(Limits { fuel: 1000, ..Limits::default() }); + assert_eq!(limit(&mut realm, &format!("var {vars};")), LimitKind::Fuel); + assert_eq!(limit(&mut realm, &format!("Function('var {vars};')();")), LimitKind::Fuel); +} + +#[test] +fn a_lower_heap_limit_applies_to_what_is_already_live() { + // A heap already past the new limit ends the next run. + let mut realm = small(Limits::default()); + realm.run("var keep = []; for (var i = 0; i < 100000; i++) keep[i] = {i: i};").unwrap(); + realm.set_limits(Limits { heap_bytes: 1 << 20, ..Limits::default() }); + assert_eq!(limit(&mut realm, "for (var j = 0; j < 1000; j++) ({j: j});"), LimitKind::Heap); + realm.set_limits(Limits::default()); + realm.run("keep = null;").unwrap(); + realm.set_limits(Limits { heap_bytes: 1 << 20, ..Limits::default() }); + assert_eq!(realm.run("for (var j = 0; j < 1000; j++) ({j: j}); j"), Ok(JsValue::Number(1000.0))); + // A heap below the new limit cannot grow past it: the next collection runs by the new limit, + // not by when it would have run under the old, higher one. + let mut realm = small(Limits::default()); + realm.run("var keep = []; for (var i = 0; i < 100000; i++) keep[i] = {i: i};").unwrap(); + let live = realm.collect_garbage(); + realm.set_limits(Limits { heap_bytes: live + (1 << 20), ..Limits::default() }); + assert_eq!(limit(&mut realm, "var more = []; for (var j = 0; j < 20000; j++) more[j] = {j: j};"), LimitKind::Heap); +} + +#[test] +fn to_number_skips_the_white_space_the_lexer_skips() { + let mut realm = small(Limits::default()); + assert_eq!(realm.run("var n = +'\\u180E'; n !== n"), Ok(JsValue::Bool(true)), "U+180E is not white space"); + assert_eq!(realm.run("var m = +'\\u180E1'; m !== m"), Ok(JsValue::Bool(true))); + assert_eq!(realm.run("+' \\t\\n\\u2028\\u3000\\uFEFF 1 \\u00A0'"), Ok(JsValue::Number(1.0))); + assert_eq!(realm.run("+' 1 '"), Ok(JsValue::Number(1.0))); +} + #[test] fn an_infinite_loop_ends_with_the_fuel_limit() { let mut realm = small(Limits { fuel: 100_000, ..Limits::default() }); diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index d13bb0481..29d3aacb2 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -145,9 +145,12 @@ impl Realm { &self.interp.limits } - /// Sets the limits for the next run. + /// Sets the limits for the next run. A heap that is already larger than the new + /// [`Limits::heap_bytes`] ends the next run with the heap limit unless a collection frees + /// enough of it. pub fn set_limits(&mut self, limits: Limits) { self.interp.limits = limits; + self.interp.reset_gc_threshold(); } /// The realm's interrupt flag. Setting it stops a running script, or the next one to start. diff --git a/crates/js/src/value.rs b/crates/js/src/value.rs index 10a34a8fd..6d2e34f9e 100644 --- a/crates/js/src/value.rs +++ b/crates/js/src/value.rs @@ -202,10 +202,10 @@ impl Drop for Str { } } -/// White space and line terminators as StrWhiteSpaceChar (section 9.3.1) counts them. +/// White space and line terminators as StrWhiteSpaceChar (section 9.3.1) counts them: the same +/// classes the lexer uses between tokens. pub(crate) fn is_str_space(u: u16) -> bool { - matches!(u, 0x09 | 0x0A | 0x0B | 0x0C | 0x0D | 0x20 | 0xA0 | 0x1680 | 0x180E | 0x2028 | 0x2029 | 0x202F | 0x205F | 0x3000 | 0xFEFF) - || (0x2000..=0x200A).contains(&u) + crate::chars::is_whitespace(u32::from(u)) || crate::chars::is_line_terminator(u32::from(u)) } /// ToNumber applied to a string (section 9.3.1): the StringNumericLiteral grammar, NaN for From ea319ebcda1afb65daad2e93fd77efebd06f8b57 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 14:09:25 -0800 Subject: [PATCH 087/160] JavaScript: identifiers follow the Unicode categories of section 7.6, and label and parameter checks are linear is_id_start accepts the letter categories (Lu, Ll, Lt, Lm, Lo, Nl) and is_id_part adds Mn, Mc, Nd and Pc, read with unicode-general-category. A superscript digit (No) inside an identifier and a mark such as U+09BE at its start are now SyntaxErrors, and identifiers with marks outside the old list, such as the Bengali and Tamil viramas, now parse. Labels in scope are kept in a map, and duplicate labels in a chain and duplicate parameters of a strict function are found with a hash set, so long chains, many labeled statements and long parameter lists are checked in linear time. --- Cargo.lock | 1 + crates/js/Cargo.toml | 3 +++ crates/js/src/chars.rs | 50 ++++++++--------------------------------- crates/js/src/parser.rs | 36 ++++++++++++++--------------- crates/js/src/tests.rs | 40 +++++++++++++++++++++++++++++++++ 5 files changed, 70 insertions(+), 60 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c48d0daae..8b98b85e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6338,6 +6338,7 @@ name = "vectorcraft-js" version = "0.21.0" dependencies = [ "proptest", + "unicode-general-category", ] [[package]] diff --git a/crates/js/Cargo.toml b/crates/js/Cargo.toml index f39ab33b6..0108b50d9 100644 --- a/crates/js/Cargo.toml +++ b/crates/js/Cargo.toml @@ -9,5 +9,8 @@ rust-version.workspace = true [lints] workspace = true +[dependencies] +unicode-general-category = "1" + [dev-dependencies] proptest = { workspace = true } diff --git a/crates/js/src/chars.rs b/crates/js/src/chars.rs index fb6da4f36..75c0b92aa 100644 --- a/crates/js/src/chars.rs +++ b/crates/js/src/chars.rs @@ -1,6 +1,8 @@ //! Character classes of ECMA-262 5.1 section 7: white space, line terminators and the characters //! identifiers are made of. +use unicode_general_category::{GeneralCategory as G, get_general_category}; + /// 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) @@ -21,7 +23,9 @@ pub fn is_id_start(c: u32) -> bool { 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)), + _ => category(c).is_some_and(|g| { + matches!(g, G::UppercaseLetter | G::LowercaseLetter | G::TitlecaseLetter | G::ModifierLetter | G::OtherLetter | G::LetterNumber) + }), } } @@ -35,49 +39,13 @@ pub fn is_id_part(c: u32) -> bool { 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), + _ => category(c).is_some_and(|g| matches!(g, G::NonspacingMark | G::SpacingMark | G::DecimalNumber | G::ConnectorPunctuation)), } } -/// 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 Unicode general category of a code point; `None` for a lone surrogate. +fn category(c: u32) -> Option<G> { + char::from_u32(c).map(get_general_category) } /// The value of a hexadecimal digit. diff --git a/crates/js/src/parser.rs b/crates/js/src/parser.rs index 17424e42f..d56b75617 100644 --- a/crates/js/src/parser.rs +++ b/crates/js/src/parser.rs @@ -7,6 +7,8 @@ //! stack, and prefix operators, assignment chains, `else if` chains and labels with loops, so long //! runs of them do not nest at all. +use std::collections::{HashMap, HashSet}; + use crate::LineTable; use crate::ast::{ AssignOp, Ast, BinaryOp, CatchClause, Expr, ExprId, ForInTarget, ForInit, Function, FunctionKind, LogicalOp, Name, Program, Property, @@ -18,20 +20,14 @@ use crate::token::{Directive, Keyword, Punct, Span, Token, TokenKind, strict_res type Result<T> = std::result::Result<T, SyntaxError>; -/// 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<Label>, + /// The labels in scope, each with whether it labels an iteration statement (`continue` may + /// name only those). Labels in scope have distinct names. + labels: HashMap<String, bool>, /// Iteration statements open in the current function. loops: u32, /// Iteration and `switch` statements open in the current function. @@ -543,10 +539,9 @@ impl<'s> Parser<'s> { let name = name.clone(); self.check_identifier(&self.tok, &name)?; let token = self.bump()?; - let found = self.ctx.labels.iter().rev().find(|l| l.name == name); - match found { + match self.ctx.labels.get(&name) { None => return Err(self.error_at_token(&token, format!("undefined label '{name}'"))), - Some(l) if !is_break && !l.is_loop => { + Some(&is_loop) if !is_break && !is_loop => { return Err(self.error_at_token(&token, format!("label '{name}' does not name a loop, so continue cannot use it"))); } Some(_) => {} @@ -674,14 +669,15 @@ impl<'s> Parser<'s> { /// `in_list`: the labeled statement stands in a statement list, so outside strict mode code /// its body may be a function declaration. fn parse_labeled(&mut self, in_list: bool) -> Result<StmtId> { - let mut labels = Vec::new(); + let mut labels: Vec<(Name, Span)> = Vec::new(); + let mut chain = HashSet::new(); while let TokenKind::Ident(name) = &self.tok.kind { if !self.peek().is_some_and(|t| t.is_punct(Punct::Colon)) { break; } let name = name.clone(); self.check_identifier(&self.tok, &name)?; - if self.ctx.labels.iter().any(|l| l.name == name) || labels.iter().any(|(l, _): &(Name, Span)| l.name == name) { + if self.ctx.labels.contains_key(&name) || !chain.insert(name.clone()) { return Err(self.error_at_token(&self.tok, format!("duplicate label '{name}'"))); } let token = self.bump()?; @@ -689,11 +685,12 @@ impl<'s> Parser<'s> { labels.push((Name { name, span: token.span }, token.span)); } let is_loop = self.at_keyword(Keyword::For) || self.at_keyword(Keyword::While) || self.at_keyword(Keyword::Do); - let outer = self.ctx.labels.len(); - self.ctx.labels.extend(labels.iter().map(|(n, _)| Label { name: n.name.clone(), is_loop })); + self.ctx.labels.extend(chain.into_iter().map(|name| (name, is_loop))); self.function_allowed = in_list && !self.ctx.strict; let body = self.parse_statement(); - self.ctx.labels.truncate(outer); + for (label, _) in &labels { + self.ctx.labels.remove(&label.name); + } let mut body = body?; for (label, label_start) in labels.into_iter().rev() { let span = Span::new(label_start.start, self.ast.stmt_span(body).end); @@ -792,8 +789,9 @@ impl<'s> Parser<'s> { return Err(self.error_at_token(token, format!("'{}' is a reserved word in strict mode", n.name))); } } - for (i, (p, token)) in params.iter().enumerate() { - if params.iter().take(i).any(|(q, _)| q.name == p.name) { + let mut seen = HashSet::new(); + for (p, token) in params { + if !seen.insert(p.name.as_str()) { return Err(self.error_at_token(token, format!("duplicate parameter name '{}' is not allowed in strict mode", p.name))); } } diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index fc4b5b1a1..fb357e1bf 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -373,6 +373,31 @@ fn identifiers_with_unicode_and_escapes() { assert!(err("var a\u{2e2f};").message.contains("unexpected character")); } +#[test] +fn identifier_characters_follow_their_unicode_categories() { + // Letters (Lu, Ll, Lt, Lm, Lo, Nl) start an identifier; marks (Mn, Mc), decimal digits (Nd) + // and connector punctuation (Pc) may follow. + for good in [ + "var \u{995}\u{9cd}\u{9b7} = 1;", + "var \u{b95}\u{bcd}\u{b95} = 1;", + "var a\u{9be} = 1;", + "var x\u{966} = 1;", + "var a\u{203f}b = 1;", + "var \u{2b0} = 1;", + "var \u{2160} = 1;", + "var \u{1c5} = 1;", + "var \u{5d0}\u{5b7} = 1;", + ] { + ok(good); + } + // Other numbers (No) are not identifier characters, and a mark cannot start an identifier. + for bad in ["var a\u{b2} = 1;", "var x\u{bd} = 1;", "var \u{9be}a = 1;", "var \u{300}a = 1;", "var \u{966} = 1;"] { + assert!(err(bad).message.contains("unexpected character"), "{bad:?}"); + } + assert!(parse("var \\u09BEa = 1;", &Options::default()).is_err()); + assert!(parse("var a\\u00B2 = 1;", &Options::default()).is_err()); +} + #[test] fn comments_and_line_terminators() { let p = ok("a /* one\ntwo */ b"); @@ -627,6 +652,21 @@ fn long_flat_runs_do_not_nest() { ok(&"a;\n".repeat(N)); } +#[test] +fn long_label_chains_and_parameter_lists_are_checked_in_linear_time() { + // Each check looks a name up in a hash set or map. Comparing every name with every earlier + // one would take about 2 * 10^10 steps at this size. + const N: usize = 200_000; + let labels: String = (0..N).map(|i| format!("l{i}: ")).collect(); + let body: String = (0..N).map(|i| format!("m{i}: break l{i}; ")).collect(); + ok(&format!("{labels}for (;;) {{ {body}continue l0; }}")); + assert!(err(&format!("{labels}l0: x;")).message.contains("duplicate label")); + assert!(err(&format!("{labels}for (;;) {{ m: break l{N}; }}")).message.contains("undefined label")); + let params = (0..N).map(|i| format!("p{i}")).collect::<Vec<_>>().join(", "); + ok(&format!("function f({params}) {{ 'use strict'; }}")); + assert!(err(&format!("function f({params}, p0) {{ 'use strict'; }}")).message.contains("duplicate parameter")); +} + #[test] fn nesting_to_the_default_limit_fits_a_small_stack() { // The deepest nesting each production allows, parsed on a thread with a 1 MiB stack (the From dcccbad29ce05ea8dc7e3568f8c4a93711820b98 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 14:18:23 -0800 Subject: [PATCH 088/160] JavaScript: hexadecimal and octal numbers round once to the nearest value ToNumber on a hexadecimal string folded its digits into a double one at a time, so a string past 2^53 could come out one unit off the literal with the same digits. The lexer kept digits exactly in a u128 but rounded per digit once the value passed 128 bits. Both now call one helper that keeps the digits exactly while they fit, records whether any later digit is set, and rounds once. --- crates/js/src/chars.rs | 33 +++++++++++++++++++++++++++++++++ crates/js/src/lexer.rs | 22 +++------------------- crates/js/src/tests.rs | 3 +++ crates/js/src/value.rs | 10 +++++++++- 4 files changed, 48 insertions(+), 20 deletions(-) diff --git a/crates/js/src/chars.rs b/crates/js/src/chars.rs index fb6da4f36..707d14471 100644 --- a/crates/js/src/chars.rs +++ b/crates/js/src/chars.rs @@ -90,6 +90,39 @@ pub fn hex_value(c: u32) -> Option<u32> { } } +/// The value of the digit values `digits` in `radix` (8 or 16), rounded once to the nearest number +/// with ties to even (sections 7.8.3 and 9.3.1), or `None` when there are no digits. Digits are +/// kept exactly in a `u128` while they fit; past that, each later digit counts only for its +/// position and for whether any of the dropped bits is set. +pub fn radix_value(digits: impl IntoIterator<Item = u32>, radix: u32) -> Option<f64> { + let bits = radix.trailing_zeros(); + let mut kept: u128 = 0; + let mut dropped_bits: u32 = 0; + let mut sticky = false; + let mut any = false; + for d in digits { + any = true; + if kept.leading_zeros() >= bits { + kept = kept << bits | u128::from(d); + } else { + dropped_bits = dropped_bits.saturating_add(bits); + sticky |= d != 0; + } + } + if !any { + return None; + } + // `kept` holds at least 125 bits once a digit is dropped, so its lowest bit lies below the + // rounding bit and stands in for every dropped bit. + let mut value = (kept | u128::from(sticky)) as f64; + while dropped_bits > 0 && value.is_finite() { + let step = dropped_bits.min(64); + value *= (1_u128 << step) as f64; + dropped_bits -= step; + } + Some(value) +} + pub fn is_decimal_digit(c: u32) -> bool { (0x30..=0x39).contains(&c) } diff --git a/crates/js/src/lexer.rs b/crates/js/src/lexer.rs index 9b16b16c7..8b82570a7 100644 --- a/crates/js/src/lexer.rs +++ b/crates/js/src/lexer.rs @@ -13,7 +13,7 @@ //! //! 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::chars::{hex_value, is_decimal_digit, is_id_part, is_id_start, is_line_terminator, is_octal_digit, is_whitespace, radix_value}; use crate::error::SyntaxError; use crate::regexp; use crate::token::{Directive, DirectiveName, Keyword, Punct, Span, Token, TokenKind}; @@ -352,26 +352,10 @@ impl<'s> Lexer<'s> { /// 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<f64> { 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); - } - } + while self.peek().and_then(hex_value).is_some_and(|d| d < radix) { self.pos += 1; } - if self.pos == begin { - return None; - } - Some(if overflow { value } else { exact as f64 }) + radix_value(self.src[begin..self.pos].iter().filter_map(|&u| hex_value(u32::from(u))), radix) } /// After `0` and a digit: an octal literal when every digit is octal, else a decimal literal diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 548b20ab0..17f9a4cca 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -321,6 +321,9 @@ fn numeric_literals() { assert_eq!(number("0x1F;"), 31.0); assert_eq!(number("0XfF;"), 255.0); assert_eq!(number("0x20000000000001;"), 9007199254740992.0); + // 2^136 + 2^83 + 1 and 2^150 + 2^97 + 1: a set bit past 128 bits rounds the half-way case up. + assert_eq!(number(&format!("0x1{}8{}1;", "0".repeat(13), "0".repeat(19))), 2f64.powi(136) + 2f64.powi(84)); + assert_eq!(number(&format!("01{}2{}1;", "0".repeat(17), "0".repeat(31))), 2f64.powi(150) + 2f64.powi(98)); assert_eq!(number("017;"), 15.0); assert_eq!(number("08;"), 8.0); assert_eq!(number("09.5;"), 9.5); diff --git a/crates/js/src/value.rs b/crates/js/src/value.rs index 6d2e34f9e..e164001ad 100644 --- a/crates/js/src/value.rs +++ b/crates/js/src/value.rs @@ -222,7 +222,7 @@ pub(crate) fn string_to_number(units: &[u16]) -> f64 { if hex.is_empty() || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { return f64::NAN; } - return hex.bytes().fold(0.0, |acc, b| acc * 16.0 + f64::from((b as char).to_digit(16).unwrap_or(0))); + return crate::chars::radix_value(hex.bytes().filter_map(|b| crate::chars::hex_value(u32::from(b))), 16).unwrap_or(f64::NAN); } let (sign, body) = match s.as_bytes().first() { Some(b'+') => (1.0, &s[1..]), @@ -338,6 +338,14 @@ mod tests { } } + #[test] + fn hex_strings_round_once_to_the_nearest_number() { + assert_eq!(string_to_number(&u("0xc83ce80553ee648")), 0xc83_ce80_553e_e648_u64 as f64); + // 2^136 + 2^83 + 1: a set bit past 128 bits rounds the half-way case up. + let digits = format!("0x1{}8{}1", "0".repeat(13), "0".repeat(19)); + assert_eq!(string_to_number(&u(&digits)), 2f64.powi(136) + 2f64.powi(84)); + } + #[test] fn int32_and_uint32_wrap() { assert_eq!(to_int32(4_294_967_296.0 + 5.0), 5); From 70cfdcb43d5eff923e796a0870a9b6b2437219ab Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 14:39:21 -0800 Subject: [PATCH 089/160] JavaScript: kept code and function source text count toward the heap A collection counts each piece of code a closure or frame keeps: its instructions, tables, names, nested functions and its source text, each shared buffer once. Code that eval and the Function constructor compile at run time is charged when it is made, after a heap reservation. Function.prototype.toString reserves and charges the text it copies. --- crates/js/src/builtins.rs | 9 ++++-- crates/js/src/heap.rs | 55 +++++++++++++++++++++++++++++++++-- crates/js/src/interp.rs | 18 +++++++++--- crates/js/src/interp_tests.rs | 42 ++++++++++++++++++++++++-- crates/js/src/lib.rs | 5 ++++ 5 files changed, 117 insertions(+), 12 deletions(-) diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index 2dbb9b31a..b16aa9c9e 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -7,7 +7,7 @@ use std::rc::Rc; use crate::bytecode::{CodeKind, Source}; use crate::compiler::{CompileFail, compile_constructed_function}; -use crate::heap::{Class, Func, HIDDEN, NativeFn, NativeKind, ObjId, Object, Prop, key}; +use crate::heap::{Class, Func, HIDDEN, NativeFn, NativeKind, ObjId, Object, Prop, key, string_bytes}; use crate::interp::{Abrupt, ErrorType, Interp, JsResult}; use crate::value::{Str, Value}; @@ -255,6 +255,7 @@ fn function_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsRe Err(CompileFail::Syntax(e)) => return Err(interp.throw(ErrorType::Syntax, &e.message)), Err(CompileFail::Internal(m)) => return Err(Abrupt::Internal(m)), }; + interp.charge_code(&code, 0)?; let env = interp.global_env; interp.make_closure(code, env).map(Value::Object) } @@ -269,7 +270,11 @@ fn function_to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> let text = match func { Some(Func::Closure { code, .. }) => { let (start, end) = (code.span.0 as usize, code.span.1 as usize); - String::from_utf16_lossy(code.source.text.get(start..end).unwrap_or_default()) + let units = code.source.text.get(start..end).unwrap_or_default(); + let bytes = string_bytes(units.len()); + interp.reserve_heap(bytes)?; + interp.heap.bytes = interp.heap.bytes.saturating_add(bytes); + return Ok(Value::String(Str::from_units(units.to_vec()))); } Some(Func::Native { name, .. }) => format!("function {name}() {{ [native code] }}"), Some(Func::Host { name, .. }) => format!("function {name}() {{ [native code] }}"), diff --git a/crates/js/src/heap.rs b/crates/js/src/heap.rs index bf8a4c5ae..82268f489 100644 --- a/crates/js/src/heap.rs +++ b/crates/js/src/heap.rs @@ -10,7 +10,7 @@ use std::collections::{HashMap, HashSet}; use std::rc::Rc; -use crate::bytecode::Code; +use crate::bytecode::{Code, Handler, Op}; use crate::value::{Str, Value}; /// A property name: its UTF-16 code units. @@ -289,6 +289,8 @@ pub(crate) const OBJECT_BYTES: usize = 96; pub(crate) const ENV_BYTES: usize = 64; /// The estimated bytes of a rope node. pub(crate) const ROPE_BYTES: usize = 48; +/// The estimated bytes of a piece of code without its instructions, tables and strings. +const CODE_BYTES: usize = 384; pub(crate) fn string_bytes(len: usize) -> usize { 32 + 2 * len @@ -455,9 +457,56 @@ fn mark_value(v: &Value, work: &mut Vec<u32>, strings: &mut StringSizes) { } } +/// The estimated bytes of `code` with the functions nested in it and its source text, for +/// charging code made at run time to the heap before anything refers to it. +pub(crate) fn code_bytes(code: &Code) -> usize { + let mut sizes = StringSizes::default(); + mark_code(code, &mut sizes); + sizes.bytes +} + +/// Adds the estimated size of `code` and the functions nested in it, counting each piece of code, +/// each source text and each string once however many closures share them. fn mark_code(code: &Code, strings: &mut StringSizes) { - for s in &code.strings { - strings.add(s); + let mut stack = vec![code]; + while let Some(code) = stack.pop() { + if !strings.seen.insert(code as *const Code as usize) { + continue; + } + if strings.seen.insert(Rc::as_ptr(&code.source) as usize) { + strings.bytes += code.source.lines.heap_bytes(); + // The text can share its buffer with the string eval was given. + if strings.seen.insert(Rc::as_ptr(&code.source.text) as *const () as usize) { + strings.bytes += string_bytes(code.source.text.len()); + } + } + let handles = code.strings.len() + + 2 * code.regexps.len() + + code.names.len() + + code.functions.len() + + code.params.len() + + code.vars.len() + + 2 * code.fun_decls.len(); + let tables = code.finally_tables.iter().map(|t| 24 + 4 * t.len()).sum::<usize>(); + strings.bytes += CODE_BYTES + + size_of::<Op>() * code.ops.len() + + 4 * code.positions.len() + + 8 * code.numbers.len() + + size_of::<Handler>() * code.handlers.len() + + 16 * handles + + tables; + for s in &code.strings { + strings.add(s); + } + for (pattern, flags) in &code.regexps { + strings.add(pattern); + strings.add(flags); + } + let keys = code.names.iter().chain(&code.params).chain(&code.vars).chain(code.fun_decls.iter().map(|(k, _)| k)); + for k in keys.chain(&code.self_name) { + strings.add_key(k); + } + stack.extend(code.functions.iter().map(|f| &**f)); } } diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 90cd81f75..e576c5e07 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -24,7 +24,7 @@ use crate::bytecode::{Code, CodeKind, Op, Source}; use crate::compiler::{CompileFail, compile_program}; use crate::heap::{ ArgMap, Binding, CONFIGURABLE, Class, ENUMERABLE, ENV_BYTES, Env, EnvId, ForIn, Func, HIDDEN, Heap, Key, NativeKind, OBJECT_BYTES, OPEN, ObjId, - Object, PROP_BYTES, Prop, PropMap, ROPE_BYTES, Roots, WRITABLE, key, string_bytes, + Object, PROP_BYTES, Prop, PropMap, ROPE_BYTES, Roots, WRITABLE, code_bytes, key, string_bytes, }; use crate::value::{Str, Value, array_index, index_key, number_to_str, string_to_number, to_int32, to_uint32}; use crate::{LimitKind, Limits, Options}; @@ -400,7 +400,7 @@ impl Interp { /// Ends the run with the heap limit when `bytes` more would not fit in it after a /// collection. Everything the caller still needs must be reachable from the roots. - fn reserve_heap(&mut self, bytes: usize) -> JsResult<()> { + pub(crate) fn reserve_heap(&mut self, bytes: usize) -> JsResult<()> { if self.heap.bytes.saturating_add(bytes) <= self.limits.heap_bytes { return Ok(()); } @@ -412,6 +412,16 @@ impl Interp { Ok(()) } + /// Charges code compiled at run time to the heap, reserving its estimated size and `extra` + /// more first. The code is not reachable from the roots yet, so a collection here leaves it + /// out of the live total and the charge adds it. + pub(crate) fn charge_code(&mut self, code: &Code, extra: usize) -> JsResult<()> { + let bytes = code_bytes(code); + self.reserve_heap(bytes.saturating_add(extra))?; + self.heap.bytes = self.heap.bytes.saturating_add(bytes); + Ok(()) + } + pub(crate) fn collect_garbage(&mut self) { let mut roots = Roots::default(); roots.values.extend(self.stack.iter()); @@ -1769,7 +1779,7 @@ impl Interp { self.check_source_units(s.len()).map_err(|e| self.compile_error_to_abrupt(e))?; let units = s.flat(); let code = self.compile_source(&units, strict, CodeKind::Eval).map_err(|e| self.compile_error_to_abrupt(e))?; - self.reserve_heap(declaration_bytes(&code).saturating_add(ENV_BYTES))?; + self.charge_code(&code, declaration_bytes(&code).saturating_add(ENV_BYTES))?; let frame = self.frame()?; let (caller_env, caller_var_env, this) = (frame.env, frame.var_env, frame.this.clone()); let (env, var_env) = if code.strict { @@ -1789,7 +1799,7 @@ impl Interp { if self.reentry >= self.limits.reentry { return Err(Abrupt::Limit(LimitKind::Reentry)); } - self.reserve_heap(declaration_bytes(&code).saturating_add(ENV_BYTES))?; + self.charge_code(&code, declaration_bytes(&code).saturating_add(ENV_BYTES))?; let (env, global) = (self.global_env, self.global); let (env, var_env) = if code.strict && is_eval { let e = self.heap.alloc_env(Env::Decl { bindings: PropMap::default(), outer: Some(env) }); diff --git a/crates/js/src/interp_tests.rs b/crates/js/src/interp_tests.rs index 292de041a..a0a349c04 100644 --- a/crates/js/src/interp_tests.rs +++ b/crates/js/src/interp_tests.rs @@ -285,8 +285,9 @@ fn names_from_source_count_toward_the_heap_when_they_are_bound() { // Without a collection in between, the estimate grows by at least each new name's code units: // a global variable (an object property), a binding made by direct eval, and a parameter of a // function made by the Function constructor. - let mut realm = small(Limits::default()); - let name = "n".repeat(100_000); + // Each case runs in a new realm, so the charges for the code itself do not add up to a + // collection. + let name = "n".repeat(50_000); let cases = [ format!("var {name} = 1;"), format!("(function () {{ eval('var {name}2 = 1; {name}2'); }})();"), @@ -294,12 +295,13 @@ fn names_from_source_count_toward_the_heap_when_they_are_bound() { ]; let mut short = Vec::new(); for (i, src) in cases.iter().enumerate() { + let mut realm = small(Limits::default()); let collections = realm.interp().heap.collections; let before = realm.interp().heap.bytes; realm.run(src).unwrap(); assert_eq!(realm.interp().heap.collections, collections, "a collection ran in case {i}"); let grown = realm.interp().heap.bytes - before; - if grown < crate::heap::string_bytes(100_000) { + if grown < crate::heap::string_bytes(50_000) { short.push((i, grown)); } } @@ -316,6 +318,40 @@ fn strings_from_host_functions_count_toward_the_heap() { assert_eq!(limit(&mut with_big(limits), "big(1000000).length"), LimitKind::Heap); } +#[test] +fn functions_kept_across_runs_count_their_code_toward_the_heap() { + // Each run keeps a function whose source holds a 64 Ki-unit comment, and drops the string the + // source was made from. The kept code fills the heap after a few runs. + let limits = Limits { heap_bytes: 1 << 20, ..Limits::default() }; + let mut realm = small(limits); + assert_eq!(realm.run("var keep = []; keep.length"), Ok(JsValue::Number(0.0))); + let step = "var s = 'x'; for (var i = 0; i < 16; i++) s += s; keep[keep.length] = Function('/*' + s + '*/'); s = null; keep.length"; + let mut ended = None; + for run in 1..=20 { + match realm.run(step) { + Ok(v) => assert_eq!(v, JsValue::Number(f64::from(run))), + Err(RunError { kind: RunErrorKind::Limit(k), .. }) => { + ended = Some((run, k)); + break; + } + Err(e) => panic!("run {run}: {e:?}"), + } + } + let Some((run, kind)) = ended else { panic!("20 kept functions fit in a 1 MiB heap") }; + assert_eq!(kind, LimitKind::Heap); + assert!(run > 2, "the heap filled on run {run}"); +} + +#[test] +fn function_source_text_counts_toward_the_heap() { + let limits = Limits { heap_bytes: 1 << 20, ..Limits::default() }; + let comment = "x".repeat(300_000); + let src = format!("var f = function () {{ /*{comment}*/ }}; f.toString().length"); + assert_eq!(limit(&mut small(limits.clone()), &src), LimitKind::Heap); + let mut realm = small(limits); + assert_eq!(realm.run("(function f(a) { return a; }).toString()"), Ok(JsValue::String("function f(a) { return a; }".into()))); +} + #[test] fn declarations_check_the_heap_before_they_are_bound() { // Each script declares more than fits in the heap. The run ends with the heap limit before diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index f17577fe6..9c0c192aa 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -155,6 +155,11 @@ impl LineTable { (u32::try_from(line).unwrap_or(u32::MAX), offset.saturating_sub(start) + 1) } + /// The estimated bytes the table holds. + pub(crate) fn heap_bytes(&self) -> usize { + 32 + 4 * self.starts.len() + } + pub(crate) fn line_count(&self) -> u32 { if self.empty { return 0; From 66b4c171d4c5ecce50e838559e50aa8783ba217a Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 14:52:43 -0800 Subject: [PATCH 090/160] .ai save: the large-canvas warning says what the editing copy keeps On a large canvas no art style is written, so objects with live effects are written as their vector art with their geometry effects applied, and their shadows, glows and feathers are left out. The warning now says that, as file-format-parity.md does, instead of "written as their drawn look". --- crates/eps/src/ai_editing/writer/styles.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index de74bc99e..ffd4b4fcf 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -18,8 +18,7 @@ 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 LARGE_CANVAS_STYLES: &str = "on a large canvas, objects with live effects are written as their vector art with their geometry effects applied, without shadows, glows or feathers, 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 From a1842028daa7ea708fd0c74a8961f03959dd2e35 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 14:52:43 -0800 Subject: [PATCH 091/160] Tests: compare the RGB values of colors in the art-style round trip art_styles_write_the_same_bytes_again failed on Windows (MSVC) on the Pink swatch: the first save wrote its CMYK equivalent as 0 0.9317 0.1527 0 and the save of the reopened file as 0 0.9389 0.1629 0. The writer computes the CMYK equivalent of an RGB color from its RGB values, and the save of the reopened file computes it from the RGB values read back at four decimals; for this out-of-gamut color the Generic CMYK conversion moves by more than the test's 0.002 there. On macOS the two saves of this document differ by at most 0.0016 in these numbers. The test now blanks the CMYK equivalents of RGB colors (the first four numbers of `c m y k r g b Xa` and of an RGB gradient stop's `c m y k r g b 2 ... Bs`) and compares every other number, the RGB values included. With them blanked, the two saves are identical on macOS. The large-canvas test's comment also says what such objects are written as. --- crates/eps/src/ai_editing/tests.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index fca9eef02..a26463c96 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -5549,7 +5549,8 @@ fn effects_in_a_styled_objects_mask_are_warned_about() { } /// 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. +/// objects with effects are written as their vector art with their geometry effects applied, with a +/// warning. #[test] fn a_large_canvas_writes_no_art_styles() { let mut doc = Document::new(20000.0, 100.0); @@ -5577,7 +5578,23 @@ fn art_styles_write_the_same_bytes_again() { 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)); + near_lines(&rgb_without_cmyk(&shown(&again)), &rgb_without_cmyk(&shown(&first.program))); +} + +/// `program` with the CMYK equivalents of RGB colors blanked out: the first four numbers of an RGB +/// color's `c m y k r g b Xa` (or `XA`) and of an RGB gradient stop's `c m y k r g b 2 ... Bs`. A +/// save of the reopened file computes them again from the RGB values read back at four decimals, +/// and on some platforms the CMYK conversion moves them by more than [`near_lines`] allows. The RGB +/// values stay compared. +fn rgb_without_cmyk(program: &str) -> String { + let blank = |line: &str| { + let (prefix, rest) = line.strip_prefix("%_").map_or(("", line), |r| ("%_", r)); + let t: Vec<&str> = rest.split(' ').collect(); + let color = t.len() == 8 && matches!(t[7], "Xa" | "XA"); + let stop = t.len() > 8 && t[7] == "2" && t.last() == Some(&"Bs"); + if color || stop { format!("{prefix}_ _ _ _ {}", t[4..].join(" ")) } else { line.to_string() } + }; + program.lines().map(blank).collect::<Vec<_>>().join("\n") } /// `n` on the first layer of `doc`. From 4d8bee277cdf18cc3e51e921b6fbeaaf3b1f7036 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 14:59:24 -0800 Subject: [PATCH 092/160] JavaScript: for-in takes fuel for each property and reserves heap for its names Listing the names for a for-in loop now takes a unit of fuel for each property it visits and reserves heap for the name list before it is kept, so a run with small limits stops on an object built under larger ones. --- crates/js/src/interp.rs | 15 +++++++++++---- crates/js/src/interp_tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index e576c5e07..13005f2be 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -1388,7 +1388,7 @@ impl Interp { /// that an object nearer the start of the chain has (enumerable or not). /// /// For a String object, the heap budget for all its index names is checked before any is - /// built, and each name takes a unit of fuel. + /// built. Each index name and each property visited takes a unit of fuel. fn enumerable_keys(&mut self, id: ObjId) -> JsResult<Vec<Key>> { let mut seen = std::collections::HashSet::new(); let mut out = Vec::new(); @@ -1411,6 +1411,9 @@ impl Interp { if is_array { seen.insert(key("length")); } + for _ in 0..self.object(o)?.props.len() { + self.use_fuel()?; + } let obj = self.object(o)?; for (k, p) in obj.props.iter() { if seen.insert(k.clone()) && p.enumerable() { @@ -1429,14 +1432,18 @@ impl Interp { } else { let o = self.to_object(&v)?; // A wrapper made by ToObject is not on the stack; a collection while the names are - // listed must not free it. + // listed or their bytes reserved must not free it. let saved = self.temp_roots.len(); self.temp_roots.push(Value::Object(o)); - let keys = self.enumerable_keys(o); + let keys = self.enumerable_keys(o).and_then(|keys| { + let bytes = keys.iter().map(|k| string_bytes(k.len())).sum::<usize>(); + self.reserve_heap(bytes)?; + self.heap.bytes = self.heap.bytes.saturating_add(bytes); + Ok(keys) + }); self.temp_roots.truncate(saved); (keys?, Some(o)) }; - self.heap.bytes += keys.iter().map(|k| string_bytes(k.len())).sum::<usize>(); let iter = self.heap.alloc_object(Object::new(Class::ForIn(Box::new(ForIn { keys, pos: 0, object })), None)); self.replace(1, Value::Object(iter)) } diff --git a/crates/js/src/interp_tests.rs b/crates/js/src/interp_tests.rs index a0a349c04..af17dec5b 100644 --- a/crates/js/src/interp_tests.rs +++ b/crates/js/src/interp_tests.rs @@ -352,6 +352,28 @@ fn function_source_text_counts_toward_the_heap() { assert_eq!(realm.run("(function f(a) { return a; }).toString()"), Ok(JsValue::String("function f(a) { return a; }".into()))); } +#[test] +fn for_in_names_take_fuel_and_heap_under_later_limits() { + // The first run, under the default limits, builds an object with many properties. Later runs + // with smaller limits list its names only within their own fuel and heap. + let mut realm = small(Limits::default()); + assert_eq!(realm.run("var o = {}; for (var i = 0; i < 100000; i++) o['k' + i] = i; o.k99999"), Ok(JsValue::Number(99999.0))); + realm.set_limits(Limits { fuel: 1000, ..Limits::default() }); + assert_eq!(limit(&mut realm, "for (var k in o) break;"), LimitKind::Fuel); + realm.set_limits(Limits::default()); + assert_eq!(realm.run("var n = 0; for (var k in o) n++; n"), Ok(JsValue::Number(100_000.0))); + realm.interp().collect_garbage(); + let live = realm.interp().heap.bytes; + // The list of names takes more than 3 MB (at least 32 + 2 * 2 bytes each), so 1 MiB more than + // the live heap holds the object but not the list. + let heap_bytes = live + (1 << 20); + realm.set_limits(Limits { heap_bytes, ..Limits::default() }); + assert_eq!(realm.run("o.k0"), Ok(JsValue::Number(0.0))); + assert_eq!(limit(&mut realm, "for (var k in o) break;"), LimitKind::Heap); + // The run ends before the list is made, so the estimate stays within the limit. + assert!(realm.interp().heap.bytes <= heap_bytes, "{} > {heap_bytes}", realm.interp().heap.bytes); +} + #[test] fn declarations_check_the_heap_before_they_are_bound() { // Each script declares more than fits in the heap. The run ends with the heap limit before From 44c17c4e9ad712e97fe8ca000bdb98f8ce5e6fd7 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 15:18:55 -0800 Subject: [PATCH 093/160] JavaScript: the test262 runner reports a failed assertion's message The harness's assert.js formats failure messages with the global String, which the interpreter does not provide yet, so the four failing runs reported "ReferenceError: String is not defined". The runner now defines a stand-in that converts with +, and the failures name the assertion (the completion value tests expect undefined where ECMA-262 5.1 section 12.6.3 gives 4). --- crates/js/tests/test262.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/js/tests/test262.rs b/crates/js/tests/test262.rs index 81ef399df..8c87bb19d 100644 --- a/crates/js/tests/test262.rs +++ b/crates/js/tests/test262.rs @@ -203,6 +203,9 @@ const MISSING_BUILTINS: &str = "Array String Number Boolean Math JSON Date RegEx fromCharCode substring substr split replace match search exec test toUpperCase toLowerCase trim toFixed toPrecision toExponential \ bind now isArray compareArray verifyProperty fnGlobalObject"; +/// The stand-in for `String` that the harness's failure messages call. +const HARNESS_STRING: &str = "function String(value) { return '' + value; }"; + /// The source names a missing built-in, as an identifier, a property name or a word inside a /// string literal (code passed to `eval` or `Function`), or does not parse as ES5 (the tests that /// use `let` and `const`). @@ -251,7 +254,10 @@ fn run_language_subset(dir: &Path) { let harness_dir = dir.join("harness"); // A file that cannot be read gives an empty source. let read = |p: &Path| String::from_utf8_lossy(&std::fs::read(p).unwrap_or_default()).into_owned(); - let prelude = format!("{}\n{}\n", read(&harness_dir.join("assert.js")), read(&harness_dir.join("sta.js"))); + // `assert.js` formats a failed assertion's message with the global `String`, which the + // interpreter does not provide yet; the stand-in converts with `+` so a failure reports the + // assertion's message instead of a ReferenceError. + let prelude = format!("{HARNESS_STRING}\n{}\n{}\n", read(&harness_dir.join("assert.js")), read(&harness_dir.join("sta.js"))); let missing: HashSet<&str> = MISSING_BUILTINS.split_whitespace().collect(); let verbose = std::env::var_os("VECTORCRAFT_TEST262_VERBOSE").is_some(); let (mut tests, mut skipped, mut passed, mut failed, mut panicked) = (0usize, 0usize, 0usize, 0usize, 0usize); From 1f352bc7c10bf9659cf8eb6c82ec9044c2cb545f Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 15:20:07 -0800 Subject: [PATCH 094/160] Plug-ins: manifests list permissions, the user grants them at install, and the host enforces the grant A manifest may list `permissions` (doc.read, doc.write, state, effect.render, export.write) and an `api` level. doc.read, doc.write and state follow the capability model proposed in storytold/photocraft#662; the other names are VectorCraft's own. An unknown, reserved, duplicate or wrong-kind name is refused by name. A manifest without the key gets the permissions its kind implies and installs as before. - Install Plug-in... and File > Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped and each string cut to 64 characters. OK installs the module only if it still has the SHA-256 shown; Cancel installs nothing. The heavy-budget prompt and the status line show the escaped name too, and OK keeps the `replace` it was given. - `plugin.install` needs `grant` listing every permission, and `plugin.inspect` reports `needsApproval`. `hash` pins the module: one with another SHA-256 is refused. The grant is kept with the module's hash in the store's `grants.json`, and only that store's grants count while it is attached; the same set does not ask again, more permissions do, and `plugin.remove` forgets it. - The Additional Plug-ins Folder and `plugin.reload` install only plug-ins the attached store has granted and report the others as needing approval. At startup the store skips an ungranted plug-in. - A filter run without doc.read is given no objects; without state it neither reads nor writes `documentData`; without doc.write an output that changes the document is refused and the document is unchanged. Docs: docs/plugins.md, docs/mcp.md, docs/target-app-parity.md. --- Cargo.lock | 1 + crates/engine/src/cmd/plugin.rs | 128 +++++++++--- crates/engine/src/store.rs | 83 +++++++- crates/engine/src/tests/plugins.rs | 224 ++++++++++++++++++++ crates/engine/tests/plugin_fuzz.rs | 52 +++++ crates/plugins/Cargo.toml | 2 + crates/plugins/src/lib.rs | 4 +- crates/plugins/src/manifest.rs | 68 ++++++ crates/plugins/src/permissions.rs | 266 ++++++++++++++++++++++++ crates/plugins/src/registry.rs | 53 ++++- crates/plugins/src/runtime.rs | 8 +- crates/ui-egui/src/dialogs/tests.rs | 2 + crates/ui-egui/src/i18n/bg.tsv | 10 + crates/ui-egui/src/i18n/bs.tsv | 22 ++ crates/ui-egui/src/i18n/ca.tsv | 22 ++ crates/ui-egui/src/i18n/cs.tsv | 22 ++ crates/ui-egui/src/i18n/da.tsv | 10 + crates/ui-egui/src/i18n/de.tsv | 22 ++ crates/ui-egui/src/i18n/el.tsv | 22 ++ crates/ui-egui/src/i18n/es.tsv | 22 ++ crates/ui-egui/src/i18n/fi.tsv | 22 ++ crates/ui-egui/src/i18n/fr.tsv | 22 ++ crates/ui-egui/src/i18n/hi.tsv | 22 ++ crates/ui-egui/src/i18n/hu.tsv | 22 ++ crates/ui-egui/src/i18n/id.tsv | 10 + crates/ui-egui/src/i18n/it.tsv | 22 ++ crates/ui-egui/src/i18n/ja.tsv | 22 ++ crates/ui-egui/src/i18n/ka.tsv | 22 ++ crates/ui-egui/src/i18n/ko.tsv | 22 ++ crates/ui-egui/src/i18n/nl.tsv | 10 + crates/ui-egui/src/i18n/pl.tsv | 22 ++ crates/ui-egui/src/i18n/pt-br.tsv | 22 ++ crates/ui-egui/src/i18n/ru.tsv | 22 ++ crates/ui-egui/src/i18n/sv.tsv | 22 ++ crates/ui-egui/src/i18n/tr.tsv | 22 ++ crates/ui-egui/src/i18n/uk.tsv | 22 ++ crates/ui-egui/src/i18n/vi.tsv | 22 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 22 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 22 ++ crates/ui-egui/src/io/open.rs | 65 +++++- crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/tests/plugins.rs | 64 ++++++ docs/mcp.md | 9 +- docs/plugins.md | 55 ++++- docs/target-app-parity.md | 1 + 45 files changed, 1583 insertions(+), 50 deletions(-) create mode 100644 crates/plugins/src/permissions.rs diff --git a/Cargo.lock b/Cargo.lock index 6f92cb310..79778322f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6415,6 +6415,7 @@ version = "0.21.0" dependencies = [ "serde", "serde_json", + "sha2", "thiserror 2.0.21", "vectorcraft-color", "vectorcraft-doc", diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 8d1bb0408..565eded7f 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -13,6 +13,12 @@ //! packing or simulation). It installs only once the user agrees (`plugin.install {allowHeavy}`), //! which loading a folder doesn't ask, so folders skip it; the app runs it in the background with //! Cancel ([`FilterRun`]). +//! +//! A plug-in whose manifest lists permissions installs once the user grants them +//! (`plugin.install {grant}`, recorded with the module's hash and kept in the plug-in store), and +//! a filter does only what they allow: without `doc.read` it is given no objects, without +//! `doc.write` an output that changes the document is refused, and without `state` it neither +//! reads nor writes the data it keeps with the document. use std::sync::{Arc, Mutex}; @@ -22,6 +28,8 @@ use vectorcraft_plugins::{Budget, Kind, Plugin, effect, export, objects, registr use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, has_doc, ids_param, str_param, switch_or}; +use vectorcraft_plugins::permissions::{self, Grant, Permission}; + /// What a plug-in error looks like to the user. A run the caller cancelled changed nothing. fn err(e: vectorcraft_plugins::Error) -> EngineError { match e { @@ -37,6 +45,7 @@ pub fn describe(p: &Plugin) -> Value { "id": m.id, "name": m.name, "version": m.version, "kind": m.kind.name(), "author": m.author, "description": m.description, "params": m.params_doc(), "paramsSchema": serde_json::to_value(m).ok().and_then(|v| v.get("params").cloned()), "size": p.size(), "source": p.source(), + "permissions": permissions::names(&m.permissions()), "hash": p.hash(), }); match m.kind { Kind::Effect => v["effect"] = json!(effect::effect_id(&m.id)), @@ -122,15 +131,17 @@ impl FilterRun { } // Bad parameters fail before anything runs. let mut params = Value::Object(m.resolve_params(&plugin_params(p)).map_err(|e| bad(C, e.to_string()))?); + let may = m.permissions(); let st = s.doc()?; let selected = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.clone()); - let inputs = objects::filter_targets(&st.doc, &selected); + // Without `doc.read` a filter is given no objects: it can only add new ones. + let inputs = if may.contains(&Permission::DocRead) { objects::filter_targets(&st.doc, &selected) } else { Vec::new() }; let parent = st.insertion_parent(); let bounds = st.doc.bounds_of(&inputs, false); let board = bounds.and_then(|b| st.doc.artboard_at(b.center())).and_then(|i| st.doc.artboards.get(i)).or(st.doc.artboards.first()); let rect = |r: vectorcraft_geom::Rect| json!([r.x0, r.y0, r.x1, r.y1]); // What the plug-in keeps with the document, as it last wrote it. - let data = st.doc.plugin_data(&m.id).cloned().unwrap_or(Value::Null); + let data = st.doc.plugin_data(&m.id).filter(|_| may.contains(&Permission::State)).cloned().unwrap_or(Value::Null); params["_context"] = json!({"mode": "filter", "bounds": bounds.map(rect), "artboard": board.map(|a| rect(a.rect)), "documentData": data}); let input = objects::encode_input(&st.doc, &inputs); let params = serde_json::to_vec(¶ms).map_err(|e| bad(C, e.to_string()))?; @@ -175,13 +186,23 @@ impl FilterRun { } /// Applies the plug-in's `output` to the active document as one undo step: its objects, and what - /// it keeps with the document from now on. + /// it keeps with the document from now on. An output its permissions don't allow is refused, + /// and the document stays as it was. fn finish(self, s: &mut Session, output: &[u8]) -> Result<Value> { let m = self.plugin.manifest(); + let may = m.permissions(); let out = objects::decode_output(output).map_err(err)?; + if out.document_data.is_some() && !may.contains(&Permission::State) { + return Err(not_granted(&m.name, Permission::State)); + } + let may_write = may.contains(&Permission::DocWrite); let (inputs, parent) = (self.inputs, self.parent); let ids = s.edit(m.name.trim_end_matches('…'), |d, sel| { + let before = (!may_write).then(|| d.layers.clone()); let ids = objects::apply_output(d, &inputs, out.objects, parent, &admit_effects).map_err(err)?; + if before.is_some_and(|b| b != d.layers) { + return Err(not_granted(&m.name, Permission::DocWrite)); + } if let Some(data) = out.document_data { d.set_plugin_data(&m.id, data); } @@ -192,6 +213,12 @@ impl FilterRun { } } +/// The error of a filter `name` whose output needs `permission`, which it wasn't granted. +fn not_granted(name: &str, permission: Permission) -> EngineError { + let (name, p) = (permissions::display_text(name, permissions::MAX_SHOWN_NAME), permission.name()); + err(vectorcraft_plugins::Error::Output(format!("“{name}” wasn't granted `{p}`, which its output needs: nothing was changed"))) +} + /// `plugin.run`: runs filter `id` on `ids` (else the selected paths and compound paths) with its /// parameters and `_context` (what it keeps with the document included), and applies its output as /// one undo step ([`objects::apply_output`]; effect records as [`admit_effects`] admits them), with @@ -283,13 +310,37 @@ fn load_given(c: &str, p: &Value) -> Result<(Plugin, Vec<u8>)> { } } +/// The permissions `grant` lists in command `c`'s params: `None` when it is left out (or null). +/// Anything but a list of permission names is an error. +fn grant_param(c: &str, p: &Value) -> Result<Option<Vec<Permission>>> { + let Some(v) = p.get("grant").filter(|v| !v.is_null()) else { + return Ok(None); + }; + let names = v.as_array().ok_or_else(|| bad(c, "`grant` must be a list of permission names"))?; + let mut out = Vec::with_capacity(names.len()); + for n in names { + let name = n.as_str().ok_or_else(|| bad(c, "`grant` must be a list of permission names"))?; + let shown = permissions::display_text(name, permissions::MAX_SHOWN_FIELD); + out.push(Permission::parse(name).ok_or_else(|| bad(c, format!("`grant` names an unknown permission `{shown}`")))?); + } + Ok(Some(out)) +} + /// `plugin.install`: loads and checks the module, refuses one that asks for a heavy budget unless -/// `allowHeavy` says the user agreed, keeps it for the next start and installs it → its -/// `plugin.list` entry. +/// `allowHeavy` says the user agreed, and one that lists permissions the user hasn't granted its id +/// unless `grant` lists them all, and one whose SHA-256 isn't the `hash` given; records the grant +/// with the module's hash, keeps it for the next start and installs it → its `plugin.list` entry. fn install(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "plugin.install"; let (replace, allow_heavy) = (switch_or(p, "replace", true, C)?, switch_or(p, "allowHeavy", false, C)?); + let grant = grant_param(C, p)?; let (plugin, bytes) = load_given(C, p)?; + // `hash` pins the module the user was shown: one with other bytes is refused. + if let Some(h) = p.get("hash").filter(|v| !v.is_null()) + && h.as_str() != Some(plugin.hash()) + { + return Err(bad(C, "the module doesn't have the SHA-256 `hash` gives: nothing was installed")); + } if !replace && registry::get(plugin.id()).is_some() { return Err(bad(C, format!("a plug-in with id {:?} is already installed", plugin.id()))); } @@ -304,14 +355,33 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> { ), )); } + // A plug-in that lists permissions installs once the user grants them: whoever installs it + // asks them first, unless they already granted its id all of them. + let m = plugin.manifest(); + let wanted = m.permissions(); + if !s.plugin_approved(&plugin) && !grant.as_ref().is_some_and(|g| wanted.iter().all(|w| g.contains(w))) { + let name = permissions::display_text(&m.name, permissions::MAX_SHOWN_NAME); + let asked = permissions::names(&wanted).join(", "); + return Err(bad(C, format!("“{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees"))); + } + if m.asks_permissions() { + let g = Grant { id: m.id.clone(), hash: plugin.hash().to_string(), permissions: wanted }; + registry::grant(g.clone()); + s.store_grant(&m.id, Some(g)); + } // Installed plug-ins stay installed after a restart (#2541). s.store_plugin(plugin.id(), &bytes); Ok(describe(®istry::install(plugin))) } -/// `plugin.inspect`: what installing the module would install, without installing it. -fn inspect(_: &mut Session, p: &Value) -> Result<Value> { - load_given("plugin.inspect", p).map(|(plugin, _)| describe(&plugin)) +/// `plugin.inspect`: what installing the module would install, without installing it, and whether +/// that needs the user to grant its permissions first (`needsApproval`). +fn inspect(s: &mut Session, p: &Value) -> Result<Value> { + load_given("plugin.inspect", p).map(|(plugin, _)| { + let mut v = describe(&plugin); + v["needsApproval"] = json!(!s.plugin_approved(&plugin)); + v + }) } /// The plug-in in the `.wasm` file `path`, and its module. @@ -328,35 +398,40 @@ fn load_path(_: &str) -> Result<(Plugin, Vec<u8>)> { Err(EngineError::Other("installing from a path is not available on the web; pass the module as `dataBase64`".into())) } -/// The plug-in folder last loaded from the preferences, and what happened. -static FOLDER: Mutex<Option<(String, Value)>> = Mutex::new(None); +/// The plug-in folder last loaded, the plug-in store whose grants it was loaded with, and what +/// happened. +static FOLDER: Mutex<Option<(String, Option<std::path::PathBuf>, Value)>> = Mutex::new(None); -/// Loads the Additional Plug-ins Folder preference (`*.wasm`) when it is set and has changed since -/// the last load. Runs whenever preferences are applied (startup, `prefs.set`). Native only. +/// Loads the Additional Plug-ins Folder preference (`*.wasm`) when it is set and it, or the +/// session's plug-in store, has changed since the last load. Runs whenever preferences are applied +/// (startup, `prefs.set`). Native only. pub(crate) fn sync_prefs(s: &Session) { let folder = s.prefs.plugins_folder.trim(); if folder.is_empty() { return; } let mut last = FOLDER.lock().unwrap_or_else(|e| e.into_inner()); - if last.as_ref().is_some_and(|(f, _)| f == folder) { + if last.as_ref().is_some_and(|(f, store, _)| f == folder && *store == s.plugin_dir) { return; } - *last = Some((folder.to_string(), load_folder(folder))); + *last = Some((folder.to_string(), s.plugin_dir.clone(), load_folder(s, folder))); } +/// Loads the plug-ins in `folder`, those that list permissions only when `s` has them granted +/// ([`Session::plugin_approved`], with the store's grants read once). #[cfg(not(target_arch = "wasm32"))] -fn load_folder(folder: &str) -> Value { +fn load_folder(s: &Session, folder: &str) -> Value { if let Err(e) = crate::file_access::check_read(folder) { return json!({"folder": folder, "error": e}); } // A file in it may be a link to one elsewhere. - match registry::load_folder_where(std::path::Path::new(folder), |f| match f.to_str() { + let allow = |f: &std::path::Path| match f.to_str() { Some(f) => crate::file_access::check_read(f), // A lossy name would check another file than the one loaded. None if crate::file_access::current().is_none() => Ok(()), None => Err(format!("automation path rejected: {} isn't valid text", f.display())), - }) { + }; + match registry::load_folder_where(std::path::Path::new(folder), allow, s.plugin_approver()) { Ok(r) => { json!({"folder": folder, "loaded": r.loaded, "failed": r.failed.iter().map(|(f, e)| json!({"file": f, "error": e})).collect::<Vec<_>>()}) } @@ -365,7 +440,7 @@ fn load_folder(folder: &str) -> Value { } #[cfg(target_arch = "wasm32")] -fn load_folder(folder: &str) -> Value { +fn load_folder(_: &Session, folder: &str) -> Value { json!({"folder": folder, "error": "plug-in folders are not available on the web"}) } @@ -378,16 +453,16 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> { if folder.is_empty() { return Err(bad(C, "no folder: pass `path` or set Preferences › Plug-ins & Scratch Disks › Additional Plug-ins Folder")); } - let report = load_folder(&folder); + let report = load_folder(s, &folder); if let Some(e) = report.get("error").and_then(Value::as_str) { return Err(EngineError::Other(e.to_string())); } - *FOLDER.lock().unwrap_or_else(|e| e.into_inner()) = Some((folder, report.clone())); + *FOLDER.lock().unwrap_or_else(|e| e.into_inner()) = Some((folder, s.plugin_dir.clone(), report.clone())); Ok(report) } fn list(_: &mut Session, _: &Value) -> Result<Value> { - let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, r)| r.clone()); + let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| r.clone()); Ok(json!({"plugins": registry::list().iter().map(|p| describe(p)).collect::<Vec<_>>(), "folder": folder})) } @@ -402,6 +477,9 @@ fn info(_: &mut Session, p: &Value) -> Result<Value> { fn remove(s: &mut Session, p: &Value) -> Result<Value> { let id = str_param(p, "id").ok_or_else(|| bad("plugin.remove", "missing `id`"))?; + // Installed again, it asks again. + registry::revoke(id); + s.store_grant(id, None); s.unstore_plugin(id); if registry::remove(id) { Ok(json!({"removed": id})) } else { Err(err(vectorcraft_plugins::Error::NotFound(id.into()))) } } @@ -415,7 +493,7 @@ pub fn specs() -> Vec<CommandSpec> { "List Plug-ins", [], None, - "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's format id, plugin.<id>, for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}", + "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's format id, plugin.<id>, for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies), hash (the module's SHA-256)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}", always, list ), @@ -442,7 +520,7 @@ pub fn specs() -> Vec<CommandSpec> { "Install Plug-in", [], None, - "{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it)} validate and install a WebAssembly plug-in for this session → its plugin.list entry", + "{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash, and replacing the plug-in with one that lists no new permission needs none; with a plug-in store attached, only the grants kept there count), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry", always, install ), @@ -451,7 +529,7 @@ pub fn specs() -> Vec<CommandSpec> { "Inspect Plug-in", [], None, - "{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have", + "{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted its id: plugin.install then needs grant)", always, inspect ), @@ -461,7 +539,7 @@ pub fn specs() -> Vec<CommandSpec> { "Reload Plug-ins", ["Object", "Plug-ins"], None, - "{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module is reported and skipped) → {folder, loaded: [id], failed: [{file, error}]}", + "{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module is reported and skipped, and so are a heavy filter and a plug-in that lists permissions the user hasn't granted its id) → {folder, loaded: [id], failed: [{file, error}]}", always, reload ), diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index 0e85391fc..71d2baf46 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -14,11 +14,14 @@ //! * **Plug-ins**: the module of each plug-in `plugin.install` installs, as a `.wasm` file named //! after its id (as Illustrator keeps plug-ins in its Plug-ins folder); `plugin.remove` deletes it. //! Attaching the folder installs them again. The Additional Plug-ins Folder preference is not -//! copied: it is loaded from where it is. +//! copied: it is loaded from where it is. `grants.json` holds what the user granted the plug-ins +//! stored there that list permissions (id, module hash, permissions); a stored plug-in whose +//! permissions it doesn't grant is skipped with a log record, and stays in the folder. //! //! The files are untrusted input like any other: sizes and counts are capped, a file that does not //! parse is skipped with a log record, and every write is atomic. +use std::collections::BTreeMap; use std::io::Read; use std::path::{Path, PathBuf}; @@ -41,6 +44,10 @@ const PROFILE_EXT: &str = "icc"; pub const MAX_PLUGIN_STORE: u64 = 512 << 20; /// The extension of a stored plug-in. const PLUGIN_EXT: &str = "wasm"; +/// The file in the plug-in store that holds the permissions the user granted. +const GRANTS_FILE: &str = "grants.json"; +/// The largest grants file read. +const MAX_GRANTS_FILE: u64 = 1 << 20; /// The file name (without extension) a stored item called `name` gets: its name made safe for any /// file system, then a hash of the name, so that names that differ only in characters a file name @@ -217,8 +224,10 @@ impl Session { } /// Keep the plug-ins this session installs in `dir`, and install the ones already there (a - /// module that doesn't load is skipped with a log record). + /// module that doesn't load, and one that lists permissions the grants there don't grant it, + /// is skipped with a log record). pub fn attach_plugin_store(&mut self, dir: PathBuf) { + let grants = stored_grants(&dir); let limits = vectorcraft_plugins::Limits::default(); let mut left = MAX_PLUGIN_STORE; for path in list_files(&dir, PLUGIN_EXT) { @@ -231,6 +240,18 @@ impl Session { vectorcraft_plugins::Plugin::load(&bytes, limits.clone()).map_err(|e| e.to_string()) }); match loaded { + // Only this store's grants count: one given elsewhere in the process doesn't let a + // module stored here load. + Ok(plugin) if plugin.manifest().asks_permissions() => { + let m = plugin.manifest(); + match grants.get(&m.id).filter(|g| g.covers(&m.permissions())) { + Some(g) => { + vectorcraft_plugins::registry::grant(g.clone()); + vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())); + } + None => log::warn!("skipping the stored plug-in {}: the user hasn't granted the permissions it lists", path.display()), + } + } Ok(plugin) => { vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())); } @@ -240,6 +261,26 @@ impl Session { self.plugin_dir = Some(dir); } + /// Whether `plugin` installs without asking the user: it lists no permissions, or its id was + /// granted every one it lists, in the grants of the attached plug-in store when there is one, + /// else in this process's ([`vectorcraft_plugins::registry::approved`]). + pub(crate) fn plugin_approved(&self, plugin: &vectorcraft_plugins::Plugin) -> bool { + self.plugin_approver()(plugin) + } + + /// [`Session::plugin_approved`] for many plug-ins: reads the store's grants once, when it is + /// made, and checks each plug-in against them. + pub(crate) fn plugin_approver(&self) -> impl Fn(&vectorcraft_plugins::Plugin) -> bool + use<> { + let grants = self.plugin_dir.as_deref().map(stored_grants); + move |plugin| { + let m = plugin.manifest(); + match &grants { + Some(grants) if m.asks_permissions() => grants.get(&m.id).is_some_and(|g| g.covers(&m.permissions())), + _ => vectorcraft_plugins::registry::approved(plugin), + } + } + } + /// Write the module `bytes` of the plug-in `id` to the store. pub(crate) fn store_plugin(&self, id: &str, bytes: &[u8]) { if let Some(dir) = &self.plugin_dir { @@ -247,6 +288,25 @@ impl Session { } } + /// Record in the store what the user granted plug-in `id` (`None`: nothing), leaving the store's + /// other grants as they are; with none left, remove the file. + pub(crate) fn store_grant(&self, id: &str, grant: Option<vectorcraft_plugins::permissions::Grant>) { + let Some(dir) = &self.plugin_dir else { return }; + let mut grants = stored_grants(dir); + let changed = match grant { + Some(g) => grants.insert(id.to_string(), g.clone()).as_ref() != Some(&g), + None => grants.remove(id).is_some(), + }; + if !changed { + return; + } + if grants.is_empty() { + take(dir, GRANTS_FILE); + } else if let Ok(bytes) = serde_json::to_vec_pretty(&grants.into_values().collect::<Vec<_>>()) { + put(dir, GRANTS_FILE, &bytes); + } + } + /// Remove the plug-in `id` from the store. pub(crate) fn unstore_plugin(&self, id: &str) { if let Some(dir) = &self.plugin_dir { @@ -255,6 +315,25 @@ impl Session { } } +/// The grants kept in the plug-in store `dir`, by plug-in id: none when there is no grants file, +/// and none, with a log record, when it is too large or doesn't parse. Grants for an invalid id are +/// left out. +fn stored_grants(dir: &Path) -> BTreeMap<String, vectorcraft_plugins::permissions::Grant> { + let path = dir.join(GRANTS_FILE); + if !path.is_file() { + return BTreeMap::new(); + } + let grants = read_capped(&path, MAX_GRANTS_FILE) + .and_then(|bytes| serde_json::from_slice::<Vec<vectorcraft_plugins::permissions::Grant>>(&bytes).map_err(|e| e.to_string())); + match grants { + Ok(grants) => grants.into_iter().filter(|g| vectorcraft_plugins::manifest::valid_id(&g.id)).map(|g| (g.id.clone(), g)).collect(), + Err(e) => { + log::warn!("skipping the stored plug-in grants {}: {e}", path.display()); + BTreeMap::new() + } + } +} + /// The presets of the files in `dir`: valid ones only, no name twice (any case), none called like a /// built-in preset, at most [`MAX_STORE_FILES`]. fn load_presets(dir: &Path) -> Vec<PdfPreset> { diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 194c56178..36694ef87 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -523,6 +523,230 @@ fn invalid_install_flags_are_refused() { assert!(vectorcraft_plugins::registry::get(light).is_none()); } +/// A test filter `id` (named "Test `id`") whose manifest lists `permissions` (a JSON list) and that +/// returns `output`. +fn asking(id: &str, permissions: &str, output: &Value) -> Vec<u8> { + let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"2.1","kind":"filter","permissions":{permissions}}}"#); + wat::parse_str(tpl::returning(&manifest, &output.to_string())).unwrap() +} + +/// A plug-in whose manifest lists permissions installs only once they are all granted (`grant`), +/// which `plugin.inspect` tells beforehand (`needsApproval`) with the permissions and the module's +/// hash. The grant is recorded with that hash: replacing the plug-in with one that lists the same +/// permissions needs no new grant, and one that lists another needs one. Removing it forgets the +/// grant. A plug-in without `permissions` installs as before, with the ones its kind implies. +#[test] +fn a_plugin_that_lists_permissions_installs_once_they_are_granted() { + let mut s = session(); + let id = "test.engine.permissions.install"; + let empty = json!({"objects": []}); + let data = |perms: &str| vectorcraft_format::base64_encode(&asking(id, perms, &empty)); + let reads = data(r#"["doc.read"]"#); + let inspected = run(&mut s, "plugin.inspect", json!({"dataBase64": reads})); + assert_eq!((&inspected["needsApproval"], &inspected["permissions"]), (&json!(true), &json!(["doc.read"]))); + let hash = vectorcraft_plugins::permissions::sha256_hex(&asking(id, r#"["doc.read"]"#, &empty)); + assert_eq!(inspected["hash"], hash); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "inspecting installs nothing"); + // Refused without a grant that holds every permission it lists, naming them. + for (grant, says) in [ + (Value::Null, "asks for the permissions doc.read"), + (json!([]), "asks for the permissions doc.read"), + (json!(["doc.write"]), "asks for the permissions doc.read"), + (json!(["doc.read", "net.fetch"]), "unknown permission `net.fetch`"), + (json!("doc.read"), "`grant` must be a list"), + (json!([1]), "`grant` must be a list"), + ] { + let e = s.execute("plugin.install", &json!({"dataBase64": reads, "grant": grant})).unwrap_err().to_string(); + assert!(e.contains(says), "grant {grant}: {e}"); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "installed with grant {grant}"); + } + // `hash` pins the module the user was shown. + for wrong in [json!("0".repeat(64)), json!(1)] { + let e = s.execute("plugin.install", &json!({"dataBase64": reads, "grant": ["doc.read"], "hash": wrong})).unwrap_err().to_string(); + assert!(e.contains("SHA-256 `hash`"), "hash {wrong}: {e}"); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "installed with hash {wrong}"); + } + let installed = run(&mut s, "plugin.install", json!({"dataBase64": reads, "grant": ["doc.read"], "hash": hash})); + assert_eq!(installed["permissions"], json!(["doc.read"])); + let g = vectorcraft_plugins::registry::granted(id).unwrap(); + assert_eq!((g.hash.as_str(), g.permissions.as_slice()), (hash.as_str(), &[vectorcraft_plugins::permissions::Permission::DocRead][..])); + // Replaced with another module that lists the same permissions: no new grant needed, and the + // grant now records the new module's hash. + let other = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null})); + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&other)}))["needsApproval"], false); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other)})); + assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, vectorcraft_plugins::permissions::sha256_hex(&other)); + // One that asks for more is refused until it is granted all of them. + let more = data(r#"["doc.read","doc.write"]"#); + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": more}))["needsApproval"], true); + let e = s.execute("plugin.install", &json!({"dataBase64": more})).unwrap_err().to_string(); + assert!(e.contains("doc.read, doc.write") && e.contains("`grant`"), "{e}"); + assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().manifest().permissions().len(), 1, "the one installed stays"); + run(&mut s, "plugin.install", json!({"dataBase64": more, "grant": ["doc.write", "doc.read", "state"]})); + assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().permissions.len(), 2, "only what it lists is recorded"); + // Removed, it is asked again. + run(&mut s, "plugin.remove", json!({"id": id})); + assert!(vectorcraft_plugins::registry::granted(id).is_none()); + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": reads}))["needsApproval"], true); + // A plug-in without `permissions` installs as before, with its kind's. + let plain = install(&mut s, DESATURATE); + assert_eq!(plain["permissions"], json!(["doc.read", "doc.write", "state"])); + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(DESATURATE)}))["needsApproval"], false); +} + +/// The host enforces what a filter was granted: without `doc.write` an output that changes the +/// document is refused and nothing changes, without `doc.read` it is given no objects, and without +/// `state` it is given no data kept with the document and may not write any. +#[test] +fn a_filter_does_only_what_its_permissions_allow() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let filter = |s: &mut Session, pid: &str| s.execute("plugin.run", &json!({"id": pid, "ids": [id.0]})); + let grant = |s: &mut Session, wasm: &[u8], perms: Value| { + run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm), "grant": perms})); + }; + // Denied `doc.write`: renaming the object is refused, and the document and history stay. + let reader = "test.engine.permissions.reader"; + grant(&mut s, &asking(reader, r#"["doc.read"]"#, &json!({"objects": [{"id": id.0, "name": "Changed"}]})), json!(["doc.read"])); + let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let e = filter(&mut s, reader).unwrap_err().to_string(); + assert!(e.contains("wasn't granted `doc.write`"), "{e}"); + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + // Deleting it (leaving it out of the output) and adding objects are changes too. + for out in [json!({"objects": []}), json!({"objects": [{"id": id.0}, {"path": triangle()}]})] { + grant(&mut s, &asking(reader, r#"["doc.read"]"#, &out), json!(["doc.read"])); + assert!(filter(&mut s, reader).is_err(), "{out}"); + assert_eq!(s.doc().unwrap().doc, before, "{out}"); + } + // Returning the object unchanged changes nothing, so it may. + grant(&mut s, &asking(reader, r#"["doc.read"]"#, &json!({"objects": [{"id": id.0}]})), json!(["doc.read"])); + filter(&mut s, reader).unwrap(); + assert_eq!(s.doc().unwrap().doc.layers, before.layers); + // Granted `doc.write` alone: it is given no objects, so its triangle is added beside the + // rectangle rather than replacing it. + let writer = "test.engine.permissions.writer"; + let manifest = format!(r#"{{"id":"{writer}","name":"W","kind":"filter","permissions":["doc.write"]}}"#); + let added = json!({"objects": [{"path": triangle(), "name": "Given nothing"}]}); + let src = tpl::when_given(&manifest, tpl::Given::Input, r#"{"objects":[]}"#, &added.to_string()); + grant(&mut s, &wat::parse_str(src).unwrap(), json!(["doc.write"])); + let made = filter(&mut s, writer).unwrap(); + let new = NodeId(made["ids"][0].as_u64().unwrap()); + assert_eq!(node(&s, new).name.as_deref(), Some("Given nothing")); + assert!(s.doc().unwrap().doc.node(id).is_some(), "the rectangle it wasn't given stays"); + // Without `state`: kept data is not handed over, and writing some is refused. + run( + &mut s, + "plugin.install", + json!({"dataBase64": vectorcraft_format::base64_encode(&asking("test.engine.permissions.keeper", "null", &json!({"objects": [{"id": id.0}], "documentData": {"pen": 1}})))}), + ); + filter(&mut s, "test.engine.permissions.keeper").unwrap(); + let stateless = "test.engine.permissions.keeper"; + let manifest = format!(r#"{{"id":"{stateless}","name":"K","kind":"filter","permissions":["doc.read","doc.write"]}}"#); + let src = + tpl::when_given(&manifest, tpl::Given::Params, r#""documentData":null"#, &json!({"objects": [{"id": id.0, "name": "No data"}]}).to_string()); + grant(&mut s, &wat::parse_str(src).unwrap(), json!(["doc.read", "doc.write"])); + filter(&mut s, stateless).unwrap(); + assert_eq!(node(&s, id).name.as_deref(), Some("No data"), "given no kept data"); + let before = s.doc().unwrap().doc.clone(); + grant( + &mut s, + &asking(stateless, r#"["doc.read","doc.write"]"#, &json!({"objects": [{"id": id.0}], "documentData": {"pen": 2}})), + json!(["doc.read", "doc.write"]), + ); + let e = filter(&mut s, stateless).unwrap_err().to_string(); + assert!(e.contains("wasn't granted `state`"), "{e}"); + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().doc.plugin_data(stateless), Some(&json!({"pen": 1}))); +} + +/// What the user granted is kept in the plug-in store with the module: the next session installs +/// the plug-in again without asking. A stored plug-in that lists permissions nobody granted it is +/// skipped, even when another store granted it, and so is one in the Additional Plug-ins Folder +/// until it is granted on its own. +#[test] +fn granted_permissions_are_kept_and_folders_skip_the_rest() { + let folder = crate::tests_links::Folder::new("plugin-store-grants"); + let id = "test.engine.permissions.stored"; + let wasm = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]})); + assert!(folder.0.join("grants.json").is_file()); + vectorcraft_plugins::registry::remove(id); + vectorcraft_plugins::registry::revoke(id); + let mut next = session(); + next.attach_plugin_store(folder.0.clone()); + assert!(vectorcraft_plugins::registry::get(id).is_some(), "installed again from the store, as granted"); + // Only a store's own grants count: the same module in another store without a grant is + // skipped, and a grant given elsewhere is not written to that store. + let other = crate::tests_links::Folder::new("plugin-store-grants-other"); + std::fs::write(other.0.join("copy.wasm"), &wasm).unwrap(); + vectorcraft_plugins::registry::remove(id); + let mut elsewhere = session(); + elsewhere.attach_plugin_store(other.0.clone()); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "granted in another store only"); + let e = elsewhere.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm)})).unwrap_err().to_string(); + assert!(e.contains("`grant`"), "a grant kept in another store doesn't count here: {e}"); + let lone = "test.engine.permissions.lone"; + let lone_wasm = asking(lone, r#"["doc.read"]"#, &json!({"objects": []})); + run(&mut elsewhere, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&lone_wasm), "grant": ["doc.read"]})); + let kept = std::fs::read_to_string(other.0.join("grants.json")).unwrap(); + assert!(kept.contains(lone) && !kept.contains(id), "{kept}"); + run(&mut elsewhere, "plugin.remove", json!({"id": lone})); + assert!(!other.0.join("grants.json").exists(), "the last grant removed takes the file with it"); + // Without the grant it is skipped, and the file stays. + vectorcraft_plugins::registry::remove(id); + vectorcraft_plugins::registry::revoke(id); + std::fs::remove_file(folder.0.join("grants.json")).unwrap(); + let mut third = session(); + third.attach_plugin_store(folder.0.clone()); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "nobody granted it"); + assert_eq!(std::fs::read_dir(&folder.0).unwrap().count(), 1, "the module stays in the store"); + + // The Additional Plug-ins Folder skips it with a note until it is granted. + let dir = crate::tests_links::Folder::new("plugin-folder-grants"); + std::fs::write(dir.0.join("asks.wasm"), &wasm).unwrap(); + let r = run(&mut third, "plugin.reload", json!({"path": dir.0.display().to_string()})); + assert_eq!(r["loaded"], json!([])); + let why = r["failed"][0]["error"].as_str().unwrap(); + assert!(why.contains("needs approval") && why.contains("doc.read"), "{why}"); + run(&mut third, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]})); + let r = run(&mut third, "plugin.reload", json!({"path": dir.0.display().to_string()})); + assert_eq!(r["loaded"], json!([id])); + run(&mut third, "plugin.remove", json!({"id": id})); + // With a store attached, only that store's grants load it from the folder: a grant given in a + // session without a store doesn't count. + let mut storeless = session(); + run(&mut storeless, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]})); + vectorcraft_plugins::registry::remove(id); + assert!(vectorcraft_plugins::registry::granted(id).is_some(), "granted in the process"); + let empty = crate::tests_links::Folder::new("plugin-store-grants-empty"); + let mut scoped = session(); + scoped.attach_plugin_store(empty.0.clone()); + let r = run(&mut scoped, "plugin.reload", json!({"path": dir.0.display().to_string()})); + vectorcraft_plugins::registry::revoke(id); + assert_eq!(r["loaded"], json!([]), "a grant outside this store doesn't load it"); + let why = r["failed"][0]["error"].as_str().unwrap(); + assert!(why.contains("needs approval"), "{why}"); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "not installed"); + // The preference's folder is loaded again for a session whose store holds other grants. + let pref_dir = crate::tests_links::Folder::new("plugin-folder-pref-grants"); + std::fs::write(pref_dir.0.join("asks.wasm"), &wasm).unwrap(); + let pref = json!({"key": "pluginsFolder", "value": pref_dir.0.display().to_string()}); + run(&mut scoped, "prefs.set", pref.clone()); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "nothing granted in this store"); + let granting = crate::tests_links::Folder::new("plugin-store-grants-pref"); + let mut granted = session(); + granted.attach_plugin_store(granting.0.clone()); + run(&mut granted, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]})); + vectorcraft_plugins::registry::remove(id); + vectorcraft_plugins::registry::revoke(id); + run(&mut granted, "prefs.set", pref); + assert!(vectorcraft_plugins::registry::get(id).is_some(), "loaded under this store's grant"); + run(&mut granted, "plugin.remove", json!({"id": id})); +} + /// The user agreed once: a heavy filter kept in the plug-in store is installed again at the next /// start without asking, with its budget. #[test] diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index ac982a20e..1f29627e0 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -268,4 +268,56 @@ proptest! { } renders(&s, "after mutated modules")?; } + + /// Manifests with hostile `permissions`, `api` and names (unknown, reserved and repeated + /// permissions, junk of every type, control and bidirectional override characters) parse to + /// an error or to a manifest whose permissions fit its kind, and installing one with a junk + /// `grant` ends as an error or an install, never a panic. A prompt never shows a hidden + /// character from the name. + #[test] + fn hostile_manifest_permissions_never_crash( + kind in prop::sample::select(vec!["filter", "effect", "exporter"]), + names in prop::collection::vec( + prop_oneof![ + prop::sample::select(vec!["doc.read", "doc.write", "state", "effect.render", "export.write", "progress", "ui.panel", ""]), + Just("doc.read\u{202E}"), + Just("\u{0007}"), + ], + 0..6, + ), + permissions in prop::option::weighted(0.8, prop_oneof![Just(Value::Null), arb_junk()]), + api in prop_oneof![3 => Just(json!(1)), 1 => arb_junk()], + name in prop_oneof![3 => "[a-z\u{202E}\u{2066}\u{200F} ]{1,64}", 1 => "[a-z\u{202E}\u{2066}\u{0007}\u{200F} ]{1,80}"], + grant in prop_oneof![Just(Value::Null), arb_junk(), Just(json!(["doc.read", "doc.write", "state", "effect.render", "export.write"]))], + ) { + let mut m = json!({"id": "fuzz.permissions", "name": name, "kind": kind, "api": api}); + if kind == "exporter" { + m["extensions"] = json!(["fz"]); + } + m["permissions"] = match permissions { + Some(Value::Null) | None => json!(names), + Some(junk) => junk, + }; + let text = m.to_string(); + let parsed = catch_quiet(|| vectorcraft_plugins::Manifest::parse(text.as_bytes())); + let parsed = parsed.map_err(|msg| TestCaseError::fail(format!("panicked on {text}: {msg}")))?; + if let Ok(man) = &parsed { + let implied = vectorcraft_plugins::permissions::Permission::implied(man.kind); + prop_assert!(man.permissions().iter().all(|p| implied.contains(p)), "{}", text); + let shown = vectorcraft_plugins::permissions::display_text(&man.name, vectorcraft_plugins::permissions::MAX_SHOWN_NAME); + prop_assert!(!shown.chars().any(|c| c.is_control() || matches!(c, '\u{202E}' | '\u{2066}' | '\u{200F}')), "{}", shown); + prop_assert!(shown.chars().count() <= vectorcraft_plugins::permissions::MAX_SHOWN_NAME); + } + let mut s = rich_session(); + let module = wat::parse_str(tpl::returning(&text, r#"{"objects":[]}"#)).unwrap(); + let data = vectorcraft_format::base64_encode(&module); + let r = catch_quiet(|| { + let _ = s.execute("plugin.inspect", &json!({"dataBase64": data})); + s.execute("plugin.install", &json!({"dataBase64": data, "grant": grant})).is_ok() + }); + let installed = r.map_err(|msg| TestCaseError::fail(format!("panicked installing {text}: {msg}")))?; + prop_assert!(!installed || parsed.is_ok(), "a manifest that doesn't parse installed: {}", text); + vectorcraft_plugins::registry::remove("fuzz.permissions"); + vectorcraft_plugins::registry::revoke("fuzz.permissions"); + } } diff --git a/crates/plugins/Cargo.toml b/crates/plugins/Cargo.toml index 1fb4bd77a..af9212c90 100644 --- a/crates/plugins/Cargo.toml +++ b/crates/plugins/Cargo.toml @@ -14,6 +14,8 @@ vectorcraft-color = { workspace = true } vectorcraft-doc = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +# The SHA-256 of a module, which the install prompt shows and a grant records. +sha2 = { version = "0.10", default-features = false } thiserror = { workspace = true } wasmi = { workspace = true } diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs index 061ef506c..9e6f9e934 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -21,7 +21,8 @@ //! time it is drawn, from an appearance stack; an **exporter** ([`export`]) writes the whole //! document in a file format of its own. The process-wide [`registry`] holds the installed //! plug-ins. A plug-in's parameters ([`manifest`]) come with what the host's dialogs need to show -//! them well: labels, units, groups, help and conditions ([`dialog`]). +//! them well: labels, units, groups, help and conditions ([`dialog`]). What a plug-in may do is +//! the set of [`permissions`] its manifest lists, which the user approves when it is installed. #![forbid(unsafe_code)] pub mod dialog; @@ -29,6 +30,7 @@ pub mod effect; pub mod export; pub mod manifest; pub mod objects; +pub mod permissions; pub mod registry; mod runtime; pub mod wat; diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index 18ffeb335..75a76cdbb 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; use crate::dialog::ParamUi; +use crate::permissions::{self, API_LEVEL, Permission}; use crate::{Error, Result}; /// Longest manifest the host reads. @@ -154,6 +155,12 @@ fn text_max() -> usize { fn list_max() -> usize { 16 } +fn first_api() -> u32 { + 1 +} +fn is_first_api(api: &u32) -> bool { + *api == 1 +} /// One declared parameter: what it holds, and how dialogs show it. In the manifest both are keys of /// the same object: `{"type": "number", "min": 0, "max": 10, "default": 1, "unit": "mm"}`. @@ -236,6 +243,13 @@ pub struct Manifest { pub description: String, #[serde(default)] pub author: String, + /// The plug-in API level it was built for, 1 to [`API_LEVEL`] (1 when left out). + #[serde(default = "first_api", skip_serializing_if = "is_first_api")] + pub api: u32, + /// The permissions it asks for ([`crate::permissions`]), which the user approves when it is + /// installed. Left out, it gets the ones its kind implies ([`Permission::implied`]). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permissions: Option<Vec<String>>, /// Parameters in declaration (dialog) order; a JSON object `{"name": spec, …}` in the manifest. #[serde(default, deserialize_with = "ordered_params", serialize_with = "params_as_object")] pub params: Vec<(String, Param)>, @@ -269,6 +283,8 @@ impl Manifest { kind: Kind::Filter, description: String::new(), author: String::new(), + api: 1, + permissions: None, params: Vec::new(), extensions: Vec::new(), budget: Budget::Light, @@ -288,6 +304,12 @@ impl Manifest { if self.version.len() > 32 || self.description.len() > 1024 || self.author.len() > 128 { return Err(Error::Manifest("`version`, `description` or `author` is too long".into())); } + if !(1..=API_LEVEL).contains(&self.api) { + return Err(Error::Manifest(format!("`api` is {}; this host implements plug-in API levels 1 to {API_LEVEL}", self.api))); + } + if let Some(names) = &self.permissions { + permissions::validate(self.kind, names)?; + } self.validate_extensions()?; match (self.budget, self.kind) { (Budget::Light, _) | (Budget::Heavy, Kind::Filter) => {} @@ -363,6 +385,19 @@ impl Manifest { self.params.iter().map(|(k, p)| (k.clone(), p.spec.default_value())).collect() } + /// What the plug-in may do: the permissions it lists, else the ones its kind implies. + pub fn permissions(&self) -> Vec<Permission> { + match &self.permissions { + Some(names) => permissions::validate(self.kind, names).unwrap_or_default(), + None => Permission::implied(self.kind).to_vec(), + } + } + + /// Does installing it need the user's approval: does it list permissions? + pub fn asks_permissions(&self) -> bool { + self.permissions.as_ref().is_some_and(|p| !p.is_empty()) + } + /// Validates user `params` against the schema: missing keys take their defaults, numbers are /// clamped into range (whole numbers rounded), unknown keys are dropped; a value of the wrong /// type is an error. @@ -595,6 +630,39 @@ mod tests { } } + /// A manifest lists the permissions it asks for, checked against its kind; without the key it + /// gets the ones its kind implies, as plug-ins did before manifests listed them, and needs no + /// approval. `api` is 1 when left out, and a later level is refused. + #[test] + fn permissions_and_api_level() { + let listed = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","api":1,"permissions":["doc.write","doc.read"]}"#).unwrap(); + assert_eq!(listed.permissions(), [Permission::DocRead, Permission::DocWrite]); + assert!(listed.asks_permissions()); + assert_eq!(serde_json::to_value(&listed).unwrap()["permissions"], json!(["doc.write", "doc.read"])); + assert!(serde_json::to_value(&listed).unwrap().get("api").is_none(), "level 1 isn't written back"); + let plain = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter"}"#).unwrap(); + assert_eq!((plain.api, plain.asks_permissions()), (1, false)); + assert_eq!(plain.permissions(), [Permission::DocRead, Permission::DocWrite, Permission::State]); + assert!(serde_json::to_value(&plain).unwrap().get("permissions").is_none()); + let effect = Manifest::parse(br#"{"id":"a","name":"x","kind":"effect"}"#).unwrap(); + assert_eq!(effect.permissions(), [Permission::DocRead, Permission::EffectRender]); + let exporter = Manifest::parse(br#"{"id":"a","name":"x","kind":"exporter","extensions":["plt"]}"#).unwrap(); + assert_eq!(exporter.permissions(), [Permission::DocRead, Permission::ExportWrite]); + assert!(!Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","permissions":[]}"#).unwrap().asks_permissions()); + for (bad, says) in [ + (r#"{"id":"a","name":"x","kind":"filter","permissions":["doc.read","net.fetch"]}"#, "unknown permission `net.fetch`"), + (r#"{"id":"a","name":"x","kind":"filter","permissions":["ui.panel"]}"#, "reserved"), + (r#"{"id":"a","name":"x","kind":"effect","permissions":["doc.read"]}"#, "effect.render"), + (r#"{"id":"a","name":"x","kind":"filter","permissions":"doc.read"}"#, "manifest"), + (r#"{"id":"a","name":"x","kind":"filter","api":2}"#, "`api` is 2"), + (r#"{"id":"a","name":"x","kind":"filter","api":0}"#, "`api` is 0"), + (r#"{"id":"a","name":"x","kind":"filter","api":-1}"#, "manifest"), + ] { + let e = Manifest::parse(bad.as_bytes()).unwrap_err().to_string(); + assert!(e.contains(says), "{bad}: {e}"); + } + } + #[test] fn strings_and_number_lists_resolve_like_the_other_types() { let m = Manifest::parse( diff --git a/crates/plugins/src/permissions.rs b/crates/plugins/src/permissions.rs new file mode 100644 index 000000000..1d6b0664e --- /dev/null +++ b/crates/plugins/src/permissions.rs @@ -0,0 +1,266 @@ +//! What a plug-in may do: the permissions its manifest lists (`permissions`), which the user +//! approves when it is installed ([`Grant`], kept in [`crate::registry`]), and which the host +//! enforces on every run. +//! +//! `doc.read`, `doc.write`, `state` and the reserved `progress` are the names of the plug-in +//! capability model in storytold/photocraft#662; the other names are VectorCraft's own. A manifest +//! without `permissions` gets the ones its kind implies ([`Permission::implied`]): what every +//! plug-in of that kind could do before manifests listed them. + +use serde::{Deserialize, Serialize}; + +use crate::{Error, Kind, Result}; + +/// The plug-in API level this host implements. A manifest's `api` (1 when left out) may be at most +/// this. +pub const API_LEVEL: u32 = 1; + +/// Most permissions a manifest may list. +pub const MAX_PERMISSIONS: usize = 16; + +/// Longest plug-in name a prompt shows, in characters. +pub const MAX_SHOWN_NAME: usize = 64; + +/// Longest version or id a prompt shows, in characters. +pub const MAX_SHOWN_FIELD: usize = 64; + +/// Names kept for host calls a later plug-in API adds. This host grants none of them, so a +/// manifest that lists one is refused when it is installed. +pub const RESERVED: &[&str] = &["progress", "ui.panel", "ui.tool"]; + +/// One thing a plug-in may do. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum Permission { + /// Read the objects the host hands it: a filter's selected paths, the object a live effect is + /// on, the document an exporter writes. + #[serde(rename = "doc.read")] + DocRead, + /// Change, add and delete objects (a filter's output), as one undo step. + #[serde(rename = "doc.write")] + DocWrite, + /// Read and write the data it keeps with the document (a filter's `documentData`). + #[serde(rename = "state")] + State, + /// Change how the objects its live effect is applied to are drawn. + #[serde(rename = "effect.render")] + EffectRender, + /// Write the document as a file in its own format (an exporter). + #[serde(rename = "export.write")] + ExportWrite, +} + +/// Every permission this host grants, in the order prompts list them. +pub const ALL: [Permission; 5] = [Permission::DocRead, Permission::DocWrite, Permission::State, Permission::EffectRender, Permission::ExportWrite]; + +impl Permission { + /// Its name in a manifest and in `plugin.install`'s `grant`. + pub fn name(self) -> &'static str { + match self { + Permission::DocRead => "doc.read", + Permission::DocWrite => "doc.write", + Permission::State => "state", + Permission::EffectRender => "effect.render", + Permission::ExportWrite => "export.write", + } + } + + /// The permission named `name`. + pub fn parse(name: &str) -> Option<Permission> { + ALL.into_iter().find(|p| p.name() == name) + } + + /// What it lets a plug-in do, in plain words (English; the app shows its own translation). + pub fn describe(self) -> &'static str { + match self { + Permission::DocRead => "Read the objects it is given", + Permission::DocWrite => "Change, add and delete objects in the document", + Permission::State => "Keep data of its own with the document", + Permission::EffectRender => "Change how the objects its effect is applied to are drawn", + Permission::ExportWrite => "Write the document as a file in its own format", + } + } + + /// The permissions a plug-in of `kind` may list, which are also the ones a manifest without + /// `permissions` gets. + pub fn implied(kind: Kind) -> &'static [Permission] { + match kind { + Kind::Filter => &[Permission::DocRead, Permission::DocWrite, Permission::State], + Kind::Effect => &[Permission::DocRead, Permission::EffectRender], + Kind::Exporter => &[Permission::DocRead, Permission::ExportWrite], + } + } + + /// The permissions a plug-in of `kind` can't work without, which its list must hold: a live + /// effect and an exporter are always handed objects, and only draw or write. A filter may + /// leave any of its permissions out. + fn required(kind: Kind) -> &'static [Permission] { + match kind { + Kind::Filter => &[], + Kind::Effect | Kind::Exporter => Permission::implied(kind), + } + } +} + +/// Checks the `permissions` a manifest of `kind` lists → them, in the order [`ALL`] has. A name +/// that is not a permission, a reserved one, one that doesn't apply to the kind, one listed twice +/// and a list without what the kind needs are errors that name it. +pub(crate) fn validate(kind: Kind, names: &[String]) -> Result<Vec<Permission>> { + if names.len() > MAX_PERMISSIONS { + return Err(Error::Manifest(format!("at most {MAX_PERMISSIONS} `permissions`"))); + } + let mut out: Vec<Permission> = Vec::with_capacity(names.len()); + for name in names { + let shown = display_text(name, MAX_SHOWN_FIELD); + let Some(p) = Permission::parse(name) else { + if RESERVED.contains(&name.as_str()) { + return Err(Error::Manifest(format!("permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it"))); + } + return Err(Error::Manifest(format!("unknown permission `{shown}`"))); + }; + if !Permission::implied(kind).contains(&p) { + return Err(Error::Manifest(format!("permission `{shown}` doesn't apply to a plug-in of kind `{}`", kind.name()))); + } + if out.contains(&p) { + return Err(Error::Manifest(format!("permission `{shown}` is listed twice"))); + } + out.push(p); + } + if let Some(missing) = Permission::required(kind).iter().find(|p| !out.contains(p)) { + return Err(Error::Manifest(format!("a plug-in of kind `{}` needs the permission `{}`", kind.name(), missing.name()))); + } + out.sort(); + Ok(out) +} + +/// The names of `permissions`, as a manifest and `grant` list them. +pub fn names(permissions: &[Permission]) -> Vec<&'static str> { + permissions.iter().map(|p| p.name()).collect() +} + +/// Is `c` a character that changes the direction text is laid out in, or a line or paragraph +/// separator, which a prompt must not let a manifest string use to disguise itself? +fn is_bidi_or_separator(c: char) -> bool { + matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' | '\u{2028}' | '\u{2029}') +} + +/// `s` as a prompt shows a string from a manifest: control, bidirectional formatting and line +/// separator characters written as `\u{202E}` escapes, and at most `max` characters, the last of +/// them `…` when it was cut. +pub fn display_text(s: &str, max: usize) -> String { + use std::fmt::Write as _; + let mut out = String::new(); + // Characters written so far, and the byte length of the longest prefix of whole pieces + // shorter than `max` characters, where the ellipsis goes when the text is cut. + let mut count = 0; + let mut cut = 0; + for c in s.chars() { + let start = out.len(); + let n = if c.is_control() || is_bidi_or_separator(c) { + let _ = write!(out, "\\u{{{:04X}}}", c as u32); + out.len() - start + } else { + out.push(c); + 1 + }; + if count + n > max { + out.truncate(cut); + out.push('…'); + return out; + } + count += n; + if count < max { + cut = out.len(); + } + } + out +} + +/// The SHA-256 of `bytes`, in lower-case hexadecimal. +pub fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect() +} + +/// What the user approved for a plug-in: its id, the SHA-256 of the module they approved +/// ([`sha256_hex`]) and the permissions it was granted. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Grant { + pub id: String, + pub hash: String, + pub permissions: Vec<Permission>, +} + +impl Grant { + /// Does it grant every one of `wanted`? + pub fn covers(&self, wanted: &[Permission]) -> bool { + wanted.iter().all(|w| self.permissions.contains(w)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn names_round_trip_and_serialize_as_names() { + for p in ALL { + assert_eq!(Permission::parse(p.name()), Some(p)); + assert_eq!(serde_json::to_value(p).unwrap(), serde_json::json!(p.name())); + } + assert_eq!(Permission::parse("document.read"), None); + assert!(RESERVED.iter().all(|r| Permission::parse(r).is_none()), "reserved names are not granted"); + } + + #[test] + fn lists_are_checked_against_the_kind() { + let list = |names: &[&str]| names.iter().map(|s| s.to_string()).collect::<Vec<_>>(); + assert_eq!(validate(Kind::Filter, &list(&["doc.write", "doc.read"])).unwrap(), [Permission::DocRead, Permission::DocWrite]); + assert_eq!(validate(Kind::Filter, &[]).unwrap(), []); + assert!(validate(Kind::Effect, &list(&["doc.read", "effect.render"])).is_ok()); + assert!(validate(Kind::Exporter, &list(&["export.write", "doc.read"])).is_ok()); + for (kind, names, says) in [ + (Kind::Filter, &["doc.read", "clipboard"][..], "unknown permission `clipboard`"), + (Kind::Filter, &["progress"], "`progress` is reserved"), + (Kind::Filter, &["export.write"], "doesn't apply to a plug-in of kind `filter`"), + (Kind::Exporter, &["doc.read", "export.write", "doc.read"], "`doc.read` is listed twice"), + (Kind::Effect, &["doc.read"], "needs the permission `effect.render`"), + (Kind::Exporter, &["export.write"], "needs the permission `doc.read`"), + ] { + let e = validate(kind, &list(names)).unwrap_err().to_string(); + assert!(e.contains(says), "{names:?}: {e}"); + } + let many = list(&["doc.read"; MAX_PERMISSIONS + 1]); + assert!(validate(Kind::Filter, &many).unwrap_err().to_string().contains("at most")); + } + + /// Control, bidirectional override and separator characters show as escapes, so a name can't + /// reverse or hide the text after it; long text is cut with an ellipsis. + #[test] + fn prompt_text_escapes_hidden_characters_and_is_capped() { + assert_eq!(display_text("Tidy\u{202E}fdp.exe", 64), "Tidy\\u{202E}fdp.exe"); + assert_eq!(display_text("a\u{0007}b\nc\u{2066}d\u{200F}", 64), "a\\u{0007}b\\u{000A}c\\u{2066}d\\u{200F}"); + assert_eq!(display_text("plain", 64), "plain"); + assert_eq!(display_text("abcdef", 6), "abcdef"); + assert_eq!(display_text("abcdefg", 6), "abcde…"); + assert_eq!(display_text(&"x".repeat(500), MAX_SHOWN_NAME).chars().count(), MAX_SHOWN_NAME); + assert_eq!(display_text("ab\u{202E}", 6), "ab…", "an escape that doesn't fit is cut whole"); + assert_eq!(display_text("ab\u{0007}", 10), "ab\\u{0007}", "an escape that ends exactly at the cap is kept"); + let long = display_text(&"\u{202E}y".repeat(500_000), MAX_SHOWN_NAME); + assert_eq!(long.chars().count(), MAX_SHOWN_NAME); + assert!(long.starts_with("\\u{202E}y\\u{202E}y") && long.ends_with('…'), "{long}"); + } + + #[test] + fn hashes_are_sha256_in_hex() { + assert_eq!(sha256_hex(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + } + + #[test] + fn a_grant_covers_its_permissions_and_no_more() { + let g = Grant { id: "a".into(), hash: String::new(), permissions: vec![Permission::DocRead, Permission::DocWrite] }; + assert!(g.covers(&[Permission::DocRead])); + assert!(g.covers(&[])); + assert!(!g.covers(&[Permission::State])); + assert!(!g.covers(&[Permission::DocRead, Permission::State])); + } +} diff --git a/crates/plugins/src/registry.rs b/crates/plugins/src/registry.rs index caf63eb54..0302ab6be 100644 --- a/crates/plugins/src/registry.rs +++ b/crates/plugins/src/registry.rs @@ -9,10 +9,41 @@ use std::sync::{Arc, Mutex, RwLock}; #[cfg(not(target_arch = "wasm32"))] use crate::Error; +use crate::permissions::Grant; use crate::{Limits, Plugin, Result}; static PLUGINS: RwLock<BTreeMap<String, Arc<Plugin>>> = RwLock::new(BTreeMap::new()); static REVISION: AtomicU64 = AtomicU64::new(0); +/// What the user approved, by plug-in id. +static GRANTS: RwLock<BTreeMap<String, Grant>> = RwLock::new(BTreeMap::new()); + +/// Records what the user approved for a plug-in, replacing an earlier grant for its id. +pub fn grant(g: Grant) { + GRANTS.write().unwrap_or_else(|e| e.into_inner()).insert(g.id.clone(), g); +} + +/// What the user approved for plug-in `id`. +pub fn granted(id: &str) -> Option<Grant> { + GRANTS.read().unwrap_or_else(|e| e.into_inner()).get(id).cloned() +} + +/// Every grant, sorted by id. +pub fn grants() -> Vec<Grant> { + GRANTS.read().unwrap_or_else(|e| e.into_inner()).values().cloned().collect() +} + +/// Forgets what the user approved for `id`; false if nothing was. +pub fn revoke(id: &str) -> bool { + GRANTS.write().unwrap_or_else(|e| e.into_inner()).remove(id).is_some() +} + +/// May `plugin` be installed without asking the user: does it list no permissions, or did the user +/// already grant its id every permission it lists? A module with other bytes but no new permission +/// needs no new approval. +pub fn approved(plugin: &Plugin) -> bool { + let m = plugin.manifest(); + !m.asks_permissions() || granted(&m.id).is_some_and(|g| g.covers(&m.permissions())) +} /// Installs `plugin`, replacing one with the same id. pub fn install(mut plugin: Plugin) -> Arc<Plugin> { @@ -93,16 +124,21 @@ pub struct FolderReport { /// Installs every `*.wasm` directly in `dir` (not recursive), in file-name order. A bad module is /// reported and skipped, the others still load; so is a heavy one ([`crate::Budget::Heavy`]), -/// which installs only once the user agrees. +/// which installs only once the user agrees, and one that lists permissions the user has not +/// granted its id ([`approved`]). #[cfg(not(target_arch = "wasm32"))] pub fn load_folder(dir: &std::path::Path) -> Result<FolderReport> { - load_folder_where(dir, |_| Ok(())) + load_folder_where(dir, |_| Ok(()), approved) } -/// [`load_folder`], loading only the files `allow` accepts; the others are reported as failed -/// with its reason. +/// [`load_folder`], loading only the files `allow` accepts, and of the plug-ins that list +/// permissions only those `approve` accepts; the others are reported as failed with the reason. #[cfg(not(target_arch = "wasm32"))] -pub fn load_folder_where(dir: &std::path::Path, allow: impl Fn(&std::path::Path) -> std::result::Result<(), String>) -> Result<FolderReport> { +pub fn load_folder_where( + dir: &std::path::Path, + allow: impl Fn(&std::path::Path) -> std::result::Result<(), String>, + approve: impl Fn(&Plugin) -> bool, +) -> Result<FolderReport> { let rd = std::fs::read_dir(dir).map_err(|e| Error::Io(format!("{}: {e}", dir.display())))?; let mut files: Vec<std::path::PathBuf> = rd.filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.extension().is_some_and(|x| x.eq_ignore_ascii_case("wasm"))).collect(); @@ -119,6 +155,13 @@ pub fn load_folder_where(dir: &std::path::Path, allow: impl Fn(&std::path::Path) Ok(p) if p.manifest().budget == crate::Budget::Heavy => { report.failed.push((f.display().to_string(), "asks for a heavy budget, which the user allows when installing it on its own".into())); } + Ok(p) if p.manifest().asks_permissions() && !approve(&p) => { + let asked = crate::permissions::names(&p.manifest().permissions()).join(", "); + report.failed.push(( + f.display().to_string(), + format!("needs approval: it asks for {asked}, which the user grants when installing it on its own"), + )); + } Ok(p) => report.loaded.push(install(p).id().to_string()), Err(e) => report.failed.push((f.display().to_string(), e.to_string())), } diff --git a/crates/plugins/src/runtime.rs b/crates/plugins/src/runtime.rs index 3a72b70d7..f5666fb5b 100644 --- a/crates/plugins/src/runtime.rs +++ b/crates/plugins/src/runtime.rs @@ -85,6 +85,7 @@ pub struct Plugin { module: Module, limits: Limits, size: usize, + hash: String, source: Option<String>, serial: u64, } @@ -174,7 +175,8 @@ impl Plugin { imp.name() ))); } - let mut p = Plugin { manifest: Manifest::placeholder(), engine, module, limits, size: bytes.len(), source: None, serial: 0 }; + let hash = crate::permissions::sha256_hex(bytes); + let mut p = Plugin { manifest: Manifest::placeholder(), engine, module, limits, size: bytes.len(), hash, source: None, serial: 0 }; let deadline = Deadline::new(&p.limits); let mut live = p.instantiate()?; let version: TypedFunc<(), i32> = live.func("vc_abi_version")?; @@ -214,6 +216,10 @@ impl Plugin { pub fn size(&self) -> usize { self.size } + /// The module's SHA-256, in lower-case hexadecimal. + pub fn hash(&self) -> &str { + &self.hash + } /// Where the module was loaded from, if it came from a file. pub fn source(&self) -> Option<&str> { self.source.as_deref() diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 8ab9cb9b8..f0f7cef6d 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -592,6 +592,8 @@ impl PluginIntField { kind: vectorcraft_plugins::Kind::Filter, description: String::new(), author: String::new(), + api: 1, + permissions: None, params: vec![("seed".into(), vectorcraft_plugins::ParamSpec::Int { min, max, default: 0 }.into())], extensions: Vec::new(), budget: Default::default(), diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index f6a8debdc..8eaa9a9d5 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5672,6 +5672,16 @@ axis Y Axis Y ос The plug-in is computing… Приставката изчислява… Inspect Plug-in Проверка на приставка Cancel Plug-in Run Отказ от изпълнението на приставката +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Да се инсталира ли „{name}“, който иска тези права? + ID: {id} ID: {id} + Version: {version} Версия: {version} + File SHA-256: {hash} SHA-256 на файла: {hash} + Read the objects it is given Четене на обектите, които получава + Change, add and delete objects in the document Промяна, добавяне и изтриване на обекти в документа + Keep data of its own with the document Съхраняване на собствени данни в документа + Change how the objects its effect is applied to are drawn Промяна на начина, по който се рисуват обектите, към които е приложен ефектът му + Write the document as a file in its own format Записване на документа като файл в собствен формат # Actions panel: action sets (#3712). New Set… Нов набор… New Set Нов набор diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index a8db1ebea..4668b8e29 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5696,6 +5696,28 @@ axis Y Axis Y osa @msg a live effect runs while the document is drawn, so it can't be `heavy` živi efekat radi dok se dokument iscrtava, pa ne može biti `heavy` @msg an exporter always gets an export's budget: leave `budget` out izvoznik uvijek dobija budžet izvoza: izostavite `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}” traži veliki budžet (svako pokretanje do {minutes} minuta i {times} puta više uobičajenih instrukcija): instalirajte ga s `allowHeavy: true` kada korisnik pristane +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Instalirati „{name}”, koji traži ove dozvole? + ID: {id} ID: {id} + Version: {version} Verzija: {version} + File SHA-256: {hash} SHA-256 datoteke: {hash} + Read the objects it is given Čitati objekte koje dobije + Change, add and delete objects in the document Mijenjati, dodavati i brisati objekte u dokumentu + Keep data of its own with the document Čuvati vlastite podatke uz dokument + Change how the objects its effect is applied to are drawn Mijenjati kako se crtaju objekti na koje je primijenjen njegov efekt + Write the document as a file in its own format Zapisati dokument kao datoteku u vlastitom formatu +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` je {_1}; ovaj domaćin podržava nivoe API-ja dodataka od 1 do {API_LEVEL} +@msg `grant` must be a list of permission names `grant` mora biti lista naziva dozvola +@msg `grant` names an unknown permission `{shown}` `grant` navodi nepoznatu dozvolu `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` dodatku vrste `{_1}` potrebna je dozvola `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` najviše {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` dozvola `{shown}` ne odnosi se na dodatak vrste `{_1}` +@msg permission `{shown}` is listed twice dozvola `{shown}` navedena je dvaput +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it dozvola `{shown}` rezervisana je za kasniji API dodataka; ovaj domaćin je ne dodjeljuje +@msg unknown permission `{shown}` nepoznata dozvola `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}” traži dozvole {asked}: instalirajte ga s `grant` koji ih navodi kada korisnik pristane +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}” nije dobio `{p}`, što je potrebno njegovom izlazu: ništa nije promijenjeno +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modul nema SHA-256 naveden u `hash`: ništa nije instalirano New Set… Novi skup… New Set Novi skup Set Options… Opcije skupa… diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 3898a677f..9e2fc7837 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5798,6 +5798,28 @@ digits Malayalam Malaiàlam @msg {status} ({e}) {status} ({e}) @msg {tool} isn't in a toolbar group of several tools {tool} no forma part d’un grup de diverses eines a la barra d’eines @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees «{_1}» demana un pressupost elevat (cada execució fins a {minutes} minuts i {times} vegades les instruccions habituals): instal·la’l amb `allowHeavy: true` quan l’usuari hi estigui d’acord +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Instal·la «{name}», que demana aquests permisos? + ID: {id} ID: {id} + Version: {version} Versió: {version} + File SHA-256: {hash} SHA-256 del fitxer: {hash} + Read the objects it is given Llegir els objectes que rep + Change, add and delete objects in the document Canviar, afegir i suprimir objectes del document + Keep data of its own with the document Desar dades pròpies amb el document + Change how the objects its effect is applied to are drawn Canviar com es dibuixen els objectes als quals s’aplica el seu efecte + Write the document as a file in its own format Escriure el document com un fitxer en un format propi +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` és {_1}; aquest amfitrió admet els nivells d’API de complements de l’1 al {API_LEVEL} +@msg `grant` must be a list of permission names `grant` ha de ser una llista de noms de permisos +@msg `grant` names an unknown permission `{shown}` `grant` esmenta un permís desconegut `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` un complement del tipus `{_1}` necessita el permís `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` com a màxim {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` el permís `{shown}` no s’aplica a un complement del tipus `{_1}` +@msg permission `{shown}` is listed twice el permís `{shown}` apareix dues vegades +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it el permís `{shown}` està reservat per a una API de complements posterior; aquest amfitrió no el concedeix +@msg unknown permission `{shown}` permís desconegut `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» demana els permisos {asked}: instal·la’l amb un `grant` que els enumeri quan l’usuari hi estigui d’acord +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a «{name}» no se li ha concedit `{p}`, que la seva sortida necessita: no s’ha canviat res +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed el mòdul no té el SHA-256 que indica `hash`: no s’ha instal·lat res @msg “{name}” is not a file «{name}» no és un fitxer axis Both Tots dos axis Horizontal Horitzontal diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index eff1d2d34..5975f088a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5788,6 +5788,28 @@ axis Y Axis osa Y @msg a live effect runs while the document is drawn, so it can't be `heavy` živý efekt běží během vykreslování dokumentu, takže nemůže být `heavy` @msg an exporter always gets an export's budget: leave `budget` out exportní zásuvný modul dostává vždy rozpočet exportu: klíč `budget` vynechte @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}“ žádá o velký výpočetní rozpočet (každé spuštění až {minutes} minut a {times}krát víc instrukcí než obvykle): nainstalujte ho s `allowHeavy: true`, jakmile uživatel souhlasí +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Nainstalovat „{name}“, který žádá o tato oprávnění? + ID: {id} ID: {id} + Version: {version} Verze: {version} + File SHA-256: {hash} SHA-256 souboru: {hash} + Read the objects it is given Číst objekty, které dostane + Change, add and delete objects in the document Měnit, přidávat a mazat objekty v dokumentu + Keep data of its own with the document Ukládat vlastní data s dokumentem + Change how the objects its effect is applied to are drawn Měnit, jak se kreslí objekty, na které je použit jeho efekt + Write the document as a file in its own format Zapsat dokument jako soubor ve vlastním formátu +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` je {_1}; tento hostitel podporuje úrovně API zásuvných modulů 1 až {API_LEVEL} +@msg `grant` must be a list of permission names `grant` musí být seznam názvů oprávnění +@msg `grant` names an unknown permission `{shown}` `grant` uvádí neznámé oprávnění `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` zásuvný modul druhu `{_1}` potřebuje oprávnění `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` nejvýše {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` oprávnění `{shown}` se nevztahuje na zásuvný modul druhu `{_1}` +@msg permission `{shown}` is listed twice oprávnění `{shown}` je uvedeno dvakrát +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it oprávnění `{shown}` je vyhrazeno pro pozdější API zásuvných modulů; tento hostitel ho neuděluje +@msg unknown permission `{shown}` neznámé oprávnění `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ žádá o oprávnění {asked}: nainstalujte ho s `grant`, který je uvádí, jakmile uživatel souhlasí +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}“ nedostal `{p}`, které jeho výstup potřebuje: nic se nezměnilo +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modul nemá SHA-256 uvedený v `hash`: nic nebylo nainstalováno # Actions panel: action sets (#3712). New Set… Nová sada… New Set Nová sada diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index abfe581e7..1f4486ba4 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5687,6 +5687,16 @@ axis Y Axis Y-akse The plug-in is computing… Plug-in'en regner… Inspect Plug-in Undersøg plug-in Cancel Plug-in Run Annullér plug-in-kørsel +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Installér »{name}«, som beder om disse rettigheder? + ID: {id} ID: {id} + Version: {version} Version: {version} + File SHA-256: {hash} Filens SHA-256: {hash} + Read the objects it is given Læse de objekter, den får + Change, add and delete objects in the document Ændre, tilføje og slette objekter i dokumentet + Keep data of its own with the document Gemme sine egne data med dokumentet + Change how the objects its effect is applied to are drawn Ændre, hvordan de objekter, dens effekt er anvendt på, tegnes + Write the document as a file in its own format Skrive dokumentet som en fil i sit eget format # Actions panel: action sets (#3712). New Set… Nyt sæt… New Set Nyt sæt diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4a4142d88..2c0c2717b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5370,6 +5370,28 @@ axis Y Axis Y-Achse @msg a live effect runs while the document is drawn, so it can't be `heavy` ein Live-Effekt läuft, während das Dokument gezeichnet wird, daher kann er nicht `heavy` sein @msg an exporter always gets an export's budget: leave `budget` out ein Export-Plug-in erhält immer das Budget eines Exports: Lassen Sie `budget` weg @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}“ verlangt ein großes Rechenbudget (jede Ausführung bis zu {minutes} Minuten und {times}-mal so viele Anweisungen wie üblich): Installieren Sie es mit `allowHeavy: true`, sobald der Benutzer zustimmt +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? „{name}“ installieren, das diese Berechtigungen verlangt? + ID: {id} ID: {id} + Version: {version} Version: {version} + File SHA-256: {hash} Datei-SHA-256: {hash} + Read the objects it is given Die Objekte lesen, die es erhält + Change, add and delete objects in the document Objekte im Dokument ändern, hinzufügen und löschen + Keep data of its own with the document Eigene Daten mit dem Dokument speichern + Change how the objects its effect is applied to are drawn Ändern, wie die Objekte gezeichnet werden, auf die sein Effekt angewendet ist + Write the document as a file in its own format Das Dokument als Datei in einem eigenen Format schreiben +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` ist {_1}; dieser Host unterstützt die Plug-in-API-Stufen 1 bis {API_LEVEL} +@msg `grant` must be a list of permission names `grant` muss eine Liste von Berechtigungsnamen sein +@msg `grant` names an unknown permission `{shown}` `grant` nennt eine unbekannte Berechtigung `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` ein Plug-in der Art `{_1}` braucht die Berechtigung `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` höchstens {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` die Berechtigung `{shown}` gilt nicht für ein Plug-in der Art `{_1}` +@msg permission `{shown}` is listed twice die Berechtigung `{shown}` ist doppelt aufgeführt +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it die Berechtigung `{shown}` ist für eine spätere Plug-in-API reserviert; dieser Host gewährt sie nicht +@msg unknown permission `{shown}` unbekannte Berechtigung `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ verlangt die Berechtigungen {asked}: Installieren Sie es mit `grant`, das sie aufführt, sobald der Benutzer zustimmt +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}“ wurde `{p}` nicht gewährt, das seine Ausgabe braucht: Es wurde nichts geändert +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed das Modul hat nicht den SHA-256-Wert aus `hash`: Es wurde nichts installiert # Actions panel: action sets (#3712). New Set… Neuer Satz… New Set Neuer Satz diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6b2a022d5..79f4b0cc0 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5739,6 +5739,28 @@ axis Y Axis Άξονας Y @msg a live effect runs while the document is drawn, so it can't be `heavy` ένα δυναμικό εφέ εκτελείται κατά τη σχεδίαση του εγγράφου, οπότε δεν μπορεί να είναι `heavy` @msg an exporter always gets an export's budget: leave `budget` out ένα πρόσθετο εξαγωγής παίρνει πάντα τον προϋπολογισμό μιας εξαγωγής: παραλείψτε το `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees το «{_1}» ζητά μεγάλο υπολογιστικό προϋπολογισμό (κάθε εκτέλεση έως {minutes} λεπτά και {times} φορές τις συνήθεις εντολές): εγκαταστήστε το με `allowHeavy: true` μόλις συμφωνήσει ο χρήστης +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Να εγκατασταθεί το «{name}», που ζητά αυτά τα δικαιώματα; + ID: {id} Αναγνωριστικό: {id} + Version: {version} Έκδοση: {version} + File SHA-256: {hash} SHA-256 αρχείου: {hash} + Read the objects it is given Ανάγνωση των αντικειμένων που του δίνονται + Change, add and delete objects in the document Αλλαγή, προσθήκη και διαγραφή αντικειμένων στο έγγραφο + Keep data of its own with the document Αποθήκευση δικών του δεδομένων μαζί με το έγγραφο + Change how the objects its effect is applied to are drawn Αλλαγή του τρόπου σχεδίασης των αντικειμένων στα οποία εφαρμόζεται το εφέ του + Write the document as a file in its own format Εγγραφή του εγγράφου ως αρχείου σε δική του μορφή +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} το `api` είναι {_1}· αυτός ο κεντρικός υπολογιστής υποστηρίζει τα επίπεδα API προσθέτων 1 έως {API_LEVEL} +@msg `grant` must be a list of permission names το `grant` πρέπει να είναι λίστα ονομάτων δικαιωμάτων +@msg `grant` names an unknown permission `{shown}` το `grant` αναφέρει άγνωστο δικαίωμα `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` ένα πρόσθετο τύπου `{_1}` χρειάζεται το δικαίωμα `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` το πολύ {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` το δικαίωμα `{shown}` δεν ισχύει για πρόσθετο τύπου `{_1}` +@msg permission `{shown}` is listed twice το δικαίωμα `{shown}` αναφέρεται δύο φορές +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it το δικαίωμα `{shown}` είναι δεσμευμένο για μεταγενέστερο API προσθέτων· αυτός ο κεντρικός υπολογιστής δεν το παραχωρεί +@msg unknown permission `{shown}` άγνωστο δικαίωμα `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees το «{name}» ζητά τα δικαιώματα {asked}: εγκαταστήστε το με ένα `grant` που τα απαριθμεί μόλις συμφωνήσει ο χρήστης +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed στο «{name}» δεν παραχωρήθηκε το `{p}`, που χρειάζεται η έξοδός του: δεν άλλαξε τίποτα +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed η λειτουργική μονάδα δεν έχει το SHA-256 που δίνει το `hash`: δεν εγκαταστάθηκε τίποτα # Actions panel: action sets (#3712). New Set… Νέο σύνολο… New Set Νέο σύνολο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6ccd50b46..420254d25 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5729,6 +5729,28 @@ axis Y Axis Eje Y @msg a live effect runs while the document is drawn, so it can't be `heavy` un efecto interactivo se ejecuta mientras se dibuja el documento, así que no puede ser `heavy` @msg an exporter always gets an export's budget: leave `budget` out un plugin de exportación siempre recibe el presupuesto de una exportación: omite `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” pide un presupuesto de cálculo grande (cada ejecución hasta {minutes} minutos y {times} veces las instrucciones habituales): instálalo con `allowHeavy: true` cuando el usuario lo acepte +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? ¿Instalar “{name}”, que pide estos permisos? + ID: {id} ID: {id} + Version: {version} Versión: {version} + File SHA-256: {hash} SHA-256 del archivo: {hash} + Read the objects it is given Leer los objetos que recibe + Change, add and delete objects in the document Cambiar, añadir y eliminar objetos del documento + Keep data of its own with the document Guardar datos propios con el documento + Change how the objects its effect is applied to are drawn Cambiar cómo se dibujan los objetos a los que se aplica su efecto + Write the document as a file in its own format Escribir el documento como un archivo en su propio formato +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` es {_1}; este host admite los niveles de API de plugins de 1 a {API_LEVEL} +@msg `grant` must be a list of permission names `grant` debe ser una lista de nombres de permisos +@msg `grant` names an unknown permission `{shown}` `grant` nombra un permiso desconocido `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` un plugin de tipo `{_1}` necesita el permiso `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` como máximo {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` el permiso `{shown}` no se aplica a un plugin de tipo `{_1}` +@msg permission `{shown}` is listed twice el permiso `{shown}` aparece dos veces +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it el permiso `{shown}` está reservado para una API de plugins posterior; este host no lo concede +@msg unknown permission `{shown}` permiso desconocido `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” pide los permisos {asked}: instálalo con un `grant` que los incluya cuando el usuario lo acepte +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a “{name}” no se le concedió `{p}`, que su resultado necesita: no se cambió nada +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed el módulo no tiene el SHA-256 que indica `hash`: no se instaló nada # Actions panel: action sets (#3712). New Set… Nuevo conjunto… New Set Nuevo conjunto diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 3cf8607c0..0cbec9246 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5818,6 +5818,28 @@ axis Y Axis Y-akseli @msg a live effect runs while the document is drawn, so it can't be `heavy` live-tehoste suoritetaan asiakirjaa piirrettäessä, joten se ei voi olla `heavy` @msg an exporter always gets an export's budget: leave `budget` out vientilaajennus saa aina viennin budjetin: jätä `budget` pois @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees ”{_1}” pyytää suurta laskentabudjettia (jokainen ajo enintään {minutes} minuuttia ja {times} kertaa tavallinen määrä käskyjä): asenna se asetuksella `allowHeavy: true`, kun käyttäjä hyväksyy +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Asennetaanko ”{name}”, joka pyytää näitä käyttöoikeuksia? + ID: {id} Tunnus: {id} + Version: {version} Versio: {version} + File SHA-256: {hash} Tiedoston SHA-256: {hash} + Read the objects it is given Lukea sille annetut objektit + Change, add and delete objects in the document Muuttaa, lisätä ja poistaa asiakirjan objekteja + Keep data of its own with the document Tallentaa omia tietojaan asiakirjaan + Change how the objects its effect is applied to are drawn Muuttaa sitä, miten objektit, joihin sen tehostetta on käytetty, piirretään + Write the document as a file in its own format Kirjoittaa asiakirjan tiedostoksi omassa muodossaan +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` on {_1}; tämä isäntä tukee laajennus-API:n tasoja 1 ja {API_LEVEL} välillä +@msg `grant` must be a list of permission names `grant` on oltava luettelo käyttöoikeuksien nimistä +@msg `grant` names an unknown permission `{shown}` `grant` nimeää tuntemattoman käyttöoikeuden `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` lajin `{_1}` laajennus tarvitsee käyttöoikeuden `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` enintään {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` käyttöoikeus `{shown}` ei koske lajin `{_1}` laajennusta +@msg permission `{shown}` is listed twice käyttöoikeus `{shown}` on lueteltu kahdesti +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it käyttöoikeus `{shown}` on varattu myöhemmälle laajennus-API:lle; tämä isäntä ei myönnä sitä +@msg unknown permission `{shown}` tuntematon käyttöoikeus `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees ”{name}” pyytää käyttöoikeuksia {asked}: asenna se `grant`-luettelolla, jossa ne ovat, kun käyttäjä suostuu +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed ”{name}” ei saanut käyttöoikeutta `{p}`, jota sen tulos tarvitsee: mitään ei muutettu +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed moduulilla ei ole `hash`-kentän SHA-256-arvoa: mitään ei asennettu # Actions panel: action sets (#3712). New Set… Uusi sarja… New Set Uusi sarja diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 67a7cb29c..f87b0cde3 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5783,6 +5783,28 @@ axis Y Axis Axe Y @msg a live effect runs while the document is drawn, so it can't be `heavy` un effet dynamique s’exécute pendant le tracé du document, il ne peut donc pas être `heavy` @msg an exporter always gets an export's budget: leave `budget` out un module externe d’exportation reçoit toujours le budget d’une exportation : omettez `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees « {_1} » demande un budget de calcul important (chaque exécution jusqu’à {minutes} minutes et {times} fois les instructions habituelles) : installez-le avec `allowHeavy: true` une fois que l’utilisateur l’accepte +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Installer « {name} », qui demande ces autorisations ? + ID: {id} ID : {id} + Version: {version} Version : {version} + File SHA-256: {hash} SHA-256 du fichier : {hash} + Read the objects it is given Lire les objets qui lui sont transmis + Change, add and delete objects in the document Modifier, ajouter et supprimer des objets du document + Keep data of its own with the document Conserver ses propres données avec le document + Change how the objects its effect is applied to are drawn Modifier le rendu des objets auxquels son effet est appliqué + Write the document as a file in its own format Écrire le document dans un fichier à son propre format +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` vaut {_1} ; cet hôte prend en charge les niveaux d’API de module externe 1 à {API_LEVEL} +@msg `grant` must be a list of permission names `grant` doit être une liste de noms d’autorisations +@msg `grant` names an unknown permission `{shown}` `grant` nomme une autorisation inconnue `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` un module externe de type `{_1}` a besoin de l’autorisation `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` au plus {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` l’autorisation `{shown}` ne s’applique pas à un module externe de type `{_1}` +@msg permission `{shown}` is listed twice l’autorisation `{shown}` figure deux fois +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it l’autorisation `{shown}` est réservée à une future API de module externe ; cet hôte ne l’accorde pas +@msg unknown permission `{shown}` autorisation inconnue `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees « {name} » demande les autorisations {asked} : installez-le avec `grant` qui les liste une fois que l’utilisateur l’accepte +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed « {name} » n’a pas reçu `{p}`, dont sa sortie a besoin : rien n’a été modifié +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed le module n’a pas le SHA-256 indiqué par `hash` : rien n’a été installé # Actions panel: action sets (#3712). New Set… Nouveau jeu… New Set Nouveau jeu diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index e1af82a3a..a045da15b 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5173,6 +5173,28 @@ @id pathfinder.add मिलाएँ @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” अधिक संसाधन माँगता है (हर रन में {minutes} मिनट और सामान्य से {times} गुना निर्देश): उपयोगकर्ता की सहमति मिलने पर इसे `allowHeavy: true` से इंस्टॉल करें @msg "{0}" is not a colour: write it as #rrggbb "{0}" एक रंग नहीं है: इसे #rrggbb के रूप में लिखें +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? "{name}" स्थापित करें, जो ये अनुमतियां मांगता है? + ID: {id} ID: {id} + Version: {version} संस्करण: {version} + File SHA-256: {hash} फ़ाइल SHA-256: {hash} + Read the objects it is given उसे दिए गए ऑब्जेक्ट्स पढ़ना + Change, add and delete objects in the document डॉक्यूमेंट में ऑब्जेक्ट्स बदलना, जोड़ना और हटाना + Keep data of its own with the document अपना डेटा डॉक्यूमेंट के साथ रखना + Change how the objects its effect is applied to are drawn जिन ऑब्जेक्ट्स पर उसका इफ़ेक्ट लगा है, उनके बनाए जाने का तरीका बदलना + Write the document as a file in its own format डॉक्यूमेंट को अपने फ़ॉर्मेट की फ़ाइल के रूप में लिखना +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` {_1} है; यह होस्ट प्लग-इन API स्तर 1 से {API_LEVEL} तक का समर्थन करता है +@msg `grant` must be a list of permission names `grant` अनुमति नामों की सूची होनी चाहिए +@msg `grant` names an unknown permission `{shown}` `grant` एक अज्ञात अनुमति `{shown}` बताता है +@msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` प्रकार के प्लग-इन को `{_2}` अनुमति चाहिए +@msg at most {MAX_PERMISSIONS} `permissions` अधिकतम {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` अनुमति `{shown}` `{_1}` प्रकार के प्लग-इन पर लागू नहीं होती +@msg permission `{shown}` is listed twice अनुमति `{shown}` दो बार सूचीबद्ध है +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it अनुमति `{shown}` बाद के प्लग-इन API के लिए आरक्षित है; यह होस्ट इसे नहीं देता +@msg unknown permission `{shown}` अज्ञात अनुमति `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” अनुमतियां {asked} मांगता है: उपयोगकर्ता की सहमति मिलने पर इसे उन्हें सूचीबद्ध करने वाले `grant` के साथ इंस्टॉल करें +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” को `{p}` नहीं दी गई, जो उसके आउटपुट को चाहिए: कुछ नहीं बदला +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed मॉड्यूल का SHA-256 वह नहीं है जो `hash` बताता है: कुछ भी इंस्टॉल नहीं हुआ @msg “{name}” is not a file "{name}" एक फ़ाइल नहीं है @msg ({note}) ({more}) ({note}) ({more}) @msg (fonts not copied: {fonts}) (फ़ॉन्ट कॉपी नहीं हुए: {fonts}) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 82f75da72..031eb3791 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5771,6 +5771,28 @@ axis Y Axis Y tengely @msg a live effect runs while the document is drawn, so it can't be `heavy` az élő effektus a dokumentum rajzolása közben fut, ezért nem lehet `heavy` @msg an exporter always gets an export's budget: leave `budget` out az exportáló bővítmény mindig az exportálás keretét kapja: hagyd ki a `budget` kulcsot @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees a(z) „{_1}” nagy számítási keretet kér (futásonként legfeljebb {minutes} perc és a szokásos utasításszám {times}-szorosa): telepítsd az `allowHeavy: true` megadásával, miután a felhasználó beleegyezett +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Telepíted a(z) „{name}” bővítményt, amely ezeket az engedélyeket kéri? + ID: {id} Azonosító: {id} + Version: {version} Verzió: {version} + File SHA-256: {hash} A fájl SHA-256 értéke: {hash} + Read the objects it is given Olvashatja a kapott objektumokat + Change, add and delete objects in the document Módosíthat, hozzáadhat és törölhet objektumokat a dokumentumban + Keep data of its own with the document Saját adatokat tárolhat a dokumentummal + Change how the objects its effect is applied to are drawn Módosíthatja, hogyan rajzolódnak ki az objektumok, amelyekre az effektusa alkalmazva van + Write the document as a file in its own format Saját formátumú fájlba írhatja a dokumentumot +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} az `api` értéke {_1}; ez a gazdaprogram az 1 és {API_LEVEL} közötti bővítmény-API-szinteket támogatja +@msg `grant` must be a list of permission names a `grant` értékének engedélynevek listájának kell lennie +@msg `grant` names an unknown permission `{shown}` a `grant` ismeretlen engedélyt nevez meg: `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` egy `{_1}` fajtájú bővítménynek szüksége van a(z) `{_2}` engedélyre +@msg at most {MAX_PERMISSIONS} `permissions` legfeljebb {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` a(z) `{shown}` engedély nem vonatkozik `{_1}` fajtájú bővítményre +@msg permission `{shown}` is listed twice a(z) `{shown}` engedély kétszer szerepel +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it a(z) `{shown}` engedély egy későbbi bővítmény-API számára van fenntartva; ez a gazdaprogram nem adja meg +@msg unknown permission `{shown}` ismeretlen engedély: `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees a(z) „{name}” a következő engedélyeket kéri: {asked}; telepítsd egy őket felsoroló `grant` értékkel, ha a felhasználó beleegyezik +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a(z) „{name}” nem kapta meg a(z) `{p}` engedélyt, amelyre a kimenetének szüksége van: semmi sem változott +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed a modul SHA-256 értéke nem az, amit a `hash` megad: semmi sem lett telepítve # Actions panel: action sets (#3712). New Set… Új készlet… New Set Új készlet diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 7230b6bfa..872f6b74e 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5687,6 +5687,16 @@ axis Y Axis Sumbu Y The plug-in is computing… Plug-in sedang menghitung… Inspect Plug-in Periksa plug-in Cancel Plug-in Run Batalkan jalannya plug-in +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Instal “{name}”, yang meminta izin berikut? + ID: {id} ID: {id} + Version: {version} Versi: {version} + File SHA-256: {hash} SHA-256 file: {hash} + Read the objects it is given Membaca objek yang diberikan kepadanya + Change, add and delete objects in the document Mengubah, menambah, dan menghapus objek dalam dokumen + Keep data of its own with the document Menyimpan datanya sendiri bersama dokumen + Change how the objects its effect is applied to are drawn Mengubah cara objek yang diberi efeknya digambar + Write the document as a file in its own format Menulis dokumen sebagai file dalam formatnya sendiri # Actions panel: action sets (#3712). New Set… Set baru… New Set Set baru diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 723fa4ab0..e5ee7d472 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5404,6 +5404,28 @@ axis Y Axis Asse Y @msg a live effect runs while the document is drawn, so it can't be `heavy` un effetto dinamico viene eseguito mentre il documento viene disegnato, quindi non può essere `heavy` @msg an exporter always gets an export's budget: leave `budget` out un plug-in di esportazione riceve sempre il budget di un’esportazione: ometti `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” chiede un budget di calcolo elevato (ogni esecuzione fino a {minutes} minuti e {times} volte le istruzioni consuete): installalo con `allowHeavy: true` quando l’utente acconsente +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Installare “{name}”, che chiede queste autorizzazioni? + ID: {id} ID: {id} + Version: {version} Versione: {version} + File SHA-256: {hash} SHA-256 del file: {hash} + Read the objects it is given Leggere gli oggetti che riceve + Change, add and delete objects in the document Modificare, aggiungere ed eliminare oggetti nel documento + Keep data of its own with the document Conservare dati propri con il documento + Change how the objects its effect is applied to are drawn Cambiare il modo in cui vengono disegnati gli oggetti a cui è applicato il suo effetto + Write the document as a file in its own format Scrivere il documento come file in un formato proprio +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` è {_1}; questo host supporta i livelli API dei plug-in da 1 a {API_LEVEL} +@msg `grant` must be a list of permission names `grant` deve essere un elenco di nomi di autorizzazioni +@msg `grant` names an unknown permission `{shown}` `grant` indica un’autorizzazione sconosciuta `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` un plug-in di tipo `{_1}` richiede l’autorizzazione `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` al massimo {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` l’autorizzazione `{shown}` non si applica a un plug-in di tipo `{_1}` +@msg permission `{shown}` is listed twice l’autorizzazione `{shown}` è elencata due volte +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it l’autorizzazione `{shown}` è riservata a un’API dei plug-in successiva; questo host non la concede +@msg unknown permission `{shown}` autorizzazione sconosciuta `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” chiede le autorizzazioni {asked}: installalo con un `grant` che le elenca quando l’utente acconsente +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a “{name}” non è stata concessa `{p}`, che serve al suo risultato: non è stato modificato nulla +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed il modulo non ha lo SHA-256 indicato da `hash`: non è stato installato nulla # Actions panel: action sets (#3712). New Set… Nuovo set… New Set Nuovo set diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8bb101269..355da7a35 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5469,6 +5469,28 @@ axis Y Axis Y 軸 @msg a live effect runs while the document is drawn, so it can't be `heavy` ライブ効果はドキュメントの描画中に実行されるため、`heavy` にはできません @msg an exporter always gets an export's budget: leave `budget` out 書き出しプラグインには常に書き出しの予算が割り当てられます。`budget` は省略してください @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees 「{_1}」は大きな計算予算を求めています (1 回の実行につき最大 {minutes} 分、通常の {times} 倍の命令)。ユーザーが同意したら `allowHeavy: true` を指定してインストールしてください +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? これらの権限を求める「{name}」をインストールしますか? + ID: {id} ID: {id} + Version: {version} バージョン: {version} + File SHA-256: {hash} ファイルの SHA-256: {hash} + Read the objects it is given 渡されたオブジェクトの読み取り + Change, add and delete objects in the document ドキュメント内のオブジェクトの変更、追加、削除 + Keep data of its own with the document 独自のデータをドキュメントと一緒に保存 + Change how the objects its effect is applied to are drawn 効果が適用されたオブジェクトの描画方法の変更 + Write the document as a file in its own format ドキュメントを独自形式のファイルとして書き出し +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` は {_1} です。このホストが対応するプラグイン API レベルは 1 から {API_LEVEL} です +@msg `grant` must be a list of permission names `grant` は権限名のリストにしてください +@msg `grant` names an unknown permission `{shown}` `grant` に不明な権限 `{shown}` が含まれています +@msg a plug-in of kind `{_1}` needs the permission `{_2}` 種類 `{_1}` のプラグインには権限 `{_2}` が必要です +@msg at most {MAX_PERMISSIONS} `permissions` `permissions` は最大 {MAX_PERMISSIONS} 個です +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` 権限 `{shown}` は種類 `{_1}` のプラグインには適用されません +@msg permission `{shown}` is listed twice 権限 `{shown}` が 2 回指定されています +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 権限 `{shown}` は今後のプラグイン API 用に予約されています。このホストはこの権限を付与しません +@msg unknown permission `{shown}` 不明な権限 `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees 「{name}」は権限 {asked} を求めています。ユーザーが同意したら、それらを列挙した `grant` を指定してインストールしてください +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed 「{name}」には出力に必要な `{p}` が付与されていません。何も変更されませんでした +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed モジュールの SHA-256 が `hash` の値と一致しません。何もインストールされませんでした # Actions panel: action sets (#3712). New Set… 新規セット… New Set 新規セット diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 343b415a4..237b5586a 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5711,6 +5711,28 @@ axis Y Axis Y ღერძი @msg a live effect runs while the document is drawn, so it can't be `heavy` ცოცხალი ეფექტი დოკუმენტის დახატვისას ეშვება, ამიტომ ის ვერ იქნება `heavy` @msg an exporter always gets an export's budget: leave `budget` out ექსპორტის პლაგინი ყოველთვის ექსპორტის ბიუჯეტს იღებს: გამოტოვეთ `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}“ დიდ გამოთვლით ბიუჯეტს ითხოვს (თითო გაშვება {minutes} წუთამდე და ჩვეულებრივზე {times}-ჯერ მეტი ინსტრუქცია): მომხმარებლის თანხმობის შემდეგ დააყენეთ ის `allowHeavy: true` პარამეტრით +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? დავაყენოთ „{name}“, რომელიც ამ ნებართვებს ითხოვს? + ID: {id} ID: {id} + Version: {version} ვერსია: {version} + File SHA-256: {hash} ფაილის SHA-256: {hash} + Read the objects it is given მიღებული ობიექტების წაკითხვა + Change, add and delete objects in the document დოკუმენტში ობიექტების შეცვლა, დამატება და წაშლა + Keep data of its own with the document საკუთარი მონაცემების დოკუმენტთან ერთად შენახვა + Change how the objects its effect is applied to are drawn იმის შეცვლა, თუ როგორ იხატება ობიექტები, რომლებზეც მისი ეფექტია გამოყენებული + Write the document as a file in its own format დოკუმენტის ჩაწერა ფაილად საკუთარ ფორმატში +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` არის {_1}; ეს ჰოსტი პლაგინის API-ის დონეებს 1-დან {API_LEVEL}-მდე უჭერს მხარს +@msg `grant` must be a list of permission names `grant` ნებართვების სახელების სია უნდა იყოს +@msg `grant` names an unknown permission `{shown}` `grant` უცნობ ნებართვას ასახელებს: `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` სახის პლაგინს სჭირდება ნებართვა `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` მაქსიმუმ {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` ნებართვა `{shown}` არ ეხება `{_1}` სახის პლაგინს +@msg permission `{shown}` is listed twice ნებართვა `{shown}` ორჯერაა ჩამოთვლილი +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it ნებართვა `{shown}` დაჯავშნილია პლაგინების მომავალი API-სთვის; ეს ჰოსტი მას არ გასცემს +@msg unknown permission `{shown}` უცნობი ნებართვა `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ ითხოვს ნებართვებს {asked}: მომხმარებლის თანხმობის შემდეგ დააყენეთ ის `grant` პარამეტრით, რომელშიც ისინი ჩამოთვლილია +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}“-ს არ მიეცა `{p}`, რომელიც მის შედეგს სჭირდება: არაფერი შეცვლილა +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed მოდულს არ აქვს `hash`-ში მითითებული SHA-256: არაფერი დაყენებულა # Actions panel: action sets (#3712). New Set… ახალი ნაკრები… New Set ახალი ნაკრები diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index f4cc45b82..5ca65c5f3 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5365,6 +5365,28 @@ axis Y Axis Y축 @msg a live effect runs while the document is drawn, so it can't be `heavy` 라이브 효과는 문서를 그리는 동안 실행되므로 `heavy`일 수 없습니다 @msg an exporter always gets an export's budget: leave `budget` out 내보내기 플러그인은 항상 내보내기 예산을 받습니다. `budget`은 생략하세요 @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}”은(는) 큰 계산 예산을 요청합니다(실행당 최대 {minutes}분, 평소의 {times}배 명령어). 사용자가 동의하면 `allowHeavy: true`로 설치하세요 +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? 이 권한을 요청하는 “{name}”을(를) 설치하시겠습니까? + ID: {id} ID: {id} + Version: {version} 버전: {version} + File SHA-256: {hash} 파일 SHA-256: {hash} + Read the objects it is given 전달받은 개체 읽기 + Change, add and delete objects in the document 문서의 개체 변경, 추가 및 삭제 + Keep data of its own with the document 자체 데이터를 문서와 함께 보관 + Change how the objects its effect is applied to are drawn 효과가 적용된 개체가 그려지는 방식 변경 + Write the document as a file in its own format 문서를 자체 형식의 파일로 쓰기 +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api`가 {_1}입니다. 이 호스트는 플러그인 API 수준 1부터 {API_LEVEL}까지 지원합니다 +@msg `grant` must be a list of permission names `grant`는 권한 이름 목록이어야 합니다 +@msg `grant` names an unknown permission `{shown}` `grant`에 알 수 없는 권한 `{shown}`이(가) 있습니다 +@msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` 종류의 플러그인에는 `{_2}` 권한이 필요합니다 +@msg at most {MAX_PERMISSIONS} `permissions` `permissions`는 최대 {MAX_PERMISSIONS}개입니다 +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` `{shown}` 권한은 `{_1}` 종류의 플러그인에 적용되지 않습니다 +@msg permission `{shown}` is listed twice `{shown}` 권한이 두 번 나열되어 있습니다 +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it `{shown}` 권한은 이후 플러그인 API용으로 예약되어 있습니다. 이 호스트는 이 권한을 부여하지 않습니다 +@msg unknown permission `{shown}` 알 수 없는 권한 `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}”은(는) {asked} 권한을 요청합니다. 사용자가 동의하면 이를 나열한 `grant`로 설치하세요 +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}”에 출력에 필요한 `{p}` 권한이 부여되지 않았습니다. 아무것도 변경되지 않았습니다 +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 모듈의 SHA-256이 `hash`에 지정된 값과 다릅니다. 아무것도 설치되지 않았습니다 # Actions panel: action sets (#3712). New Set… 새 세트… New Set 새 세트 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 2e8d6f34c..a1c721371 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5733,6 +5733,16 @@ axis Y Axis Y-as The plug-in is computing… De plug-in is aan het rekenen… Inspect Plug-in Plug-in inspecteren Cancel Plug-in Run Plug-in-run annuleren +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? „{name}” installeren, dat om deze machtigingen vraagt? + ID: {id} ID: {id} + Version: {version} Versie: {version} + File SHA-256: {hash} SHA-256 van het bestand: {hash} + Read the objects it is given De objecten lezen die het krijgt + Change, add and delete objects in the document Objecten in het document wijzigen, toevoegen en verwijderen + Keep data of its own with the document Eigen gegevens bij het document bewaren + Change how the objects its effect is applied to are drawn Wijzigen hoe de objecten waarop het effect is toegepast worden getekend + Write the document as a file in its own format Het document als bestand in een eigen indeling schrijven # Actions panel: action sets (#3712). New Set… Nieuwe set… New Set Nieuwe set diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index c9e1256c3..f9c6ca1ee 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5641,6 +5641,28 @@ axis Y Axis Oś Y @msg a live effect runs while the document is drawn, so it can't be `heavy` efekt na żywo działa podczas rysowania dokumentu, więc nie może być `heavy` @msg an exporter always gets an export's budget: leave `budget` out wtyczka eksportu zawsze dostaje budżet eksportu: pomiń `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}” prosi o duży budżet obliczeń (każde uruchomienie do {minutes} minut i {times} razy więcej instrukcji niż zwykle): zainstaluj wtyczkę z `allowHeavy: true`, gdy użytkownik się zgodzi +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Zainstalować „{name}”, wtyczkę, która prosi o te uprawnienia? + ID: {id} ID: {id} + Version: {version} Wersja: {version} + File SHA-256: {hash} SHA-256 pliku: {hash} + Read the objects it is given Odczytywać obiekty, które otrzymuje + Change, add and delete objects in the document Zmieniać, dodawać i usuwać obiekty w dokumencie + Keep data of its own with the document Przechowywać własne dane w dokumencie + Change how the objects its effect is applied to are drawn Zmieniać sposób rysowania obiektów, do których zastosowano jej efekt + Write the document as a file in its own format Zapisywać dokument jako plik we własnym formacie +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` ma wartość {_1}; ten host obsługuje poziomy API wtyczek od 1 do {API_LEVEL} +@msg `grant` must be a list of permission names `grant` musi być listą nazw uprawnień +@msg `grant` names an unknown permission `{shown}` `grant` wymienia nieznane uprawnienie `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` wtyczka rodzaju `{_1}` potrzebuje uprawnienia `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` najwyżej {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` uprawnienie `{shown}` nie dotyczy wtyczki rodzaju `{_1}` +@msg permission `{shown}` is listed twice uprawnienie `{shown}` występuje dwa razy +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it uprawnienie `{shown}` jest zarezerwowane dla późniejszego API wtyczek; ten host go nie przyznaje +@msg unknown permission `{shown}` nieznane uprawnienie `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}” prosi o uprawnienia {asked}: zainstaluj ją z `grant`, który je wymienia, gdy użytkownik się zgodzi +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}” nie otrzymała `{p}`, którego potrzebuje jej wynik: nic nie zmieniono +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed moduł nie ma skrótu SHA-256 podanego w `hash`: nic nie zainstalowano # Actions panel: action sets (#3712). New Set… Nowy zestaw… New Set Nowy zestaw diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 062b7d88b..d22b6b4d1 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5703,6 +5703,28 @@ axis Y Axis Eixo Y @msg a live effect runs while the document is drawn, so it can't be `heavy` um efeito dinâmico é executado enquanto o documento é desenhado, então não pode ser `heavy` @msg an exporter always gets an export's budget: leave `budget` out um plug-in de exportação sempre recebe o orçamento de uma exportação: omita `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” pede um orçamento de processamento grande (cada execução até {minutes} minutos e {times} vezes as instruções habituais): instale-o com `allowHeavy: true` depois que o usuário concordar +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Instalar “{name}”, que pede estas permissões? + ID: {id} ID: {id} + Version: {version} Versão: {version} + File SHA-256: {hash} SHA-256 do arquivo: {hash} + Read the objects it is given Ler os objetos que recebe + Change, add and delete objects in the document Alterar, adicionar e excluir objetos no documento + Keep data of its own with the document Guardar dados próprios com o documento + Change how the objects its effect is applied to are drawn Alterar como são desenhados os objetos aos quais seu efeito é aplicado + Write the document as a file in its own format Gravar o documento como um arquivo em formato próprio +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` é {_1}; este host aceita os níveis de API de plug-in de 1 a {API_LEVEL} +@msg `grant` must be a list of permission names `grant` deve ser uma lista de nomes de permissões +@msg `grant` names an unknown permission `{shown}` `grant` cita uma permissão desconhecida `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` um plug-in do tipo `{_1}` precisa da permissão `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` no máximo {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` a permissão `{shown}` não se aplica a um plug-in do tipo `{_1}` +@msg permission `{shown}` is listed twice a permissão `{shown}` aparece duas vezes +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it a permissão `{shown}` está reservada para uma API de plug-in futura; este host não a concede +@msg unknown permission `{shown}` permissão desconhecida `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” pede as permissões {asked}: instale-o com `grant` listando-as depois que o usuário concordar +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” não recebeu `{p}`, de que sua saída precisa: nada foi alterado +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed o módulo não tem o SHA-256 indicado em `hash`: nada foi instalado # Actions panel: action sets (#3712). New Set… Novo Conjunto… New Set Novo Conjunto diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a83d9edf3..695853915 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5399,6 +5399,28 @@ axis Y Axis Ось Y @msg a live effect runs while the document is drawn, so it can't be `heavy` динамический эффект выполняется во время отрисовки документа, поэтому он не может быть `heavy` @msg an exporter always gets an export's budget: leave `budget` out модуль экспорта всегда получает бюджет экспорта: не указывайте `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees «{_1}» запрашивает большой вычислительный бюджет (каждый запуск до {minutes} минут и в {times} раз больше инструкций, чем обычно): установите его с `allowHeavy: true`, когда пользователь согласится +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Установить «{name}», которому нужны эти разрешения? + ID: {id} ID: {id} + Version: {version} Версия: {version} + File SHA-256: {hash} SHA-256 файла: {hash} + Read the objects it is given Читать переданные ему объекты + Change, add and delete objects in the document Изменять, добавлять и удалять объекты в документе + Keep data of its own with the document Хранить свои данные в документе + Change how the objects its effect is applied to are drawn Изменять то, как рисуются объекты, к которым применён его эффект + Write the document as a file in its own format Записывать документ в файл собственного формата +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` равно {_1}; этот хост поддерживает уровни API модулей от 1 до {API_LEVEL} +@msg `grant` must be a list of permission names `grant` должен быть списком имён разрешений +@msg `grant` names an unknown permission `{shown}` `grant` называет неизвестное разрешение `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` модулю вида `{_1}` нужно разрешение `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` не более {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` разрешение `{shown}` не относится к модулю вида `{_1}` +@msg permission `{shown}` is listed twice разрешение `{shown}` указано дважды +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it разрешение `{shown}` зарезервировано для будущего API модулей; этот хост его не выдаёт +@msg unknown permission `{shown}` неизвестное разрешение `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» запрашивает разрешения {asked}: установите его с `grant`, где они перечислены, когда пользователь согласится +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed «{name}» не получил разрешение `{p}`, нужное для его результата: ничего не изменено +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed у модуля не тот SHA-256, что указан в `hash`: ничего не установлено # Actions panel: action sets (#3712). New Set… Новый набор… New Set Новый набор diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 780d23bea..019067d99 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5811,6 +5811,28 @@ axis Y Axis Y-axel @msg a live effect runs while the document is drawn, so it can't be `heavy` en live-effekt körs medan dokumentet ritas, så den kan inte vara `heavy` @msg an exporter always gets an export's budget: leave `budget` out ett exportinsticksprogram får alltid en exports budget: utelämna `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees ”{_1}” begär en stor beräkningsbudget (varje körning upp till {minutes} minuter och {times} gånger de vanliga instruktionerna): installera det med `allowHeavy: true` när användaren har godkänt +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Installera ”{name}”, som begär dessa behörigheter? + ID: {id} ID: {id} + Version: {version} Version: {version} + File SHA-256: {hash} Filens SHA-256: {hash} + Read the objects it is given Läsa objekten den får + Change, add and delete objects in the document Ändra, lägga till och ta bort objekt i dokumentet + Keep data of its own with the document Spara egna data med dokumentet + Change how the objects its effect is applied to are drawn Ändra hur objekten som dess effekt används på ritas + Write the document as a file in its own format Skriva dokumentet som en fil i sitt eget format +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` är {_1}; den här värden stöder plugin-API-nivå 1 till {API_LEVEL} +@msg `grant` must be a list of permission names `grant` måste vara en lista med behörighetsnamn +@msg `grant` names an unknown permission `{shown}` `grant` anger en okänd behörighet `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` ett plugin av typen `{_1}` behöver behörigheten `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` högst {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` behörigheten `{shown}` gäller inte ett plugin av typen `{_1}` +@msg permission `{shown}` is listed twice behörigheten `{shown}` står två gånger +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it behörigheten `{shown}` är reserverad för ett senare plugin-API; den här värden beviljar den inte +@msg unknown permission `{shown}` okänd behörighet `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees ”{name}” begär behörigheterna {asked}: installera det med en `grant` som listar dem när användaren har godkänt +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed ”{name}” beviljades inte `{p}`, som dess utdata behöver: ingenting ändrades +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modulen har inte den SHA-256 som `hash` anger: ingenting installerades # Actions panel: action sets (#3712). New Set… Ny uppsättning… New Set Ny uppsättning diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 55a9cdc73..5c0091946 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5745,6 +5745,28 @@ axis Y Axis Y ekseni @msg a live effect runs while the document is drawn, so it can't be `heavy` canlı efekt belge çizilirken çalışır, bu yüzden `heavy` olamaz @msg an exporter always gets an export's budget: leave `budget` out dışa aktarma eklentisi her zaman bir dışa aktarmanın bütçesini alır: `budget` anahtarını çıkarın @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” büyük bir hesaplama bütçesi istiyor (her çalıştırma en fazla {minutes} dakika ve olağan komutların {times} katı): kullanıcı onayladıktan sonra `allowHeavy: true` ile yükleyin +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Bu izinleri isteyen “{name}” yüklensin mi? + ID: {id} Kimlik: {id} + Version: {version} Sürüm: {version} + File SHA-256: {hash} Dosya SHA-256: {hash} + Read the objects it is given Kendisine verilen nesneleri okuma + Change, add and delete objects in the document Belgedeki nesneleri değiştirme, ekleme ve silme + Keep data of its own with the document Kendi verilerini belgeyle birlikte saklama + Change how the objects its effect is applied to are drawn Efektinin uygulandığı nesnelerin nasıl çizildiğini değiştirme + Write the document as a file in its own format Belgeyi kendi biçiminde bir dosya olarak yazma +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` değeri {_1}; bu ana bilgisayar 1 ile {API_LEVEL} arasındaki eklenti API düzeylerini destekler +@msg `grant` must be a list of permission names `grant` izin adlarından oluşan bir liste olmalıdır +@msg `grant` names an unknown permission `{shown}` `grant` bilinmeyen bir izin adlandırıyor: `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` türündeki bir eklentinin `{_2}` iznine ihtiyacı vardır +@msg at most {MAX_PERMISSIONS} `permissions` en fazla {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` `{shown}` izni `{_1}` türündeki bir eklentiye uygulanmaz +@msg permission `{shown}` is listed twice `{shown}` izni iki kez listelenmiş +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it `{shown}` izni sonraki bir eklenti API’si için ayrılmıştır; bu ana bilgisayar bu izni vermez +@msg unknown permission `{shown}` bilinmeyen izin `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” {asked} izinlerini istiyor: kullanıcı onayladıktan sonra bunları listeleyen bir `grant` ile yükleyin +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” öğesine çıktısının ihtiyaç duyduğu `{p}` izni verilmedi: hiçbir şey değiştirilmedi +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modül `hash` içinde verilen SHA-256 değerine sahip değil: hiçbir şey yüklenmedi # Actions panel: action sets (#3712). New Set… Yeni Set… New Set Yeni Set diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index bbfc332f9..ae36d2e9e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5384,6 +5384,28 @@ axis Y Axis Вісь Y @msg a live effect runs while the document is drawn, so it can't be `heavy` динамічний ефект виконується під час відмальовування документа, тому він не може бути `heavy` @msg an exporter always gets an export's budget: leave `budget` out модуль експорту завжди отримує бюджет експорту: не вказуйте `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees «{_1}» запитує великий обчислювальний бюджет (кожен запуск до {minutes} хвилин і в {times} разів більше інструкцій, ніж зазвичай): встановіть його з `allowHeavy: true`, коли користувач погодиться +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Встановити «{name}», якому потрібні ці дозволи? + ID: {id} ID: {id} + Version: {version} Версія: {version} + File SHA-256: {hash} SHA-256 файлу: {hash} + Read the objects it is given Читати передані йому об’єкти + Change, add and delete objects in the document Змінювати, додавати й видаляти об’єкти в документі + Keep data of its own with the document Зберігати власні дані в документі + Change how the objects its effect is applied to are drawn Змінювати те, як малюються об’єкти, до яких застосовано його ефект + Write the document as a file in its own format Записувати документ у файл власного формату +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` дорівнює {_1}; цей хост підтримує рівні API модулів від 1 до {API_LEVEL} +@msg `grant` must be a list of permission names `grant` має бути списком назв дозволів +@msg `grant` names an unknown permission `{shown}` `grant` називає невідомий дозвіл `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` модулю виду `{_1}` потрібен дозвіл `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` щонайбільше {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` дозвіл `{shown}` не стосується модуля виду `{_1}` +@msg permission `{shown}` is listed twice дозвіл `{shown}` указано двічі +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it дозвіл `{shown}` зарезервовано для пізнішого API модулів; цей хост його не надає +@msg unknown permission `{shown}` невідомий дозвіл `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» запитує дозволи {asked}: установіть його з `grant`, де їх перелічено, коли користувач погодиться +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed «{name}» не отримав дозволу `{p}`, потрібного для його результату: нічого не змінено +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed модуль має не той SHA-256, що вказаний у `hash`: нічого не встановлено # Actions panel: action sets (#3712). New Set… Новий набір… New Set Новий набір diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 47915fb17..5a83ec144 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5385,6 +5385,28 @@ axis Y Axis Trục Y @msg a live effect runs while the document is drawn, so it can't be `heavy` hiệu ứng trực tiếp chạy trong lúc tài liệu được vẽ nên không thể là `heavy` @msg an exporter always gets an export's budget: leave `budget` out plug-in xuất luôn nhận ngân sách của một lần xuất: hãy bỏ `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” yêu cầu ngân sách tính toán lớn (mỗi lần chạy tới {minutes} phút và gấp {times} lần số lệnh thông thường): hãy cài đặt với `allowHeavy: true` khi người dùng đồng ý +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Cài đặt “{name}”, plug-in yêu cầu các quyền này? + ID: {id} ID: {id} + Version: {version} Phiên bản: {version} + File SHA-256: {hash} SHA-256 của tệp: {hash} + Read the objects it is given Đọc các đối tượng được trao cho nó + Change, add and delete objects in the document Thay đổi, thêm và xóa đối tượng trong tài liệu + Keep data of its own with the document Lưu dữ liệu riêng cùng tài liệu + Change how the objects its effect is applied to are drawn Thay đổi cách vẽ các đối tượng được áp dụng hiệu ứng của nó + Write the document as a file in its own format Ghi tài liệu thành tệp theo định dạng riêng +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` là {_1}; máy chủ này hỗ trợ các mức API plug-in từ 1 đến {API_LEVEL} +@msg `grant` must be a list of permission names `grant` phải là danh sách tên quyền +@msg `grant` names an unknown permission `{shown}` `grant` nêu một quyền không xác định `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` plug-in loại `{_1}` cần quyền `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` tối đa {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` quyền `{shown}` không áp dụng cho plug-in loại `{_1}` +@msg permission `{shown}` is listed twice quyền `{shown}` được liệt kê hai lần +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it quyền `{shown}` được dành cho một API plug-in sau này; máy chủ này không cấp quyền đó +@msg unknown permission `{shown}` quyền không xác định `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” yêu cầu các quyền {asked}: hãy cài đặt với `grant` liệt kê chúng khi người dùng đồng ý +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” chưa được cấp `{p}`, quyền mà đầu ra của nó cần: không có gì thay đổi +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed mô-đun không có SHA-256 mà `hash` cho biết: không có gì được cài đặt # Actions panel: action sets (#3712). New Set… Bộ mới… New Set Bộ mới diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e646c956b..de6f8ea37 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5626,6 +5626,28 @@ axis Y Axis Y 轴 @msg a live effect runs while the document is drawn, so it can't be `heavy` 实时效果在绘制文档时运行,因此不能为 `heavy` @msg an exporter always gets an export's budget: leave `budget` out 导出增效工具始终使用导出的预算:请省略 `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}”需要大量计算预算(每次运行最多 {minutes} 分钟,指令数为通常的 {times} 倍):请在用户同意后使用 `allowHeavy: true` 安装 +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? 要安装需要这些权限的“{name}”吗? + ID: {id} ID:{id} + Version: {version} 版本:{version} + File SHA-256: {hash} 文件 SHA-256:{hash} + Read the objects it is given 读取交给它的对象 + Change, add and delete objects in the document 更改、添加和删除文档中的对象 + Keep data of its own with the document 在文档中保存自己的数据 + Change how the objects its effect is applied to are drawn 更改应用了其效果的对象的绘制方式 + Write the document as a file in its own format 将文档写为其自有格式的文件 +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` 为 {_1};此主机支持的增效工具 API 级别为 1 到 {API_LEVEL} +@msg `grant` must be a list of permission names `grant` 必须是权限名称的列表 +@msg `grant` names an unknown permission `{shown}` `grant` 包含未知权限 `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` 类型的增效工具需要权限 `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` 最多 {MAX_PERMISSIONS} 个 `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` 权限 `{shown}` 不适用于 `{_1}` 类型的增效工具 +@msg permission `{shown}` is listed twice 权限 `{shown}` 列出了两次 +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 权限 `{shown}` 保留给以后的增效工具 API;此主机不授予该权限 +@msg unknown permission `{shown}` 未知权限 `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}”需要权限 {asked}:请在用户同意后使用列出这些权限的 `grant` 安装 +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}”未获授予其输出所需的 `{p}`:未作任何更改 +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 模块的 SHA-256 与 `hash` 给出的不符:未安装任何内容 # Actions panel: action sets (#3712). New Set… 新建组… New Set 新建组 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 468ad851a..14b66871f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5422,6 +5422,28 @@ axis Y Axis Y 軸 @msg a live effect runs while the document is drawn, so it can't be `heavy` 即時效果會在繪製文件時執行,因此不能是 `heavy` @msg an exporter always gets an export's budget: leave `budget` out 匯出增效模組一律使用匯出的預算:請移除 `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees 「{_1}」需要大量運算預算(每次執行最多 {minutes} 分鐘,指令數為一般的 {times} 倍):請在使用者同意後以 `allowHeavy: true` 安裝 +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? 要安裝需要這些權限的「{name}」嗎? + ID: {id} ID:{id} + Version: {version} 版本:{version} + File SHA-256: {hash} 檔案 SHA-256:{hash} + Read the objects it is given 讀取交給它的物件 + Change, add and delete objects in the document 變更、新增和刪除文件中的物件 + Keep data of its own with the document 在文件中保存自己的資料 + Change how the objects its effect is applied to are drawn 變更套用其效果的物件的繪製方式 + Write the document as a file in its own format 將文件寫成其自有格式的檔案 +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` 為 {_1};此主機支援的增效模組 API 層級為 1 到 {API_LEVEL} +@msg `grant` must be a list of permission names `grant` 必須是權限名稱的清單 +@msg `grant` names an unknown permission `{shown}` `grant` 包含未知權限 `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` 類型的增效模組需要權限 `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` 最多 {MAX_PERMISSIONS} 個 `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` 權限 `{shown}` 不適用於 `{_1}` 類型的增效模組 +@msg permission `{shown}` is listed twice 權限 `{shown}` 列出了兩次 +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 權限 `{shown}` 保留給日後的增效模組 API;此主機不授予該權限 +@msg unknown permission `{shown}` 未知權限 `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees 「{name}」需要權限 {asked}:請在使用者同意後以列出這些權限的 `grant` 安裝 +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed 「{name}」未獲授予其輸出所需的 `{p}`:未做任何變更 +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 模組的 SHA-256 與 `hash` 提供的不符:未安裝任何內容 # Actions panel: action sets (#3712). New Set… 新增組… New Set 新增組 diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index d9d2b470a..85ce45c40 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -1,3 +1,5 @@ +use vectorcraft_plugins::permissions; + use super::save::suggested; use super::{Dialog, FilePick, Format, Value, VectorcraftApp, dialogs, fileio, json}; @@ -163,32 +165,77 @@ pub fn install_plugin(app: &mut VectorcraftApp, p: &Value) -> Result<Value, Stri install_module(app, json!({ "path": path })) } +/// What permission `name` lets a plug-in do, in plain words in the UI language; a name the app +/// doesn't know is shown as it is. +fn permission_words(name: &str) -> String { + let words = match permissions::Permission::parse(name) { + Some(permissions::Permission::DocRead) => tl!("Read the objects it is given"), + Some(permissions::Permission::DocWrite) => tl!("Change, add and delete objects in the document"), + Some(permissions::Permission::State) => tl!("Keep data of its own with the document"), + Some(permissions::Permission::EffectRender) => tl!("Change how the objects its effect is applied to are drawn"), + Some(permissions::Permission::ExportWrite) => tl!("Write the document as a file in its own format"), + None => return permissions::display_text(name, permissions::MAX_SHOWN_FIELD), + }; + words.to_string() +} + /// Installs the plug-in module that `module` names (`plugin.install`'s `path`, or `dataBase64` -/// with `name`) → `plugin.install`'s result. A filter that asks for a heavy budget the user hasn't -/// allowed yet (`allowHeavy`) asks them first, and OK installs it → `{pending}` while it asks. +/// with `name`) → `plugin.install`'s result. A plug-in that lists permissions the user hasn't +/// granted it yet (`grant`) asks them first, showing its name, id, version, file hash and what each +/// permission lets it do, and so does a filter that asks for a heavy budget the user hasn't allowed +/// yet (`allowHeavy`); OK installs it if the module still has the hash the prompt showed, Cancel +/// installs nothing → `{pending}` while it asks. Every +/// text from the manifest is shown with its hidden characters escaped and its length capped +/// ([`permissions::display_text`]). pub fn install_module(app: &mut VectorcraftApp, module: Value) -> Result<Value, String> { - if module.get("allowHeavy").and_then(Value::as_bool) != Some(true) { + let heavy_allowed = module.get("allowHeavy").and_then(Value::as_bool) == Some(true); + let granted = module.get("grant").is_some_and(|g| !g.is_null()); + if !heavy_allowed || !granted { let given = |k: &str| module.get(k).map(|v| (k.to_string(), v.clone())); let source = Value::Object(["path", "dataBase64", "name"].into_iter().filter_map(given).collect()); - let info = app.run("plugin.inspect", source.clone())?; - if info["budget"] == "heavy" { - let name = info["name"].as_str().unwrap_or_default(); + // What OK installs: the same module, with what the user has agreed to so far, pinned to + // the bytes the prompt shows (`hash`). + let mut answered = + Value::Object(["path", "dataBase64", "name", "replace", "grant", "allowHeavy", "hash"].into_iter().filter_map(given).collect()); + let info = app.run("plugin.inspect", source)?; + if answered.get("hash").is_none() { + answered["hash"] = info["hash"].clone(); + } + let shown = |k: &str, max: usize| permissions::display_text(info[k].as_str().unwrap_or_default(), max); + let name = shown("name", permissions::MAX_SHOWN_NAME); + if !granted && info["needsApproval"] == true { + let message = crate::i18n::fmt(tl!("Install “{name}”, which asks for these permissions?"), &[("name", &name)]); + let mut lines = vec![crate::i18n::fmt(tl!("ID: {id}"), &[("id", &shown("id", permissions::MAX_SHOWN_FIELD))])]; + let version = shown("version", permissions::MAX_SHOWN_FIELD); + if !version.is_empty() { + lines.push(crate::i18n::fmt(tl!("Version: {version}"), &[("version", &version)])); + } + lines.push(crate::i18n::fmt(tl!("File SHA-256: {hash}"), &[("hash", &shown("hash", 64))])); + lines.push(String::new()); + let asked = info["permissions"].as_array().cloned().unwrap_or_default(); + lines.extend(asked.iter().filter_map(Value::as_str).map(|p| format!("• {}", permission_words(p)))); + let mut again = answered; + again["grant"] = Value::Array(asked); + dialogs::confirm::ask(app, &message, &lines.join("\n"), "ui.installPlugin", again); + return Ok(json!({ "pending": dialogs::confirm::KIND })); + } + if !heavy_allowed && info["budget"] == "heavy" { let (minutes, times) = vectorcraft_plugins::heavy_terms(); - let message = crate::i18n::fmt(tl!("Install “{name}”, which asks for a heavy budget?"), &[("name", name)]); + let message = crate::i18n::fmt(tl!("Install “{name}”, which asks for a heavy budget?"), &[("name", &name)]); let detail = crate::i18n::fmt( tl!( "Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it." ), &[("minutes", &minutes.to_string()), ("times", ×.to_string())], ); - let mut allowed = source; + let mut allowed = answered; allowed["allowHeavy"] = json!(true); dialogs::confirm::ask(app, &message, &detail, "ui.installPlugin", allowed); return Ok(json!({ "pending": dialogs::confirm::KIND })); } } let r = app.run("plugin.install", module)?; - app.status(format!("Installed plug-in {}", r["name"].as_str().unwrap_or_default())); + app.status(format!("Installed plug-in {}", permissions::display_text(r["name"].as_str().unwrap_or_default(), permissions::MAX_SHOWN_NAME))); Ok(r) } diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 988abad5b..10e829577 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1397,7 +1397,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.installPlugin", "Install Plug-in…", "", - "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), allowHeavy?: bool (the user agreed to its heavy budget)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A filter that asks for a heavy budget asks the user first: OK installs it → plugin.install's result | {pending: \"confirm\"} while it asks", + "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), allowHeavy?: bool (the user agreed to its heavy budget), grant?: [permission] (the permissions the user granted it), replace?: bool, hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, and so does a filter that asks for a heavy budget: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing → plugin.install's result | {pending: \"confirm\"} while it asks", ), ( "plugin.cancelRuns", diff --git a/crates/ui-egui/src/tests/plugins.rs b/crates/ui-egui/src/tests/plugins.rs index 3bd693afa..dbb32769a 100644 --- a/crates/ui-egui/src/tests/plugins.rs +++ b/crates/ui-egui/src/tests/plugins.rs @@ -119,3 +119,67 @@ fn plugin_effects_open_the_effect_dialog_with_their_schema() { app.run("effect.dialog", json!({"effect": "plugin.test.ui.fx", "index": 0})).unwrap(); assert_eq!(app.ui.dialog.as_ref().unwrap().fields.get("mode"), Some(&Value::from("b"))); } + +/// A filter `id` named `name` whose manifest lists `permissions` (a JSON list), at a version with +/// a bidirectional override, a tab and a bell in it. +fn asking(id: &str, name: &str, permissions: &str) -> Vec<u8> { + let manifest = json!({"id": id, "name": name, "version": "3.0\u{202E}\t\u{0007}", "kind": "filter"}).to_string(); + let manifest = format!(r#"{},"permissions":{permissions}}}"#, manifest.trim_end_matches('}')); + wat::parse_str(tpl::returning(&manifest, r#"{"objects":[]}"#)).unwrap() +} + +/// Installing a plug-in that lists permissions asks first, showing its name, id, version, file hash +/// and each permission in plain words, with hidden characters from the manifest escaped. Cancel +/// installs nothing, OK installs it; installing it again with the same permissions doesn't ask, and +/// one that asks for another does. +#[test] +fn installing_a_plugin_that_lists_permissions_asks_first() { + let mut app = app(); + let id = "test.ui.permissions"; + // U+202E would show the text after it reversed, and U+2066 isolates it; a manifest's name may + // hold them, though not control characters, which its version may. + let name = "Tidy\u{202E}fdp.exe\u{2066}"; + let bytes = asking(id, name, r#"["doc.read","doc.write"]"#); + let data = vectorcraft_format::base64_encode(&bytes); + let r = app.run("ui.installPlugin", json!({"dataBase64": data, "name": "tidy.wasm"})).unwrap(); + assert_eq!(r["pending"], dialogs::confirm::KIND); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.str("message"), "Install “Tidy\\u{202E}fdp.exe\\u{2066}”, which asks for these permissions?"); + let hash = vectorcraft_plugins::permissions::sha256_hex(&bytes); + assert_eq!(d.fields.get("__params").map(|p| p["hash"].clone()), Some(json!(hash)), "OK installs the module shown"); + let detail = d.str("detail"); + assert_eq!( + detail, + format!( + "ID: {id}\nVersion: 3.0\\u{{202E}}\\u{{0009}}\\u{{0007}}\nFile SHA-256: {hash}\n\n• Read the objects it is given\n• Change, add and delete objects in the document" + ) + ); + assert!(!detail.chars().chain(d.str("message").chars()).any(|c| matches!(c, '\u{202E}' | '\u{2066}' | '\t' | '\u{0007}'))); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "not before the user agrees"); + dialogs::cancel(&mut app); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "Cancel installs nothing"); + // File › Open asks too, and OK installs it with what it lists granted. + io::open_bytes(&mut app, "tidy.wasm", &bytes, None).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::KIND)); + dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert!(vectorcraft_plugins::registry::get(id).is_some()); + assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(hash)); + // Again with the same permissions: installed without asking. + let again = asking(id, "Tidy 2", r#"["doc.write","doc.read"]"#); + app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&again)})).unwrap(); + assert!(app.ui.dialog.is_none(), "the same permissions don't ask again"); + assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some("Tidy 2".into())); + // One that asks for another permission asks again. + let more = asking(id, "Tidy 3", r#"["doc.read","doc.write","state"]"#); + let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&more)})).unwrap(); + assert_eq!(r["pending"], dialogs::confirm::KIND); + assert!(app.ui.dialog.as_ref().unwrap().str("detail").ends_with("• Keep data of its own with the document")); + dialogs::cancel(&mut app); + assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some("Tidy 2".into()), "Cancel keeps the one installed"); + // OK keeps the `replace` it was asked with: `false` leaves the installed one in place. + app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&more), "replace": false})).unwrap(); + let _ = dialogs::confirm(&mut app); + assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some("Tidy 2".into()), "replace: false is kept"); + app.run("plugin.remove", json!({"id": id})).unwrap(); +} diff --git a/docs/mcp.md b/docs/mcp.md index 1adcf32b1..030d4fe33 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5915,7 +5915,14 @@ that no built-in format has. A failing plug-in returns an error and leaves the d no file). A filter whose manifest asks for a heavy budget (minutes rather than a minute, `budget: "heavy"` in its `plugin.inspect {path | dataBase64}` entry) installs only with `allowHeavy: true`: ask the user first. `plugin.run` runs it where it is called, so the call may take minutes; `notifications/cancelled` stops it. -`plugin.install` rejects a `replace` or `allowHeavy` value that is not a boolean. `prefs.set` returns an error and +A plug-in whose manifest lists permissions (`permissions` in its `plugin.inspect` entry, with `needsApproval: true`) +installs only with `grant: [permission]` listing every one of them: show the user the list and ask first, and pass +the entry's `hash` as `hash` so that a module that changed since is refused. Without `grant`, `plugin.install` returns +an error naming the permissions needed. The grant is kept with the module's hash; a module +with the same id that lists no new permission installs without `grant`, and `plugin.remove` forgets the grant. A +filter without `doc.write` or `state` that returns output needing them gets an error and the document is unchanged. +`plugin.install` rejects a `replace` or `allowHeavy` value that is not a boolean, a `grant` that is not a list of +permission names, and a `hash` that is not the module's SHA-256. `prefs.set` returns an error and changes nothing when a new `pluginsFolder` is missing, is not a folder or can't be listed. It does not check an empty value or the folder the preference already holds, and the web build does not check folders. diff --git a/docs/plugins.md b/docs/plugins.md index e1fcce813..96f8ac6e3 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -23,13 +23,13 @@ This page is the contract for plug-in authors and the reference for the host (`c | Command | Params | What it does | |---|---|---| -| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, size, source}], "folder": report}` (`budget: "heavy"` for a [heavy filter](#heavy-filters)) | +| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, size, source, permissions, hash}], "folder": report}` (`budget: "heavy"` for a [heavy filter](#heavy-filters); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256) | | `plugin.info` | `{"id"}` | One plug-in: the `plugin.list` entry plus `manifest`, `defaults` and `lastError` (a live effect's last failure). | | `plugin.run` | `{"id", "params"?: {…}, "ids"?: [...]}` (plug-in params may also be top-level keys) | Runs an object filter on the selection (or `ids`). One undo step; the result is selected, except the objects it locked or made guides. | -| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace": bool = true`, `"allowHeavy": bool = false` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`, once the user agreed. | -| `plugin.inspect` | as `plugin.install`: `{"path"}` or `{"dataBase64", "name"?}` | Validates a module without installing it; returns the `plugin.list` entry it would have. | +| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace": bool = true`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`, once the user agreed. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, once the user agreed. With `hash`, a module whose SHA-256 differs is refused. | +| `plugin.inspect` | as `plugin.install`: `{"path"}` or `{"dataBase64", "name"?}` | Validates a module without installing it; returns the `plugin.list` entry it would have, plus `needsApproval` (true when `plugin.install` needs `grant`). | | `plugin.remove` | `{"id"}` | Uninstalls, and deletes it from the `Plug-ins` folder. | -| `plugin.reload` | `{"path"?: "folder"}` (default: the preference folder) | Installs every `*.wasm` in a folder; a bad module is reported and skipped, and so is a heavy filter (nobody was asked). | +| `plugin.reload` | `{"path"?: "folder"}` (default: the preference folder) | Installs every `*.wasm` in a folder; a bad module is reported and skipped, and so are a heavy filter and a plug-in whose permissions the user hasn't granted (nobody was asked). | Live effects are applied like any effect: `effect.apply {"effect": "plugin.<id>", "params": {…}}`, edited with `effect.setParams`, listed by `effect.list` (menu `Effect › Plug-ins`) and baked by Object › Expand Appearance. @@ -47,7 +47,8 @@ heading and buttons in view. Exporters are formats of **File › Export › Expo Artboards is greyed out (the plug-in writes the whole document); one with parameters asks for them in a dialog of the same kind once the file is picked, and its Export button writes the file. **File › Open** of a `.wasm` file installs a plug-in too (on the web this is how plug-ins get in). A heavy filter asks before it is installed, and runs in the -background ([below](#heavy-filters)). +background ([below](#heavy-filters)). A plug-in that lists permissions asks for them before it is installed +([below](#permissions)). **Preference folder.** Edit › Preferences › Plug-ins & Scratch Disks › *Additional Plug-ins Folder*: when set, every `*.wasm` in that folder (not recursive) is installed at startup and whenever the preference changes (desktop builds; @@ -101,6 +102,48 @@ much there is only once the run has ended. - `plugin.run` itself runs a heavy filter where it is called, as it runs any filter: an MCP client waits for it, and its `notifications/cancelled` stops it. +### Permissions + +A manifest may list what the plug-in may do in `permissions`. `doc.read`, `doc.write` and `state` follow the +capability model proposed for PhotoCraft plug-ins +([storytold/photocraft#662](https://github.com/storytold/photocraft/issues/662)); the other names are VectorCraft's own: + +| Permission | Kinds | What it allows | +|---|---|---| +| `doc.read` | all | Read the objects it is given. | +| `doc.write` | filter | Change, add and delete objects in the document. | +| `state` | filter | Keep data of its own with the document (`documentData`). | +| `effect.render` | effect | Change how the objects its effect is applied to are drawn. | +| `export.write` | exporter | Write the document as a file in its own format. | + +- A manifest without `permissions` gets the ones its kind implies, as plug-ins did before the key existed: a filter + `doc.read`, `doc.write` and `state`; an effect `doc.read` and `effect.render`; an exporter `doc.read` and + `export.write`. Such a plug-in installs without a prompt. This rule is VectorCraft's own. A filter whose list is + empty may do none of these, and also installs without a prompt. +- An effect or an exporter that lists permissions lists both of its implied ones. A filter may list any of its three. + An unknown name, a name listed twice, a name that doesn't apply to the kind, more than 16 names, or one of the names + kept for a later plug-in API (`progress`, `ui.panel`, `ui.tool`) is refused when the module is installed, and the + error names it. +- **Installing asks first.** Install Plug-in… and File › Open show the plug-in's name, id, version, the SHA-256 of + the file and each permission in plain words, and install it once the user clicks OK, if the file still has the + SHA-256 shown; Cancel installs nothing. The + names shown have control and bidirectional characters escaped (`\u{202E}`) and are cut to 64 characters. + `plugin.install` refuses the module, naming the permissions it needs, unless `grant` lists them all, which an agent + passes once the user agreed (`plugin.inspect`'s `needsApproval` tells it beforehand), with `hash` set to the + `plugin.inspect` entry's `hash` so that the module installed is the one the user saw. +- The grant is recorded with the plug-in's id, the module's hash and the permissions, in `grants.json` in the + `Plug-ins` folder. Installing a module with the same id that lists no new permission needs no new approval; one that + lists more asks again. `plugin.remove` forgets the grant. When a `Plug-ins` folder is in use, only the grants kept + in it count for `plugin.install`, `plugin.inspect`, `plugin.reload` and the Additional Plug-ins Folder. At startup, + a stored plug-in whose permissions are not granted is skipped. The Additional Plug-ins Folder and `plugin.reload` + install only plug-ins whose permissions were granted, and report the others. +- The host enforces the grant on a filter run: without `doc.read` the filter is given no objects; without `state` its + `documentData` is `null` and an output that sets it is refused; without `doc.write` an output that changes the + document is refused. A refused output leaves the document as it was. +- `api` is the plug-in API level the manifest is written for: `1` (the default) is the only level this host + implements, and a higher one is refused. +- Parameter declarations are unchanged by permissions (`params`, below). + Every run gets a **fresh instance**, so no state survives between calls. Traps, exhausted budgets, malformed modules, bad manifests, out-of-range pointers, negative return codes and invalid output all become errors, and the document is left untouched: a filter's output is checked completely before one edit applies it. A live effect that @@ -159,6 +202,8 @@ pointers are offsets into the module's memory. the kind when the plug-in is installed. - `budget` (filters only): `"light"` (the default) or `"heavy"`, for work that takes minutes ([heavy filters](#heavy-filters)). Hosts from before it ignore the key, so there the filter gets a light run's budget. +- `api`: the plug-in API level, `1` when left out. `permissions`: what the plug-in may do, a list of names + ([permissions](#permissions)); without it, what its kind implies. - `extensions` (exporters only, and required): 1–8 file extensions, lower-case letters and digits without the dot (`["plt"]`); the first is the one Export As gives the file. A built-in format keeps an extension both list (`svg`): `document.export` then writes the exporter's format only when asked for by its format id. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0af2f0670..6ee68a15f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1428,6 +1428,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Filter plug-ins set effects and stroke styles (G17):** a filter is given each object's effect records and its strokes' `join`, `cap` and `miterLimit`, and may return them; the records are admitted as `effect.apply` admits them (a known effect or installed live-effect plug-in, parameters it takes, one 3D effect that fits, no texture read from a file), and one refused refuses the output. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged. - **Plug-in data kept with the document (G17):** a filter writes JSON of at most 1 MiB with `documentData` (one undo step with its objects; `null` removes it) and reads it back in `_context.documentData`, which no other filter is given. It is the document's foreign data (`unknown.pluginData`, by plug-in id), as the perspective grid is, so the native file keeps it with no section of its own; an exporter is given the whole document, so it can read every plug-in's. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_export.rs` (the exporter), `crates/doc/src/plugin_data.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged. - **Heavy filter plug-ins (G17):** a filter's manifest may ask for `"budget": "heavy"`: 1 G instructions (20 times a light run's) and 300 s a run, for routing, packing or simulation. Install Plug-in… and File › Open ask the user first, `plugin.install` needs `allowHeavy: true` (`plugin.inspect` tells beforehand), the Additional Plug-ins Folder skips such filters, and the plug-in store keeps the answer. Its dialog has no Preview; the app runs it on a thread of its own with a progress window (Cancel, Continue in Background) and applies the result as `plugin.run` with the same parameters, one undo step, unless the document or the plug-in changed meanwhile. Regressions: `crates/plugins/tests/abi.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. +- **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one. From 5ebf32e973d9472ea6cfa8658d7504aa68574a2e Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 15:24:53 -0800 Subject: [PATCH 095/160] JavaScript: shrinking an array takes fuel for each property Assigning a smaller length to an array visits every property in one operation. It now takes fuel for each one before anything is removed, so a run with a small fuel limit or a set interrupt flag stops there and leaves the array unchanged. --- crates/js/src/interp.rs | 15 +++++++++++---- crates/js/src/interp_tests.rs | 12 ++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 13005f2be..6a8be82a2 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -1061,12 +1061,19 @@ impl Interp { if f64::from(new) != n { return Err(self.throw(ErrorType::Range, "invalid array length")); } - let o = self.object_mut(id)?; - let Class::Array { length, length_writable } = &mut o.class else { return internal("not an array") }; - if !*length_writable { + let o = self.object(id)?; + let Class::Array { length: old, length_writable } = o.class else { return internal("not an array") }; + if !length_writable { return self.reject(throw, &key("length"), "it is read-only"); } - let old = *length; + if new < old { + // Shrinking visits every property: each takes fuel before anything is removed. + for _ in 0..o.props.len() { + self.use_fuel()?; + } + } + let o = self.object_mut(id)?; + let Class::Array { length, .. } = &mut o.class else { return internal("not an array") }; *length = new; if new < old { let doomed: Vec<Key> = o.props.keys().into_iter().filter(|k| array_index(k).is_some_and(|i| i >= new)).collect(); diff --git a/crates/js/src/interp_tests.rs b/crates/js/src/interp_tests.rs index af17dec5b..0634f6cf9 100644 --- a/crates/js/src/interp_tests.rs +++ b/crates/js/src/interp_tests.rs @@ -374,6 +374,18 @@ fn for_in_names_take_fuel_and_heap_under_later_limits() { assert!(realm.interp().heap.bytes <= heap_bytes, "{} > {heap_bytes}", realm.interp().heap.bytes); } +#[test] +fn shrinking_an_array_takes_fuel_for_each_property() { + let mut realm = small(Limits::default()); + assert_eq!(realm.run("var a = []; for (var i = 0; i < 100000; i++) a[i] = i; a.length"), Ok(JsValue::Number(100_000.0))); + realm.set_limits(Limits { fuel: 1000, ..Limits::default() }); + assert_eq!(limit(&mut realm, "a.length = 0;"), LimitKind::Fuel); + realm.set_limits(Limits::default()); + // The run stopped before anything was removed. + assert_eq!(realm.run("a.length + a[99999]"), Ok(JsValue::Number(199_999.0))); + assert_eq!(realm.run("a.length = 1; a.length + a[0] + (99999 in a ? 1 : 0)"), Ok(JsValue::Number(1.0))); +} + #[test] fn declarations_check_the_heap_before_they_are_bound() { // Each script declares more than fits in the heap. The run ends with the heap limit before From 0b936bf60e266fcb91f53de313afdcd0cb560c2a Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 15:29:40 -0800 Subject: [PATCH 096/160] JavaScript: the script a realm runs does not count against the re-entry limit Limits::reentry bounds runs inside an operation (getters, setters, conversions, indirect eval). The top-level script took one of those slots, so a limit of 0 refused every script and a limit of 1 left no room for a getter. Only indirect eval now counts when it starts a global run. --- crates/js/src/interp.rs | 9 ++++++--- crates/js/src/interp_tests.rs | 11 +++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 6a8be82a2..7df501699 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -1810,7 +1810,10 @@ impl Interp { /// Indirect eval and scripts: runs `code` in the global environment and returns its /// completion value. pub(crate) fn run_global(&mut self, code: Rc<Code>, is_eval: bool) -> JsResult<Value> { - if self.reentry >= self.limits.reentry { + // Indirect eval runs inside an operation and counts against the re-entry limit; the + // script a realm runs does not. + let nested = usize::from(is_eval); + if is_eval && self.reentry >= self.limits.reentry { return Err(Abrupt::Limit(LimitKind::Reentry)); } self.charge_code(&code, declaration_bytes(&code).saturating_add(ENV_BYTES))?; @@ -1823,9 +1826,9 @@ impl Interp { }; self.instantiate_global(&code, var_env, is_eval)?; self.push_code_frame(code, env, var_env, Value::Object(global), true)?; - self.reentry += 1; + self.reentry += nested; let r = self.execute(); - self.reentry -= 1; + self.reentry -= nested; r } diff --git a/crates/js/src/interp_tests.rs b/crates/js/src/interp_tests.rs index 0634f6cf9..30c84d857 100644 --- a/crates/js/src/interp_tests.rs +++ b/crates/js/src/interp_tests.rs @@ -515,6 +515,17 @@ fn reentry_through_getters_ends_with_the_reentry_limit() { } } +#[test] +fn the_script_itself_does_not_count_against_the_reentry_limit() { + let mut realm = small(Limits { reentry: 0, ..Limits::default() }); + assert_eq!(realm.run("1 + 1"), Ok(JsValue::Number(2.0))); + assert_eq!(limit(&mut realm, "var o = { get x() { return 1; } }; o.x"), LimitKind::Reentry); + realm.set_limits(Limits { reentry: 1, ..Limits::default() }); + assert_eq!(realm.run("o.x"), Ok(JsValue::Number(1.0))); + assert_eq!(realm.run("(0, eval)('2 + 2')"), Ok(JsValue::Number(4.0))); + assert_eq!(limit(&mut realm, "var p = { get x() { return o.x; } }; p.x"), LimitKind::Reentry); +} + #[test] fn memory_bombs_end_with_the_heap_limit() { let limits = Limits { heap_bytes: 4 << 20, ..Limits::default() }; From 7ca34c522dca3c1d77ba20c02026699e4ae39411 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 15:49:34 -0800 Subject: [PATCH 097/160] .ai save: the large-canvas warning names every effect the editing copy leaves out On a large canvas no art style is written, so objects with live effects keep only their geometry effects in the editing data for other apps: blurs are left out as well as shadows, glows and feathers. The warning now says that no art styles are written and that only geometry effects are kept. The comment on rgb_without_cmyk in art_styles_write_the_same_bytes_again now gives the cause of the Windows difference: the Generic CMYK separation of the default Pink swatch, at the edge of the press gamut, lands on C0 M93.9 Y16.3 or C0 M93.2 Y15.3 when its RGB changes in the last float bits, and the platform's float math decides which. The comment also says that named and spot RGB colors are not blanked. --- crates/eps/src/ai_editing/tests.rs | 12 +++++++----- crates/eps/src/ai_editing/writer/styles.rs | 3 ++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index a26463c96..f501555d8 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -5581,11 +5581,13 @@ fn art_styles_write_the_same_bytes_again() { near_lines(&rgb_without_cmyk(&shown(&again)), &rgb_without_cmyk(&shown(&first.program))); } -/// `program` with the CMYK equivalents of RGB colors blanked out: the first four numbers of an RGB -/// color's `c m y k r g b Xa` (or `XA`) and of an RGB gradient stop's `c m y k r g b 2 ... Bs`. A -/// save of the reopened file computes them again from the RGB values read back at four decimals, -/// and on some platforms the CMYK conversion moves them by more than [`near_lines`] allows. The RGB -/// values stay compared. +/// `program` with the CMYK equivalents of plain RGB colors blanked out: the first four numbers of +/// `c m y k r g b Xa` (or `XA`) and of an RGB gradient stop's `c m y k r g b 2 ... Bs`. Named and +/// spot RGB colors (`... (name) tint Xk`, `Xx` and spot stops) are not blanked; the document this +/// is used on has none. The Generic CMYK separation of a color at the edge of the press gamut can +/// land on one of two results that differ by up to 0.01 when the RGB changes in its last float +/// bits, as it does between the first save and the save of the reopened file, and the platform's +/// float math decides which. The RGB values stay compared. fn rgb_without_cmyk(program: &str) -> String { let blank = |line: &str| { let (prefix, rest) = line.strip_prefix("%_").map_or(("", line), |r| ("%_", r)); diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index ffd4b4fcf..8307ea302 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -18,7 +18,8 @@ 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 vector art with their geometry effects applied, without shadows, glows or feathers, in the editing data for other apps"; +pub(in crate::ai_editing) const LARGE_CANVAS_STYLES: &str = + "on a large canvas, no art styles are written: objects with live effects keep only their geometry effects 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 From 263c7cb92609fb4669d54018bb6158e783c8d29d Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 17:10:48 -0800 Subject: [PATCH 098/160] JavaScript: arguments from apply stay alive while a native function runs A native function called through apply could run JavaScript that started a collection, which freed the arguments apply had read from the array-like object. The native call now roots its receiver and arguments. The parsing entry in docs/target-app-parity.md no longer says that nothing runs scripts. --- crates/js/src/interp.rs | 11 ++++++++++- crates/js/src/interp_tests.rs | 13 +++++++++++++ docs/target-app-parity.md | 2 +- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 7df501699..1740c5733 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -1508,7 +1508,16 @@ impl Interp { this = args.first().cloned().unwrap_or(Value::Undefined); args = self.list_from_array_like(&list)?; } - Func::Native { f: native, .. } => return Ok(Prepared::Done(native(self, this, &args, false)?)), + Func::Native { f: native, .. } => { + // A list from `apply` is not on the operand stack, and a native function can run + // JavaScript that collects, so the arguments stay rooted for the call. + let mark = self.temp_roots.len(); + self.temp_roots.push(this.clone()); + self.temp_roots.extend(args.iter().cloned()); + let r = native(self, this, &args, false); + self.temp_roots.truncate(mark); + return Ok(Prepared::Done(r?)); + } Func::Host { f: host, .. } => { let js_args: Vec<crate::JsValue> = args.iter().map(|v| self.to_js_value(v)).collect(); return match host(&js_args) { diff --git a/crates/js/src/interp_tests.rs b/crates/js/src/interp_tests.rs index 30c84d857..6c2608f2b 100644 --- a/crates/js/src/interp_tests.rs +++ b/crates/js/src/interp_tests.rs @@ -456,6 +456,19 @@ fn a_lower_heap_limit_applies_to_what_is_already_live() { assert_eq!(limit(&mut realm, "var more = []; for (var j = 0; j < 20000; j++) more[j] = {j: j};"), LimitKind::Heap); } +#[test] +fn apply_keeps_its_arguments_alive_while_a_native_function_runs() { + // The first argument's toString makes 20,000 objects, more than a 1 MiB heap holds, so a + // collection runs before it returns. The second argument is reachable only from the list + // that apply read from the array-like object. + let mut realm = small(Limits { heap_bytes: 1 << 20, ..Limits::default() }); + let src = "var list = {length: 2, \ + get 0() { return {toString: function () { for (var i = 0; i < 20000; i++) ({i: i}); return 'a'; }}; }, \ + get 1() { return {toString: function () { return 'return a * 2'; }}; }}; \ + Function.apply(null, list)(21)"; + assert_eq!(realm.run(src), Ok(JsValue::Number(42.0))); +} + #[test] fn to_number_skips_the_white_space_the_lexer_skips() { let mut realm = small(Limits::default()); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 27e8b3d79..ccc436f7c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -653,7 +653,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads). - Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785); `run --commands FILE` (or `-` for standard input) reads the steps from a JSON array or JSON Lines, past the Windows command-line limit (#3147, `apps/vectorcraft-cli/tests/it/cli.rs` `run_reads_*`, `run_refuses_malformed_commands_naming_the_step`). - File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files. - - `vectorcraft-js` parses JavaScript as ECMAScript 5.1 defines it, with the legacy octal forms, block functions and lenient regular expressions of Annex B, into a syntax tree with source spans; it reads the `#target`, `#include` and other preprocessor lines as directives without running them, and a script that breaks the grammar or an early-error rule gives a `SyntaxError` with its line and column. Nesting past the parser's depth limit (`Options::max_depth`, 200 by default) gives a `SyntaxError` instead of exhausting the stack. `cargo xtask test262` fetches the conformance suite at a pinned commit and parses its ES5 tests: 5,411 of 5,418 runs pass (the 7 others use `let` and `const`). Nothing runs scripts yet. Tests: `crates/js/src/tests.rs`, `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs`. + - `vectorcraft-js` parses JavaScript as ECMAScript 5.1 defines it, with the legacy octal forms, block functions and lenient regular expressions of Annex B, into a syntax tree with source spans; it reads the `#target`, `#include` and other preprocessor lines as directives without running them, and a script that breaks the grammar or an early-error rule gives a `SyntaxError` with its line and column. Nesting past the parser's depth limit (`Options::max_depth`, 200 by default) gives a `SyntaxError` instead of exhausting the stack. `cargo xtask test262` fetches the conformance suite at a pinned commit and parses its ES5 tests: 5,411 of 5,418 runs pass (the 7 others use `let` and `const`). This entry covers parsing; the next entry covers running scripts. Tests: `crates/js/src/tests.rs`, `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs`. - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap. Built-ins are limited to `Object`, `Function` with `call` and `apply`, `eval` and the error types. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only these built-ins: 3,074 of 3,078 runs pass (the 4 others expect the completion values of later editions). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. - **UI:** Illustrator 2026 layout restyled to measured values: - Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs. From aa3bcc4c77347800d0c88dd179d8bce364bbc444 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 17:22:26 -0800 Subject: [PATCH 099/160] JavaScript: each NativeError prototype is an Error object Object.prototype.toString gives "[object Error]" for TypeError.prototype and the other NativeError prototypes, as ECMAScript 5.1 section 15.11.7.7 defines them. Before, only Error.prototype had the Error class. --- crates/js/src/builtins.rs | 7 +++---- crates/js/src/interp_tests.rs | 5 +++++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index b16aa9c9e..3eda7b6a6 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -113,12 +113,11 @@ fn install_errors(interp: &mut Interp, object_proto: ObjId) -> JsResult<()> { ]; let mut error_proto = object_proto; for (i, (name, f)) in kinds.into_iter().enumerate() { - let proto = alloc(interp, Class::Ordinary, Some(if i == 0 { object_proto } else { error_proto })); + // Error.prototype and each NativeError prototype are Error objects (sections 15.11.4 and + // 15.11.7.7). + let proto = alloc(interp, Class::Error, Some(if i == 0 { object_proto } else { error_proto })); if i == 0 { error_proto = proto; - if let Some(p) = interp.heap.object_mut(proto) { - p.class = Class::Error; - } method(interp, proto, "toString", error_to_string, 0)?; } if let Some(slot) = interp.intrinsics.error_protos.get_mut(i) { diff --git a/crates/js/src/interp_tests.rs b/crates/js/src/interp_tests.rs index 6c2608f2b..8dff13050 100644 --- a/crates/js/src/interp_tests.rs +++ b/crates/js/src/interp_tests.rs @@ -118,6 +118,11 @@ fn objects_arrays_and_accessors() { truth("'abc'.length === 3 && 'abc'[1] === 'b' && 'abc'.x === undefined"); truth("var o = {}; o.hasOwnProperty('x') === false && ({x: 1}).hasOwnProperty('x')"); truth("Object.prototype.toString.call([]) === '[object Array]' && Object.prototype.toString.call(null) === '[object Null]'"); + // Error.prototype and each NativeError prototype are Error objects (sections 15.11.4 and 15.11.7.7). + truth( + "var s = Object.prototype.toString, all = [Error, EvalError, RangeError, ReferenceError, SyntaxError, TypeError, URIError], ok = true;\ + for (var i = 0; i < all.length; i++) ok = ok && s.call(all[i].prototype) === '[object Error]'; ok", + ); let e = fail("var a = []; a.length = -1"); assert!(e.message.starts_with("RangeError"), "{}", e.message); let e = fail("'use strict'; var o = { get x() { return 1; } }; o.x = 2;"); From ed5de5edcb1c822ee481ce16a287368431419973 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 17:32:54 -0800 Subject: [PATCH 100/160] =?UTF-8?q?Plug-ins:=20a=20plug-in=20adds=20menu?= =?UTF-8?q?=20commands=20of=20its=20own=20under=20Object=20=E2=80=BA=20Plu?= =?UTF-8?q?g-ins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A filter or live effect may declare up to 32 `commands` in its manifest, each `{id, label, params?}`. While the plug-in is installed each one is the command `plugin.<id>.<command>`: listed by `command.list` and in the plug-in's `plugin.list` entry, run through `run`, MCP and `command.batch` with its parameters checked before the plug-in runs, and recorded in actions. A run goes through `vc_run` on the selection with `_context.mode` "command" and `_context.command`, under the plug-in's permissions, as one undo step named after the command. A failing run leaves the document as it was, and its error starts with the plug-in's name and the command's label. Removing or replacing the plug-in removes its commands. In the app, a plug-in with commands is a submenu named after it under Object › Plug-ins (Effect › Plug-ins for a live effect), holding its own item and then its commands; a `menu` key in a manifest is ignored. A command with parameters opens a dialog for them from its menu item or a shortcut. Edit › Keyboard Shortcuts lists the commands for the user to give a shortcut; a manifest cannot set one. Labels and names are shown with control and bidirectional characters escaped, and a malformed entry is refused by name when the module is installed. Docs: docs/plugins.md, docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd.rs | 10 +- crates/engine/src/cmd/menucmds.rs | 2 +- crates/engine/src/cmd/plugin.rs | 194 +++++++++++++++++- crates/engine/src/session/documents.rs | 5 +- crates/engine/src/session/execute.rs | 2 +- crates/engine/src/tests/plugins.rs | 136 ++++++++++++ crates/engine/tests/plugin_fuzz.rs | 74 +++++++ crates/plugins/src/lib.rs | 6 +- crates/plugins/src/manifest.rs | 191 +++++++++++++++++ crates/ui-egui/src/dialogs/plugin.rs | 97 +++++++-- crates/ui-egui/src/dialogs/shortcut_import.rs | 6 +- crates/ui-egui/src/dialogs/tests.rs | 1 + crates/ui-egui/src/i18n/ca.tsv | 8 + crates/ui-egui/src/i18n/cs.tsv | 8 + crates/ui-egui/src/i18n/de.tsv | 8 + crates/ui-egui/src/i18n/el.tsv | 8 + crates/ui-egui/src/i18n/es.tsv | 8 + crates/ui-egui/src/i18n/fi.tsv | 8 + crates/ui-egui/src/i18n/fr.tsv | 8 + crates/ui-egui/src/i18n/hi.tsv | 8 + crates/ui-egui/src/i18n/hu.tsv | 8 + crates/ui-egui/src/i18n/it.tsv | 8 + crates/ui-egui/src/i18n/ja.tsv | 8 + crates/ui-egui/src/i18n/ka.tsv | 8 + crates/ui-egui/src/i18n/ko.tsv | 8 + crates/ui-egui/src/i18n/pl.tsv | 8 + crates/ui-egui/src/i18n/pt-br.tsv | 8 + crates/ui-egui/src/i18n/ru.tsv | 8 + crates/ui-egui/src/i18n/sv.tsv | 8 + crates/ui-egui/src/i18n/tr.tsv | 8 + crates/ui-egui/src/i18n/uk.tsv | 8 + crates/ui-egui/src/i18n/vi.tsv | 8 + crates/ui-egui/src/i18n/zh-hans.tsv | 8 + crates/ui-egui/src/i18n/zh-hant.tsv | 8 + crates/ui-egui/src/keymap.rs | 20 +- crates/ui-egui/src/menus/entries.rs | 2 + crates/ui-egui/src/menus/invoke.rs | 4 + crates/ui-egui/src/menus/labels.rs | 16 +- crates/ui-egui/src/palette.rs | 3 +- crates/ui-egui/src/shortcut_editor.rs | 88 ++++++-- crates/ui-egui/src/shortcuts.rs | 2 +- crates/ui-egui/src/tests/plugins.rs | 84 ++++++++ docs/mcp.md | 7 + docs/plugins.md | 33 ++- docs/target-app-parity.md | 1 + 45 files changed, 1104 insertions(+), 56 deletions(-) diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index f02d6f6aa..30d7302a7 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -350,8 +350,16 @@ pub fn command_specs() -> &'static [CommandSpec] { }) } +/// The command `id`: a built-in one, or a command an installed plug-in adds +/// ([`plugin::command_specs`]). pub fn find_command(id: &str) -> Option<&'static CommandSpec> { - command_specs().iter().find(|c| c.id == id) + command_specs().iter().find(|c| c.id == id).or_else(|| plugin::find_spec(id)) +} + +/// Runs command `spec`'s implementation with `p`. A plug-in command's runs the plug-in +/// ([`plugin::run_command`]), which needs the command's id. +pub(crate) fn run_spec(s: &mut Session, spec: &CommandSpec, p: &Value) -> Result<Value> { + if plugin::is_command_id(spec.id) { plugin::run_command(s, spec.id, p) } else { (spec.run)(s, p) } } // ---------- param helpers ---------- diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 95f8c3e8f..089cf8d18 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -206,7 +206,7 @@ pub(crate) fn whole_or(p: &Value, key: &str, default: u64, cmd: &str) -> Result< /// Run a command's implementation without the enablement check or journaling. pub(crate) fn run_raw(s: &mut Session, id: &str, p: &Value) -> Result<Value> { let c = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?; - (c.run)(s, p) + super::run_spec(s, c, p) } /// Merge the undo entries pushed since the history had `from` entries into one labelled step. diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 565eded7f..53fdd5ad2 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -19,12 +19,19 @@ //! a filter does only what they allow: without `doc.read` it is given no objects, without //! `doc.write` an output that changes the document is refused, and without `state` it neither //! reads nor writes the data it keeps with the document. +//! +//! A filter or a live effect may add menu commands of its own (its manifest's `commands`). Each +//! is the command `plugin.<plug-in id>.<command id>` while the plug-in is installed +//! ([`command_specs`], which [`super::find_command`] and the command listings read): under +//! Object › Plug-ins › the plug-in's name (Effect › Plug-ins for a live effect's), journaled so +//! actions record it, and run as a filter run of the plug-in ([`run_command`]) with the command's +//! id in `_context`, its own checked parameters and the plug-in's permissions, as one undo step. -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, RwLock}; use serde_json::{Map, Value, json}; use vectorcraft_doc::{Effect, Node}; -use vectorcraft_plugins::{Budget, Kind, Plugin, effect, export, objects, registry}; +use vectorcraft_plugins::{Budget, Command, Kind, Plugin, effect, export, objects, registry}; use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, has_doc, ids_param, str_param, switch_or}; @@ -58,6 +65,18 @@ pub fn describe(p: &Plugin) -> Value { if m.budget == Budget::Heavy { v["budget"] = json!("heavy"); } + if !m.commands.is_empty() { + let commands: Vec<Value> = m + .commands + .iter() + .map(|c| { + let run = m.for_command(c); + json!({"id": c.id, "command": command_id(&m.id, &c.id), "label": c.shown_label(), "params": run.params_doc(), + "paramsSchema": serde_json::to_value(&run).ok().and_then(|v| v.get("params").cloned())}) + }) + .collect(); + v["commands"] = json!(commands); + } v } @@ -88,6 +107,8 @@ fn plugin_by_id(cmd: &str, p: &Value) -> Result<Arc<Plugin>> { /// like any other command. pub struct FilterRun { plugin: Arc<Plugin>, + /// The plug-in command it runs (`plugin.<id>.<command>`), if it isn't `plugin.run`'s run. + command: Option<Command>, input: Vec<u8>, params: Vec<u8>, /// The objects the plug-in is given, and the container new objects go in. @@ -129,8 +150,18 @@ impl FilterRun { )); } } - // Bad parameters fail before anything runs. - let mut params = Value::Object(m.resolve_params(&plugin_params(p)).map_err(|e| bad(C, e.to_string()))?); + Self::prepare_with(s, plugin, None, C, p) + } + + /// The run of `plugin`, or of its `command`, that command `c`'s params `p` ask for, on the + /// active document. Bad parameters fail before anything runs. + fn prepare_with(s: &Session, plugin: Arc<Plugin>, command: Option<Command>, c: &str, p: &Value) -> Result<FilterRun> { + let m = plugin.manifest(); + let resolved = match &command { + Some(cmd) => m.for_command(cmd).resolve_params(&plugin_params(p)), + None => m.resolve_params(&plugin_params(p)), + }; + let mut params = Value::Object(resolved.map_err(|e| bad(c, e.to_string()))?); let may = m.permissions(); let st = s.doc()?; let selected = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.clone()); @@ -143,10 +174,15 @@ impl FilterRun { // What the plug-in keeps with the document, as it last wrote it. let data = st.doc.plugin_data(&m.id).filter(|_| may.contains(&Permission::State)).cloned().unwrap_or(Value::Null); params["_context"] = json!({"mode": "filter", "bounds": bounds.map(rect), "artboard": board.map(|a| rect(a.rect)), "documentData": data}); + // A command run says which command it is. + if let Some(cmd) = &command { + params["_context"]["mode"] = json!("command"); + params["_context"]["command"] = json!(cmd.id); + } let input = objects::encode_input(&st.doc, &inputs); - let params = serde_json::to_vec(¶ms).map_err(|e| bad(C, e.to_string()))?; + let params = serde_json::to_vec(¶ms).map_err(|e| bad(c, e.to_string()))?; let document = (st.uid, st.revision); - Ok(FilterRun { plugin, input, params, inputs, parent, document }) + Ok(FilterRun { plugin, command, input, params, inputs, parent, document }) } /// The plug-in, for its name and budget. @@ -197,7 +233,9 @@ impl FilterRun { } let may_write = may.contains(&Permission::DocWrite); let (inputs, parent) = (self.inputs, self.parent); - let ids = s.edit(m.name.trim_end_matches('…'), |d, sel| { + // A command's undo step is named after the command. + let label = self.command.as_ref().map_or_else(|| m.name.trim_end_matches('…').to_string(), Command::shown_label); + let ids = s.edit(&label, |d, sel| { let before = (!may_write).then(|| d.layers.clone()); let ids = objects::apply_output(d, &inputs, out.objects, parent, &admit_effects).map_err(err)?; if before.is_some_and(|b| b != d.layers) { @@ -209,7 +247,11 @@ impl FilterRun { sel.set(ids.iter().copied().filter(|&id| d.node(id).is_some_and(objects::selectable))); Ok(ids) })?; - Ok(json!({"plugin": m.id, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})) + let mut out = json!({"plugin": m.id, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}); + if let Some(cmd) = &self.command { + out["command"] = json!(cmd.id); + } + Ok(out) } } @@ -239,6 +281,140 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> { r.finish(s, &output) } +/// The command id of plug-in `plugin`'s command `command`: `plugin.<plugin>.<command>`. +pub fn command_id(plugin: &str, command: &str) -> String { + format!("{}{plugin}.{command}", vectorcraft_plugins::PREFIX) +} + +/// Is `id` shaped as a plug-in command's id (`plugin.<plug-in id>.<command id>`)? This module's +/// own `plugin.*` commands have no dot after the prefix, and a command id has none. +pub fn is_command_id(id: &str) -> bool { + id.strip_prefix(vectorcraft_plugins::PREFIX).is_some_and(|rest| rest.contains('.')) +} + +/// The installed plug-in and its command that command id `id` names. +pub fn command_of(id: &str) -> Option<(Arc<Plugin>, Command)> { + let (plugin, command) = id.strip_prefix(vectorcraft_plugins::PREFIX)?.rsplit_once('.')?; + let p = registry::get(plugin)?; + let c = p.manifest().command(command)?.clone(); + Some((p, c)) +} + +/// The plug-in commands of the installed plug-ins, the registry revision they were listed at, and +/// a count that changes whenever the list does ([`commands_generation`]). +struct Listed { + revision: Option<u64>, + generation: u64, + specs: Vec<&'static CommandSpec>, +} + +static LISTED: RwLock<Listed> = RwLock::new(Listed { revision: None, generation: 0, specs: Vec::new() }); + +/// Every command spec made for a plug-in command, made once: specs hold static strings, so memory +/// stays bounded by the plug-in commands ever installed. +static MADE: Mutex<Vec<&'static CommandSpec>> = Mutex::new(Vec::new()); + +/// The commands the installed plug-ins add, in plug-in id order and then in each manifest's order. +pub fn command_specs() -> Vec<&'static CommandSpec> { + let revision = registry::revision(); + if let Ok(l) = LISTED.read() + && l.revision == Some(revision) + { + return l.specs.clone(); + } + let specs: Vec<&'static CommandSpec> = + registry::list().iter().flat_map(|p| p.manifest().commands.iter().map(|c| spec_of(p, c)).collect::<Vec<_>>()).collect(); + let mut l = LISTED.write().unwrap_or_else(|e| e.into_inner()); + if l.specs.len() != specs.len() || l.specs.iter().zip(&specs).any(|(a, b)| !std::ptr::eq(*a, *b)) { + l.generation += 1; + } + l.revision = Some(revision); + l.specs.clone_from(&specs); + specs +} + +/// Changes whenever the plug-in commands [`command_specs`] lists change, for caches built from +/// them (the keyboard shortcuts' catalogue). +pub fn commands_generation() -> u64 { + command_specs(); + LISTED.read().map(|l| l.generation).unwrap_or_else(|e| e.into_inner().generation) +} + +/// The spec of installed plug-in command `id`. +pub fn find_spec(id: &str) -> Option<&'static CommandSpec> { + if !is_command_id(id) { + return None; + } + command_specs().into_iter().find(|c| c.id == id) +} + +/// The command spec of plug-in `p`'s command `c`: its id, its label as menus show it (escaped and +/// capped), its menu (Object › Plug-ins › the plug-in's name, or Effect › Plug-ins › it for a live +/// effect: never a menu the manifest names) and its params description. +fn spec_of(p: &Plugin, c: &Command) -> &'static CommandSpec { + let m = p.manifest(); + let id = command_id(&m.id, &c.id); + let label = c.shown_label(); + let top = if m.kind == Kind::Effect { "Effect" } else { "Object" }; + let name = permissions::display_text(m.name.trim(), permissions::MAX_SHOWN_NAME); + let mut fields = String::from("ids?: [..] (default: the selection), params?: {…} (its parameters; or give them as top-level keys)"); + let doc = m.for_command(c).params_doc(); + if let Some(own) = doc.strip_prefix('{').and_then(|d| d.strip_suffix('}')).filter(|d| !d.is_empty()) { + fields.push_str(", "); + fields.push_str(own); + } + let params = format!( + "{{{fields}}} run “{label}”, a command of plug-in {}, on the selected paths and compound paths as plugin.run runs a filter, with _context.command {:?}: one undo step → {{plugin, command, ids}}", + m.id, c.id + ); + let mut made = MADE.lock().unwrap_or_else(|e| e.into_inner()); + let same = |s: &&&'static CommandSpec| s.id == id && s.label == label && s.menu == [top, "Plug-ins", name.as_str()] && s.params == params; + if let Some(s) = made.iter().find(same) { + return s; + } + let menu: &'static [&'static str] = Box::leak(vec![top, "Plug-ins", registry::intern(&name)].into_boxed_slice()); + let spec: &'static CommandSpec = Box::leak(Box::new(CommandSpec { + id: registry::intern(&id), + label: registry::intern(&label), + menu, + shortcut: None, + params: registry::intern(¶ms), + enabled: has_doc, + run: unrouted, + journal: true, + })); + made.push(spec); + spec +} + +/// A plug-in command's `run`: plug-in commands run through [`super::run_spec`], which passes +/// [`run_command`] the command id. +fn unrouted(_: &mut Session, _: &Value) -> Result<Value> { + Err(EngineError::Internal { cmd: "plugin".into(), msg: "a plug-in command runs through Session::execute".into() }) +} + +/// Runs plug-in command `id` (`plugin.<plug-in id>.<command id>`): a filter run of the plug-in on +/// `ids` (else the selection) with the command's own parameters, checked before it runs, and +/// `_context.command`, under the plug-in's permissions and budget, applied as one undo step named +/// after the command → `{plugin, command, ids}`. A run that fails (a trap, an exhausted budget, an +/// output it may not make) leaves the document as it was, and the error names the plug-in and the +/// command. +pub(crate) fn run_command(s: &mut Session, id: &str, p: &Value) -> Result<Value> { + // An output handed in is for `plugin.run` alone. + s.plugin_output = None; + let (plugin, command) = command_of(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?; + let failed = { + let (name, label) = (permissions::display_text(&plugin.manifest().name, permissions::MAX_SHOWN_NAME), command.shown_label()); + move |e: EngineError| match e { + EngineError::Cancelled => EngineError::Cancelled, + e => EngineError::Other(format!("“{name}” › {label}: {e}")), + } + }; + let r = FilterRun::prepare_with(s, plugin, Some(command), id, p)?; + let output = r.run().map_err(&failed)?; + r.finish(s, &output).map_err(failed) +} + /// Admits the effect records a filter returned for object `n` (written, its effects still its own) /// as `effect.apply` would apply them: each new record a known effect (one of ours, or an installed /// live-effect plug-in) with parameters it takes, filled in to their defaults. A record the object @@ -493,7 +669,7 @@ pub fn specs() -> Vec<CommandSpec> { "List Plug-ins", [], None, - "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's format id, plugin.<id>, for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies), hash (the module's SHA-256)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}", + "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's format id, plugin.<id>, for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies), hash (the module's SHA-256), commands?: [{id, command (its command id, plugin.<id>.<command id>, which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}", always, list ), diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 41946c0eb..c15ebf41d 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -313,9 +313,10 @@ impl Session { true } - /// Commands with enablement (for menus, palette, MCP `list_commands`). + /// Commands with enablement (for menus, palette, MCP `list_commands`), the installed plug-ins' + /// commands last. pub fn commands(&self) -> Vec<CommandInfo> { - command_specs().iter().map(|c| c.info(self)).collect() + command_specs().iter().chain(crate::cmd::plugin::command_specs()).map(|c| c.info(self)).collect() } } diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index f993ac585..d78c11d04 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -91,7 +91,7 @@ impl Session { if let Some(ids) = &select_first { s.execute("select.set", &serde_json::json!({ "ids": ids }))?; } - let r = (spec.run)(s, params); + let r = cmd::run_spec(s, spec, params); if r.is_err() && let Some((uid, selection, layer)) = selected_before && let Some(st) = s.docs.iter_mut().find(|d| d.uid == uid) diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 36694ef87..2dfd61b48 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -854,3 +854,139 @@ fn a_cancelled_filter_run_changes_nothing() { assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}"); assert_eq!(s.doc().unwrap().doc, before); } + +/// The manifest of a filter `id` named "Cmd Test" of `kind` with `commands` (a JSON list). +fn with_commands(id: &str, kind: &str, commands: &str) -> String { + format!(r#"{{"id":"{id}","name":"Cmd Test","version":"1","kind":"{kind}","commands":{commands}}}"#) +} + +/// Two commands: Grow, with a parameter, and Tidy, whose `menu` names another menu. +const GROW_AND_TIDY: &str = r#"[{"id":"grow","label":"Grow","params":{"by":{"type":"number","min":0,"max":10,"default":2}}}, + {"id":"tidy","label":"Tidy","menu":"File"}]"#; + +/// A plug-in that adds a triangle when it runs as its command `grow` (`_context.command`) and +/// returns its input unchanged otherwise. +fn grower(id: &str) -> String { + let triangle = r#"{"objects":[{"path":{"subpaths":[{"closed":true,"anchors":[{"p":[0,0]},{"p":[5,0]},{"p":[5,5]}]}]}}]}"#; + tpl::when_given(&with_commands(id, "filter", GROW_AND_TIDY), tpl::Given::Params, r#""command":"grow""#, triangle) +} + +/// The command listing's entry for `id`. +fn listed(s: &Session, id: &str) -> Option<crate::cmd::CommandInfo> { + s.commands().into_iter().find(|c| c.id == id) +} + +fn objects(s: &Session) -> usize { + s.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum() +} + +/// A plug-in's commands are commands of their own while it is installed: listed under Object › +/// Plug-ins › its name whatever menu the manifest names, run with their checked parameters and +/// `_context.command` as one undo step named after them, and gone once it is removed or replaced +/// by a module without them. +#[test] +fn plugin_commands_are_registered_run_and_removed() { + let mut s = session(); + let id = "test.engine.cmds"; + let (grow, tidy) = ("plugin.test.engine.cmds.grow", "plugin.test.engine.cmds.tidy"); + let info = install_wat(&mut s, &grower(id)); + assert_eq!(info["commands"][0]["command"], grow); + assert_eq!(info["commands"][1]["label"], "Tidy"); + for (cmd, label) in [(grow, "Grow"), (tidy, "Tidy")] { + let c = listed(&s, cmd).unwrap_or_else(|| panic!("{cmd} is listed")); + assert_eq!((c.label, c.menu.as_slice()), (label, &["Object", "Plug-ins", "Cmd Test"][..]), "{cmd}"); + assert!(c.enabled, "{cmd}"); + } + assert!(crate::cmd::find_command(grow).is_some()); + + run(&mut s, "select.none", json!({})); + let (before, undo) = (objects(&s), s.doc().unwrap().history.undo.len()); + let r = run(&mut s, grow, json!({"by": 3})); + assert_eq!((r["plugin"].as_str(), r["command"].as_str()), (Some(id), Some("grow"))); + assert_eq!(objects(&s), before + 1, "the command added its triangle"); + let st = s.doc().unwrap(); + assert_eq!((st.history.undo.len(), st.history.undo.last().unwrap().label.as_str()), (undo + 1, "Grow")); + run(&mut s, "edit.undo", json!({})); + assert_eq!(objects(&s), before); + // Its parameters are checked before it runs. + let e = s.execute(grow, &json!({"by": "lots"})).unwrap_err(); + assert!(matches!(&e, EngineError::BadParams { cmd, .. } if cmd == grow), "{e}"); + assert_eq!(objects(&s), before); + // Another command of the same plug-in is told apart by its id. + run(&mut s, tidy, json!({})); + assert_eq!(objects(&s), before, "tidy returns its input unchanged"); + + // Replaced by a module without commands, its commands are gone; installed again, back. + install_wat(&mut s, &tpl::module(&tpl::manifest(id, "filter", "{}"), tpl::ECHO, "", 1)); + assert!(listed(&s, grow).is_none() && crate::cmd::find_command(grow).is_none()); + install_wat(&mut s, &grower(id)); + assert!(listed(&s, grow).is_some()); + run(&mut s, "plugin.remove", json!({"id": id})); + assert!(listed(&s, grow).is_none() && listed(&s, tidy).is_none()); + assert!(s.execute(grow, &json!({})).is_err(), "removed, it no longer runs"); +} + +/// A command run is journaled like any other, so an action records it with its parameters and +/// plays it back. +#[test] +fn actions_record_and_play_plugin_commands() { + let mut s = session(); + let id = "test.engine.cmds.actions"; + let grow = crate::cmd::plugin::command_id(id, "grow"); + install_wat(&mut s, &grower(id)); + run(&mut s, "select.none", json!({})); + let before = objects(&s); + let start = s.journal.len(); + run(&mut s, &grow, json!({"by": 4})); + let steps = s.journal_for_action(start); + assert_eq!(steps, [(grow.clone(), json!({"by": 4}))]); + run(&mut s, "edit.undo", json!({})); + let commands: Vec<Value> = steps.iter().map(|(c, p)| json!({"command": c, "params": p})).collect(); + // The triangle a run adds is selected, and the next run would be given it: deselect between. + let none = json!({"command": "select.none", "params": {}}); + run(&mut s, "command.batch", json!({"label": "Grow Twice", "commands": [commands[0].clone(), none, commands[0].clone()]})); + assert_eq!(objects(&s), before + 2, "both steps ran"); + run(&mut s, "plugin.remove", json!({"id": id})); +} + +/// A command that traps leaves the document and its history as they were, and the error names +/// the plug-in and the command. +#[test] +fn a_failing_plugin_command_changes_nothing_and_names_itself() { + let mut s = session(); + rect(&mut s, 10.0, "#ff0000"); + run(&mut s, "select.all", json!({})); + let id = "test.engine.cmds.trap"; + install_wat(&mut s, &tpl::module(&with_commands(id, "filter", r#"[{"id":"boom","label":"Boom"}]"#), "unreachable", "", 1)); + let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let e = s.execute(&crate::cmd::plugin::command_id(id, "boom"), &json!({})).unwrap_err().to_string(); + assert!(e.contains("“Cmd Test” › Boom: ") && e.contains("trapped"), "{e}"); + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + run(&mut s, "plugin.remove", json!({"id": id})); +} + +/// A live effect's commands are listed under Effect › Plug-ins, run under its permissions (it may +/// not change the document), and labels show bidirectional characters escaped. A malformed +/// `commands` entry is refused at install, and the error names it. +#[test] +fn effect_commands_labels_and_malformed_entries() { + let mut s = session(); + let id = "test.engine.cmds.effect"; + let triangle = r#"{"objects":[{"path":{"subpaths":[{"closed":true,"anchors":[{"p":[0,0]},{"p":[5,0]},{"p":[5,5]}]}]}}]}"#; + install_wat(&mut s, &tpl::returning(&with_commands(id, "effect", "[{\"id\":\"flip\",\"label\":\"Flip\u{202E}txt\"}]"), triangle)); + let c = listed(&s, &crate::cmd::plugin::command_id(id, "flip")).expect("listed"); + assert_eq!((c.label, c.menu.as_slice()), ("Flip\\u{202E}txt", &["Effect", "Plug-ins", "Cmd Test"][..])); + run(&mut s, "select.none", json!({})); + let before = s.doc().unwrap().doc.clone(); + let e = s.execute(&crate::cmd::plugin::command_id(id, "flip"), &json!({})).unwrap_err().to_string(); + assert!(e.contains("wasn't granted `doc.write`"), "{e}"); + assert_eq!(s.doc().unwrap().doc, before); + run(&mut s, "plugin.remove", json!({"id": id})); + + let bad = id.replace("effect", "bad"); + let wasm = wat::parse_str(tpl::module(&with_commands(&bad, "filter", r#"[{"id":"ok","label":"A"},{"id":"Bad","label":"B"}]"#), tpl::ECHO, "", 1)); + let e = s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm.unwrap())})).unwrap_err().to_string(); + assert!(e.contains("command `Bad`"), "{e}"); + assert!(vectorcraft_plugins::registry::get(&bad).is_none()); +} diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index 1f29627e0..4b853889d 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -320,4 +320,78 @@ proptest! { vectorcraft_plugins::registry::remove("fuzz.permissions"); vectorcraft_plugins::registry::revoke("fuzz.permissions"); } + + /// Manifests with hostile `commands` (junk of every type, bad and repeated ids, labels with + /// control and bidirectional override characters, a `menu` naming another menu, junk + /// parameters) parse to an error or to commands whose menu labels show no hidden character and + /// that sit under Plug-ins alone; installing one and running each of its commands ends as an + /// error or an edit, never a panic, and the document then renders and exports. + #[test] + fn hostile_manifest_commands_never_crash( + kind in prop::sample::select(vec!["filter", "effect", "exporter"]), + entries in prop::collection::vec( + prop_oneof![ + 3 => ( + prop_oneof![Just(json!("grow")), Just(json!("a_1")), Just(json!("Grow")), Just(json!("")), Just(json!("a.b")), arb_junk()], + prop_oneof![ + 3 => "[a-zA-Z\u{202E}\u{2066}\u{200F} ]{0,70}".prop_map(Value::from), + 1 => "[a-z\u{0007}\n]{1,8}".prop_map(Value::from), + 1 => arb_junk(), + ], + prop_oneof![Just(Value::Null), Just(json!("File")), arb_junk()], + prop_oneof![Just(Value::Null), Just(json!({"by": {"type": "number", "min": 0, "max": 10, "default": 2}})), arb_junk()], + ) + .prop_map(|(id, label, menu, params)| { + let mut c = json!({"id": id, "label": label}); + if !menu.is_null() { + c["menu"] = menu; + } + if !params.is_null() { + c["params"] = params; + } + c + }), + 1 => arb_junk(), + ], + 0..5, + ), + junk in prop::option::weighted(0.1, arb_junk()), + given in prop_oneof![Just(json!({})), Just(json!({"by": 3})), arb_junk()], + ) { + let mut m = json!({"id": "fuzz.commands", "name": "Fuzz \u{202E}Commands", "kind": kind}); + if kind == "exporter" { + m["extensions"] = json!(["fz"]); + } + m["commands"] = junk.unwrap_or(json!(entries)); + let text = m.to_string(); + let parsed = catch_quiet(|| vectorcraft_plugins::Manifest::parse(text.as_bytes())); + let parsed = parsed.map_err(|msg| TestCaseError::fail(format!("panicked on {text}: {msg}")))?; + if let Ok(man) = &parsed { + for c in &man.commands { + let shown = c.shown_label(); + prop_assert!(!shown.chars().any(|ch| ch.is_control() || matches!(ch, '\u{202E}' | '\u{2066}' | '\u{200F}')), "{}", shown); + prop_assert!(shown.chars().count() <= vectorcraft_plugins::permissions::MAX_SHOWN_NAME); + } + } + let mut s = rich_session(); + s.execute("select.all", &json!({})).unwrap(); + let module = wat::parse_str(tpl::returning(&text, r#"{"objects":[]}"#)).unwrap(); + let r = catch_quiet(|| { + let mut ran = vec![]; + if install(&mut s, &module).is_ok() { + for c in s.commands().into_iter().filter(|c| c.id.starts_with("plugin.fuzz.commands.")) { + ran.push((c.id, c.menu.clone())); + let _ = s.execute(c.id, &given); + } + } + ran + }); + let ran = r.map_err(|msg| TestCaseError::fail(format!("panicked running {text}: {msg}")))?; + prop_assert!(ran.is_empty() || parsed.is_ok(), "a manifest that doesn't parse installed: {}", text); + for (id, menu) in &ran { + prop_assert!(menu.len() == 3 && menu[1] == "Plug-ins" && matches!(menu[0], "Object" | "Effect"), "{id}: {menu:?}"); + } + renders(&s, "after hostile commands")?; + vectorcraft_plugins::registry::remove("fuzz.commands"); + } } diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs index 9e6f9e934..f7fde222d 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -22,7 +22,9 @@ //! document in a file format of its own. The process-wide [`registry`] holds the installed //! plug-ins. A plug-in's parameters ([`manifest`]) come with what the host's dialogs need to show //! them well: labels, units, groups, help and conditions ([`dialog`]). What a plug-in may do is -//! the set of [`permissions`] its manifest lists, which the user approves when it is installed. +//! the set of [`permissions`] its manifest lists, which the user approves when it is installed. A +//! filter or a live effect may also add menu commands of its own ([`Command`]), which the host +//! runs through `vc_run` with the command's id in `_context`. #![forbid(unsafe_code)] pub mod dialog; @@ -36,7 +38,7 @@ mod runtime; pub mod wat; pub use dialog::ParamUi; -pub use manifest::{Budget, Kind, Manifest, Param, ParamSpec}; +pub use manifest::{Budget, Command, Kind, Manifest, Param, ParamSpec}; pub use runtime::{ABI_VERSION, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Limits, Plugin, heavy_terms}; /// What the host puts before a plug-in's id to name what it adds: its live effect diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index 75a76cdbb..fe543d7ea 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -20,6 +20,10 @@ pub const MAX_TEXT: usize = 4_096; pub const MAX_LIST: usize = 256; /// Most file extensions an exporter may declare. pub const MAX_EXTENSIONS: usize = 8; +/// Most menu commands a plug-in may add (`commands`). +pub const MAX_COMMANDS: usize = 32; +/// Longest command id, in characters. +pub const MAX_COMMAND_ID: usize = 32; /// What a plug-in is. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -172,6 +176,30 @@ pub struct Param { pub ui: ParamUi, } +/// A menu command a plug-in adds (`commands`): the host lists it under Object › Plug-ins › the +/// plug-in's name (Effect › Plug-ins for a live effect's), runs it as the command +/// `plugin.<plug-in id>.<id>`, and calls the plug-in's `vc_run` with `_context.command` set to +/// `id` and the parameters it declares. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Command { + /// `[a-z][a-z0-9_]*`, at most [`MAX_COMMAND_ID`] characters, unique within the manifest. + pub id: String, + /// The menu label: 1 to 64 printable characters, shown with bidirectional characters escaped + /// ([`crate::permissions::display_text`]). + pub label: String, + /// Parameters in declaration (dialog) order, declared as the manifest's own are. + #[serde(default, deserialize_with = "ordered_params", serialize_with = "params_as_object")] + pub params: Vec<(String, Param)>, +} + +impl Command { + /// The label as menus and errors show it: control and bidirectional characters escaped, at + /// most [`crate::permissions::MAX_SHOWN_NAME`] characters. + pub fn shown_label(&self) -> String { + crate::permissions::display_text(self.label.trim(), crate::permissions::MAX_SHOWN_NAME) + } +} + impl From<ParamSpec> for Param { fn from(spec: ParamSpec) -> Self { Param { spec, ui: ParamUi::default() } @@ -260,6 +288,9 @@ pub struct Manifest { /// How much a run may compute: only a filter may be [`Budget::Heavy`]. #[serde(default, skip_serializing_if = "Budget::is_light")] pub budget: Budget, + /// The menu commands it adds, in menu order (a filter or a live effect only). + #[serde(default, deserialize_with = "ordered_commands", skip_serializing_if = "Vec::is_empty")] + pub commands: Vec<Command>, } impl Manifest { @@ -288,9 +319,21 @@ impl Manifest { params: Vec::new(), extensions: Vec::new(), budget: Budget::Light, + commands: Vec::new(), } } + /// The manifest command `c` runs with: this one with `c`'s label as its name and `c`'s + /// parameters as its own, so dialogs show them and [`Manifest::resolve_params`] checks them. + pub fn for_command(&self, c: &Command) -> Manifest { + Manifest { name: c.label.clone(), params: c.params.clone(), commands: Vec::new(), ..self.clone() } + } + + /// The command with id `id`. + pub fn command(&self, id: &str) -> Option<&Command> { + self.commands.iter().find(|c| c.id == id) + } + /// Checks what the module declared: its id, name and text lengths, its extensions, a budget its /// kind may have (only a filter may be heavy), and each parameter and its dialog layout. fn validate(&self) -> Result<()> { @@ -320,6 +363,12 @@ impl Manifest { return Err(Error::Manifest("an exporter always gets an export's budget: leave `budget` out".into())); } } + self.validate_params()?; + self.validate_commands() + } + + /// Checks each parameter and its dialog layout. + fn validate_params(&self) -> Result<()> { for (i, (k, p)) in self.params.iter().enumerate() { if !valid_key(k) { return Err(Error::Manifest(format!("invalid parameter name {k:?}"))); @@ -333,6 +382,47 @@ impl Manifest { Ok(()) } + /// A filter or a live effect lists at most [`MAX_COMMANDS`] commands, each with a distinct + /// id, a printable label and parameters declared as the manifest's own are. A heavy filter + /// lists none. The error names the command: its id, or its place in the list. + fn validate_commands(&self) -> Result<()> { + if self.commands.is_empty() { + return Ok(()); + } + if self.kind == Kind::Exporter { + return Err(Error::Manifest("only a filter or a live effect declares `commands`".into())); + } + if self.budget == Budget::Heavy { + return Err(Error::Manifest("a heavy filter runs in the background, so it can't declare `commands`".into())); + } + if self.commands.len() > MAX_COMMANDS { + return Err(Error::Manifest(format!("at most {MAX_COMMANDS} `commands`"))); + } + for (i, c) in self.commands.iter().enumerate() { + let name = command_name(i, &c.id); + if !valid_command_id(&c.id) { + return Err(Error::Manifest(format!( + "command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter" + ))); + } + let label = c.label.trim(); + if label.is_empty() || label.chars().count() > 64 || label.chars().any(char::is_control) { + return Err(Error::Manifest(format!("command {name}: `label` must be 1-64 printable characters"))); + } + if self.commands.get(..i).is_some_and(|before| before.iter().any(|b| b.id == c.id)) { + return Err(Error::Manifest(format!("command {name} is declared twice"))); + } + self.for_command(c).validate_params().map_err(|e| { + let e = match e { + Error::Manifest(why) => why, + other => other.to_string(), + }; + Error::Manifest(format!("command {name}: {e}")) + })?; + } + Ok(()) + } + /// An exporter lists 1 to [`MAX_EXTENSIONS`] distinct extensions of 1 to 16 lower-case /// letters and digits; other kinds list none. fn validate_extensions(&self) -> Result<()> { @@ -488,6 +578,45 @@ fn ordered_params<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result d.deserialize_any(V) } +/// Reads the `commands` list one entry at a time, so a malformed entry is an error that names it +/// (its id, or its place in the list), and a list longer than [`MAX_COMMANDS`] is refused before +/// its entries are read. +fn ordered_commands<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Vec<Command>, D::Error> { + use serde::de::Error as _; + let entries = match Value::deserialize(d)? { + Value::Null => return Ok(Vec::new()), + Value::Array(entries) => entries, + _ => return Err(D::Error::custom("`commands` must be a list of {id, label, params?}")), + }; + if entries.len() > MAX_COMMANDS { + return Err(D::Error::custom(format!("at most {MAX_COMMANDS} `commands`"))); + } + entries + .into_iter() + .enumerate() + .map(|(i, v)| { + let name = command_name(i, v.get("id").and_then(Value::as_str).unwrap_or_default()); + serde_json::from_value::<Command>(v).map_err(|e| D::Error::custom(format!("command {name}: {e}"))) + }) + .collect() +} + +/// How errors name the command at place `i` with id `id`: the id in backticks, escaped and capped +/// as prompts show text, or `[i]` when it has none. +fn command_name(i: usize, id: &str) -> String { + match id { + "" => format!("[{i}]"), + id => format!("`{}`", crate::permissions::display_text(id, crate::permissions::MAX_SHOWN_FIELD)), + } +} + +/// `[a-z][a-z0-9_]*`, 1 to [`MAX_COMMAND_ID`] characters. +fn valid_command_id(id: &str) -> bool { + id.len() <= MAX_COMMAND_ID + && id.bytes().next().is_some_and(|b| b.is_ascii_lowercase()) + && id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') +} + fn params_as_object<S: serde::Serializer>(p: &[(String, Param)], s: S) -> std::result::Result<S::Ok, S::Error> { use serde::ser::SerializeMap; let mut m = s.serialize_map(Some(p.len()))?; @@ -663,6 +792,68 @@ mod tests { } } + /// A filter or a live effect lists menu commands with their own parameters; a `menu` key is + /// ignored. A malformed entry, a bad or repeated id, a label that isn't printable and too many + /// commands are refused, and the error names the command. + #[test] + fn commands_are_checked_and_errors_name_them() { + let m = Manifest::parse( + br#"{"id":"org.example.tidy","name":"Tidy","kind":"filter","commands":[ + {"id":"tidy_all","label":"Tidy All","menu":"File"}, + {"id":"grow","label":"Grow","params":{"by":{"type":"number","min":0,"max":10,"default":2},"mode":{"type":"bool"}}}]}"#, + ) + .unwrap(); + assert_eq!(m.commands.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), ["tidy_all", "grow"]); + let grow = m.command("grow").unwrap(); + assert_eq!(grow.params.iter().map(|p| p.0.as_str()).collect::<Vec<_>>(), ["by", "mode"]); + let run = m.for_command(grow); + assert_eq!((run.name.as_str(), run.id.as_str()), ("Grow", "org.example.tidy")); + assert_eq!(Value::Object(run.resolve_params(&json!({"by": 50})).unwrap()), json!({"by": 10.0, "mode": false})); + let written = serde_json::to_value(&m).unwrap(); + assert_eq!(written["commands"][1]["params"]["by"]["max"], json!(10.0)); + assert!(written["commands"][0].get("menu").is_none(), "a menu key is not kept"); + assert!(serde_json::to_value(Manifest::parse(br#"{"id":"a","name":"x","kind":"filter"}"#).unwrap()).unwrap().get("commands").is_none()); + let effect = Manifest::parse(br#"{"id":"a","name":"x","kind":"effect","commands":[{"id":"c","label":"C"}]}"#).unwrap(); + assert_eq!(effect.commands.len(), 1); + let label = |l: &str| Command { id: "c".into(), label: l.into(), params: Vec::new() }.shown_label(); + assert_eq!(label("Flip\u{202E}txt.exe"), "Flip\\u{202E}txt.exe"); + + let many: Vec<String> = (0..=MAX_COMMANDS).map(|i| format!(r#"{{"id":"c{i}","label":"C"}}"#)).collect(); + let too_many = format!(r#"{{"id":"a","name":"x","kind":"filter","commands":[{}]}}"#, many.join(",")); + for (bad, says) in [ + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"ok","label":"A"},{"label":"B"}]}"#, "command [1]: missing field `id`"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"nolabel"}]}"#, "command `nolabel`: missing field `label`"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"Upper","label":"A"}]}"#, "command `Upper`: use an id"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"9lives","label":"A"}]}"#, "command `9lives`: use an id"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"a.b","label":"A"}]}"#, "command `a.b`: use an id"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"","label":"A"}]}"#, "command [0]: use an id"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":" "}]}"#, "command `x`: `label`"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":"a\nb"}]}"#, "command `x`: `label`"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":"A"},{"id":"x","label":"B"}]}"#, "command `x` is declared twice"), + ( + r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":"A","params":{"r":{"type":"number","min":5,"max":1,"default":1}}}]}"#, + "command `x`: parameter `r`: bad range", + ), + ( + r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":"A","params":{"id":{"type":"bool"}}}]}"#, + "command `x`: invalid parameter", + ), + (r#"{"id":"a","name":"x","kind":"filter","commands":[1]}"#, "command [0]"), + (r#"{"id":"a","name":"x","kind":"filter","commands":{"id":"x"}}"#, "must be a list"), + (r#"{"id":"a","name":"x","kind":"exporter","extensions":["plt"],"commands":[{"id":"x","label":"A"}]}"#, "only a filter or a live effect"), + (r#"{"id":"a","name":"x","kind":"filter","budget":"heavy","commands":[{"id":"x","label":"A"}]}"#, "heavy filter"), + (too_many.as_str(), "at most 32 `commands`"), + ] { + let e = Manifest::parse(bad.as_bytes()).unwrap_err().to_string(); + assert!(e.contains(says), "{bad}: {e}"); + } + let long_id = format!(r#"{{"id":"a","name":"x","kind":"filter","commands":[{{"id":"{}","label":"A"}}]}}"#, "c".repeat(MAX_COMMAND_ID + 1)); + assert!(Manifest::parse(long_id.as_bytes()).is_err()); + let hidden = + Manifest::parse("{\"id\":\"a\",\"name\":\"x\",\"kind\":\"filter\",\"commands\":[{\"id\":\"x\u{202E}\",\"label\":\"A\"}]}".as_bytes()); + assert!(hidden.unwrap_err().to_string().contains("command `x\\u{202E}`"), "an id in an error is escaped"); + } + #[test] fn strings_and_number_lists_resolve_like_the_other_types() { let m = Manifest::parse( diff --git a/crates/ui-egui/src/dialogs/plugin.rs b/crates/ui-egui/src/dialogs/plugin.rs index eef4787ee..eebb29018 100644 --- a/crates/ui-egui/src/dialogs/plugin.rs +++ b/crates/ui-egui/src/dialogs/plugin.rs @@ -6,14 +6,16 @@ //! declared parameter (fields named after them; `__plugin` is the plug-in id) with a live //! Preview, and OK keeps the run as one undo step (`plugin.run`). A filter without parameters runs //! at once. A heavy filter (its manifest's `budget`) has no Preview, since a run may take minutes: -//! it runs in the background ([`crate::plugin_runs`]). Exporters are formats of File › Export › +//! it runs in the background ([`crate::plugin_runs`]). A filter or live effect with commands of +//! its own is a submenu named after it, holding its item and then its commands; a command with +//! parameters opens the same dialog for them ([`open_command`]). Exporters are formats of File › Export › //! Export As; one with parameters opens dialog `pluginExport` once the file is picked, and its //! Export button writes the file. use std::sync::Arc; use serde_json::{Map, Value, json}; -use vectorcraft_plugins::{Budget, Kind, Manifest, Plugin, registry}; +use vectorcraft_plugins::{Budget, Kind, Manifest, Plugin, permissions, registry}; use super::{DialogSpec, form}; use crate::VectorcraftApp; @@ -38,12 +40,30 @@ fn label(name: &str, has_params: bool) -> &'static str { registry::intern(&format!("{name}{dots}")) } -/// Object › Plug-ins: the installed object filters, then Install Plug-in… and Reload Plug-ins. +/// A filter or live effect `plugin`'s item `own`, as the menu lists it: alone, or for a plug-in +/// with commands of its own, a submenu named after it holding `own` and then its commands. Each +/// command runs its command id (`plugin.<id>.<command>`), with an ellipsis when it has parameters, +/// whose dialog [`open_command`] opens. +fn with_commands(plugin: &Plugin, own: Item) -> Item { + let m = plugin.manifest(); + if m.commands.is_empty() { + return own; + } + let mut items = vec![own, Item::Sep]; + items.extend(m.commands.iter().map(|c| { + let id = registry::intern(&vectorcraft_engine::cmd::plugin::command_id(&m.id, &c.id)); + Item::Cmd(label(&c.shown_label(), !c.params.is_empty()), id, Value::Null) + })); + Item::Sub(registry::intern(&permissions::display_text(m.name.trim(), permissions::MAX_SHOWN_NAME)), items) +} + +/// Object › Plug-ins: the installed object filters (each with its commands, if it has any), then +/// Install Plug-in… and Reload Plug-ins. pub fn object_menu() -> Vec<Item> { let mut items: Vec<Item> = registry::list() .iter() .filter(|p| p.manifest().kind == Kind::Filter) - .map(|p| Item::Cmd(label(&p.manifest().name, !p.manifest().params.is_empty()), "plugin.dialog", json!({ "id": p.id() }))) + .map(|p| with_commands(p, Item::Cmd(label(&p.manifest().name, !p.manifest().params.is_empty()), "plugin.dialog", json!({ "id": p.id() })))) .collect(); if items.is_empty() { items.push(Item::Header(NO_FILTERS)); @@ -56,14 +76,20 @@ pub fn object_menu() -> Vec<Item> { items } -/// Effect › Plug-ins: the live-effect plug-ins (`None` while none is installed). Effects without -/// options apply at once, as built-in ones do. +/// Effect › Plug-ins: the live-effect plug-ins, each with its commands if it has any (`None` +/// while none is installed). Effects without options apply at once, as built-in ones do. pub fn effect_menu() -> Option<Item> { let items: Vec<Item> = vectorcraft_effects::plugin_effects() .into_iter() - .map(|e| match e.defaults.as_object().is_some_and(|o| o.is_empty()) { - true => Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })), - false => Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })), + .map(|e| { + let own = match e.defaults.as_object().is_some_and(|o| o.is_empty()) { + true => Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })), + false => Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })), + }; + match vectorcraft_plugins::effect::plugin_id(e.id).and_then(registry::get) { + Some(plugin) => with_commands(&plugin, own), + None => own, + } }) .collect(); (!items.is_empty()).then_some(Item::Sub("Plug-ins", items)) @@ -113,8 +139,44 @@ fn described(ui: &mut egui::Ui, d: &Dialog) -> Option<Arc<Plugin>> { Some(plugin) } +/// Opens the dialog of plug-in command `id` (`plugin.<plug-in id>.<command id>`), given no params, +/// when it has parameters and a document is open: its fields at their defaults (`__command` is the +/// command id) with a live Preview, and OK keeps the run as one undo step. False for any other +/// command, which then runs as it was given. +pub fn open_command(app: &mut VectorcraftApp, id: &str) -> bool { + let Some((plugin, c)) = vectorcraft_engine::cmd::plugin::command_of(id) else { return false }; + if c.params.is_empty() || app.session.active().is_none() { + return false; + } + let mut fields = fields_for(&plugin.manifest().for_command(&c)); + fields.insert("__command".into(), json!(id)); + fields.insert("__label".into(), json!(c.shown_label())); + fields.insert("preview".into(), json!(true)); + app.ui.dialog = Some(Dialog { kind: KIND.into(), fields }); + true +} + +/// The plug-in command dialog `d` is for (`__command`), and the manifest it runs with; `None` for +/// a filter's own dialog. +fn command_of(d: &Dialog) -> Option<Manifest> { + let (plugin, c) = vectorcraft_engine::cmd::plugin::command_of(&d.str("__command"))?; + Some(plugin.manifest().for_command(&c)) +} + +/// The command dialog `d` runs: its plug-in command, or `plugin.run`. +fn command(d: &Dialog) -> String { + match d.str("__command") { + c if c.is_empty() => CMD.to_string(), + c => c, + } +} + +/// The params of the command dialog `d` runs ([`command`]). fn params(d: &Dialog) -> Value { - json!({"id": d.str("__plugin"), "params": form::params(d)}) + match d.str("__command").is_empty() { + true => json!({"id": d.str("__plugin"), "params": form::params(d)}), + false => json!({"params": form::params(d)}), + } } /// Runs filter `plugin` with `plugin.run`'s params `p`: at once, or in the background when it is @@ -126,15 +188,20 @@ fn run(app: &mut VectorcraftApp, plugin: &Plugin, p: Value) -> Result<Value, Str } } -/// The plug-in's description and fields, then the live Preview; a heavy filter has a note instead -/// that it runs in the background, since a run may take minutes. +/// The plug-in's description and fields (a command's: its own parameters), then the live +/// Preview; a heavy filter has a note instead that it runs in the background, since a run may take +/// minutes. A command dialog closes once the plug-in no longer has the command. fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let Some(plugin) = described(ui, d) else { return true }; - form::schema_fields(app, ui, d, plugin.manifest()); + let run_with = command_of(d); + if run_with.is_none() && !d.str("__command").is_empty() { + return true; + } + form::schema_fields(app, ui, d, run_with.as_ref().unwrap_or(plugin.manifest())); match plugin.manifest().budget { Budget::Light => { let (label, p) = (d.str("__label"), params(d)); - form::preview(app, ui, d, &label, CMD, p); + form::preview(app, ui, d, &label, &command(d), p); } // No Preview: each run may take minutes. Budget::Heavy => { @@ -157,7 +224,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> { } r } - _ => form::commit_preview(app, CMD, params(d)), + _ => form::commit_preview(app, &command(d), params(d)), } } diff --git a/crates/ui-egui/src/dialogs/shortcut_import.rs b/crates/ui-egui/src/dialogs/shortcut_import.rs index c65b4e602..7360b3a32 100644 --- a/crates/ui-egui/src/dialogs/shortcut_import.rs +++ b/crates/ui-egui/src/dialogs/shortcut_import.rs @@ -375,9 +375,13 @@ pub fn candidates() -> Vec<Candidate> { .collect() } -/// Keyboard Shortcuts' entry `key`. +/// Keyboard Shortcuts' entry `key`. The commands of installed plug-ins are looked up in +/// [`shortcut_editor::entries`] as it is now. fn entry(key: &str) -> Option<&'static Entry> { static BY_KEY: OnceLock<HashMap<&'static str, &'static Entry>> = OnceLock::new(); + if vectorcraft_engine::cmd::plugin::is_command_id(key) { + return shortcut_editor::entries().iter().find(|e| e.key == key); + } BY_KEY.get_or_init(|| shortcut_editor::entries().iter().map(|e| (e.key.as_str(), e)).collect()).get(key).copied() } diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index f0f7cef6d..0202d8db8 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -597,6 +597,7 @@ impl PluginIntField { params: vec![("seed".into(), vectorcraft_plugins::ParamSpec::Int { min, max, default: 0 }.into())], extensions: Vec::new(), budget: Default::default(), + commands: Vec::new(), }; let mut f = PluginIntField { ctx, d: Dialog::new(plugin::KIND, json!({ "seed": start })), m, pos: egui::Pos2::ZERO }; fn number_rect(shape: &egui::Shape, text: &str) -> Option<egui::Rect> { diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 9e2fc7837..4af530924 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5819,6 +5819,14 @@ digits Malayalam Malaiàlam @msg unknown permission `{shown}` permís desconegut `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» demana els permisos {asked}: instal·la’l amb un `grant` que els enumeri quan l’usuari hi estigui d’acord @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a «{name}» no se li ha concedit `{p}`, que la seva sortida necessita: no s’ha canviat res +@msg a heavy filter runs in the background, so it can't declare `commands` un filtre pesat s’executa en segon pla, de manera que no pot declarar `commands` +@msg at most {MAX_COMMANDS} `commands` com a màxim {MAX_COMMANDS} `commands` +@msg command {name} is declared twice l’ordre {name} està declarada dues vegades +@msg command {name}: `label` must be 1-64 printable characters ordre {name}: `label` ha de tenir 1-64 caràcters imprimibles +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter ordre {name}: fes servir un id d’1-{MAX_COMMAND_ID} caràcters d’a-z 0-9 _ que comenci per una lletra +@msg command {name}: {e} ordre {name}: {e} +@msg only a filter or a live effect declares `commands` només un filtre o un efecte dinàmic declara `commands` +@msg “{name}” › {label}: {e} «{name}» › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed el mòdul no té el SHA-256 que indica `hash`: no s’ha instal·lat res @msg “{name}” is not a file «{name}» no és un fitxer axis Both Tots dos diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5975f088a..9bfb0d884 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5809,6 +5809,14 @@ axis Y Axis osa Y @msg unknown permission `{shown}` neznámé oprávnění `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ žádá o oprávnění {asked}: nainstalujte ho s `grant`, který je uvádí, jakmile uživatel souhlasí @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}“ nedostal `{p}`, které jeho výstup potřebuje: nic se nezměnilo +@msg a heavy filter runs in the background, so it can't declare `commands` těžký filtr běží na pozadí, takže nemůže deklarovat `commands` +@msg at most {MAX_COMMANDS} `commands` nejvýše {MAX_COMMANDS} `commands` +@msg command {name} is declared twice příkaz {name} je deklarován dvakrát +@msg command {name}: `label` must be 1-64 printable characters příkaz {name}: `label` musí mít 1-64 tisknutelných znaků +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter příkaz {name}: použijte id o 1-{MAX_COMMAND_ID} znacích z a-z 0-9 _, které začíná písmenem +@msg command {name}: {e} příkaz {name}: {e} +@msg only a filter or a live effect declares `commands` `commands` deklaruje jen filtr nebo živý efekt +@msg “{name}” › {label}: {e} „{name}“ › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modul nemá SHA-256 uvedený v `hash`: nic nebylo nainstalováno # Actions panel: action sets (#3712). New Set… Nová sada… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2c0c2717b..2a9994019 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5391,6 +5391,14 @@ axis Y Axis Y-Achse @msg unknown permission `{shown}` unbekannte Berechtigung `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ verlangt die Berechtigungen {asked}: Installieren Sie es mit `grant`, das sie aufführt, sobald der Benutzer zustimmt @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}“ wurde `{p}` nicht gewährt, das seine Ausgabe braucht: Es wurde nichts geändert +@msg a heavy filter runs in the background, so it can't declare `commands` ein schwerer Filter läuft im Hintergrund und kann daher keine `commands` deklarieren +@msg at most {MAX_COMMANDS} `commands` höchstens {MAX_COMMANDS} `commands` +@msg command {name} is declared twice der Befehl {name} ist doppelt deklariert +@msg command {name}: `label` must be 1-64 printable characters Befehl {name}: `label` muss 1-64 druckbare Zeichen haben +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter Befehl {name}: Verwenden Sie eine ID aus 1-{MAX_COMMAND_ID} Zeichen aus a-z 0-9 _, die mit einem Buchstaben beginnt +@msg command {name}: {e} Befehl {name}: {e} +@msg only a filter or a live effect declares `commands` nur ein Filter oder ein Live-Effekt deklariert `commands` +@msg “{name}” › {label}: {e} „{name}“ › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed das Modul hat nicht den SHA-256-Wert aus `hash`: Es wurde nichts installiert # Actions panel: action sets (#3712). New Set… Neuer Satz… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 79f4b0cc0..8764bfe73 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5760,6 +5760,14 @@ axis Y Axis Άξονας Y @msg unknown permission `{shown}` άγνωστο δικαίωμα `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees το «{name}» ζητά τα δικαιώματα {asked}: εγκαταστήστε το με ένα `grant` που τα απαριθμεί μόλις συμφωνήσει ο χρήστης @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed στο «{name}» δεν παραχωρήθηκε το `{p}`, που χρειάζεται η έξοδός του: δεν άλλαξε τίποτα +@msg a heavy filter runs in the background, so it can't declare `commands` ένα βαρύ φίλτρο εκτελείται στο παρασκήνιο, οπότε δεν μπορεί να δηλώνει `commands` +@msg at most {MAX_COMMANDS} `commands` το πολύ {MAX_COMMANDS} `commands` +@msg command {name} is declared twice η εντολή {name} δηλώνεται δύο φορές +@msg command {name}: `label` must be 1-64 printable characters εντολή {name}: το `label` πρέπει να έχει 1-64 εκτυπώσιμους χαρακτήρες +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter εντολή {name}: χρησιμοποιήστε αναγνωριστικό 1-{MAX_COMMAND_ID} χαρακτήρων από a-z 0-9 _ που αρχίζει με γράμμα +@msg command {name}: {e} εντολή {name}: {e} +@msg only a filter or a live effect declares `commands` μόνο ένα φίλτρο ή ένα δυναμικό εφέ δηλώνει `commands` +@msg “{name}” › {label}: {e} «{name}» › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed η λειτουργική μονάδα δεν έχει το SHA-256 που δίνει το `hash`: δεν εγκαταστάθηκε τίποτα # Actions panel: action sets (#3712). New Set… Νέο σύνολο… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 420254d25..4752576ba 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5750,6 +5750,14 @@ axis Y Axis Eje Y @msg unknown permission `{shown}` permiso desconocido `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” pide los permisos {asked}: instálalo con un `grant` que los incluya cuando el usuario lo acepte @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a “{name}” no se le concedió `{p}`, que su resultado necesita: no se cambió nada +@msg a heavy filter runs in the background, so it can't declare `commands` un filtro pesado se ejecuta en segundo plano, así que no puede declarar `commands` +@msg at most {MAX_COMMANDS} `commands` como máximo {MAX_COMMANDS} `commands` +@msg command {name} is declared twice el comando {name} está declarado dos veces +@msg command {name}: `label` must be 1-64 printable characters comando {name}: `label` debe tener 1-64 caracteres imprimibles +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter comando {name}: usa un id de 1-{MAX_COMMAND_ID} caracteres de a-z 0-9 _ que empiece por una letra +@msg command {name}: {e} comando {name}: {e} +@msg only a filter or a live effect declares `commands` solo un filtro o un efecto interactivo declara `commands` +@msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed el módulo no tiene el SHA-256 que indica `hash`: no se instaló nada # Actions panel: action sets (#3712). New Set… Nuevo conjunto… diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 0cbec9246..f76d6ce09 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5839,6 +5839,14 @@ axis Y Axis Y-akseli @msg unknown permission `{shown}` tuntematon käyttöoikeus `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees ”{name}” pyytää käyttöoikeuksia {asked}: asenna se `grant`-luettelolla, jossa ne ovat, kun käyttäjä suostuu @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed ”{name}” ei saanut käyttöoikeutta `{p}`, jota sen tulos tarvitsee: mitään ei muutettu +@msg a heavy filter runs in the background, so it can't declare `commands` raskas suodin suoritetaan taustalla, joten se ei voi määritellä `commands`-kohtaa +@msg at most {MAX_COMMANDS} `commands` enintään {MAX_COMMANDS} `commands` +@msg command {name} is declared twice komento {name} on määritelty kahdesti +@msg command {name}: `label` must be 1-64 printable characters komento {name}: `label`-kentässä on oltava 1-64 tulostettavaa merkkiä +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter komento {name}: käytä 1-{MAX_COMMAND_ID} merkin tunnusta merkeistä a-z 0-9 _, joka alkaa kirjaimella +@msg command {name}: {e} komento {name}: {e} +@msg only a filter or a live effect declares `commands` vain suodin tai live-tehoste määrittelee `commands`-kohdan +@msg “{name}” › {label}: {e} ”{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed moduulilla ei ole `hash`-kentän SHA-256-arvoa: mitään ei asennettu # Actions panel: action sets (#3712). New Set… Uusi sarja… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f87b0cde3..c53f5c4d5 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5804,6 +5804,14 @@ axis Y Axis Axe Y @msg unknown permission `{shown}` autorisation inconnue `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees « {name} » demande les autorisations {asked} : installez-le avec `grant` qui les liste une fois que l’utilisateur l’accepte @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed « {name} » n’a pas reçu `{p}`, dont sa sortie a besoin : rien n’a été modifié +@msg a heavy filter runs in the background, so it can't declare `commands` un filtre lourd s’exécute en arrière-plan, il ne peut donc pas déclarer `commands` +@msg at most {MAX_COMMANDS} `commands` au plus {MAX_COMMANDS} `commands` +@msg command {name} is declared twice la commande {name} est déclarée deux fois +@msg command {name}: `label` must be 1-64 printable characters commande {name} : `label` doit compter 1-64 caractères imprimables +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter commande {name} : utilisez un id de 1-{MAX_COMMAND_ID} caractères parmi a-z 0-9 _ commençant par une lettre +@msg command {name}: {e} commande {name} : {e} +@msg only a filter or a live effect declares `commands` seul un filtre ou un effet dynamique déclare `commands` +@msg “{name}” › {label}: {e} « {name} » › {label} : {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed le module n’a pas le SHA-256 indiqué par `hash` : rien n’a été installé # Actions panel: action sets (#3712). New Set… Nouveau jeu… diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index a045da15b..6242c9d51 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5194,6 +5194,14 @@ @msg unknown permission `{shown}` अज्ञात अनुमति `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” अनुमतियां {asked} मांगता है: उपयोगकर्ता की सहमति मिलने पर इसे उन्हें सूचीबद्ध करने वाले `grant` के साथ इंस्टॉल करें @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” को `{p}` नहीं दी गई, जो उसके आउटपुट को चाहिए: कुछ नहीं बदला +@msg a heavy filter runs in the background, so it can't declare `commands` भारी फ़िल्टर पृष्ठभूमि में चलता है, इसलिए वह `commands` घोषित नहीं कर सकता +@msg at most {MAX_COMMANDS} `commands` अधिकतम {MAX_COMMANDS} `commands` +@msg command {name} is declared twice कमांड {name} दो बार घोषित है +@msg command {name}: `label` must be 1-64 printable characters कमांड {name}: `label` में 1-64 प्रिंट करने योग्य वर्ण होने चाहिए +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter कमांड {name}: a-z 0-9 _ के 1-{MAX_COMMAND_ID} वर्णों वाली ऐसी id इस्तेमाल करें जो अक्षर से शुरू हो +@msg command {name}: {e} कमांड {name}: {e} +@msg only a filter or a live effect declares `commands` केवल फ़िल्टर या लाइव इफ़ेक्ट ही `commands` घोषित करता है +@msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed मॉड्यूल का SHA-256 वह नहीं है जो `hash` बताता है: कुछ भी इंस्टॉल नहीं हुआ @msg “{name}” is not a file "{name}" एक फ़ाइल नहीं है @msg ({note}) ({more}) ({note}) ({more}) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 031eb3791..9d66d791f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5792,6 +5792,14 @@ axis Y Axis Y tengely @msg unknown permission `{shown}` ismeretlen engedély: `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees a(z) „{name}” a következő engedélyeket kéri: {asked}; telepítsd egy őket felsoroló `grant` értékkel, ha a felhasználó beleegyezik @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a(z) „{name}” nem kapta meg a(z) `{p}` engedélyt, amelyre a kimenetének szüksége van: semmi sem változott +@msg a heavy filter runs in the background, so it can't declare `commands` a nehéz szűrő a háttérben fut, ezért nem deklarálhat `commands` értéket +@msg at most {MAX_COMMANDS} `commands` legfeljebb {MAX_COMMANDS} `commands` +@msg command {name} is declared twice a(z) {name} parancs kétszer van deklarálva +@msg command {name}: `label` must be 1-64 printable characters {name} parancs: a `label` 1-64 nyomtatható karakter legyen +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter {name} parancs: használj 1-{MAX_COMMAND_ID} karakteres, a-z 0-9 _ karakterekből álló, betűvel kezdődő azonosítót +@msg command {name}: {e} {name} parancs: {e} +@msg only a filter or a live effect declares `commands` `commands` értéket csak szűrő vagy élő effektus deklarálhat +@msg “{name}” › {label}: {e} „{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed a modul SHA-256 értéke nem az, amit a `hash` megad: semmi sem lett telepítve # Actions panel: action sets (#3712). New Set… Új készlet… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index e5ee7d472..4927181d4 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5425,6 +5425,14 @@ axis Y Axis Asse Y @msg unknown permission `{shown}` autorizzazione sconosciuta `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” chiede le autorizzazioni {asked}: installalo con un `grant` che le elenca quando l’utente acconsente @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a “{name}” non è stata concessa `{p}`, che serve al suo risultato: non è stato modificato nulla +@msg a heavy filter runs in the background, so it can't declare `commands` un filtro pesante viene eseguito in background, quindi non può dichiarare `commands` +@msg at most {MAX_COMMANDS} `commands` al massimo {MAX_COMMANDS} `commands` +@msg command {name} is declared twice il comando {name} è dichiarato due volte +@msg command {name}: `label` must be 1-64 printable characters comando {name}: `label` deve avere 1-64 caratteri stampabili +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter comando {name}: usa un id di 1-{MAX_COMMAND_ID} caratteri tra a-z 0-9 _ che inizi con una lettera +@msg command {name}: {e} comando {name}: {e} +@msg only a filter or a live effect declares `commands` solo un filtro o un effetto dinamico dichiara `commands` +@msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed il modulo non ha lo SHA-256 indicato da `hash`: non è stato installato nulla # Actions panel: action sets (#3712). New Set… Nuovo set… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 355da7a35..517fa0e4a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5490,6 +5490,14 @@ axis Y Axis Y 軸 @msg unknown permission `{shown}` 不明な権限 `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees 「{name}」は権限 {asked} を求めています。ユーザーが同意したら、それらを列挙した `grant` を指定してインストールしてください @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed 「{name}」には出力に必要な `{p}` が付与されていません。何も変更されませんでした +@msg a heavy filter runs in the background, so it can't declare `commands` 重いフィルターはバックグラウンドで実行されるため、`commands` を宣言できません +@msg at most {MAX_COMMANDS} `commands` `commands` は最大 {MAX_COMMANDS} 個です +@msg command {name} is declared twice コマンド {name} が 2 回宣言されています +@msg command {name}: `label` must be 1-64 printable characters コマンド {name}: `label` は印刷可能な 1-64 文字にしてください +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter コマンド {name}: a-z 0-9 _ からなり英字で始まる 1-{MAX_COMMAND_ID} 文字の ID を使用してください +@msg command {name}: {e} コマンド {name}: {e} +@msg only a filter or a live effect declares `commands` `commands` を宣言できるのはフィルターかライブ効果だけです +@msg “{name}” › {label}: {e} 「{name}」 › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed モジュールの SHA-256 が `hash` の値と一致しません。何もインストールされませんでした # Actions panel: action sets (#3712). New Set… 新規セット… diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 237b5586a..6bc3b90cb 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5732,6 +5732,14 @@ axis Y Axis Y ღერძი @msg unknown permission `{shown}` უცნობი ნებართვა `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ ითხოვს ნებართვებს {asked}: მომხმარებლის თანხმობის შემდეგ დააყენეთ ის `grant` პარამეტრით, რომელშიც ისინი ჩამოთვლილია @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}“-ს არ მიეცა `{p}`, რომელიც მის შედეგს სჭირდება: არაფერი შეცვლილა +@msg a heavy filter runs in the background, so it can't declare `commands` მძიმე ფილტრი ფონურ რეჟიმში მუშაობს, ამიტომ მას არ შეუძლია `commands`-ის გამოცხადება +@msg at most {MAX_COMMANDS} `commands` მაქსიმუმ {MAX_COMMANDS} `commands` +@msg command {name} is declared twice ბრძანება {name} ორჯერაა გამოცხადებული +@msg command {name}: `label` must be 1-64 printable characters ბრძანება {name}: `label` უნდა შედგებოდეს 1-64 დასაბეჭდი სიმბოლოსგან +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter ბრძანება {name}: გამოიყენეთ 1-{MAX_COMMAND_ID} სიმბოლოიანი id a-z 0-9 _ სიმბოლოებიდან, რომელიც ასოთი იწყება +@msg command {name}: {e} ბრძანება {name}: {e} +@msg only a filter or a live effect declares `commands` `commands`-ს მხოლოდ ფილტრი ან ცოცხალი ეფექტი აცხადებს +@msg “{name}” › {label}: {e} „{name}“ › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed მოდულს არ აქვს `hash`-ში მითითებული SHA-256: არაფერი დაყენებულა # Actions panel: action sets (#3712). New Set… ახალი ნაკრები… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 5ca65c5f3..a402f57a7 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5386,6 +5386,14 @@ axis Y Axis Y축 @msg unknown permission `{shown}` 알 수 없는 권한 `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}”은(는) {asked} 권한을 요청합니다. 사용자가 동의하면 이를 나열한 `grant`로 설치하세요 @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}”에 출력에 필요한 `{p}` 권한이 부여되지 않았습니다. 아무것도 변경되지 않았습니다 +@msg a heavy filter runs in the background, so it can't declare `commands` 무거운 필터는 백그라운드에서 실행되므로 `commands`를 선언할 수 없습니다 +@msg at most {MAX_COMMANDS} `commands` `commands`는 최대 {MAX_COMMANDS}개입니다 +@msg command {name} is declared twice 명령 {name}이(가) 두 번 선언되었습니다 +@msg command {name}: `label` must be 1-64 printable characters 명령 {name}: `label`은 인쇄 가능한 문자 1-64자여야 합니다 +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter 명령 {name}: a-z 0-9 _로 이루어지고 문자로 시작하는 1-{MAX_COMMAND_ID}자 ID를 사용하세요 +@msg command {name}: {e} 명령 {name}: {e} +@msg only a filter or a live effect declares `commands` 필터나 라이브 효과만 `commands`를 선언합니다 +@msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 모듈의 SHA-256이 `hash`에 지정된 값과 다릅니다. 아무것도 설치되지 않았습니다 # Actions panel: action sets (#3712). New Set… 새 세트… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f9c6ca1ee..6ae0b2493 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5662,6 +5662,14 @@ axis Y Axis Oś Y @msg unknown permission `{shown}` nieznane uprawnienie `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}” prosi o uprawnienia {asked}: zainstaluj ją z `grant`, który je wymienia, gdy użytkownik się zgodzi @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}” nie otrzymała `{p}`, którego potrzebuje jej wynik: nic nie zmieniono +@msg a heavy filter runs in the background, so it can't declare `commands` ciężki filtr działa w tle, więc nie może deklarować `commands` +@msg at most {MAX_COMMANDS} `commands` najwyżej {MAX_COMMANDS} `commands` +@msg command {name} is declared twice polecenie {name} jest zadeklarowane dwa razy +@msg command {name}: `label` must be 1-64 printable characters polecenie {name}: `label` musi mieć 1-64 drukowalnych znaków +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter polecenie {name}: użyj id z 1-{MAX_COMMAND_ID} znaków spośród a-z 0-9 _, zaczynającego się od litery +@msg command {name}: {e} polecenie {name}: {e} +@msg only a filter or a live effect declares `commands` `commands` deklaruje tylko filtr lub efekt na żywo +@msg “{name}” › {label}: {e} „{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed moduł nie ma skrótu SHA-256 podanego w `hash`: nic nie zainstalowano # Actions panel: action sets (#3712). New Set… Nowy zestaw… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index d22b6b4d1..a543fcdd7 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5724,6 +5724,14 @@ axis Y Axis Eixo Y @msg unknown permission `{shown}` permissão desconhecida `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” pede as permissões {asked}: instale-o com `grant` listando-as depois que o usuário concordar @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” não recebeu `{p}`, de que sua saída precisa: nada foi alterado +@msg a heavy filter runs in the background, so it can't declare `commands` um filtro pesado é executado em segundo plano, então não pode declarar `commands` +@msg at most {MAX_COMMANDS} `commands` no máximo {MAX_COMMANDS} `commands` +@msg command {name} is declared twice o comando {name} está declarado duas vezes +@msg command {name}: `label` must be 1-64 printable characters comando {name}: `label` deve ter 1-64 caracteres imprimíveis +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter comando {name}: use um id de 1-{MAX_COMMAND_ID} caracteres de a-z 0-9 _ que comece com uma letra +@msg command {name}: {e} comando {name}: {e} +@msg only a filter or a live effect declares `commands` só um filtro ou um efeito dinâmico declara `commands` +@msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed o módulo não tem o SHA-256 indicado em `hash`: nada foi instalado # Actions panel: action sets (#3712). New Set… Novo Conjunto… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 695853915..e56b2ee60 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5420,6 +5420,14 @@ axis Y Axis Ось Y @msg unknown permission `{shown}` неизвестное разрешение `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» запрашивает разрешения {asked}: установите его с `grant`, где они перечислены, когда пользователь согласится @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed «{name}» не получил разрешение `{p}`, нужное для его результата: ничего не изменено +@msg a heavy filter runs in the background, so it can't declare `commands` тяжёлый фильтр выполняется в фоне, поэтому не может объявлять `commands` +@msg at most {MAX_COMMANDS} `commands` не более {MAX_COMMANDS} `commands` +@msg command {name} is declared twice команда {name} объявлена дважды +@msg command {name}: `label` must be 1-64 printable characters команда {name}: `label` должен содержать 1-64 печатаемых символа +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter команда {name}: используйте id из 1-{MAX_COMMAND_ID} символов a-z 0-9 _, начинающийся с буквы +@msg command {name}: {e} команда {name}: {e} +@msg only a filter or a live effect declares `commands` `commands` объявляет только фильтр или динамический эффект +@msg “{name}” › {label}: {e} «{name}» › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed у модуля не тот SHA-256, что указан в `hash`: ничего не установлено # Actions panel: action sets (#3712). New Set… Новый набор… diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 019067d99..991b26195 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5832,6 +5832,14 @@ axis Y Axis Y-axel @msg unknown permission `{shown}` okänd behörighet `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees ”{name}” begär behörigheterna {asked}: installera det med en `grant` som listar dem när användaren har godkänt @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed ”{name}” beviljades inte `{p}`, som dess utdata behöver: ingenting ändrades +@msg a heavy filter runs in the background, so it can't declare `commands` ett tungt filter körs i bakgrunden, så det kan inte deklarera `commands` +@msg at most {MAX_COMMANDS} `commands` högst {MAX_COMMANDS} `commands` +@msg command {name} is declared twice kommandot {name} deklareras två gånger +@msg command {name}: `label` must be 1-64 printable characters kommando {name}: `label` måste ha 1-64 utskrivbara tecken +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter kommando {name}: använd ett id med 1-{MAX_COMMAND_ID} tecken av a-z 0-9 _ som börjar med en bokstav +@msg command {name}: {e} kommando {name}: {e} +@msg only a filter or a live effect declares `commands` bara ett filter eller en live-effekt deklarerar `commands` +@msg “{name}” › {label}: {e} ”{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modulen har inte den SHA-256 som `hash` anger: ingenting installerades # Actions panel: action sets (#3712). New Set… Ny uppsättning… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 5c0091946..4e6e96614 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5766,6 +5766,14 @@ axis Y Axis Y ekseni @msg unknown permission `{shown}` bilinmeyen izin `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” {asked} izinlerini istiyor: kullanıcı onayladıktan sonra bunları listeleyen bir `grant` ile yükleyin @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” öğesine çıktısının ihtiyaç duyduğu `{p}` izni verilmedi: hiçbir şey değiştirilmedi +@msg a heavy filter runs in the background, so it can't declare `commands` ağır bir filtre arka planda çalışır, bu yüzden `commands` bildiremez +@msg at most {MAX_COMMANDS} `commands` en fazla {MAX_COMMANDS} `commands` +@msg command {name} is declared twice {name} komutu iki kez bildirilmiş +@msg command {name}: `label` must be 1-64 printable characters {name} komutu: `label` 1-64 yazdırılabilir karakter olmalıdır +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter {name} komutu: a-z 0-9 _ karakterlerinden oluşan ve harfle başlayan 1-{MAX_COMMAND_ID} karakterlik bir kimlik kullanın +@msg command {name}: {e} {name} komutu: {e} +@msg only a filter or a live effect declares `commands` `commands` yalnızca bir filtre veya canlı efekt tarafından bildirilir +@msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modül `hash` içinde verilen SHA-256 değerine sahip değil: hiçbir şey yüklenmedi # Actions panel: action sets (#3712). New Set… Yeni Set… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ae36d2e9e..ce2c6d787 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5405,6 +5405,14 @@ axis Y Axis Вісь Y @msg unknown permission `{shown}` невідомий дозвіл `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» запитує дозволи {asked}: установіть його з `grant`, де їх перелічено, коли користувач погодиться @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed «{name}» не отримав дозволу `{p}`, потрібного для його результату: нічого не змінено +@msg a heavy filter runs in the background, so it can't declare `commands` важкий фільтр виконується у фоні, тому не може оголошувати `commands` +@msg at most {MAX_COMMANDS} `commands` щонайбільше {MAX_COMMANDS} `commands` +@msg command {name} is declared twice команду {name} оголошено двічі +@msg command {name}: `label` must be 1-64 printable characters команда {name}: `label` має містити 1-64 друковані символи +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter команда {name}: використайте id з 1-{MAX_COMMAND_ID} символів a-z 0-9 _, що починається з літери +@msg command {name}: {e} команда {name}: {e} +@msg only a filter or a live effect declares `commands` `commands` оголошує лише фільтр або динамічний ефект +@msg “{name}” › {label}: {e} «{name}» › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed модуль має не той SHA-256, що вказаний у `hash`: нічого не встановлено # Actions panel: action sets (#3712). New Set… Новий набір… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 5a83ec144..fbc972fff 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5406,6 +5406,14 @@ axis Y Axis Trục Y @msg unknown permission `{shown}` quyền không xác định `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” yêu cầu các quyền {asked}: hãy cài đặt với `grant` liệt kê chúng khi người dùng đồng ý @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” chưa được cấp `{p}`, quyền mà đầu ra của nó cần: không có gì thay đổi +@msg a heavy filter runs in the background, so it can't declare `commands` bộ lọc nặng chạy ở chế độ nền nên không thể khai báo `commands` +@msg at most {MAX_COMMANDS} `commands` tối đa {MAX_COMMANDS} `commands` +@msg command {name} is declared twice lệnh {name} được khai báo hai lần +@msg command {name}: `label` must be 1-64 printable characters lệnh {name}: `label` phải có 1-64 ký tự in được +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter lệnh {name}: hãy dùng id gồm 1-{MAX_COMMAND_ID} ký tự từ a-z 0-9 _ và bắt đầu bằng một chữ cái +@msg command {name}: {e} lệnh {name}: {e} +@msg only a filter or a live effect declares `commands` chỉ bộ lọc hoặc hiệu ứng trực tiếp mới khai báo `commands` +@msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed mô-đun không có SHA-256 mà `hash` cho biết: không có gì được cài đặt # Actions panel: action sets (#3712). New Set… Bộ mới… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index de6f8ea37..466e2afcb 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5647,6 +5647,14 @@ axis Y Axis Y 轴 @msg unknown permission `{shown}` 未知权限 `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}”需要权限 {asked}:请在用户同意后使用列出这些权限的 `grant` 安装 @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}”未获授予其输出所需的 `{p}`:未作任何更改 +@msg a heavy filter runs in the background, so it can't declare `commands` 重型滤镜在后台运行,因此不能声明 `commands` +@msg at most {MAX_COMMANDS} `commands` 最多 {MAX_COMMANDS} 个 `commands` +@msg command {name} is declared twice 命令 {name} 声明了两次 +@msg command {name}: `label` must be 1-64 printable characters 命令 {name}:`label` 必须是 1-64 个可打印字符 +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter 命令 {name}:请使用由 a-z 0-9 _ 组成、以字母开头的 1-{MAX_COMMAND_ID} 个字符的 ID +@msg command {name}: {e} 命令 {name}:{e} +@msg only a filter or a live effect declares `commands` 只有滤镜或实时效果才能声明 `commands` +@msg “{name}” › {label}: {e} “{name}” › {label}:{e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 模块的 SHA-256 与 `hash` 给出的不符:未安装任何内容 # Actions panel: action sets (#3712). New Set… 新建组… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 14b66871f..a3e9d119b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5443,6 +5443,14 @@ axis Y Axis Y 軸 @msg unknown permission `{shown}` 未知權限 `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees 「{name}」需要權限 {asked}:請在使用者同意後以列出這些權限的 `grant` 安裝 @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed 「{name}」未獲授予其輸出所需的 `{p}`:未做任何變更 +@msg a heavy filter runs in the background, so it can't declare `commands` 重型濾鏡在背景執行,因此不能宣告 `commands` +@msg at most {MAX_COMMANDS} `commands` 最多 {MAX_COMMANDS} 個 `commands` +@msg command {name} is declared twice 指令 {name} 宣告了兩次 +@msg command {name}: `label` must be 1-64 printable characters 指令 {name}:`label` 必須是 1-64 個可列印字元 +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter 指令 {name}:請使用由 a-z 0-9 _ 組成、以字母開頭的 1-{MAX_COMMAND_ID} 個字元的 ID +@msg command {name}: {e} 指令 {name}:{e} +@msg only a filter or a live effect declares `commands` 只有濾鏡或即時效果才能宣告 `commands` +@msg “{name}” › {label}: {e} 「{name}」 › {label}:{e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 模組的 SHA-256 與 `hash` 提供的不符:未安裝任何內容 # Actions panel: action sets (#3712). New Set… 新增組… diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 572c6b175..844db0a98 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -7,7 +7,7 @@ //! outside it are VectorCraft's too (`Cmd+Shift+]`, `Comma`, `Period`, `~` for the backtick key, //! `=` for Plus with modifiers). Inside an [`Overrides`] keys and texts are the crate's. -use std::sync::{OnceLock, RwLock}; +use std::sync::{Mutex, OnceLock, RwLock}; use craft_keymap::{Bindable, Binding, Chord, CommandRef, Key, Keymap, Kind, Lookup, Mods, Overrides, Platform, Scope, Scopes}; use serde_json::{Value, json}; @@ -212,11 +212,25 @@ pub(crate) fn per_platform<T>(cells: &'static [OnceLock<T>; 3], p: Platform, mak cell.get_or_init(make) } -/// The keymap with the default shortcuts for [`platform`], built once per platform. +/// The keymap with the default shortcuts for [`platform`], built once per platform, and again for +/// each set of plug-in commands installed ([`vectorcraft_engine::cmd::plugin::commands_generation`]). pub fn template() -> &'static Keymap { static TEMPLATES: [OnceLock<Keymap>; 3] = [OnceLock::new(), OnceLock::new(), OnceLock::new()]; + static WITH_PLUGINS: Mutex<Vec<(Platform, u64, &'static Keymap)>> = Mutex::new(Vec::new()); let p = platform(); - per_platform(&TEMPLATES, p, || keymap_for(p)) + let generation = vectorcraft_engine::cmd::plugin::commands_generation(); + if generation == 0 { + return per_platform(&TEMPLATES, p, || keymap_for(p)); + } + let mut made = WITH_PLUGINS.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((_, _, km)) = made.iter().find(|(q, g, _)| *q == p && *g == generation) { + return km; + } + made.retain(|(_, g, _)| *g == generation); + // Kept for the rest of the process: one keymap for each set of plug-in commands installed. + let km: &'static Keymap = Box::leak(Box::new(keymap_for(p))); + made.push((p, generation, km)); + km } /// The default keymap of `p`. The chords the operating system takes can't be assigned. diff --git a/crates/ui-egui/src/menus/entries.rs b/crates/ui-egui/src/menus/entries.rs index e9cfc538a..36811ba4c 100644 --- a/crates/ui-egui/src/menus/entries.rs +++ b/crates/ui-egui/src/menus/entries.rs @@ -33,6 +33,8 @@ pub fn entry<'a>(app: &VectorcraftApp, it: &'a Item) -> Option<Entry<'a>> { Some(match it { Item::Sep => Entry::Sep, Item::Header(h) => Entry::Header(t(h)), + // A plug-in's submenu shows its name as it is, as its items do. + Item::Sub(label, children) if super::labels::holds_plugin_commands(children) => Entry::Sub(label, children), Item::Sub(label, children) => Entry::Sub(t(label), children), Item::Todo(label, sc) => Entry::Item(Row { command: None, diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 073b1130e..0a0695e0e 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -152,6 +152,10 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { app.ui.dialog = Some(crate::state::Dialog::new(kind, fields)); return; } + // A plug-in command with parameters asks for them, from its menu item or its shortcut. + if p.as_object().is_none_or(|o| o.is_empty()) && crate::dialogs::plugin::open_command(app, id) { + return; + } if open_view_dialog(app, id, &p) || open_object_dialog(app, id, &p) { return; } diff --git a/crates/ui-egui/src/menus/labels.rs b/crates/ui-egui/src/menus/labels.rs index b8d314346..6141352ab 100644 --- a/crates/ui-egui/src/menus/labels.rs +++ b/crates/ui-egui/src/menus/labels.rs @@ -139,15 +139,18 @@ pub(super) fn shows_a_name(id: &str, label: &str) -> bool { SLOTS.iter().any(|s| id.starts_with(s)) || saved_selection_slot(id).is_some() || matches!(id, "text.setStyle" | "plugin.dialog" | "window.toolbar.custom") + || vectorcraft_engine::cmd::plugin::is_command_id(id) || (id == "window.workspace" && !crate::workspaces::is_builtin(label)) || (matches!(id, "effect.apply" | "effect.dialog") && vectorcraft_effects::plugin_effects().iter().any(|e| e.label == label)) } /// Does the menu item `id` with `params` list an installed plug-in (an Object › Plug-ins filter or -/// an Effect › Plug-ins effect), by the plug-in's own name? Told by the item itself, so it holds -/// while another thread installs or removes plug-ins. +/// an Effect › Plug-ins effect) or one of its commands, by the plug-in's own text? Told by the item +/// itself, so it holds while another thread installs or removes plug-ins. pub(crate) fn lists_plugin(id: &str, params: &Value) -> bool { - id == "plugin.dialog" || params.get("effect").and_then(Value::as_str).and_then(vectorcraft_plugins::effect::plugin_id).is_some() + id == "plugin.dialog" + || vectorcraft_engine::cmd::plugin::is_command_id(id) + || params.get("effect").and_then(Value::as_str).and_then(vectorcraft_plugins::effect::plugin_id).is_some() } /// The label of a menu item as drawn: [`dynamic_label`] in the UI language. Labels assembled @@ -307,6 +310,11 @@ pub const DYNAMIC_LABELS: &[&str] = &[ "Lock Grid", ]; +/// Is `children` a plug-in's submenu: does it hold a command the plug-in declares? +pub(crate) fn holds_plugin_commands(children: &[Item]) -> bool { + children.iter().any(|c| matches!(c, Item::Cmd(_, id, _) if vectorcraft_engine::cmd::plugin::is_command_id(id))) +} + /// Every English string the menus, the command palette, the panel registry, the toolbar and the /// Preferences dialog can show: what a complete language catalog has to cover. Names that are user /// data (fonts, recent files, saved views, libraries, installed plug-ins), the point sizes and the @@ -335,6 +343,8 @@ pub fn menu_strings() -> std::collections::BTreeSet<String> { Item::Todo(l, _) | Item::Header(l) => { out.insert(l.to_string()); } + // A plug-in's submenu is named after the plug-in. + Item::Sub(_, children) if holds_plugin_commands(children) => walk(children, under, out), Item::Sub(l, children) => { out.insert(l.to_string()); // Type › Font lists the installed families: names, not UI text. diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index fda2e1f7f..54ff9986e 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -11,7 +11,8 @@ use crate::{VectorcraftApp, menus}; pub fn items() -> Vec<(String, String, String)> { let mut items = vec![]; // Aliases kept for older scripts run a command that is listed already. - for c in vectorcraft_engine::command_specs().iter().filter(|c| !vectorcraft_engine::cmd::is_alias(c.id)) { + let plugin_commands = vectorcraft_engine::cmd::plugin::command_specs(); + for c in vectorcraft_engine::command_specs().iter().filter(|c| !vectorcraft_engine::cmd::is_alias(c.id)).chain(plugin_commands) { let path = c.menu.join(" › "); let label = if path.is_empty() { c.label.to_string() } else { format!("{path} › {}", c.label) }; items.push((label, c.id.to_string(), menus::shortcut_of(c.id).unwrap_or("").to_string())); diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index ee2b6ea83..07a6af693 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -66,10 +66,13 @@ pub(crate) use ui_mirror; pub(crate) struct Mirror { pub keymap: Keymap, text: HashMap<String, Option<&'static str>>, + /// The plug-in commands the keymap holds ([`vectorcraft_engine::cmd::plugin::commands_generation`]). + plugins: u64, } impl Mirror { fn new(overrides: Overrides) -> Mirror { + let plugins = vectorcraft_engine::cmd::plugin::commands_generation(); let keymap = crate::keymap::template().clone().with_overrides(overrides); let text = keymap .overrides() @@ -80,20 +83,36 @@ impl Mirror { (key, t) }) .collect(); - Mirror { keymap, text } + Mirror { keymap, text, plugins } } } ui_mirror!(fn store() -> Option<Arc<Mirror>>); -/// The mirror: the keymap in effect. +/// The mirror: the keymap in effect. It is made again when the plug-in commands change. pub(crate) fn current() -> Arc<Mirror> { - static DEFAULTS: [OnceLock<Arc<Mirror>>; 3] = [OnceLock::new(), OnceLock::new(), OnceLock::new()]; - store() - .read() - .ok() - .and_then(|m| m.clone()) - .unwrap_or_else(|| crate::keymap::per_platform(&DEFAULTS, crate::keymap::platform(), || Arc::new(Mirror::new(Overrides::new()))).clone()) + static DEFAULTS: Mutex<Vec<Arc<Mirror>>> = Mutex::new(Vec::new()); + let plugins = vectorcraft_engine::cmd::plugin::commands_generation(); + if let Some(m) = store().read().ok().and_then(|m| m.clone()) { + if m.plugins == plugins { + return m; + } + let fresh = Arc::new(Mirror::new(m.keymap.overrides().clone())); + if let Ok(mut m) = store().write() { + *m = Some(fresh.clone()); + } + GENERATION.fetch_add(1, Ordering::Relaxed); + return fresh; + } + let p = crate::keymap::platform(); + let mut defaults = DEFAULTS.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(m) = defaults.iter().find(|m| m.plugins == plugins && m.keymap.platform() == p) { + return m.clone(); + } + defaults.retain(|m| m.plugins == plugins); + let m = Arc::new(Mirror::new(Overrides::new())); + defaults.push(m.clone()); + m } /// Leak-once interning for override strings (bounded by the chords a user ever assigns). @@ -113,7 +132,11 @@ pub fn sync(ui: &UiState) { let same = store() .read() .map(|m| match &*m { - Some(m) => *m.keymap.overrides() == ui.shortcut_overrides && m.keymap.platform() == crate::keymap::platform(), + Some(m) => { + *m.keymap.overrides() == ui.shortcut_overrides + && m.keymap.platform() == crate::keymap::platform() + && m.plugins == vectorcraft_engine::cmd::plugin::commands_generation() + } None => ui.shortcut_overrides.is_empty(), }) .unwrap_or(false); @@ -210,6 +233,7 @@ pub fn command_for_key(key: &str) -> Option<&'static str> { let c = crate::keymap::lookup(¤t().keymap, &vc_chord(key)?)?; vectorcraft_engine::command_specs() .iter() + .chain(vectorcraft_engine::cmd::plugin::command_specs()) .map(|c| c.id) .chain(menus::UI_COMMANDS.iter().map(|c| c.0)) .find(|id| *id == c.id && c.params.is_null()) @@ -334,8 +358,37 @@ pub struct Entry { pub is_tool: bool, } -/// Every tool, command and panel the editor can give a shortcut; [`listed`] says which it lists. +/// Every tool, command and panel the editor can give a shortcut, followed by the commands of the +/// installed plug-ins; [`listed`] says which it lists. The list is made again when the plug-in +/// commands change ([`vectorcraft_engine::cmd::plugin::commands_generation`]). pub fn entries() -> &'static [Entry] { + static WITH_PLUGINS: Mutex<Option<(u64, &'static [Entry])>> = Mutex::new(None); + let base = built_in_entries(); + let generation = vectorcraft_engine::cmd::plugin::commands_generation(); + if generation == 0 { + return base; + } + let mut with = WITH_PLUGINS.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((g, e)) = *with + && g == generation + { + return e; + } + let mut v = base.to_vec(); + v.extend(vectorcraft_engine::cmd::plugin::command_specs().into_iter().map(|c| Entry { + key: c.id.into(), + label: c.label.into(), + group: c.menu.join(" › "), + is_tool: false, + })); + // Kept for the rest of the process: one list for each set of plug-in commands installed. + let e: &'static [Entry] = Box::leak(v.into_boxed_slice()); + *with = Some((generation, e)); + e +} + +/// [`entries`] without the plug-in commands. +fn built_in_entries() -> &'static [Entry] { static E: OnceLock<Vec<Entry>> = OnceLock::new(); E.get_or_init(|| { let mut v: Vec<Entry> = vectorcraft_tools::catalog::all_tools() @@ -379,6 +432,7 @@ pub fn entries() -> &'static [Entry] { } v }) + .as_slice() } /// One Effect menu item a shortcut can be given (#3296): the effect's id, the command its item runs @@ -863,24 +917,30 @@ struct DialogView { keymap: Keymap, texts: Vec<Option<String>>, conflicts: usize, + /// The plug-in commands [`entries`] held ([`vectorcraft_engine::cmd::plugin::commands_generation`]). + plugins: u64, } impl DialogView { fn new(overrides: Overrides) -> DialogView { + let plugins = vectorcraft_engine::cmd::plugin::commands_generation(); let keymap = keymap_with(&overrides); let backtick = backtick_text(); let texts = entries().iter().map(|e| dialog_shortcuts_with(&keymap, &e.key, backtick.as_deref())).collect(); let conflicts = conflicts_in(&keymap).len(); - DialogView { source: stored::to_value(keymap.overrides()), backtick, keymap, texts, conflicts } + DialogView { source: stored::to_value(keymap.overrides()), backtick, keymap, texts, conflicts, plugins } } - /// The view of `d`'s working set, from the cache unless the set or the backtick key's - /// character changed. The dialog's `overrides` field is left in its stored form. + /// The view of `d`'s working set, from the cache unless the set, the backtick key's character + /// or the plug-in commands changed. The dialog's `overrides` field is left in its stored form. fn of(ctx: &egui::Context, d: &mut Dialog) -> Arc<DialogView> { let id = egui::Id::unique("dialog-shortcuts-view"); let source = d.fields.get("overrides").unwrap_or(&Value::Null); let backtick = backtick_text(); - if let Some(v) = ctx.data(|m| m.get_temp::<Arc<DialogView>>(id)).filter(|v| v.source == *source && v.backtick == backtick) { + let plugins = vectorcraft_engine::cmd::plugin::commands_generation(); + if let Some(v) = + ctx.data(|m| m.get_temp::<Arc<DialogView>>(id)).filter(|v| v.source == *source && v.backtick == backtick && v.plugins == plugins) + { return v; } let v = Arc::new(DialogView::new(dialog_overrides(d))); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index f6ceaec9d..6ad83deda 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -97,7 +97,7 @@ pub(crate) fn is_modifier(key: Key) -> bool { /// command's params: `{}`, or `{panel}` for the panel shortcuts (`window.panel`). pub(crate) fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str, serde_json::Value)> { let mut v = vec![]; - for c in vectorcraft_engine::command_specs() { + for c in vectorcraft_engine::command_specs().iter().chain(vectorcraft_engine::cmd::plugin::command_specs()) { if let Some(sc) = crate::menus::shortcut_of(c.id).and_then(parse) { v.push((sc, c.id, json!({}))); } diff --git a/crates/ui-egui/src/tests/plugins.rs b/crates/ui-egui/src/tests/plugins.rs index dbb32769a..3f898f49f 100644 --- a/crates/ui-egui/src/tests/plugins.rs +++ b/crates/ui-egui/src/tests/plugins.rs @@ -183,3 +183,87 @@ fn installing_a_plugin_that_lists_permissions_asks_first() { assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some("Tidy 2".into()), "replace: false is kept"); app.run("plugin.remove", json!({"id": id})).unwrap(); } + +/// A plug-in with two commands: Grow, which adds a triangle (`_context.command` is `grow`), and +/// Size, with a parameter, whose `menu` names another menu. +fn commands_plugin(id: &str) -> Vec<u8> { + let manifest = format!( + r#"{{"id":"{id}","name":"Cmd Test","version":"1","kind":"filter","commands":[{{"id":"grow","label":"Grow"}}, + {{"id":"size","label":"Size","menu":"File","params":{{"by":{{"type":"number","min":0,"max":10,"default":2}}}}}}]}}"# + ); + let triangle = r#"{"objects":[{"path":{"subpaths":[{"closed":true,"anchors":[{"p":[0,0]},{"p":[5,0]},{"p":[5,5]}]}]}}]}"#; + wat::parse_str(tpl::when_given(&manifest, tpl::Given::Params, r#""command":"grow""#, triangle)).unwrap() +} + +fn objects(app: &VectorcraftApp) -> usize { + app.session.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum() +} + +/// A plug-in's commands are items of Object › Plug-ins › its name (never the menu a manifest +/// names), entries of Edit › Keyboard Shortcuts with no shortcut of their own, and gone from both +/// once it is removed. A command with parameters opens its dialog. +#[test] +fn plugin_commands_are_menu_items_and_shortcut_entries() { + let mut app = app(); + let id = "test.ui.cmds"; + let (grow, size) = ("plugin.test.ui.cmds.grow", "plugin.test.ui.cmds.size"); + app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&commands_plugin(id))})).unwrap(); + let entries = menus::menu_entries(&app); + let under = ["Object", "Plug-ins", "Cmd Test"]; + let item = entry(&entries, &under, "Grow").expect("Grow is listed"); + assert_eq!((item.command.as_deref(), item.enabled), (Some(grow), true)); + assert!( + entry(&entries, &under, "Size…").is_some(), + "Size is listed: {:?}", + entries.iter().filter(|e| e.path == under).map(|e| &e.label).collect::<Vec<_>>() + ); + assert!(!entries.iter().any(|e| e.command.as_deref() == Some(size) && e.path.first().map(String::as_str) != Some("Object")), "not in File"); + assert!(!menus::menu_strings().contains("Cmd Test"), "the plug-in's name is not text to translate"); + + let listed = crate::shortcut_editor::entries().iter().find(|e| e.key == grow).expect("in Keyboard Shortcuts"); + assert_eq!((listed.label.as_str(), listed.group.as_str()), ("Grow", "Object › Plug-ins › Cmd Test")); + assert_eq!(crate::shortcut_editor::default_of(grow), None, "a manifest gives no shortcut"); + assert!(crate::keymap::template().bindable(&crate::keymap::command_of(grow)).is_some(), "a shortcut can be given to it"); + + // Size asks for its parameter; OK runs it as one undo step named after it. + let undo = app.session.doc().unwrap().history.undo.len(); + menus::invoke(&mut app, size, json!({})); + let d = app.ui.dialog.as_ref().expect("Size opens its dialog"); + assert_eq!((d.str("__command"), d.str("__label"), d.fields.get("by")), (size.to_string(), "Size".to_string(), Some(&json!(2.0)))); + frame(&mut app); + dialogs::confirm(&mut app).unwrap(); + let st = app.session.doc().unwrap(); + assert_eq!((st.history.undo.len(), st.history.undo.last().unwrap().label.as_str()), (undo + 1, "Size")); + + app.run("plugin.remove", json!({"id": id})).unwrap(); + assert!(menus::menu_entries(&app).iter().all(|e| e.command.as_deref() != Some(grow))); + assert!(crate::shortcut_editor::entries().iter().all(|e| e.key != grow)); +} + +/// A shortcut the user gives a plug-in command runs it. +#[test] +fn a_shortcut_runs_a_plugin_command() { + let mut app = app(); + let id = "test.ui.cmds.keys"; + let grow = vectorcraft_engine::cmd::plugin::command_id(id, "grow"); + app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&commands_plugin(id))})).unwrap(); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, &grow, "Cmd+Alt+Shift+F9"); + crate::shortcut_editor::sync(&app.ui); + assert_eq!(crate::shortcut_editor::command_for_key("Cmd+Alt+Shift+F9"), Some(grow.as_str())); + let before = objects(&app); + let m = if crate::keymap::platform().is_mac() { egui::Modifiers::MAC_CMD } else { egui::Modifiers::CTRL } | egui::Modifiers::COMMAND; + let m = m | egui::Modifiers::ALT | egui::Modifiers::SHIFT; + let events = vec![ + egui::Event::ModifiersChanged(m), + egui::Event::Key { key: egui::Key::F9, physical_key: Some(egui::Key::F9), pressed: true, repeat: false, modifiers: m }, + ]; + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + app.logic(ui.ctx()); + crate::shortcuts::handle(&mut app, ui.ctx()); + }); + out.textures_delta.clear(); + crate::shortcut_editor::sync(&crate::state::UiState::default()); + assert_eq!(objects(&app), before + 1, "the command added its triangle: {}", app.ui.status); + app.run("plugin.remove", json!({"id": id})).unwrap(); +} diff --git a/docs/mcp.md b/docs/mcp.md index 030d4fe33..0945d7070 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5925,11 +5925,18 @@ filter without `doc.write` or `state` that returns output needing them gets an e permission names, and a `hash` that is not the module's SHA-256. `prefs.set` returns an error and changes nothing when a new `pluginsFolder` is missing, is not a folder or can't be listed. It does not check an empty value or the folder the preference already holds, and the web build does not check folders. +A filter or live effect may declare commands of its own ([plugins.md](plugins.md#commands)): each is the command +`plugin.<id>.<command>` while the plug-in is installed, listed by `command.list` and in its `plugin.list` entry +(`commands: [{id, command, label, params, paramsSchema}]`). Run it with `{ids?, params?}` (its parameters may also +be top-level keys): the parameters are checked before the plug-in runs, a run is one undo step named after the +command, and a failing run returns an error that starts with the plug-in's name and the command's label and leaves +the document as it was. ```json {"name":"run_command","arguments":{"command":"plugin.install","params":{"path":"/plugins/desaturate.wasm"}}} {"name":"run_command","arguments":{"command":"plugin.run","params":{"id":"org.vectorcraft.example.desaturate","params":{"amount":60}}}} {"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"plugin.org.example.wobble","params":{"size":4}}}} +{"name":"run_command","arguments":{"command":"plugin.org.example.tidy.grow","params":{"by":3}}} ``` ## Colour adjustments diff --git a/docs/plugins.md b/docs/plugins.md index 96f8ac6e3..d44a1b291 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -23,7 +23,7 @@ This page is the contract for plug-in authors and the reference for the host (`c | Command | Params | What it does | |---|---|---| -| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, size, source, permissions, hash}], "folder": report}` (`budget: "heavy"` for a [heavy filter](#heavy-filters); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256) | +| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, commands?, size, source, permissions, hash}], "folder": report}` (`budget: "heavy"` for a [heavy filter](#heavy-filters); `commands` lists its [commands](#commands); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256) | | `plugin.info` | `{"id"}` | One plug-in: the `plugin.list` entry plus `manifest`, `defaults` and `lastError` (a live effect's last failure). | | `plugin.run` | `{"id", "params"?: {…}, "ids"?: [...]}` (plug-in params may also be top-level keys) | Runs an object filter on the selection (or `ids`). One undo step; the result is selected, except the objects it locked or made guides. | | `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace": bool = true`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`, once the user agreed. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, once the user agreed. With `hash`, a module whose SHA-256 differs is refused. | @@ -50,6 +50,29 @@ a plug-in too (on the web this is how plug-ins get in). A heavy filter asks befo background ([below](#heavy-filters)). A plug-in that lists permissions asks for them before it is installed ([below](#permissions)). +### Commands + +A filter or a live effect may declare commands of its own in its manifest (`commands`, [below](#manifest)). Each +becomes the command `plugin.<id>.<command>` while the plug-in is installed, and is gone once the plug-in is removed or +replaced by a module without it. + +- **Menus.** A plug-in with commands is a submenu of **Object › Plug-ins** (a live effect: **Effect › Plug-ins**) + named after the plug-in. The submenu holds the plug-in's own item, then its commands. A command with parameters has + an ellipsis and opens a dialog for them, with a live preview, as a filter's own item does. A manifest cannot put a + command in another menu: a `menu` key in a command is ignored. +- **Keyboard Shortcuts.** Edit › Keyboard Shortcuts lists each command under its submenu, and the user can give it a + shortcut. A manifest cannot give a command a shortcut. +- **Actions and agents.** A command run is recorded in an action like any other command and plays back with its + parameters. `run`, MCP and `command.batch` call it as `plugin.<id>.<command>` with `{"ids"?: [...], "params"?: {…}}` + (its parameters may also be top-level keys). `command.list` lists it, and `plugin.list` lists a plug-in's commands + as `commands: [{id, command, label, params, paramsSchema}]`. +- **Runs.** The parameters are checked against the command's declarations before the plug-in runs. The plug-in runs + through `vc_run` on the selection (or `ids`) as a filter run does, with `_context.mode` `"command"` and + `_context.command` set to the command's id ([Parameters](#parameters)), under the plug-in's + [permissions](#permissions): a live effect's command has no `doc.write`, so an output that changes the document is + refused. One run is one undo step named after the command. A run that traps or fails leaves the document as it was, + and the error starts with the plug-in's name and the command's label (`“Cmd Test” › Grow: …`). + **Preference folder.** Edit › Preferences › Plug-ins & Scratch Disks › *Additional Plug-ins Folder*: when set, every `*.wasm` in that folder (not recursive) is installed at startup and whenever the preference changes (desktop builds; the web build installs from bytes). On the desktop, Choose… beside the field picks the folder; the dialog refuses a @@ -207,6 +230,11 @@ pointers are offsets into the module's memory. - `extensions` (exporters only, and required): 1–8 file extensions, lower-case letters and digits without the dot (`["plt"]`); the first is the one Export As gives the file. A built-in format keeps an extension both list (`svg`): `document.export` then writes the exporter's format only when asked for by its format id. +- `commands` (filters and live effects; not heavy filters): up to 32 commands of the plug-in's own + ([Commands](#commands)), each `{"id": "grow", "label": "Grow", "params"?: {…}}`. An `id` is 1–32 characters from + `a-z 0-9 _` that starts with a letter, and appears once. A `label` is 1–64 printable characters; menus show it with + control and bidirectional characters escaped. `params` are declared as the plug-in's own `params` are. A malformed + entry is refused when the module is installed, and the error names the command by its id (or its place, `[0]`). - `params`: in dialog order. Names are `[A-Za-z0-9_]`, not starting with `_`, and not `id`, `ids`, `params` or `preview`. The host validates the user's values before calling: missing values take the default, numbers are clamped to `min..max` (ints rounded), a wrong type or an unknown choice is an error. Numeric defaults @@ -299,7 +327,8 @@ type and `blob` but without their data, which follows the JSON in a saved file; "_context": {"mode": "filter", "bounds": [10, 0, 75, 60], "artboard": [0, 0, 612, 792], "documentData": {"pens": 4}}} ``` -`mode` is `"filter"`, `"effect"` or `"export"`; `bounds` the selection's geometric bounds (a live effect: the reference +`mode` is `"filter"`, `"effect"`, `"export"` or `"command"` (a [command](#commands) run, whose `command` is the +command's id); `bounds` the selection's geometric bounds (a live effect: the reference box of the effect, the object's bounds or those of the previous effect's result); `artboard` (filters) the artboard under the selection's centre, else the first; `documentData` (filters) what the plug-in last kept with the document (`null` until it keeps something; see Output). A live effect's `item` says where it sits: `"object"` (one of the diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6ee68a15f..04f4a6433 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1429,6 +1429,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Plug-in data kept with the document (G17):** a filter writes JSON of at most 1 MiB with `documentData` (one undo step with its objects; `null` removes it) and reads it back in `_context.documentData`, which no other filter is given. It is the document's foreign data (`unknown.pluginData`, by plug-in id), as the perspective grid is, so the native file keeps it with no section of its own; an exporter is given the whole document, so it can read every plug-in's. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_export.rs` (the exporter), `crates/doc/src/plugin_data.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged. - **Heavy filter plug-ins (G17):** a filter's manifest may ask for `"budget": "heavy"`: 1 G instructions (20 times a light run's) and 300 s a run, for routing, packing or simulation. Install Plug-in… and File › Open ask the user first, `plugin.install` needs `allowHeavy: true` (`plugin.inspect` tells beforehand), the Additional Plug-ins Folder skips such filters, and the plug-in store keeps the answer. Its dialog has no Preview; the app runs it on a thread of its own with a progress window (Cancel, Continue in Background) and applies the result as `plugin.run` with the same parameters, one undo step, unless the document or the plug-in changed meanwhile. Regressions: `crates/plugins/tests/abi.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. +- **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin.<id>.<command>` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions, is one undo step named after the command, and on failure leaves the document as it was with an error naming the plug-in and the command. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one. From ea27cc0a2ecb8aba259752435e9d1d486cbdc47d Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 17:43:02 -0800 Subject: [PATCH 101/160] Plug-ins: a command list read during an install is not cached under the new revision registry::install now changes the revision while it holds the plug-in set's write lock, so a reader that sees the new revision and lists the plug-ins always finds the new one. command_specs also skips caching a list when the revision changed while it was listing. Before, a menu or shortcut lookup that ran during an install could keep the old command list until the next install or removal. --- crates/engine/src/cmd/plugin.rs | 5 +++++ crates/plugins/src/registry.rs | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 53fdd5ad2..427a03f98 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -324,6 +324,11 @@ pub fn command_specs() -> Vec<&'static CommandSpec> { } let specs: Vec<&'static CommandSpec> = registry::list().iter().flat_map(|p| p.manifest().commands.iter().map(|c| spec_of(p, c)).collect::<Vec<_>>()).collect(); + // When a plug-in was installed or removed during the listing, the list may not match + // `revision`. It is returned without being cached, and the next call lists again. + if registry::revision() != revision { + return specs; + } let mut l = LISTED.write().unwrap_or_else(|e| e.into_inner()); if l.specs.len() != specs.len() || l.specs.iter().zip(&specs).any(|(a, b)| !std::ptr::eq(*a, *b)) { l.generation += 1; diff --git a/crates/plugins/src/registry.rs b/crates/plugins/src/registry.rs index 0302ab6be..5524166fa 100644 --- a/crates/plugins/src/registry.rs +++ b/crates/plugins/src/registry.rs @@ -45,11 +45,13 @@ pub fn approved(plugin: &Plugin) -> bool { !m.asks_permissions() || granted(&m.id).is_some_and(|g| g.covers(&m.permissions())) } -/// Installs `plugin`, replacing one with the same id. +/// Installs `plugin`, replacing one with the same id. The revision changes while the set is locked, +/// so a caller that reads the new [`revision`] and then [`list`]s always finds the plug-in. pub fn install(mut plugin: Plugin) -> Arc<Plugin> { + let mut plugins = PLUGINS.write().unwrap_or_else(|e| e.into_inner()); plugin.set_serial(REVISION.fetch_add(1, Ordering::Relaxed) + 1); let p = Arc::new(plugin); - PLUGINS.write().unwrap_or_else(|e| e.into_inner()).insert(p.id().to_string(), p.clone()); + plugins.insert(p.id().to_string(), p.clone()); p } From 864b9b99504b01173882450835cb0c09e11d098c Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 17:55:26 -0800 Subject: [PATCH 102/160] Plug-ins: a command's parameters keep their declaration order The commands list is read one entry at a time from the manifest itself, so a command's dialog shows its parameters in the order the manifest declares them, and a parameter declared twice in a command is refused with an error naming the command. --- crates/plugins/src/manifest.rs | 120 +++++++++++++++++++++++++++------ 1 file changed, 99 insertions(+), 21 deletions(-) diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index fe543d7ea..d2f780bf6 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -578,27 +578,101 @@ fn ordered_params<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result d.deserialize_any(V) } -/// Reads the `commands` list one entry at a time, so a malformed entry is an error that names it -/// (its id, or its place in the list), and a list longer than [`MAX_COMMANDS`] is refused before -/// its entries are read. +/// Reads the `commands` list one entry at a time from the manifest itself, so each command's +/// `params` keep their declaration order and a repeated parameter is refused. A malformed entry +/// is an error that names it (its id when the id comes before the fault, otherwise its place in +/// the list), and a list is refused at its first entry past [`MAX_COMMANDS`]. fn ordered_commands<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Vec<Command>, D::Error> { - use serde::de::Error as _; - let entries = match Value::deserialize(d)? { - Value::Null => return Ok(Vec::new()), - Value::Array(entries) => entries, - _ => return Err(D::Error::custom("`commands` must be a list of {id, label, params?}")), - }; - if entries.len() > MAX_COMMANDS { - return Err(D::Error::custom(format!("at most {MAX_COMMANDS} `commands`"))); + use serde::de::{Error as _, IgnoredAny, MapAccess, SeqAccess, Visitor}; + + /// A command's `params`, read in declaration order. + struct Params(Vec<(String, Param)>); + impl<'de> Deserialize<'de> for Params { + fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> { + ordered_params(d).map(Params) + } } - entries - .into_iter() - .enumerate() - .map(|(i, v)| { - let name = command_name(i, v.get("id").and_then(Value::as_str).unwrap_or_default()); - serde_json::from_value::<Command>(v).map_err(|e| D::Error::custom(format!("command {name}: {e}"))) - }) - .collect() + + /// The entry at place `.0` in the list. + struct Entry(usize); + impl<'de> serde::de::DeserializeSeed<'de> for Entry { + type Value = Command; + fn deserialize<D: serde::Deserializer<'de>>(self, d: D) -> std::result::Result<Command, D::Error> { + d.deserialize_any(self) + } + } + impl<'de> Visitor<'de> for Entry { + type Value = Command; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "command [{}] as {{id, label, params?}}", self.0) + } + fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> std::result::Result<Command, A::Error> { + let (mut id, mut label, mut params) = (None::<String>, None::<String>, None::<Params>); + let named = |id: &Option<String>, e: &dyn std::fmt::Display| { + A::Error::custom(format!("command {}: {e}", command_name(self.0, id.as_deref().unwrap_or_default()))) + }; + while let Some(key) = map.next_key::<String>().map_err(|e| named(&id, &e))? { + let repeated = match key.as_str() { + "id" if id.is_none() => { + id = Some(map.next_value().map_err(|e| named(&id, &e))?); + false + } + "label" if label.is_none() => { + label = Some(map.next_value().map_err(|e| named(&id, &e))?); + false + } + "params" if params.is_none() => { + params = Some(map.next_value().map_err(|e| named(&id, &e))?); + false + } + "id" | "label" | "params" => true, + _ => { + map.next_value::<IgnoredAny>().map_err(|e| named(&id, &e))?; + false + } + }; + if repeated { + return Err(named(&id, &format_args!("duplicate field `{key}`"))); + } + } + let Some(id) = id else { + return Err(named(&None, &"missing field `id`")); + }; + let Some(label) = label else { + return Err(named(&Some(id), &"missing field `label`")); + }; + Ok(Command { id, label, params: params.map(|p| p.0).unwrap_or_default() }) + } + } + + struct List; + impl<'de> Visitor<'de> for List { + type Value = Vec<Command>; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("`commands` as a list of {id, label, params?}") + } + fn visit_unit<E: serde::de::Error>(self) -> std::result::Result<Self::Value, E> { + Ok(Vec::new()) + } + fn visit_map<A: MapAccess<'de>>(self, _: A) -> std::result::Result<Self::Value, A::Error> { + Err(A::Error::custom("`commands` must be a list of {id, label, params?}")) + } + fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> std::result::Result<Self::Value, A::Error> { + let mut out = Vec::new(); + while out.len() < MAX_COMMANDS { + match seq.next_element_seed(Entry(out.len()))? { + Some(c) => out.push(c), + None => return Ok(out), + } + } + match seq.next_element::<IgnoredAny>()? { + Some(_) => Err(A::Error::custom(format!("at most {MAX_COMMANDS} `commands`"))), + None => Ok(out), + } + } + } + + d.deserialize_any(List) } /// How errors name the command at place `i` with id `id`: the id in backticks, escaped and capped @@ -800,12 +874,12 @@ mod tests { let m = Manifest::parse( br#"{"id":"org.example.tidy","name":"Tidy","kind":"filter","commands":[ {"id":"tidy_all","label":"Tidy All","menu":"File"}, - {"id":"grow","label":"Grow","params":{"by":{"type":"number","min":0,"max":10,"default":2},"mode":{"type":"bool"}}}]}"#, + {"id":"grow","label":"Grow","params":{"mode":{"type":"bool"},"by":{"type":"number","min":0,"max":10,"default":2}}}]}"#, ) .unwrap(); assert_eq!(m.commands.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), ["tidy_all", "grow"]); let grow = m.command("grow").unwrap(); - assert_eq!(grow.params.iter().map(|p| p.0.as_str()).collect::<Vec<_>>(), ["by", "mode"]); + assert_eq!(grow.params.iter().map(|p| p.0.as_str()).collect::<Vec<_>>(), ["mode", "by"], "declaration order"); let run = m.for_command(grow); assert_eq!((run.name.as_str(), run.id.as_str()), ("Grow", "org.example.tidy")); assert_eq!(Value::Object(run.resolve_params(&json!({"by": 50})).unwrap()), json!({"by": 10.0, "mode": false})); @@ -838,6 +912,10 @@ mod tests { r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":"A","params":{"id":{"type":"bool"}}}]}"#, "command `x`: invalid parameter", ), + ( + r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":"A","params":{"r":{"type":"bool"},"r":{"type":"int"}}}]}"#, + "command `x`: parameter `r` is declared twice", + ), (r#"{"id":"a","name":"x","kind":"filter","commands":[1]}"#, "command [0]"), (r#"{"id":"a","name":"x","kind":"filter","commands":{"id":"x"}}"#, "must be a list"), (r#"{"id":"a","name":"x","kind":"exporter","extensions":["plt"],"commands":[{"id":"x","label":"A"}]}"#, "only a filter or a live effect"), From 02fffcd00cfb42cea334002b65e762366a73999d Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 17:42:13 -0800 Subject: [PATCH 103/160] JavaScript: String, Number, RegExp and Date built-ins A realm now has String, Number, RegExp and Date as ECMAScript 5.1 sections 15.5, 15.7, 15.9 and 15.10 define them, with Annex B's substr, getYear, setYear and toGMTString. - String: every ES5 prototype method; split, replace, match and search take strings and RegExps. Each new string's length is checked against the heap limit before it is built, and scans take fuel. - Number: toString(radix), toFixed, toExponential and toPrecision format from the number's exact decimal value. - RegExp: a backtracking matcher keeps its choice points and capture state on the heap, within the heap room left, and takes fuel for every step, so a catastrophic pattern stops at the fuel limit. Patterns nested past Options::max_depth give a SyntaxError. Flags g, i and m, lastIndex, exec, test and toString. - Date: chrono converts days to calendar dates, shifting by whole 400-year cycles past its years. Realm::set_clock and Realm::set_time_zone set the clock and local time (UTC by default). Without a hint, a Date converts to a primitive as a string. cargo xtask test262 also runs the suite's built-ins tests for the four and prints their counts. --- Cargo.lock | 1 + crates/js/Cargo.toml | 1 + crates/js/src/builtins.rs | 21 +- crates/js/src/date_builtins.rs | 842 +++++++++++++++++++++ crates/js/src/heap.rs | 18 +- crates/js/src/interp.rs | 30 +- crates/js/src/lib.rs | 17 +- crates/js/src/number.rs | 308 +++++++- crates/js/src/number_builtins.rs | 127 ++++ crates/js/src/realm.rs | 15 + crates/js/src/regexp_builtins.rs | 269 +++++++ crates/js/src/regexp_vm.rs | 1188 ++++++++++++++++++++++++++++++ crates/js/src/string_builtins.rs | 699 ++++++++++++++++++ crates/js/src/text_tests.rs | 441 +++++++++++ crates/js/tests/test262.rs | 140 +++- docs/target-app-parity.md | 1 + xtask/src/main.rs | 3 +- xtask/src/test262.rs | 3 +- 18 files changed, 4062 insertions(+), 62 deletions(-) create mode 100644 crates/js/src/date_builtins.rs create mode 100644 crates/js/src/number_builtins.rs create mode 100644 crates/js/src/regexp_builtins.rs create mode 100644 crates/js/src/regexp_vm.rs create mode 100644 crates/js/src/string_builtins.rs create mode 100644 crates/js/src/text_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 8b98b85e9..6205742a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6337,6 +6337,7 @@ dependencies = [ name = "vectorcraft-js" version = "0.21.0" dependencies = [ + "chrono", "proptest", "unicode-general-category", ] diff --git a/crates/js/Cargo.toml b/crates/js/Cargo.toml index 4cb1e62c2..5848c1a91 100644 --- a/crates/js/Cargo.toml +++ b/crates/js/Cargo.toml @@ -10,6 +10,7 @@ rust-version.workspace = true workspace = true [dependencies] +chrono = { workspace = true } unicode-general-category = "1" [dev-dependencies] diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index b16aa9c9e..5f6cb4761 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -1,7 +1,8 @@ //! The built-in objects the interpreter itself needs (ECMA-262 5.1 section 15): `Object` and the //! basic methods of `Object.prototype`, `Function` with `Function.prototype.call`, `apply` and //! `toString`, `eval`, the error constructors and `Error.prototype.toString`, and the prototypes -//! primitive values look properties up on. The rest of the standard library is not here yet. +//! primitive values look properties up on. `String`, `Number`, `RegExp` and `Date` are in their +//! own modules; the rest of the standard library is not here yet. use std::rc::Rc; @@ -16,24 +17,24 @@ fn alloc(interp: &mut Interp, class: Class, proto: Option<ObjId>) -> ObjId { } /// A built-in function object. -fn native(interp: &mut Interp, name: &'static str, f: NativeFn, length: u32, kind: NativeKind, constructor: bool) -> JsResult<ObjId> { +pub(crate) fn native(interp: &mut Interp, name: &'static str, f: NativeFn, length: u32, kind: NativeKind, constructor: bool) -> JsResult<ObjId> { let proto = interp.intrinsics.function_proto; let o = alloc(interp, Class::Function(Func::Native { f, kind, constructor, name }), Some(proto)); interp.define(o, key("length"), Prop::Data { value: Value::Number(f64::from(length)), flags: 0 })?; Ok(o) } -fn method(interp: &mut Interp, on: ObjId, name: &'static str, f: NativeFn, length: u32) -> JsResult<()> { +pub(crate) fn method(interp: &mut Interp, on: ObjId, name: &'static str, f: NativeFn, length: u32) -> JsResult<()> { let m = native(interp, name, f, length, NativeKind::Plain, false)?; interp.define(on, key(name), Prop::Data { value: Value::Object(m), flags: HIDDEN }) } -fn data(interp: &mut Interp, on: ObjId, name: &str, value: Value, flags: u8) -> JsResult<()> { +pub(crate) fn data(interp: &mut Interp, on: ObjId, name: &str, value: Value, flags: u8) -> JsResult<()> { interp.define(on, key(name), Prop::Data { value, flags }) } /// Links a constructor and its prototype and makes the constructor a global. -fn constructor(interp: &mut Interp, name: &'static str, ctor: ObjId, proto: ObjId) -> JsResult<()> { +pub(crate) fn constructor(interp: &mut Interp, name: &'static str, ctor: ObjId, proto: ObjId) -> JsResult<()> { data(interp, ctor, "prototype", Value::Object(proto), 0)?; data(interp, proto, "constructor", Value::Object(ctor), HIDDEN)?; let global = interp.global; @@ -65,7 +66,7 @@ fn try_install(interp: &mut Interp) -> JsResult<()> { } interp.intrinsics.array_proto = alloc(interp, Class::Array { length: 0, length_writable: true }, Some(object_proto)); interp.intrinsics.string_proto = alloc(interp, Class::String(Str::from("")), Some(object_proto)); - interp.intrinsics.number_proto = alloc(interp, Class::Number, Some(object_proto)); + interp.intrinsics.number_proto = alloc(interp, Class::Number(0.0), Some(object_proto)); interp.intrinsics.boolean_proto = alloc(interp, Class::Boolean, Some(object_proto)); interp.intrinsics.regexp_proto = alloc(interp, Class::Ordinary, Some(object_proto)); let thrower = native(interp, "", throw_type_error, 0, NativeKind::Plain, false)?; @@ -98,7 +99,11 @@ fn try_install(interp: &mut Interp) -> JsResult<()> { data(interp, function_proto, "apply", Value::Object(apply), HIDDEN)?; method(interp, function_proto, "toString", function_to_string, 0)?; - install_errors(interp, object_proto) + install_errors(interp, object_proto)?; + crate::string_builtins::install(interp)?; + crate::number_builtins::install(interp)?; + crate::regexp_builtins::install(interp)?; + crate::date_builtins::install(interp) } fn install_errors(interp: &mut Interp, object_proto: ObjId) -> JsResult<()> { @@ -132,7 +137,7 @@ fn install_errors(interp: &mut Interp, object_proto: ObjId) -> JsResult<()> { Ok(()) } -fn arg(args: &[Value], i: usize) -> Value { +pub(crate) fn arg(args: &[Value], i: usize) -> Value { args.get(i).cloned().unwrap_or(Value::Undefined) } diff --git a/crates/js/src/date_builtins.rs b/crates/js/src/date_builtins.rs new file mode 100644 index 000000000..b453910ff --- /dev/null +++ b/crates/js/src/date_builtins.rs @@ -0,0 +1,842 @@ +//! `Date` (ECMA-262 5.1 section 15.9), with `getYear`, `setYear` and `toGMTString` from Annex B. +//! +//! Time values are milliseconds since 1970-01-01 UTC (section 15.9.1). `chrono` turns days into +//! calendar dates and back; past the years it represents, dates are shifted by whole 400-year +//! cycles. The current time comes from the host's clock and local time from the host's +//! time zone ([`DateHost`]): by default the system clock (none in a wasm build, where `Date.now()` +//! is NaN until the host sets one) and UTC. `Date.parse` reads the format of section 15.9.1.15 +//! and the formats `toString` and `toUTCString` write. + +use std::rc::Rc; + +use chrono::{Datelike, NaiveDate}; + +use crate::builtins::{arg, constructor, data, method, native}; +use crate::heap::{Class, NativeFn, NativeKind, Object, key}; +use crate::interp::{ErrorType, Hint, Interp, JsResult}; +use crate::string_builtins::{charge_units, reserve_units, to_integer}; +use crate::value::{Str, Value}; + +/// The clock and time zone a realm's `Date` reads. +#[derive(Clone)] +pub(crate) struct DateHost { + /// The current time value. + pub(crate) clock: Rc<dyn Fn() -> f64>, + /// The offset of local time from UTC, in milliseconds, at a UTC time value. + pub(crate) offset: Rc<dyn Fn(f64) -> f64>, +} + +impl Default for DateHost { + fn default() -> Self { + Self { clock: Rc::new(system_clock), offset: Rc::new(|_| 0.0) } + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn system_clock() -> f64 { + match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { + Ok(d) => d.as_millis() as f64, + Err(e) => -(e.duration().as_millis() as f64), + } +} + +#[cfg(target_arch = "wasm32")] +fn system_clock() -> f64 { + f64::NAN +} + +const MS_PER_DAY: f64 = 86_400_000.0; +const MS_PER_HOUR: f64 = 3_600_000.0; +const MS_PER_MINUTE: f64 = 60_000.0; +const MS_PER_SECOND: f64 = 1000.0; + +const DAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +// ---- section 15.9.1 ---- + +fn day(t: f64) -> f64 { + (t / MS_PER_DAY).floor() +} + +fn time_within_day(t: f64) -> f64 { + t.rem_euclid(MS_PER_DAY) +} + +/// `chrono`'s day number (0001-01-01 is day 1) of 1970-01-01. +const EPOCH_CE_DAYS: i64 = 719_163; +/// The days in 400 Gregorian years, after which the calendar repeats. +const CYCLE_DAYS: i64 = 146_097; +/// The days from 1970-01-01 to 2000-01-01, where the 400-year cycle `chrono` is asked about starts. +const DAYS_TO_2000: i64 = 10_957; + +/// The year, the month (0 to 11) and the day of the month of a day (days since 1970-01-01). +/// `chrono` computes them for the matching day of the 400-year cycle starting in 2000, and the +/// whole cycles are added back to the year, so days past `chrono`'s range have dates too. +fn civil(day: f64) -> Option<(f64, f64, f64)> { + if !day.is_finite() || day.abs() > 1e9 { + return None; + } + let d = day as i64; + let cycles = (d - DAYS_TO_2000).div_euclid(CYCLE_DAYS); + let date = NaiveDate::from_num_days_from_ce_opt(i32::try_from(d - cycles * CYCLE_DAYS + EPOCH_CE_DAYS).ok()?)?; + Some((f64::from(date.year()) + 400.0 * cycles as f64, f64::from(date.month0()), f64::from(date.day()))) +} + +/// The year of the 400-year cycle starting in 2000 that has `year`'s calendar, and the number of +/// whole cycles between the two. +fn cycle_year(year: f64) -> Option<(i32, i64)> { + if !year.is_finite() || year.abs() > 1e7 { + return None; + } + let y = year as i64; + let cycles = (y - 2000).div_euclid(400); + Some((i32::try_from(y - 400 * cycles).ok()?, cycles)) +} + +/// The day (days since 1970-01-01) on which month `month0` (0 to 11) of `year` starts. +fn first_day_of_month(year: f64, month0: u32) -> Option<f64> { + let (y, cycles) = cycle_year(year)?; + let date = NaiveDate::from_ymd_opt(y, month0 + 1, 1)?; + Some((i64::from(date.num_days_from_ce()) - EPOCH_CE_DAYS + cycles * CYCLE_DAYS) as f64) +} + +/// Month `month` (1 to 12) of `year` has a day `date`. +fn is_date(year: f64, month: f64, date: f64) -> bool { + (1.0..=12.0).contains(&month) + && (1.0..=31.0).contains(&date) + && cycle_year(year).is_some_and(|(y, _)| NaiveDate::from_ymd_opt(y, month as u32, date as u32).is_some()) +} + +fn year_from_time(t: f64) -> f64 { + civil(day(t)).map_or(f64::NAN, |(y, _, _)| y) +} + +fn month_from_time(t: f64) -> f64 { + civil(day(t)).map_or(f64::NAN, |(_, m, _)| m) +} + +fn date_from_time(t: f64) -> f64 { + civil(day(t)).map_or(f64::NAN, |(_, _, d)| d) +} + +fn week_day(t: f64) -> f64 { + (day(t) + 4.0).rem_euclid(7.0) +} + +fn hour_from_time(t: f64) -> f64 { + (t / MS_PER_HOUR).floor().rem_euclid(24.0) +} + +fn min_from_time(t: f64) -> f64 { + (t / MS_PER_MINUTE).floor().rem_euclid(60.0) +} + +fn sec_from_time(t: f64) -> f64 { + (t / MS_PER_SECOND).floor().rem_euclid(60.0) +} + +fn ms_from_time(t: f64) -> f64 { + t.rem_euclid(MS_PER_SECOND) +} + +fn make_time(h: f64, m: f64, s: f64, ms: f64) -> f64 { + if !(h.is_finite() && m.is_finite() && s.is_finite() && ms.is_finite()) { + return f64::NAN; + } + to_integer(h) * MS_PER_HOUR + to_integer(m) * MS_PER_MINUTE + to_integer(s) * MS_PER_SECOND + to_integer(ms) +} + +fn make_day(year: f64, month: f64, date: f64) -> f64 { + if !(year.is_finite() && month.is_finite() && date.is_finite()) { + return f64::NAN; + } + let (y, m, dt) = (to_integer(year), to_integer(month), to_integer(date)); + let ym = y + (m / 12.0).floor(); + // Years this far out are past every time value TimeClip keeps. + if ym.abs() > 400_000.0 { + return f64::NAN; + } + first_day_of_month(ym, m.rem_euclid(12.0) as u32).map_or(f64::NAN, |first| first + dt - 1.0) +} + +fn make_date(day: f64, time: f64) -> f64 { + if !(day.is_finite() && time.is_finite()) { + return f64::NAN; + } + day * MS_PER_DAY + time +} + +fn time_clip(t: f64) -> f64 { + if !t.is_finite() || t.abs() > 8.64e15 { f64::NAN } else { to_integer(t) } +} + +impl Interp { + /// The host's offset of local time from UTC at `utc`; an offset that is not finite or is a + /// day or more counts as 0. + fn local_offset(&self, utc: f64) -> f64 { + let o = (self.date_host.offset)(utc); + if o.is_finite() && o.abs() < MS_PER_DAY { o } else { 0.0 } + } + + /// LocalTime (section 15.9.1.9). + fn local_time(&self, t: f64) -> f64 { + t + self.local_offset(t) + } + + /// UTC (section 15.9.1.9): the time value of a local time. + fn utc(&self, t: f64) -> f64 { + if !t.is_finite() { + return f64::NAN; + } + let guess = t - self.local_offset(t); + t - self.local_offset(guess) + } + + fn now(&self) -> f64 { + time_clip((self.date_host.clock)()) + } +} + +// ---- formatting ---- + +fn year_text(y: f64) -> String { + if y < 0.0 { format!("-{:06}", -y as i64) } else { format!("{:04}", y as i64) } +} + +fn offset_text(offset_ms: f64) -> String { + let minutes = (offset_ms / MS_PER_MINUTE).round() as i64; + let sign = if minutes < 0 { '-' } else { '+' }; + format!("GMT{sign}{:02}{:02}", minutes.abs() / 60, minutes.abs() % 60) +} + +fn date_text(t: f64) -> String { + let wd = DAYS.get(week_day(t) as usize).copied().unwrap_or(""); + let mon = MONTHS.get(month_from_time(t) as usize).copied().unwrap_or(""); + format!("{wd} {mon} {:02} {}", date_from_time(t) as i64, year_text(year_from_time(t))) +} + +fn time_text(t: f64) -> String { + format!("{:02}:{:02}:{:02}", hour_from_time(t) as i64, min_from_time(t) as i64, sec_from_time(t) as i64) +} + +impl Interp { + /// `toString`'s format: `Tue Nov 07 2026 12:00:00 GMT+0100`. + fn date_to_string(&self, t: f64) -> String { + if t.is_nan() { + return "Invalid Date".into(); + } + let local = self.local_time(t); + format!("{} {} {}", date_text(local), time_text(local), offset_text(local - t)) + } +} + +fn utc_text(t: f64) -> String { + if t.is_nan() { + return "Invalid Date".into(); + } + let wd = DAYS.get(week_day(t) as usize).copied().unwrap_or(""); + let mon = MONTHS.get(month_from_time(t) as usize).copied().unwrap_or(""); + format!("{wd}, {:02} {mon} {} {} GMT", date_from_time(t) as i64, year_text(year_from_time(t)), time_text(t)) +} + +fn iso_text(t: f64) -> String { + let y = year_from_time(t); + let year = if (0.0..=9999.0).contains(&y) { + format!("{:04}", y as i64) + } else if y < 0.0 { + format!("-{:06}", -y as i64) + } else { + format!("+{:06}", y as i64) + }; + format!("{year}-{:02}-{:02}T{}.{:03}Z", month_from_time(t) as i64 + 1, date_from_time(t) as i64, time_text(t), ms_from_time(t) as i64) +} + +// ---- parsing ---- + +/// Reads the format of section 15.9.1.15: `YYYY[-MM[-DD]][THH:mm[:ss[.sss]][Z|±HH:mm]]`, with +/// six-digit signed years. A missing offset means UTC. +fn parse_iso(s: &[u8]) -> Option<f64> { + let mut i = 0; + let num = |i: &mut usize, n: usize| -> Option<f64> { + let part = s.get(*i..*i + n)?; + if !part.iter().all(u8::is_ascii_digit) { + return None; + } + *i += n; + Some(part.iter().fold(0.0, |v, d| v * 10.0 + f64::from(d - b'0'))) + }; + let year = match s.first() { + Some(b'+') | Some(b'-') => { + let neg = s.first() == Some(&b'-'); + i = 1; + let y = num(&mut i, 6)?; + if neg && y == 0.0 { + return None; + } + if neg { -y } else { y } + } + _ => num(&mut i, 4)?, + }; + let (mut month, mut date) = (1.0, 1.0); + if s.get(i) == Some(&b'-') { + i += 1; + month = num(&mut i, 2)?; + if s.get(i) == Some(&b'-') { + i += 1; + date = num(&mut i, 2)?; + } + } + let (mut h, mut m, mut sec, mut ms) = (0.0, 0.0, 0.0, 0.0); + let mut offset = 0.0; + if s.get(i) == Some(&b'T') { + i += 1; + h = num(&mut i, 2)?; + if s.get(i) != Some(&b':') { + return None; + } + i += 1; + m = num(&mut i, 2)?; + if s.get(i) == Some(&b':') { + i += 1; + sec = num(&mut i, 2)?; + if s.get(i) == Some(&b'.') { + i += 1; + let start = i; + while s.get(i).is_some_and(u8::is_ascii_digit) { + i += 1; + } + let digits = s.get(start..i)?; + if digits.is_empty() { + return None; + } + ms = digits.iter().take(3).fold(0.0, |v, d| v * 10.0 + f64::from(d - b'0')) * 10f64.powi(3 - digits.len().min(3) as i32); + } + } + match s.get(i) { + Some(b'Z') => i += 1, + Some(&c @ (b'+' | b'-')) => { + i += 1; + let oh = num(&mut i, 2)?; + if s.get(i) != Some(&b':') { + return None; + } + i += 1; + let om = num(&mut i, 2)?; + if oh > 23.0 || om > 59.0 { + return None; + } + offset = (oh * 60.0 + om) * MS_PER_MINUTE * if c == b'-' { -1.0 } else { 1.0 }; + } + _ => {} + } + } + if i != s.len() { + return None; + } + let valid = is_date(year, month, date) && (h < 24.0 || (h == 24.0 && m == 0.0 && sec == 0.0 && ms == 0.0)) && m < 60.0 && sec < 60.0; + if !valid { + return None; + } + let t = make_date(make_day(year, month - 1.0, date), make_time(h, m, sec, ms)) - offset; + Some(time_clip(t)) +} + +/// Reads the formats `toString` and `toUTCString` write: a weekday name, a month name, a day, a +/// year, an optional `HH:mm[:ss]` time and an optional `GMT[±hhmm]` offset, in either order of +/// day and month. Without an offset the time is local. +fn parse_text(interp: &Interp, s: &str) -> Option<f64> { + let mut words: Vec<&str> = Vec::new(); + for w in s.split(|c: char| c.is_whitespace() || c == ',') { + if w.starts_with('(') { + break; + } + if !w.is_empty() { + words.push(w); + } + } + let (mut month, mut date, mut year) = (None, None, None); + let (mut h, mut m, mut sec) = (0.0, 0.0, 0.0); + let mut offset: Option<f64> = None; + for w in words { + let lower = w.to_ascii_lowercase(); + if DAYS.iter().any(|d| lower.starts_with(&d.to_ascii_lowercase())) && month.is_none() && date.is_none() && w.len() >= 3 && !w.contains(':') { + continue; + } + if let Some(mi) = MONTHS.iter().position(|mo| lower.starts_with(&mo.to_ascii_lowercase())) { + month = Some(mi as f64); + continue; + } + if let Some(rest) = lower.strip_prefix("gmt").or_else(|| lower.strip_prefix("utc")).or_else(|| (lower == "z").then_some("")) { + if rest.is_empty() { + offset = Some(0.0); + continue; + } + let (sign, digits) = match rest.as_bytes().first() { + Some(b'+') => (1.0, rest.get(1..)?), + Some(b'-') => (-1.0, rest.get(1..)?), + _ => return None, + }; + let digits = digits.replace(':', ""); + if digits.len() != 4 || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let v: f64 = digits.parse().ok()?; + offset = Some(sign * ((v / 100.0).floor() * 60.0 + v % 100.0) * MS_PER_MINUTE); + continue; + } + if w.contains(':') { + let parts: Vec<&str> = w.split(':').collect(); + let field = |i: usize| -> Option<f64> { parts.get(i).map(|p| p.parse::<f64>().ok()).unwrap_or(Some(0.0)) }; + if parts.len() < 2 || parts.len() > 3 || !parts.iter().all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())) { + return None; + } + (h, m, sec) = (field(0)?, field(1)?, field(2)?); + if h > 24.0 || m > 59.0 || sec > 59.0 { + return None; + } + continue; + } + let (neg, digits) = match w.strip_prefix('-') { + Some(d) => (true, d), + None => (false, w), + }; + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let v: f64 = digits.parse().ok()?; + if date.is_none() && !neg && digits.len() <= 2 { + date = Some(v); + } else if year.is_none() { + year = Some(if neg { -v } else { v }); + } else { + return None; + } + } + let (month, date, year) = (month?, date?, year?); + if !(1.0..=31.0).contains(&date) { + return None; + } + let local = make_date(make_day(year, month, date), make_time(h, m, sec, 0.0)); + let t = match offset { + Some(o) => local - o, + None => interp.utc(local), + }; + Some(time_clip(t)) +} + +/// Takes fuel for the units of `s` the parsers read. +fn parse_date(interp: &mut Interp, s: &Str) -> JsResult<f64> { + charge_units(interp, s.len())?; + let text = s.to_rust_string(); + let trimmed = text.trim(); + Ok(parse_iso(trimmed.as_bytes()).or_else(|| parse_text(interp, trimmed)).unwrap_or(f64::NAN)) +} + +// ---- the constructor ---- + +pub(crate) fn install(interp: &mut Interp) -> JsResult<()> { + let object_proto = interp.intrinsics.object_proto; + let proto = interp.heap.alloc_object(Object::new(Class::Date(f64::NAN), Some(object_proto))); + interp.intrinsics.date_proto = proto; + let ctor = native(interp, "Date", date_ctor, 7, NativeKind::Plain, true)?; + constructor(interp, "Date", ctor, proto)?; + method(interp, ctor, "parse", date_parse, 1)?; + method(interp, ctor, "UTC", date_utc, 7)?; + method(interp, ctor, "now", date_now, 0)?; + let methods: [(&'static str, NativeFn, u32); 44] = [ + ("toString", to_string, 0), + ("toDateString", to_date_string, 0), + ("toTimeString", to_time_string, 0), + ("toLocaleString", to_string, 0), + ("toLocaleDateString", to_date_string, 0), + ("toLocaleTimeString", to_time_string, 0), + ("valueOf", get_time, 0), + ("getTime", get_time, 0), + ("getFullYear", get_full_year, 0), + ("getUTCFullYear", get_utc_full_year, 0), + ("getMonth", get_month, 0), + ("getUTCMonth", get_utc_month, 0), + ("getDate", get_date, 0), + ("getUTCDate", get_utc_date, 0), + ("getDay", get_day, 0), + ("getUTCDay", get_utc_day, 0), + ("getHours", get_hours, 0), + ("getUTCHours", get_utc_hours, 0), + ("getMinutes", get_minutes, 0), + ("getUTCMinutes", get_utc_minutes, 0), + ("getSeconds", get_seconds, 0), + ("getUTCSeconds", get_utc_seconds, 0), + ("getMilliseconds", get_milliseconds, 0), + ("getUTCMilliseconds", get_utc_milliseconds, 0), + ("getTimezoneOffset", get_timezone_offset, 0), + ("setTime", set_time, 1), + ("setMilliseconds", set_milliseconds, 1), + ("setUTCMilliseconds", set_utc_milliseconds, 1), + ("setSeconds", set_seconds, 2), + ("setUTCSeconds", set_utc_seconds, 2), + ("setMinutes", set_minutes, 3), + ("setUTCMinutes", set_utc_minutes, 3), + ("setHours", set_hours, 4), + ("setUTCHours", set_utc_hours, 4), + ("setDate", set_date, 1), + ("setUTCDate", set_utc_date, 1), + ("setMonth", set_month, 2), + ("setUTCMonth", set_utc_month, 2), + ("setFullYear", set_full_year, 3), + ("setUTCFullYear", set_utc_full_year, 3), + ("toISOString", to_iso_string, 0), + ("toJSON", to_json, 1), + ("getYear", get_year, 0), + ("setYear", set_year, 1), + ]; + for (name, f, len) in methods { + method(interp, proto, name, f, len)?; + } + // Annex B.2.6: `toGMTString` is the same function object as `toUTCString`. + method(interp, proto, "toUTCString", to_utc_string, 0)?; + let utc = interp.get_value(&Value::Object(proto), &key("toUTCString"))?; + data(interp, proto, "toGMTString", utc, crate::heap::HIDDEN) +} + +fn text(interp: &mut Interp, s: &str) -> JsResult<Value> { + reserve_units(interp, s.len())?; + Ok(Value::String(Str::from(s))) +} + +/// The numbers of the arguments from `year` on, as `new Date(y, m, …)` and `Date.UTC` read them +/// (sections 15.9.3.1 and 15.9.4.3): the local or UTC date they describe, before TimeClip. An +/// omitted month is January, which `Date.UTC(year)` uses (section 15.9.4.3 leaves a call with +/// fewer than two arguments to the implementation). +fn date_from_parts(interp: &mut Interp, args: &[Value]) -> JsResult<f64> { + let mut n = [f64::NAN, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0]; + for (i, slot) in n.iter_mut().enumerate() { + if let Some(v) = args.get(i) { + *slot = interp.to_number(v)?; + } + } + let [y, m, dt, h, min, s, ms] = n; + let yi = to_integer(y); + let yr = if !y.is_nan() && (0.0..=99.0).contains(&yi) { 1900.0 + yi } else { y }; + Ok(make_date(make_day(yr, m, dt), make_time(h, min, s, ms))) +} + +/// `Date(…)`, `new Date()`, `new Date(value)` and `new Date(y, m, …)` (sections 15.9.2 and +/// 15.9.3). +fn date_ctor(interp: &mut Interp, _: Value, args: &[Value], construct: bool) -> JsResult<Value> { + if !construct { + let s = interp.date_to_string(interp.now()); + return text(interp, &s); + } + let tv = match args.len() { + 0 => interp.now(), + 1 => { + let v = interp.to_primitive(&args[0], Hint::Default)?; + match &v { + Value::String(s) => parse_date(interp, s)?, + _ => time_clip(interp.to_number(&v)?), + } + } + _ => { + let local = date_from_parts(interp, args)?; + time_clip(interp.utc(local)) + } + }; + let proto = interp.intrinsics.date_proto; + Ok(Value::Object(interp.heap.alloc_object(Object::new(Class::Date(tv), Some(proto))))) +} + +fn date_parse(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> { + let s = interp.to_str(&arg(args, 0))?; + Ok(Value::Number(parse_date(interp, &s)?)) +} + +fn date_utc(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> { + let t = date_from_parts(interp, args)?; + Ok(Value::Number(time_clip(t))) +} + +fn date_now(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult<Value> { + Ok(Value::Number(interp.now())) +} + +// ---- Date.prototype ---- + +/// The time value of a Date object `this`. +fn this_time(interp: &mut Interp, this: &Value) -> JsResult<f64> { + if let Some(Class::Date(t)) = this.as_object().and_then(|o| interp.heap.object(o)).map(|o| &o.class) { + return Ok(*t); + } + Err(interp.type_error("Date.prototype method called on a value that is not a Date object")) +} + +fn set_this_time(interp: &mut Interp, this: &Value, t: f64) -> JsResult<Value> { + if let Some(o) = this.as_object().and_then(|o| interp.heap.object_mut(o)) + && let Class::Date(slot) = &mut o.class + { + *slot = t; + } + Ok(Value::Number(t)) +} + +fn to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> { + let t = this_time(interp, &this)?; + let s = interp.date_to_string(t); + text(interp, &s) +} + +fn to_date_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> { + let t = this_time(interp, &this)?; + let s = if t.is_nan() { "Invalid Date".into() } else { date_text(interp.local_time(t)) }; + text(interp, &s) +} + +fn to_time_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> { + let t = this_time(interp, &this)?; + let s = if t.is_nan() { + "Invalid Date".into() + } else { + let local = interp.local_time(t); + format!("{} {}", time_text(local), offset_text(local - t)) + }; + text(interp, &s) +} + +fn to_utc_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> { + let t = this_time(interp, &this)?; + text(interp, &utc_text(t)) +} + +/// `toISOString` (section 15.9.5.43). +fn to_iso_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> { + let t = this_time(interp, &this)?; + if !t.is_finite() { + return Err(interp.throw(ErrorType::Range, "invalid time value")); + } + text(interp, &iso_text(t)) +} + +/// `toJSON` (section 15.9.5.44). +fn to_json(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> { + let o = interp.to_object(&this)?; + let ov = Value::Object(o); + interp.temp_roots.push(ov.clone()); + let r = (|| { + let tv = interp.to_primitive(&ov, Hint::Number)?; + if let Value::Number(n) = tv + && !n.is_finite() + { + return Ok(Value::Null); + } + let f = interp.get_value(&ov, &key("toISOString"))?; + if !interp.is_callable(&f) { + return Err(interp.type_error("toISOString is not a function")); + } + interp.call(&f, &ov, &[]) + })(); + interp.temp_roots.pop(); + r +} + +fn get_time(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> { + this_time(interp, &this).map(Value::Number) +} + +/// A getter: `f` of the time value, in local time when `local`, NaN for an invalid date. +fn getter(interp: &mut Interp, this: &Value, local: bool, f: fn(f64) -> f64) -> JsResult<Value> { + let t = this_time(interp, this)?; + if t.is_nan() { + return Ok(Value::Number(f64::NAN)); + } + let t = if local { interp.local_time(t) } else { t }; + Ok(Value::Number(f(t))) +} + +macro_rules! getters { + ($($name:ident, $local:expr, $f:expr;)*) => { + $(fn $name(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> { + getter(interp, &this, $local, $f) + })* + }; +} + +getters! { + get_full_year, true, year_from_time; + get_utc_full_year, false, year_from_time; + get_month, true, month_from_time; + get_utc_month, false, month_from_time; + get_date, true, date_from_time; + get_utc_date, false, date_from_time; + get_day, true, week_day; + get_utc_day, false, week_day; + get_hours, true, hour_from_time; + get_utc_hours, false, hour_from_time; + get_minutes, true, min_from_time; + get_utc_minutes, false, min_from_time; + get_seconds, true, sec_from_time; + get_utc_seconds, false, sec_from_time; + get_milliseconds, true, ms_from_time; + get_utc_milliseconds, false, ms_from_time; +} + +/// `getYear` (Annex B.2.4). +fn get_year(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> { + getter(interp, &this, true, |t| year_from_time(t) - 1900.0) +} + +fn get_timezone_offset(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> { + let t = this_time(interp, &this)?; + if t.is_nan() { + return Ok(Value::Number(f64::NAN)); + } + Ok(Value::Number((t - interp.local_time(t)) / MS_PER_MINUTE)) +} + +fn set_time(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> { + this_time(interp, &this)?; + let t = interp.to_number(&arg(args, 0))?; + set_this_time(interp, &this, time_clip(t)) +} + +/// The time fields a setter starts at: the setter sets that field and the smaller ones after it +/// from its arguments. +#[derive(Clone, Copy)] +enum TimeField { + Hour = 0, + Minute = 1, + Second = 2, + Ms = 3, +} + +/// `setHours`, `setMinutes`, `setSeconds` and `setMilliseconds`, local or UTC (sections +/// 15.9.5.28 to 15.9.5.35). +fn set_time_fields(interp: &mut Interp, this: &Value, args: &[Value], local: bool, first: TimeField, count: usize) -> JsResult<Value> { + let tv = this_time(interp, this)?; + let t = if local { interp.local_time(tv) } else { tv }; + let mut fields = [hour_from_time(t), min_from_time(t), sec_from_time(t), ms_from_time(t)]; + let first = first as usize; + for k in 0..count { + let given = k == 0 || args.get(k).is_some(); + if given { + let v = interp.to_number(&arg(args, k))?; + if let Some(slot) = fields.get_mut(first + k) { + *slot = v; + } + } + } + let [h, m, s, ms] = fields; + let date = make_date(day(t), make_time(h, m, s, ms)); + let u = time_clip(if local { interp.utc(date) } else { date }); + set_this_time(interp, this, u) +} + +/// The date fields a setter starts at. +#[derive(Clone, Copy)] +enum DateField { + Year = 0, + Month = 1, + Date = 2, +} + +/// `setFullYear`, `setMonth` and `setDate`, local or UTC (sections 15.9.5.36 to 15.9.5.41). +fn set_date_fields(interp: &mut Interp, this: &Value, args: &[Value], local: bool, first: DateField, count: usize) -> JsResult<Value> { + let tv = this_time(interp, this)?; + let mut t = if local { interp.local_time(tv) } else { tv }; + // setFullYear starts from +0 on an invalid date. + if matches!(first, DateField::Year) && t.is_nan() { + t = 0.0; + } + let mut fields = [year_from_time(t), month_from_time(t), date_from_time(t)]; + let first = first as usize; + for k in 0..count { + let given = k == 0 || args.get(k).is_some(); + if given { + let v = interp.to_number(&arg(args, k))?; + if let Some(slot) = fields.get_mut(first + k) { + *slot = v; + } + } + } + let [y, m, d] = fields; + let date = make_date(make_day(y, m, d), time_within_day(t)); + let u = time_clip(if local { interp.utc(date) } else { date }); + set_this_time(interp, this, u) +} + +macro_rules! setters { + ($($name:ident, $f:ident, $local:expr, $field:expr, $count:expr;)*) => { + $(fn $name(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> { + $f(interp, &this, args, $local, $field, $count) + })* + }; +} + +setters! { + set_milliseconds, set_time_fields, true, TimeField::Ms, 1; + set_utc_milliseconds, set_time_fields, false, TimeField::Ms, 1; + set_seconds, set_time_fields, true, TimeField::Second, 2; + set_utc_seconds, set_time_fields, false, TimeField::Second, 2; + set_minutes, set_time_fields, true, TimeField::Minute, 3; + set_utc_minutes, set_time_fields, false, TimeField::Minute, 3; + set_hours, set_time_fields, true, TimeField::Hour, 4; + set_utc_hours, set_time_fields, false, TimeField::Hour, 4; + set_date, set_date_fields, true, DateField::Date, 1; + set_utc_date, set_date_fields, false, DateField::Date, 1; + set_month, set_date_fields, true, DateField::Month, 2; + set_utc_month, set_date_fields, false, DateField::Month, 2; + set_full_year, set_date_fields, true, DateField::Year, 3; + set_utc_full_year, set_date_fields, false, DateField::Year, 3; +} + +/// `setYear` (Annex B.2.5). +fn set_year(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> { + let tv = this_time(interp, &this)?; + let t = if tv.is_nan() { 0.0 } else { interp.local_time(tv) }; + let y = interp.to_number(&arg(args, 0))?; + if y.is_nan() { + return set_this_time(interp, &this, f64::NAN); + } + let yi = to_integer(y); + let yr = if (0.0..=99.0).contains(&yi) { 1900.0 + yi } else { y }; + let d = make_day(yr, month_from_time(t), date_from_time(t)); + let u = time_clip(interp.utc(make_date(d, time_within_day(t)))); + set_this_time(interp, &this, u) +} + +#[cfg(test)] +mod tests { + use super::{date_from_time, first_day_of_month, iso_text, make_date, make_day, make_time, month_from_time, parse_iso, week_day, year_from_time}; + + #[test] + fn time_values_split_into_fields_and_back() { + // 2000-02-29 is a leap day; 1969-12-31 is before the epoch. + let cases: &[(f64, f64, f64, f64)] = &[(2000.0, 1.0, 29.0, 2.0), (1969.0, 11.0, 31.0, 3.0), (1970.0, 0.0, 1.0, 4.0), (-1.0, 0.0, 1.0, 5.0)]; + for &(y, m, d, wd) in cases { + let t = make_date(make_day(y, m, d), make_time(13.0, 5.0, 7.0, 9.0)); + assert_eq!((year_from_time(t), month_from_time(t), date_from_time(t), week_day(t)), (y, m, d, wd), "{y}-{m}-{d}"); + } + assert_eq!(first_day_of_month(1970.0, 0), Some(0.0)); + assert_eq!(make_day(2001.0, -1.0, 1.0), make_day(2000.0, 11.0, 1.0)); + // The ends of the time value range lie past chrono's years; whole 400-year cycles reach them. + assert_eq!(make_day(275_760.0, 8.0, 13.0), 1e8); + assert_eq!(make_day(-271_821.0, 3.0, 20.0), -1e8); + assert_eq!((year_from_time(8.64e15), month_from_time(8.64e15), date_from_time(8.64e15)), (275_760.0, 8.0, 13.0)); + assert_eq!((year_from_time(-8.64e15), month_from_time(-8.64e15), date_from_time(-8.64e15)), (-271_821.0, 3.0, 20.0)); + assert_eq!(make_day(1600.0, 1.0, 29.0) + 1.0, make_day(1600.0, 2.0, 1.0)); + assert_eq!(make_day(1900.0, 1.0, 29.0), make_day(1900.0, 2.0, 1.0)); + } + + #[test] + fn the_iso_format_reads_back_what_to_iso_string_writes() { + for t in [0.0, 951_782_400_123.0, -62_198_755_200_000.0, 8.64e15, -8.64e15] { + let text = iso_text(t); + assert_eq!(parse_iso(text.as_bytes()), Some(t), "{text}"); + } + assert_eq!(parse_iso(b"1970"), Some(0.0)); + assert_eq!(parse_iso(b"1970-01-01T00:00+01:00"), Some(-3_600_000.0)); + assert_eq!(parse_iso(b"1970-01-01T24:00:00Z"), Some(86_400_000.0)); + for bad in ["1970-13-01", "1970-02-30", "1970-01-01T24:00:01", "-000000-01-01", "1970-1-1", "1970-01-01T10", "1970-01-01 10:00"] { + assert_eq!(parse_iso(bad.as_bytes()), None, "{bad}"); + } + } +} diff --git a/crates/js/src/heap.rs b/crates/js/src/heap.rs index 82268f489..69b0b4f40 100644 --- a/crates/js/src/heap.rs +++ b/crates/js/src/heap.rs @@ -206,10 +206,12 @@ pub(crate) enum Class { Arguments(Box<ArgMap>), /// A `Boolean` object made by ToObject. Boolean, - /// A `Number` object made by ToObject. - Number, + /// A `Number` object and its number. + Number(f64), String(Str), - RegExp, + RegExp(Rc<crate::regexp_vm::Regex>), + /// A `Date` object and its time value. + Date(f64), ForIn(Box<ForIn>), } @@ -223,9 +225,10 @@ impl Class { Class::Error => "Error", Class::Arguments(_) => "Arguments", Class::Boolean => "Boolean", - Class::Number => "Number", + Class::Number(_) => "Number", Class::String(_) => "String", - Class::RegExp => "RegExp", + Class::RegExp(_) => "RegExp", + Class::Date(_) => "Date", } } } @@ -528,6 +531,11 @@ fn trace_object(o: &Object, work: &mut Vec<u32>, strings: &mut StringSizes) -> u } Class::Arguments(map) => work.push(map.env.0), Class::String(s) => strings.add(s), + Class::RegExp(re) => { + if strings.seen.insert(Rc::as_ptr(re) as *const () as usize) { + strings.bytes += re.heap_bytes(); + } + } Class::ForIn(state) => { work.extend(state.object.iter().map(|o| o.0)); strings.bytes += state.keys.iter().map(|k| string_bytes(k.len())).sum::<usize>(); diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 7df501699..974e0c181 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -68,6 +68,7 @@ pub(crate) struct Intrinsics { pub(crate) number_proto: ObjId, pub(crate) boolean_proto: ObjId, pub(crate) regexp_proto: ObjId, + pub(crate) date_proto: ObjId, pub(crate) error_protos: [ObjId; 7], pub(crate) eval: ObjId, pub(crate) throw_type_error: ObjId, @@ -84,6 +85,7 @@ impl Intrinsics { number_proto: o, boolean_proto: o, regexp_proto: o, + date_proto: o, error_protos: [o; 7], eval: o, throw_type_error: o, @@ -112,6 +114,7 @@ impl Intrinsics { self.number_proto, self.boolean_proto, self.regexp_proto, + self.date_proto, self.eval, self.throw_type_error, ]; @@ -161,6 +164,8 @@ pub(crate) struct Interp { pub(crate) throw_site: Option<ThrowSite>, /// The last exception a run did not catch. pub(crate) last_thrown: Value, + /// The clock and time zone `Date` reads. + pub(crate) date_host: crate::date_builtins::DateHost, } /// The least heap size a collection waits for. @@ -286,6 +291,7 @@ impl Interp { source_units: 0, throw_site: None, last_thrown: Value::Undefined, + date_host: crate::date_builtins::DateHost::default(), }; crate::builtins::install(&mut interp); interp.gc_threshold = interp.next_threshold(); @@ -412,6 +418,13 @@ impl Interp { Ok(()) } + /// Collects garbage now, for a caller whose work ran out of heap room, and sets when the + /// next collection runs. + pub(crate) fn collect_for_room(&mut self) { + self.collect_garbage(); + self.gc_threshold = self.next_threshold(); + } + /// Charges code compiled at run time to the heap, reserving its estimated size and `extra` /// more first. The code is not reachable from the roots yet, so a collection here leaves it /// out of the live total and the charge adds it. @@ -1334,16 +1347,7 @@ impl Interp { fn op_regexp(&mut self, i: u32) -> JsResult<()> { let Some((source, flags)) = self.frame()?.code.regexps.get(i as usize).cloned() else { return internal("regular expression out of range") }; - let proto = self.intrinsics.regexp_proto; - let r = self.heap.alloc_object(Object::new(Class::RegExp, Some(proto))); - let flag_units = flags.flat(); - let has = |c: u8| flag_units.contains(&u16::from(c)); - let (g, i_, m) = (has(b'g'), has(b'i'), has(b'm')); - self.define(r, key("source"), Prop::Data { value: Value::String(source), flags: 0 })?; - self.define(r, key("global"), Prop::Data { value: Value::Bool(g), flags: 0 })?; - self.define(r, key("ignoreCase"), Prop::Data { value: Value::Bool(i_), flags: 0 })?; - self.define(r, key("multiline"), Prop::Data { value: Value::Bool(m), flags: 0 })?; - self.define(r, key("lastIndex"), Prop::Data { value: Value::Number(0.0), flags: WRITABLE })?; + let r = crate::regexp_builtins::new_regexp(self, &source, &flags)?; self.push(Value::Object(r)) } @@ -1928,7 +1932,9 @@ impl Interp { #[allow(clippy::wrong_self_convention)] pub(crate) fn to_primitive(&mut self, v: &Value, hint: Hint) -> JsResult<Value> { let Value::Object(o) = v else { return Ok(v.clone()) }; - let order = if hint == Hint::String { ["toString", "valueOf"] } else { ["valueOf", "toString"] }; + // Without a hint, a Date object converts as with the String hint. + let date = hint == Hint::Default && matches!(self.heap.object(*o).map(|ob| &ob.class), Some(Class::Date(_))); + let order = if hint == Hint::String || date { ["toString", "valueOf"] } else { ["valueOf", "toString"] }; for name in order { let f = self.get_from(*o, &key(name), v)?; if self.is_callable(&f) { @@ -2002,7 +2008,7 @@ impl Interp { Value::Object(o) => return Ok(*o), Value::Undefined | Value::Null => return Err(self.type_error("cannot convert undefined or null to an object")), Value::Bool(_) => (Class::Boolean, self.intrinsics.boolean_proto), - Value::Number(_) => (Class::Number, self.intrinsics.number_proto), + Value::Number(n) => (Class::Number(*n), self.intrinsics.number_proto), Value::String(s) => (Class::String(s.clone()), self.intrinsics.string_proto), }; Ok(self.heap.alloc_object(Object::new(class, Some(proto)))) diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 9c0c192aa..3c36efb13 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -23,9 +23,13 @@ //! verifier, and runs it on an interpreter that keeps JavaScript calls on its own frame stack, so //! deep recursion in a script does not use the native stack. Objects and environments live in an //! arena with a mark-and-sweep collector; strings built by repeated `+` are flattened and dropped -//! with loops. The realm provides the language (sections 10 to 14 of ECMA-262 5.1) and a minimal -//! set of built-ins: `Object` and `Object.prototype`'s basic methods, `Function` with -//! `Function.prototype.call` and `apply`, `eval`, and the error constructors. Every run is bounded +//! with loops. The realm provides the language (sections 10 to 14 of ECMA-262 5.1) and these +//! built-ins: `Object` and `Object.prototype`'s basic methods, `Function` with +//! `Function.prototype.call` and `apply`, `eval`, the error constructors, and `String`, `Number`, +//! `RegExp` and `Date` (sections 15.5, 15.7, 15.9 and 15.10). Regular expressions run on a +//! backtracking matcher that keeps its choice points on its own stack and takes fuel for every +//! step; `Date` reads the clock and time zone the host sets with [`Realm::set_clock`] and +//! [`Realm::set_time_zone`]. Every run is bounded //! by [`Limits`]: instructions run (fuel), heap bytes, frames, nested host calls, and an //! [`Interrupt`] flag another thread can set. A run that reaches a limit ends with //! [`RunErrorKind::Limit`], which a script's `try` cannot catch. @@ -36,14 +40,19 @@ mod builtins; mod bytecode; mod chars; mod compiler; +mod date_builtins; mod error; mod heap; mod interp; mod lexer; mod number; +mod number_builtins; mod parser; mod realm; mod regexp; +mod regexp_builtins; +mod regexp_vm; +mod string_builtins; mod token; mod value; @@ -173,3 +182,5 @@ impl LineTable { mod interp_tests; #[cfg(test)] mod tests; +#[cfg(test)] +mod text_tests; diff --git a/crates/js/src/number.rs b/crates/js/src/number.rs index 4afd48076..e7782ac8e 100644 --- a/crates/js/src/number.rs +++ b/crates/js/src/number.rs @@ -1,4 +1,7 @@ -//! Number to string conversion (ECMA-262 5.1 section 9.8.1), for numeric property names. +//! Number to string conversion (ECMA-262 5.1 section 9.8.1), for numeric property names, and the +//! formats of `Number.prototype`'s `toString(radix)`, `toFixed`, `toExponential` and +//! `toPrecision` (section 15.7.4). The fixed, exponential and precision formats round the +//! number's exact decimal value, ties away from zero, as the specification's "larger n" says. /// 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. @@ -38,9 +41,310 @@ pub fn to_string(x: f64) -> String { } } +/// The exact decimal digits of a positive finite `x`, without trailing zeros, and the position of +/// the decimal point: `x = 0.d1d2… × 10^point`. +fn exact_digits(x: f64) -> (Vec<u8>, i64) { + // A double's exact decimal expansion has at most 767 significant digits. + let sci = format!("{x:.800e}"); + let (mantissa, exp) = sci.split_once('e').unwrap_or((&sci, "0")); + let mut digits: Vec<u8> = mantissa.bytes().filter(u8::is_ascii_digit).map(|b| b - b'0').collect(); + while digits.len() > 1 && digits.last() == Some(&0) { + digits.pop(); + } + (digits, exp.parse::<i64>().unwrap_or(0) + 1) +} + +/// The shortest digits that read back as a positive finite `x`, and the decimal point's position. +fn shortest_digits(x: f64) -> (Vec<u8>, i64) { + let sci = format!("{x:e}"); + let (mantissa, exp) = sci.split_once('e').unwrap_or((&sci, "0")); + (mantissa.bytes().filter(u8::is_ascii_digit).map(|b| b - b'0').collect(), exp.parse::<i64>().unwrap_or(0) + 1) +} + +/// Rounds `digits` (a number `0.d1d2… × 10^point`) to its first `keep` digits, a half rounding +/// up. Returns exactly `keep` digits and the new point; `keep` may be 0 or negative, which leaves +/// either nothing or a single `1` one place up. +fn round_digits(digits: &[u8], point: i64, keep: i64) -> (Vec<u8>, i64) { + if keep < 0 { + return (Vec::new(), point); + } + let k = usize::try_from(keep).unwrap_or(usize::MAX); + if digits.len() <= k { + let mut out = digits.to_vec(); + out.resize(k, 0); + return (out, point); + } + let mut out: Vec<u8> = digits.get(..k).unwrap_or_default().to_vec(); + if digits.get(k).is_some_and(|&d| d >= 5) { + let mut i = out.len(); + loop { + if i == 0 { + out.insert(0, 1); + out.truncate(k.max(1)); + return (out, point + 1); + } + i -= 1; + match out.get_mut(i) { + Some(d) if *d == 9 => *d = 0, + Some(d) => { + *d += 1; + break; + } + None => break, + } + } + } + (out, point) +} + +fn digits_text(digits: &[u8]) -> String { + digits.iter().map(|d| char::from(b'0' + d)).collect() +} + +/// `Number.prototype.toFixed` (section 15.7.4.5) for `f` digits after the point, 0 to 20. +pub(crate) fn to_fixed(x: f64, f: usize) -> String { + if x.is_nan() { + return "NaN".into(); + } + if x.abs() >= 1e21 { + return to_string(x); + } + let sign = if x < 0.0 { "-" } else { "" }; + let x = x.abs(); + let fi = i64::try_from(f).unwrap_or(0); + let n = if x == 0.0 { + vec![0] + } else { + let (digits, point) = exact_digits(x); + let (mut n, new_point) = round_digits(&digits, point, point + fi); + if n.is_empty() { + n.push(0); + } else if new_point > point { + // A carry past the first digit leaves one digit more than kept. + n.push(0); + n.truncate(usize::try_from(new_point + fi).unwrap_or(n.len())); + } + n + }; + let mut m = digits_text(&n); + let m_trim = m.trim_start_matches('0'); + m = if m_trim.is_empty() { "0".into() } else { m_trim.into() }; + if f == 0 { + return format!("{sign}{m}"); + } + if m.len() <= f { + m = format!("{}{m}", "0".repeat(f + 1 - m.len())); + } + let (int, frac) = m.split_at(m.len() - f); + format!("{sign}{int}.{frac}") +} + +/// `Number.prototype.toExponential` (section 15.7.4.6) for `f` digits after the point (0 to 20), +/// or as many as the number needs when `f` is `None`. +pub(crate) fn to_exponential(x: f64, f: Option<usize>) -> String { + if !x.is_finite() { + return to_string(x); + } + let sign = if x < 0.0 { "-" } else { "" }; + let x = x.abs(); + let (digits, e) = if x == 0.0 { + (vec![0; f.unwrap_or(0) + 1], 0) + } else { + match f { + None => { + let (d, point) = shortest_digits(x); + (d, point - 1) + } + Some(f) => { + let (d, point) = exact_digits(x); + let (d, point) = round_digits(&d, point, i64::try_from(f).unwrap_or(0) + 1); + (d, point - 1) + } + } + }; + let text = digits_text(&digits); + let (first, rest) = text.split_at(1.min(text.len())); + let mantissa = if rest.is_empty() { first.to_string() } else { format!("{first}.{rest}") }; + let esign = if e < 0 { '-' } else { '+' }; + format!("{sign}{mantissa}e{esign}{}", e.abs()) +} + +/// `Number.prototype.toPrecision` (section 15.7.4.7) for `p` significant digits, 1 to 21. +pub(crate) fn to_precision(x: f64, p: usize) -> String { + if !x.is_finite() { + return to_string(x); + } + let sign = if x < 0.0 { "-" } else { "" }; + let x = x.abs(); + let pi = i64::try_from(p).unwrap_or(1); + let (digits, e) = if x == 0.0 { + (vec![0; p], 0) + } else { + let (d, point) = exact_digits(x); + let (d, point) = round_digits(&d, point, pi); + (d, point - 1) + }; + let m = digits_text(&digits); + if e < -6 || e >= pi { + let (first, rest) = m.split_at(1.min(m.len())); + let mantissa = if rest.is_empty() { first.to_string() } else { format!("{first}.{rest}") }; + let esign = if e < 0 { '-' } else { '+' }; + return format!("{sign}{mantissa}e{esign}{}", e.abs()); + } + if e == pi - 1 { + return format!("{sign}{m}"); + } + if e >= 0 { + let (int, frac) = m.split_at(usize::try_from(e + 1).unwrap_or(0).min(m.len())); + return format!("{sign}{int}.{frac}"); + } + format!("{sign}0.{}{m}", "0".repeat(usize::try_from(-(e + 1)).unwrap_or(0))) +} + +/// `Number.prototype.toString(radix)` (section 15.7.4.2) for a radix of 2 to 36 other than 10: +/// the integer part exactly, and fraction digits until the number would read back unchanged. +pub(crate) fn to_radix_string(x: f64, radix: u32) -> String { + if !x.is_finite() || x == 0.0 { + return to_string(x); + } + const CHARS: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + let r = f64::from(radix); + let sign = if x < 0.0 { "-" } else { "" }; + let x = x.abs(); + let mut integer = x.floor(); + let mut fraction = x - integer; + // Half the distance to the next double: digits past this resolution are not needed. + let next = f64::from_bits(x.to_bits() + 1); + let mut delta = (0.5 * (next - x)).max(f64::from_bits(1)); + let mut frac_digits: Vec<u8> = Vec::new(); + if fraction >= delta { + loop { + fraction *= r; + delta *= r; + let digit = fraction.floor(); + frac_digits.push(digit as u8); + fraction -= digit; + if (fraction > 0.5 || (fraction == 0.5 && (digit as u8) & 1 == 1)) && fraction + delta > 1.0 { + // Round up, carrying into the integer part when every digit is the largest. + loop { + match frac_digits.last_mut() { + None => { + integer += 1.0; + break; + } + Some(d) if u32::from(*d) + 1 == radix => { + frac_digits.pop(); + } + Some(d) => { + *d += 1; + break; + } + } + } + break; + } + if fraction < delta { + break; + } + } + } + let mut int_digits: Vec<u8> = Vec::new(); + // Digits below the double's precision are zeros. + while (integer / r) >= 9_007_199_254_740_992.0 { + integer /= r; + int_digits.push(0); + } + loop { + let rem = integer % r; + int_digits.push(rem as u8); + integer = (integer - rem) / r; + if integer <= 0.0 { + break; + } + } + let mut out = String::from(sign); + out.extend(int_digits.iter().rev().map(|&d| char::from(CHARS.get(usize::from(d)).copied().unwrap_or(b'0')))); + if !frac_digits.is_empty() { + out.push('.'); + out.extend(frac_digits.iter().map(|&d| char::from(CHARS.get(usize::from(d)).copied().unwrap_or(b'0')))); + } + out +} + #[cfg(test)] mod tests { - use super::to_string; + use super::{to_exponential, to_fixed, to_precision, to_radix_string, to_string}; + + #[test] + fn fixed_exponential_and_precision_round_the_exact_value_with_ties_away_from_zero() { + let fixed: &[(f64, usize, &str)] = &[ + (0.5, 0, "1"), + (2.5, 0, "3"), + (1.45, 1, "1.4"), + (1.255, 2, "1.25"), + (1.005, 2, "1.00"), + (0.1, 20, "0.10000000000000000555"), + (123.456, 2, "123.46"), + (-1.5, 0, "-2"), + (-0.0000001, 2, "-0.00"), + (-0.0, 2, "0.00"), + (0.0, 0, "0"), + (99.995, 2, "100.00"), + (9.5, 0, "10"), + (1e21, 2, "1e+21"), + (1000000000000000128.0, 0, "1000000000000000128"), + (f64::NAN, 2, "NaN"), + ]; + for (x, f, want) in fixed { + assert_eq!(to_fixed(*x, *f), *want, "({x}).toFixed({f})"); + } + let exp: &[(f64, Option<usize>, &str)] = &[ + (123456.0, Some(2), "1.23e+5"), + (123456.0, None, "1.23456e+5"), + (0.00015, Some(1), "1.5e-4"), + (1.25, Some(1), "1.3e+0"), + (0.0, Some(2), "0.00e+0"), + (0.0, None, "0e+0"), + (-6.9e-11, Some(1), "-6.9e-11"), + (9.99, Some(1), "1.0e+1"), + (f64::INFINITY, Some(2), "Infinity"), + ]; + for (x, f, want) in exp { + assert_eq!(to_exponential(*x, *f), *want, "({x}).toExponential({f:?})"); + } + let prec: &[(f64, usize, &str)] = &[ + (123.456, 4, "123.5"), + (0.000123, 2, "0.00012"), + (0.0000001234, 2, "1.2e-7"), + (123456.0, 2, "1.2e+5"), + (123456.0, 6, "123456"), + (0.0, 3, "0.00"), + (1.0, 1, "1"), + (99.5, 2, "1.0e+2"), + (1e21, 21, "1.00000000000000000000e+21"), + ]; + for (x, p, want) in prec { + assert_eq!(to_precision(*x, *p), *want, "({x}).toPrecision({p})"); + } + } + + #[test] + fn radix_strings_give_exact_integers_and_enough_fraction_digits() { + let cases: &[(f64, u32, &str)] = &[ + (255.0, 16, "ff"), + (255.0, 2, "11111111"), + (-255.0, 36, "-73"), + (0.5, 2, "0.1"), + (0.5, 16, "0.8"), + (3.75, 2, "11.11"), + (2f64.powi(60), 16, "1000000000000000"), + (1e21, 36, "5v1j4f4ds7c000"), + (0.1, 3, "0.0022002200220022002200220022002201"), + ]; + for (x, r, want) in cases { + assert_eq!(to_radix_string(*x, *r), *want, "({x}).toString({r})"); + } + } #[test] fn numbers_print_as_section_9_8_1_lays_them_out() { diff --git a/crates/js/src/number_builtins.rs b/crates/js/src/number_builtins.rs new file mode 100644 index 000000000..59ea12813 --- /dev/null +++ b/crates/js/src/number_builtins.rs @@ -0,0 +1,127 @@ +//! `Number` (ECMA-262 5.1 section 15.7): the constructor, its constants and the methods of +//! `Number.prototype`. The formats are in [`crate::number`]. + +use crate::builtins::{arg, constructor, data, method, native}; +use crate::heap::{Class, NativeKind, Object}; +use crate::interp::{ErrorType, Interp, JsResult}; +use crate::number; +use crate::string_builtins::{reserve_units, to_integer}; +use crate::value::{Str, Value}; + +pub(crate) fn install(interp: &mut Interp) -> JsResult<()> { + let proto = interp.intrinsics.number_proto; + let ctor = native(interp, "Number", number_ctor, 1, NativeKind::Plain, true)?; + constructor(interp, "Number", ctor, proto)?; + let constants = [ + ("MAX_VALUE", f64::MAX), + ("MIN_VALUE", f64::from_bits(1)), + ("NaN", f64::NAN), + ("NEGATIVE_INFINITY", f64::NEG_INFINITY), + ("POSITIVE_INFINITY", f64::INFINITY), + ]; + for (name, v) in constants { + data(interp, ctor, name, Value::Number(v), 0)?; + } + method(interp, proto, "toString", to_string, 1)?; + method(interp, proto, "toLocaleString", to_locale_string, 0)?; + method(interp, proto, "valueOf", value_of, 0)?; + method(interp, proto, "toFixed", to_fixed, 1)?; + method(interp, proto, "toExponential", to_exponential, 1)?; + method(interp, proto, "toPrecision", to_precision, 1) +} + +/// `Number(value)` and `new Number(value)` (sections 15.7.1 and 15.7.2). +fn number_ctor(interp: &mut Interp, _: Value, args: &[Value], construct: bool) -> JsResult<Value> { + let n = match args.first() { + Some(v) => interp.to_number(v)?, + None => 0.0, + }; + if !construct { + return Ok(Value::Number(n)); + } + let proto = interp.intrinsics.number_proto; + Ok(Value::Object(interp.heap.alloc_object(Object::new(Class::Number(n), Some(proto))))) +} + +/// The number a `Number.prototype` method works on: a number, or a Number object's number. +fn this_number(interp: &mut Interp, this: &Value, name: &str) -> JsResult<f64> { + match this { + Value::Number(n) => Ok(*n), + Value::Object(o) => match interp.heap.object(*o).map(|o| &o.class) { + Some(Class::Number(n)) => Ok(*n), + _ => Err(interp.type_error(&format!("Number.prototype.{name} was called on a value that is not a number"))), + }, + _ => Err(interp.type_error(&format!("Number.prototype.{name} was called on a value that is not a number"))), + } +} + +fn text(interp: &mut Interp, s: &str) -> JsResult<Value> { + reserve_units(interp, s.len())?; + Ok(Value::String(Str::from(s))) +} + +/// `toString(radix)` (section 15.7.4.2). +fn to_string(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> { + let x = this_number(interp, &this, "toString")?; + let radix = match arg(args, 0) { + Value::Undefined => 10.0, + r => to_integer(interp.to_number(&r)?), + }; + if !(2.0..=36.0).contains(&radix) { + return Err(interp.throw(ErrorType::Range, "the radix must be between 2 and 36")); + } + let s = if radix == 10.0 { number::to_string(x) } else { number::to_radix_string(x, radix as u32) }; + text(interp, &s) +} + +fn to_locale_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> { + let x = this_number(interp, &this, "toLocaleString")?; + text(interp, &number::to_string(x)) +} + +fn value_of(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> { + this_number(interp, &this, "valueOf").map(Value::Number) +} + +/// `toFixed(fractionDigits)` (section 15.7.4.5). +fn to_fixed(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> { + let x = this_number(interp, &this, "toFixed")?; + let f = to_integer(interp.to_number(&arg(args, 0))?); + if !(0.0..=20.0).contains(&f) { + return Err(interp.throw(ErrorType::Range, "toFixed() digits must be between 0 and 20")); + } + text(interp, &number::to_fixed(x, f as usize)) +} + +/// `toExponential(fractionDigits)` (section 15.7.4.6). +fn to_exponential(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> { + let x = this_number(interp, &this, "toExponential")?; + let digits = arg(args, 0); + let f = to_integer(interp.to_number(&digits)?); + if !x.is_finite() { + return text(interp, &number::to_string(x)); + } + let f = match digits { + Value::Undefined => None, + _ if !(0.0..=20.0).contains(&f) => return Err(interp.throw(ErrorType::Range, "toExponential() digits must be between 0 and 20")), + _ => Some(f as usize), + }; + text(interp, &number::to_exponential(x, f)) +} + +/// `toPrecision(precision)` (section 15.7.4.7). +fn to_precision(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> { + let x = this_number(interp, &this, "toPrecision")?; + let precision = arg(args, 0); + if matches!(precision, Value::Undefined) { + return text(interp, &number::to_string(x)); + } + let p = to_integer(interp.to_number(&precision)?); + if !x.is_finite() { + return text(interp, &number::to_string(x)); + } + if !(1.0..=21.0).contains(&p) { + return Err(interp.throw(ErrorType::Range, "toPrecision() argument must be between 1 and 21")); + } + text(interp, &number::to_precision(x, p as usize)) +} diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index 29d3aacb2..eee516f5b 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -209,6 +209,21 @@ impl Realm { let _ = interp.define(global, key(name), Prop::Data { value: v, flags: HIDDEN }); } + /// Sets the clock `Date.now()` and `new Date()` read: the current time in milliseconds since + /// 1970-01-01 UTC. By default it is the system clock; a wasm build has none, and its default + /// gives NaN. + pub fn set_clock(&mut self, clock: impl Fn() -> f64 + 'static) { + self.interp.date_host.clock = std::rc::Rc::new(clock); + } + + /// Sets the time zone `Date` uses for local time: `offset` gives local time's offset from UTC + /// in milliseconds at a time value (milliseconds since 1970-01-01 UTC), so it can follow + /// daylight saving time. An offset that is not finite or is a day or more counts as 0. By + /// default local time is UTC. + pub fn set_time_zone(&mut self, offset: impl Fn(f64) -> f64 + 'static) { + self.interp.date_host.offset = std::rc::Rc::new(offset); + } + /// Collects garbage now and returns the heap's estimated size in bytes afterwards. pub fn collect_garbage(&mut self) -> usize { self.interp.collect_garbage(); diff --git a/crates/js/src/regexp_builtins.rs b/crates/js/src/regexp_builtins.rs new file mode 100644 index 000000000..a882efd3a --- /dev/null +++ b/crates/js/src/regexp_builtins.rs @@ -0,0 +1,269 @@ +//! `RegExp` (ECMA-262 5.1 section 15.10.3 to 15.10.7): the constructor, regular expression +//! literals' objects, and `exec`, `test` and `toString`. Matching is in [`crate::regexp_vm`]. + +use std::rc::Rc; + +use crate::LimitKind; +use crate::builtins::{arg, constructor, method, native}; +use crate::heap::{Class, NativeKind, OBJECT_BYTES, OPEN, ObjId, Object, PROP_BYTES, Prop, WRITABLE, key, string_bytes}; +use crate::interp::{Abrupt, ErrorType, Interp, JsResult}; +use crate::regexp_vm::{CompileFail, Flags, Matcher, Regex, compile, parse_flags, use_fuel_n}; +use crate::string_builtins::{new_array, reserve_units, rooted, set_element, substring_value, to_integer}; +use crate::value::{Str, Value}; + +pub(crate) fn install(interp: &mut Interp) -> JsResult<()> { + let proto = interp.intrinsics.regexp_proto; + // The prototype is itself a regular expression object, matching the empty string. + let empty = compile_regex(interp, &[], &[])?; + if let Some(p) = interp.heap.object_mut(proto) { + p.class = Class::RegExp(empty); + } + define_flags(interp, proto, Str::from("(?:)"), false, false, false)?; + let ctor = native(interp, "RegExp", regexp_ctor, 2, NativeKind::Plain, true)?; + constructor(interp, "RegExp", ctor, proto)?; + method(interp, proto, "exec", exec, 1)?; + method(interp, proto, "test", test, 1)?; + method(interp, proto, "toString", to_string, 0) +} + +/// The object and the compiled form of a RegExp object. +pub(crate) fn regexp_of(interp: &Interp, v: &Value) -> Option<(ObjId, Rc<Regex>)> { + let o = v.as_object()?; + match &interp.heap.object(o)?.class { + Class::RegExp(re) => Some((o, re.clone())), + _ => None, + } +} + +/// Checks and compiles a pattern, throwing a `SyntaxError` for a pattern or flags that are not +/// valid. The compiled form's bytes are reserved on the heap. +fn compile_regex(interp: &mut Interp, pattern: &[u16], flags: &[u16]) -> JsResult<Rc<Regex>> { + let flags: Flags = match parse_flags(flags) { + Ok(f) => f, + Err(m) => return Err(interp.throw(ErrorType::Syntax, &m)), + }; + let depth = interp.options.max_depth; + if let Err(m) = crate::regexp::check(pattern, depth) { + return Err(interp.throw(ErrorType::Syntax, &format!("invalid regular expression: {m}"))); + } + // A compile that runs out of room collects garbage and tries once more with the room it frees. + let mut collected = false; + let re = loop { + let room = interp.limits.heap_bytes.saturating_sub(interp.heap.bytes); + match compile(pattern, flags, depth, room, &mut |n| use_fuel_n(interp, n)) { + Ok(re) => break re, + Err(CompileFail::Abrupt(Abrupt::Limit(LimitKind::Heap))) if !collected => { + interp.collect_for_room(); + collected = true; + } + Err(CompileFail::Syntax(m)) => return Err(interp.throw(ErrorType::Syntax, &format!("invalid regular expression: {m}"))), + Err(CompileFail::Abrupt(a)) => return Err(a), + } + }; + let bytes = re.heap_bytes(); + interp.reserve_heap(bytes)?; + interp.heap.bytes = interp.heap.bytes.saturating_add(bytes); + Ok(Rc::new(re)) +} + +/// A new RegExp object whose `source` property is `source` (section 15.10.4.1, steps 7 to 12). +fn make(interp: &mut Interp, source: Str, pattern: &[u16], flags: &[u16]) -> JsResult<ObjId> { + let re = compile_regex(interp, pattern, flags)?; + interp.reserve_heap(OBJECT_BYTES + 5 * (PROP_BYTES + string_bytes(10)))?; + let (g, i, m) = (parse_flags(flags).unwrap_or_default().global, re.ignore_case, re.multiline); + let proto = interp.intrinsics.regexp_proto; + let r = interp.heap.alloc_object(Object::new(Class::RegExp(re), Some(proto))); + define_flags(interp, r, source, g, i, m)?; + Ok(r) +} + +/// Defines a RegExp object's `source`, `global`, `ignoreCase`, `multiline` and `lastIndex` +/// (section 15.10.7). +fn define_flags(interp: &mut Interp, r: ObjId, source: Str, g: bool, i: bool, m: bool) -> JsResult<()> { + interp.define(r, key("source"), Prop::Data { value: Value::String(source), flags: 0 })?; + interp.define(r, key("global"), Prop::Data { value: Value::Bool(g), flags: 0 })?; + interp.define(r, key("ignoreCase"), Prop::Data { value: Value::Bool(i), flags: 0 })?; + interp.define(r, key("multiline"), Prop::Data { value: Value::Bool(m), flags: 0 })?; + interp.define(r, key("lastIndex"), Prop::Data { value: Value::Number(0.0), flags: WRITABLE })?; + Ok(()) +} + +/// The object a regular expression literal evaluates to (section 7.8.5): a new RegExp object +/// each time. +pub(crate) fn new_regexp(interp: &mut Interp, source: &Str, flags: &Str) -> JsResult<ObjId> { + let pattern = source.flat(); + make(interp, source.clone(), &pattern, &flags.flat()) +} + +/// The `source` text for a pattern given to the constructor: `(?:)` for the empty pattern, and +/// `/` and line terminators escaped, so that `/source/` reads back as the same pattern. Its +/// length is reserved on the heap before it is built. +pub(crate) fn source_text(interp: &mut Interp, pattern: &[u16]) -> JsResult<Str> { + if pattern.is_empty() { + return Ok(Str::from("(?:)")); + } + let mut len = 0usize; + source_units(pattern, |u| len = len.saturating_add(u.len())); + reserve_units(interp, len)?; + let mut out = Vec::with_capacity(len); + source_units(pattern, |u| out.extend_from_slice(u)); + Ok(Str::from_units(out)) +} + +/// Passes the units of [`source_text`] for a non-empty pattern to `emit`, a piece at a time. +fn source_units(pattern: &[u16], mut emit: impl FnMut(&[u16])) { + const BACKSLASH: u16 = 0x5C; + let mut in_class = false; + let mut i = 0; + while let Some(&u) = pattern.get(i) { + match u { + 0x5C => { + emit(pattern.get(i..i + 2).unwrap_or(&[BACKSLASH])); + i += 2; + continue; + } + 0x5B => in_class = true, + 0x5D => in_class = false, + 0x2F if !in_class => emit(&[BACKSLASH]), + _ => {} + } + match u { + 0x0A => emit(&[BACKSLASH, 0x6E]), + 0x0D => emit(&[BACKSLASH, 0x72]), + 0x2028 => emit(&[BACKSLASH, 0x75, 0x32, 0x30, 0x32, 0x38]), + 0x2029 => emit(&[BACKSLASH, 0x75, 0x32, 0x30, 0x32, 0x39]), + _ => emit(&[u]), + } + i += 1; + } +} + +/// `new RegExp(pattern, flags)` (section 15.10.4.1). +pub(crate) fn new_regexp_from(interp: &mut Interp, pattern: &Value, flags: &Value) -> JsResult<ObjId> { + if let Some((o, _)) = regexp_of(interp, pattern) { + if !matches!(flags, Value::Undefined) { + return Err(interp.type_error("flags cannot be given with a RegExp object")); + } + let pv = Value::Object(o); + let source = interp.get_value(&pv, &key("source"))?; + let source = interp.to_str(&source)?; + let mut f = String::new(); + for (name, c) in [("global", 'g'), ("ignoreCase", 'i'), ("multiline", 'm')] { + if interp.get_value(&pv, &key(name))?.truthy() { + f.push(c); + } + } + let units = source.flat(); + let pattern: &[u16] = if is_empty_group(&units) { &[] } else { &units }; + return make(interp, source.clone(), pattern, &f.encode_utf16().collect::<Vec<u16>>()); + } + let p = if matches!(pattern, Value::Undefined) { Str::from("") } else { interp.to_str(pattern)? }; + let f = if matches!(flags, Value::Undefined) { Str::from("") } else { interp.to_str(flags)? }; + let units = p.flat(); + let source = source_text(interp, &units)?; + make(interp, source, &units, &f.flat()) +} + +fn is_empty_group(units: &[u16]) -> bool { + units.iter().copied().eq("(?:)".encode_utf16()) +} + +/// `RegExp(pattern, flags)` and `new RegExp(pattern, flags)` (sections 15.10.3 and 15.10.4). +fn regexp_ctor(interp: &mut Interp, _: Value, args: &[Value], construct: bool) -> JsResult<Value> { + let (pattern, flags) = (arg(args, 0), arg(args, 1)); + if !construct && regexp_of(interp, &pattern).is_some() && matches!(flags, Value::Undefined) { + return Ok(pattern); + } + new_regexp_from(interp, &pattern, &flags).map(Value::Object) +} + +fn this_regexp(interp: &mut Interp, this: &Value, name: &str) -> JsResult<ObjId> { + match regexp_of(interp, this) { + Some((o, _)) => Ok(o), + None => Err(interp.type_error(&format!("RegExp.prototype.{name} was called on a value that is not a RegExp object"))), + } +} + +/// The matching steps of `exec` (section 15.10.6.2, steps 3 to 11): the positions of the match +/// and of its groups ([`crate::regexp_vm::NONE`] for a group that did not take part), or `None`. +/// Reads `lastIndex` and `global`, and sets `lastIndex` as `exec` does. +pub(crate) fn exec_raw(interp: &mut Interp, rx: ObjId, s: &Str) -> JsResult<Option<Vec<usize>>> { + let Some((_, re)) = regexp_of(interp, &Value::Object(rx)) else { return Err(interp.type_error("not a RegExp object")) }; + let rv = Value::Object(rx); + let last_index = key("lastIndex"); + let li = interp.get_value(&rv, &last_index)?; + let li = to_integer(interp.to_number(&li)?); + let global = interp.get_value(&rv, &key("global"))?.truthy(); + let units = s.flat(); + let i = if global { li } else { 0.0 }; + let found = if i < 0.0 || i > units.len() as f64 { + None + } else { + let mut m = Matcher::new(&re, interp)?; + if m.search(interp, &units, i as usize)? { Some(m.caps) } else { None } + }; + match found { + None => { + interp.put(rx, last_index, Value::Number(0.0), true)?; + Ok(None) + } + Some(caps) => { + if global { + let end = caps.get(1).copied().unwrap_or(0); + interp.put(rx, last_index, Value::Number(end as f64), true)?; + } + Ok(Some(caps)) + } + } +} + +/// `exec`'s result for `rx` on `s`: the match array, or `null`. +pub(crate) fn exec_value(interp: &mut Interp, rx: ObjId, s: &Str) -> JsResult<Value> { + rooted(interp, |interp| { + let Some(caps) = exec_raw(interp, rx, s)? else { return Ok(Value::Null) }; + let units = s.flat(); + let a = new_array(interp); + interp.reserve_heap(2 * (PROP_BYTES + string_bytes(5)))?; + let index = caps.first().copied().unwrap_or(0); + interp.define(a, key("index"), Prop::Data { value: Value::Number(index as f64), flags: OPEN })?; + interp.define(a, key("input"), Prop::Data { value: Value::String(s.clone()), flags: OPEN })?; + for (n, pair) in caps.chunks(2).enumerate() { + let v = match pair { + [x, y] if *x != crate::regexp_vm::NONE && *y != crate::regexp_vm::NONE => substring_value(interp, &units, *x, *y)?, + _ => Value::Undefined, + }; + set_element(interp, a, n, v)?; + } + Ok(Value::Object(a)) + }) +} + +/// `RegExp.prototype.exec` (section 15.10.6.2). +fn exec(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> { + let rx = this_regexp(interp, &this, "exec")?; + let s = interp.to_str(&arg(args, 0))?; + exec_value(interp, rx, &s) +} + +/// `RegExp.prototype.test` (section 15.10.6.3). +fn test(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> { + let rx = this_regexp(interp, &this, "test")?; + let s = interp.to_str(&arg(args, 0))?; + Ok(Value::Bool(exec_raw(interp, rx, &s)?.is_some())) +} + +/// `RegExp.prototype.toString` (section 15.10.6.4). +fn to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> { + let rx = this_regexp(interp, &this, "toString")?; + let rv = Value::Object(rx); + let source = interp.get_value(&rv, &key("source"))?; + let source = interp.to_str(&source)?; + let mut flags = String::from("/"); + for (name, c) in [("global", 'g'), ("ignoreCase", 'i'), ("multiline", 'm')] { + if interp.get_value(&rv, &key(name))?.truthy() { + flags.push(c); + } + } + let head = interp.concat(&Str::from("/"), &source)?; + interp.concat(&head, &Str::from(flags.as_str())).map(Value::String) +} diff --git a/crates/js/src/regexp_vm.rs b/crates/js/src/regexp_vm.rs new file mode 100644 index 000000000..8f379d1c0 --- /dev/null +++ b/crates/js/src/regexp_vm.rs @@ -0,0 +1,1188 @@ +//! Compiling and matching regular expressions (ECMA-262 5.1 section 15.10.2, with the lenient +//! patterns of Annex B that [`crate::regexp::check`] accepts). +//! +//! A pattern compiles to a list of instructions for a backtracking matcher. The compiler reads the +//! pattern with a loop and a stack of open groups, and the matcher keeps its choice points and the +//! values it must restore on its own stack, so neither recurses however deeply the groups nest. +//! Quantifiers count their iterations in registers instead of repeating their atom's code. Every +//! instruction run and every choice point taken back costs fuel, and the matcher's stack is +//! charged against the heap limit, so a pattern that backtracks without end stops at a limit. + +use std::collections::HashMap; +use std::sync::OnceLock; + +use crate::LimitKind; +use crate::interp::{Abrupt, Interp, JsResult}; + +/// A capture position that is not set. +pub(crate) const NONE: usize = usize::MAX; + +/// Steps the matcher counts before it takes them from the fuel. +const FUEL_BATCH: u64 = 1024; + +#[derive(Clone, Copy, Debug)] +enum Inst { + /// One code unit, already canonicalized when the pattern ignores case. + Char(u16), + /// `.`: any unit but a line terminator. + Any, + Set(u32), + LineStart, + LineEnd, + /// `\b` (true) or `\B` (false). + WordBoundary(bool), + /// Goes on at the first offset, and at the second when that fails. + Split(i32, i32), + Jump(i32), + GroupStart(u32), + GroupEnd(u32), + /// Sets a quantifier's iteration count to zero. + RepInit(u32), + /// A quantifier's head: its atom follows, then [`Inst::RepEnd`]. `caps` is the range of groups + /// inside the atom, cleared before each iteration; `exit` leads past the atom. + Rep { + reg: u32, + min: u64, + max: u64, + greedy: bool, + caps: (u32, u32), + exit: i32, + }, + RepEnd { + reg: u32, + head: i32, + }, + /// A lookahead: its pattern follows, then [`Inst::LookEnd`]; `end` leads past it. + Look { + negative: bool, + end: i32, + }, + LookEnd, + BackRef(u32), + Match, +} + +/// A character class: sorted, merged ranges of code units. +#[derive(Clone, Debug, Default)] +struct CharSet { + ranges: Vec<(u16, u16)>, + negated: bool, +} + +impl CharSet { + fn contains_raw(&self, u: u16) -> bool { + let i = self.ranges.partition_point(|&(_, hi)| hi < u); + self.ranges.get(i).is_some_and(|&(lo, _)| lo <= u) + } +} + +/// The flags of a regular expression (section 15.10.4.1). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct Flags { + pub(crate) global: bool, + pub(crate) ignore_case: bool, + pub(crate) multiline: bool, +} + +/// Reads flags: each of `g`, `i` and `m` at most once. +pub(crate) fn parse_flags(units: &[u16]) -> Result<Flags, String> { + let mut f = Flags::default(); + for &u in units { + let slot = match u { + 0x67 => &mut f.global, + 0x69 => &mut f.ignore_case, + 0x6D => &mut f.multiline, + _ => return Err(format!("invalid regular expression flag '{}'", String::from_utf16_lossy(&[u]))), + }; + if *slot { + return Err(format!("regular expression flag '{}' given twice", String::from_utf16_lossy(&[u]))); + } + *slot = true; + } + Ok(f) +} + +/// A compiled regular expression. +pub(crate) struct Regex { + insts: Vec<Inst>, + sets: Vec<CharSet>, + /// Capturing groups, not counting the whole match. + pub(crate) groups: usize, + loops: usize, + pub(crate) ignore_case: bool, + pub(crate) multiline: bool, +} + +/// Why a pattern did not compile. +pub(crate) enum CompileFail { + Syntax(String), + Abrupt(Abrupt), +} + +impl From<String> for CompileFail { + fn from(s: String) -> Self { + CompileFail::Syntax(s) + } +} + +impl From<&str> for CompileFail { + fn from(s: &str) -> Self { + CompileFail::Syntax(s.into()) + } +} + +impl Regex { + /// The estimated bytes the compiled form holds. + pub(crate) fn heap_bytes(&self) -> usize { + 64 + size_of::<Inst>() * self.insts.len() + self.sets.iter().map(set_bytes).sum::<usize>() + } + + fn canon(&self, u: u16) -> u16 { + if self.ignore_case { canonicalize(u) } else { u } + } + + fn set_matches(&self, set: &CharSet, u: u16) -> bool { + let found = if self.ignore_case { + let k = canonicalize(u); + match case_groups().get(&k) { + Some(group) => group.iter().any(|&c| set.contains_raw(c)), + None => set.contains_raw(u), + } + } else { + set.contains_raw(u) + }; + found != set.negated + } +} + +// ---- characters ---- + +fn is_line_terminator(u: u16) -> bool { + crate::chars::is_line_terminator(u32::from(u)) +} + +fn is_word_char(u: u16) -> bool { + matches!(u, 0x30..=0x39 | 0x41..=0x5A | 0x5F | 0x61..=0x7A) +} + +/// Canonicalize (section 15.10.2.8): the unit's upper case when that is one unit, unless it +/// would turn a unit from 128 up into one below 128. +fn canonicalize(u: u16) -> u16 { + static TABLE: OnceLock<Vec<u16>> = OnceLock::new(); + let table = TABLE.get_or_init(|| (0..=u16::MAX).map(upper_unit).collect()); + table.get(usize::from(u)).copied().unwrap_or(u) +} + +fn upper_unit(u: u16) -> u16 { + let Some(c) = char::from_u32(u32::from(u)) else { return u }; + let mut upper = c.to_uppercase(); + match (upper.next(), upper.next()) { + (Some(up), None) => match u16::try_from(u32::from(up)) { + Ok(cu) if !(u >= 128 && cu < 128) => cu, + _ => u, + }, + _ => u, + } +} + +/// For each canonical unit that more than one unit canonicalizes to, those units. +fn case_groups() -> &'static HashMap<u16, Vec<u16>> { + static GROUPS: OnceLock<HashMap<u16, Vec<u16>>> = OnceLock::new(); + GROUPS.get_or_init(|| { + let mut groups: HashMap<u16, Vec<u16>> = HashMap::new(); + for u in 0..=u16::MAX { + let k = canonicalize(u); + if k != u { + groups.entry(k).or_default().push(u); + } + } + for (k, members) in &mut groups { + if canonicalize(*k) == *k { + members.push(*k); + } + } + groups + }) +} + +fn digit_ranges() -> Vec<(u16, u16)> { + vec![(0x30, 0x39)] +} + +fn word_ranges() -> Vec<(u16, u16)> { + vec![(0x30, 0x39), (0x41, 0x5A), (0x5F, 0x5F), (0x61, 0x7A)] +} + +/// White space and line terminators (`\s`). +fn space_ranges() -> Vec<(u16, u16)> { + let mut out: Vec<(u16, u16)> = Vec::new(); + for u in 0..=u16::MAX { + if crate::value::is_str_space(u) { + match out.last_mut() { + Some((_, hi)) if u32::from(*hi) + 1 == u32::from(u) => *hi = u, + _ => out.push((u, u)), + } + } + } + out +} + +fn complement(ranges: &[(u16, u16)]) -> Vec<(u16, u16)> { + let mut out = Vec::new(); + let mut next: u32 = 0; + for &(lo, hi) in ranges { + if u32::from(lo) > next { + out.push((next as u16, lo - 1)); + } + next = u32::from(hi) + 1; + } + if next <= 0xFFFF { + out.push((next as u16, 0xFFFF)); + } + out +} + +/// Sorts and merges ranges. +fn normalize(mut ranges: Vec<(u16, u16)>) -> Vec<(u16, u16)> { + ranges.sort_unstable(); + let mut out: Vec<(u16, u16)> = Vec::with_capacity(ranges.len()); + for (lo, hi) in ranges { + match out.last_mut() { + Some((_, last)) if u32::from(lo) <= u32::from(*last) + 1 => *last = (*last).max(hi), + _ => out.push((lo, hi)), + } + } + out +} + +/// The ranges of a class escape letter (`d`, `D`, `s`, `S`, `w` or `W`). +fn escape_ranges(letter: u16) -> Option<Vec<(u16, u16)>> { + static SPACE: OnceLock<Vec<(u16, u16)>> = OnceLock::new(); + let space = || SPACE.get_or_init(space_ranges).clone(); + Some(match letter { + 0x64 => digit_ranges(), + 0x44 => complement(&digit_ranges()), + 0x73 => space(), + 0x53 => complement(&space()), + 0x77 => word_ranges(), + 0x57 => complement(&word_ranges()), + _ => return None, + }) +} + +// ---- compiling ---- + +#[derive(Clone, Copy)] +enum Kind { + Top, + Capture(u32), + Group, + Look(bool), +} + +/// A piece of code being compiled: one instruction, or a finished fragment placed whole, so that +/// closing a group or quantifying it moves one item however much code it holds. +#[derive(Clone, Copy)] +enum Item { + Inst(Inst), + Frag(usize), +} + +/// The finished fragments, with the number of instructions each one flattens to. +#[derive(Default)] +struct Frags { + items: Vec<Vec<Item>>, + lens: Vec<usize>, +} + +impl Frags { + fn len_of(&self, items: &[Item]) -> usize { + items + .iter() + .map(|i| match i { + Item::Inst(_) => 1, + Item::Frag(f) => self.lens.get(*f).copied().unwrap_or(0), + }) + .fold(0usize, usize::saturating_add) + } + + fn add(&mut self, items: Vec<Item>) -> Item { + let len = self.len_of(&items); + self.items.push(items); + self.lens.push(len); + Item::Frag(self.items.len() - 1) + } + + /// The instructions of `top` with every fragment in place, written with a loop. + fn flatten(&self, top: &[Item]) -> Vec<Inst> { + let mut out = Vec::with_capacity(self.len_of(top)); + let mut stack: Vec<(&[Item], usize)> = vec![(top, 0)]; + while let Some((items, i)) = stack.pop() { + let Some(item) = items.get(i) else { continue }; + stack.push((items, i + 1)); + match item { + Item::Inst(inst) => out.push(*inst), + Item::Frag(f) => { + if let Some(child) = self.items.get(*f) { + stack.push((child, 0)); + } + } + } + } + out + } +} + +struct Ctx { + kind: Kind, + alts: Vec<Vec<Item>>, + cur: Vec<Item>, + /// The last term, when it may take a quantifier: where it starts in `cur` and how many groups + /// were opened before it. + atom: Option<(usize, u32)>, + /// Where the group starts in its parent's `cur` and how many groups were opened before it. + opened_at: (usize, u32), +} + +impl Ctx { + fn new(kind: Kind, opened_at: (usize, u32)) -> Self { + Self { kind, alts: Vec::new(), cur: Vec::new(), atom: None, opened_at } + } +} + +/// Adds `inst` to `ctx`, counting it in `emitted`. +fn emit(emitted: &mut usize, ctx: &mut Ctx, inst: Inst) { + *emitted += 1; + ctx.cur.push(Item::Inst(inst)); +} + +fn offset(n: usize) -> Result<i32, CompileFail> { + i32::try_from(n).map_err(|_| CompileFail::Syntax("regular expression too large".into())) +} + +/// The alternatives of a disjunction as one piece of code. +fn join(mut alts: Vec<Vec<Item>>, frags: &mut Frags) -> Result<Vec<Item>, CompileFail> { + if alts.len() == 1 { + return Ok(alts.pop().unwrap_or_default()); + } + let lens: Vec<usize> = alts.iter().map(|a| frags.len_of(a)).collect(); + let total = lens.iter().sum::<usize>() + 2 * alts.len().saturating_sub(1); + let last = alts.len().saturating_sub(1); + let mut out = Vec::with_capacity(2 * alts.len()); + let mut at = 0usize; + for (i, (a, len)) in alts.into_iter().zip(lens).enumerate() { + if i == last { + out.push(frags.add(a)); + } else { + out.push(Item::Inst(Inst::Split(1, offset(len + 2)?))); + out.push(frags.add(a)); + at += len + 1; + out.push(Item::Inst(Inst::Jump(offset(total - at)?))); + at += 1; + } + } + Ok(out) +} + +/// The number of capturing groups in `pattern`. +fn count_groups(pattern: &[u16]) -> usize { + let mut i = 0; + let mut n = 0; + let mut in_class = false; + while let Some(&u) = pattern.get(i) { + match u { + 0x5C => i += 1, + 0x5B => in_class = true, + 0x5D => in_class = false, + 0x28 if !in_class && pattern.get(i + 1) != Some(&0x3F) => n += 1, + _ => {} + } + i += 1; + } + n +} + +fn hex_digits(pattern: &[u16], at: usize, n: usize) -> Option<u16> { + let mut v: u32 = 0; + for k in 0..n { + let d = crate::chars::hex_value(u32::from(*pattern.get(at + k)?))?; + v = v * 16 + d; + } + u16::try_from(v).ok() +} + +/// A legacy octal escape (Annex B) whose first digit is at `at`: its value and the position after +/// it. +fn octal(pattern: &[u16], at: usize) -> (u16, usize) { + let digit = |i: usize| pattern.get(i).filter(|&&u| (0x30..=0x37).contains(&u)).map(|&u| u - 0x30); + let Some(first) = digit(at) else { return (0, at) }; + let max = if first <= 3 { 3 } else { 2 }; + let mut value = first; + let mut i = at + 1; + while i < at + max + && let Some(d) = digit(i) + { + value = value * 8 + d; + i += 1; + } + (value, i) +} + +/// A `{n}`, `{n,}` or `{n,m}` quantifier whose `{` is at `i`: its bounds and the position after it. +fn braces(pattern: &[u16], i: usize) -> Option<(u64, u64, usize)> { + let digits = |mut j: usize| -> Option<(u64, usize)> { + let start = j; + let mut v: u64 = 0; + while let Some(d) = pattern.get(j).filter(|&&u| (0x30..=0x39).contains(&u)) { + v = v.saturating_mul(10).saturating_add(u64::from(d - 0x30)); + j += 1; + } + (j > start).then_some((v, j)) + }; + let (min, mut j) = digits(i + 1)?; + let max = if pattern.get(j) == Some(&0x2C) { + j += 1; + match digits(j) { + Some((max, end)) => { + j = end; + max + } + None => u64::MAX, + } + } else { + min + }; + (pattern.get(j) == Some(&0x7D)).then_some((min, max, j + 1)) +} + +/// A class atom: one unit, or the ranges of a class escape. +enum ClassAtom { + Unit(u16), + Ranges(Vec<(u16, u16)>), +} + +/// The class atom at `i` and the position after it. +fn class_atom(pattern: &[u16], i: usize) -> Result<(ClassAtom, usize), CompileFail> { + let unit = *pattern.get(i).ok_or("unterminated character class")?; + if unit != 0x5C { + return Ok((ClassAtom::Unit(unit), i + 1)); + } + let next = *pattern.get(i + 1).ok_or("\\ at end of pattern")?; + if let Some(r) = escape_ranges(next) { + return Ok((ClassAtom::Ranges(r), i + 2)); + } + let unit = |u: u16, end: usize| Ok((ClassAtom::Unit(u), end)); + match next { + 0x62 => unit(0x08, i + 2), + 0x66 => unit(0x0C, i + 2), + 0x6E => unit(0x0A, i + 2), + 0x72 => unit(0x0D, i + 2), + 0x74 => unit(0x09, i + 2), + 0x76 => unit(0x0B, i + 2), + 0x78 => hex_digits(pattern, i + 2, 2).map_or(unit(next, i + 2), |v| unit(v, i + 4)), + 0x75 => hex_digits(pattern, i + 2, 4).map_or(unit(next, i + 2), |v| unit(v, i + 6)), + 0x63 => match pattern.get(i + 2) { + Some(&l) if matches!(l, 0x30..=0x39 | 0x41..=0x5A | 0x61..=0x7A | 0x5F) => unit(l % 32, i + 3), + _ => unit(0x5C, i + 1), + }, + 0x30..=0x37 => { + let (v, end) = octal(pattern, i + 1); + unit(v, end) + } + _ => unit(next, i + 2), + } +} + +/// The character class whose `[` is at `i`, and the position after its `]`. +fn class(pattern: &[u16], mut i: usize, room: usize) -> Result<(CharSet, usize), CompileFail> { + i += 1; + let negated = pattern.get(i) == Some(&0x5E); + if negated { + i += 1; + } + let mut ranges = Vec::new(); + loop { + // The ranges and the copy `normalize` makes of them stay within `room`. + if ranges.len().saturating_mul(2 * size_of::<(u16, u16)>()) > room { + return Err(CompileFail::Abrupt(Abrupt::Limit(LimitKind::Heap))); + } + let u = *pattern.get(i).ok_or("unterminated character class")?; + if u == 0x5D { + return Ok((CharSet { ranges: normalize(ranges), negated }, i + 1)); + } + let (atom, next) = class_atom(pattern, i)?; + i = next; + let dash = pattern.get(i) == Some(&0x2D) && pattern.get(i + 1).is_some_and(|&u| u != 0x5D); + if dash { + let (high, after) = class_atom(pattern, i + 1)?; + match (atom, high) { + (ClassAtom::Unit(lo), ClassAtom::Unit(hi)) => { + if lo > hi { + return Err("range out of order in character class".into()); + } + ranges.push((lo, hi)); + } + // Annex B: a range with a class escape at one end is its atoms and a `-`. + (lo, hi) => { + for a in [lo, ClassAtom::Unit(0x2D), hi] { + match a { + ClassAtom::Unit(u) => ranges.push((u, u)), + ClassAtom::Ranges(r) => ranges.extend(r), + } + } + } + } + i = after; + } else { + match atom { + ClassAtom::Unit(u) => ranges.push((u, u)), + ClassAtom::Ranges(r) => ranges.extend(r), + } + } + } +} + +/// The bytes a character class holds. +fn set_bytes(set: &CharSet) -> usize { + 32 + size_of::<(u16, u16)>() * set.ranges.len() +} + +/// The bytes compiling holds: each instruction as an item and in the flattened code, each +/// fragment with its item, and the classes. +pub(crate) fn compile_bytes(instructions: usize, fragments: usize, sets_size: usize) -> usize { + instructions + .saturating_mul(size_of::<Item>() + size_of::<Inst>()) + .saturating_add(fragments.saturating_mul(size_of::<Item>() + 32)) + .saturating_add(sets_size) +} + +/// Compiles `pattern`. Groups may nest `max_depth` deep; `charge` takes fuel for the work done. +/// Compiling stops with the heap limit when what it holds (instructions, fragments and classes +/// made so far, with the two instructions each `|` adds) would pass `room` bytes. +pub(crate) fn compile( + pattern: &[u16], + flags: Flags, + max_depth: usize, + room: usize, + charge: &mut dyn FnMut(u64) -> JsResult<()>, +) -> Result<Regex, CompileFail> { + let total_groups = count_groups(pattern); + let mut sets: Vec<CharSet> = Vec::new(); + let mut sets_size = 0usize; + let mut stack: Vec<Ctx> = vec![Ctx::new(Kind::Top, (0, 0))]; + let mut frags = Frags::default(); + let mut groups: u32 = 0; + let mut loops: u32 = 0; + let mut i = 0; + let mut work: u64 = 0; + // The instructions made so far, counting the `Split` and `Jump` each `|` adds when it is joined. + let mut emitted = 0usize; + // The fragments the joins of the groups still open will add, one for each `|` read in them. + let mut joined = 0usize; + let canon = |u: u16| if flags.ignore_case { canonicalize(u) } else { u }; + while let Some(&u) = pattern.get(i) { + work += 1; + if work >= FUEL_BATCH { + charge(work).map_err(CompileFail::Abrupt)?; + work = 0; + } + if compile_bytes(emitted, frags.items.len() + joined, sets_size) > room { + return Err(CompileFail::Abrupt(Abrupt::Limit(LimitKind::Heap))); + } + let ctx = stack.last_mut().ok_or("unmatched ')'")?; + let start = ctx.cur.len(); + let before = groups; + match u { + 0x5E | 0x24 => { + emit(&mut emitted, ctx, if u == 0x5E { Inst::LineStart } else { Inst::LineEnd }); + ctx.atom = None; + i += 1; + } + 0x7C => { + emitted += 2; + joined += 1; + let cur = std::mem::take(&mut ctx.cur); + ctx.alts.push(cur); + ctx.atom = None; + i += 1; + } + 0x2E => { + emit(&mut emitted, ctx, Inst::Any); + ctx.atom = Some((start, before)); + i += 1; + } + 0x5B => { + let (set, next) = class(pattern, i, room.saturating_sub(compile_bytes(emitted, frags.items.len() + joined, sets_size)))?; + emit(&mut emitted, ctx, Inst::Set(u32::try_from(sets.len()).map_err(|_| "too many classes")?)); + sets_size = sets_size.saturating_add(set_bytes(&set)); + sets.push(set); + ctx.atom = Some((start, before)); + i = next; + } + 0x28 => { + let kind = match (pattern.get(i + 1), pattern.get(i + 2)) { + (Some(0x3F), Some(0x3A)) => Kind::Group, + (Some(0x3F), Some(0x3D)) => Kind::Look(false), + (Some(0x3F), Some(0x21)) => Kind::Look(true), + (Some(0x3F), _) => return Err("invalid group".into()), + _ => { + groups += 1; + Kind::Capture(groups) + } + }; + i += if matches!(kind, Kind::Capture(_)) { 1 } else { 3 }; + if stack.len() > max_depth { + return Err(crate::error::TOO_DEEP.into()); + } + stack.push(Ctx::new(kind, (start, before))); + } + 0x29 => { + if stack.len() <= 1 { + return Err("unmatched ')'".into()); + } + let Some(mut done) = stack.pop() else { return Err("unmatched ')'".into()) }; + let cur = std::mem::take(&mut done.cur); + // The fragments counted for this group's `|`s become real ones in the join. + joined = joined.saturating_sub(done.alts.len()); + done.alts.push(cur); + let body = join(std::mem::take(&mut done.alts), &mut frags)?; + work += body.len() as u64; + let body_len = frags.len_of(&body); + let body = frags.add(body); + let (start, before) = done.opened_at; + let parent = stack.last_mut().ok_or("unmatched ')'")?; + match done.kind { + Kind::Capture(n) => { + emit(&mut emitted, parent, Inst::GroupStart(n)); + parent.cur.push(body); + emit(&mut emitted, parent, Inst::GroupEnd(n)); + } + Kind::Group | Kind::Top => parent.cur.push(body), + Kind::Look(negative) => { + let end = offset(body_len + 2)?; + emit(&mut emitted, parent, Inst::Look { negative, end }); + parent.cur.push(body); + emit(&mut emitted, parent, Inst::LookEnd); + } + } + parent.atom = Some((start, before)); + i += 1; + } + 0x2A | 0x2B | 0x3F | 0x7B => { + let (min, max, next) = match u { + 0x2A => (0, u64::MAX, i + 1), + 0x2B => (1, u64::MAX, i + 1), + 0x3F => (0, 1, i + 1), + _ => match braces(pattern, i) { + Some(b) => b, + None => { + // Annex B: a `{` that starts no quantifier is itself. + emit(&mut emitted, ctx, Inst::Char(canon(u))); + ctx.atom = Some((start, before)); + i += 1; + continue; + } + }, + }; + if max < min { + return Err("numbers out of order in {} quantifier".into()); + } + let Some((atom_start, atom_groups)) = ctx.atom.take() else { return Err("nothing to repeat".into()) }; + let greedy = pattern.get(next) != Some(&0x3F); + i = if greedy { next } else { next + 1 }; + if min == 1 && max == 1 { + continue; + } + let body: Vec<Item> = ctx.cur.drain(atom_start..).collect(); + work += body.len() as u64; + let reg = loops; + loops = loops.checked_add(1).ok_or("too many quantifiers")?; + let n = frags.len_of(&body); + let body = frags.add(body); + emit(&mut emitted, ctx, Inst::RepInit(reg)); + emit(&mut emitted, ctx, Inst::Rep { reg, min, max, greedy, caps: (atom_groups + 1, groups + 1), exit: offset(n + 2)? }); + ctx.cur.push(body); + emit(&mut emitted, ctx, Inst::RepEnd { reg, head: -offset(n + 1)? }); + } + 0x5C => { + let next = *pattern.get(i + 1).ok_or("\\ at end of pattern")?; + let mut atom = true; + match next { + 0x62 | 0x42 => { + emit(&mut emitted, ctx, Inst::WordBoundary(next == 0x62)); + atom = false; + i += 2; + } + 0x31..=0x39 => { + let mut j = i + 1; + let mut n: u64 = 0; + while let Some(d) = pattern.get(j).filter(|&&u| (0x30..=0x39).contains(&u)) { + n = n.saturating_mul(10).saturating_add(u64::from(d - 0x30)); + j += 1; + } + if n <= total_groups as u64 { + emit(&mut emitted, ctx, Inst::BackRef(u32::try_from(n).map_err(|_| "too many groups")?)); + i = j; + } else if next <= 0x37 { + let (v, end) = octal(pattern, i + 1); + emit(&mut emitted, ctx, Inst::Char(canon(v))); + i = end; + } else { + emit(&mut emitted, ctx, Inst::Char(canon(next))); + i += 2; + } + } + 0x30 => { + if pattern.get(i + 2).is_some_and(|&d| (0x30..=0x37).contains(&d)) { + let (v, end) = octal(pattern, i + 1); + emit(&mut emitted, ctx, Inst::Char(canon(v))); + i = end; + } else { + emit(&mut emitted, ctx, Inst::Char(0)); + i += 2; + } + } + _ => { + if let Some(r) = escape_ranges(next) { + emit(&mut emitted, ctx, Inst::Set(u32::try_from(sets.len()).map_err(|_| "too many classes")?)); + let set = CharSet { ranges: r, negated: false }; + sets_size = sets_size.saturating_add(set_bytes(&set)); + sets.push(set); + i += 2; + } else { + let (atom_unit, end) = match class_atom(pattern, i)? { + (ClassAtom::Unit(v), end) => (v, end), + (ClassAtom::Ranges(_), end) => (next, end), + }; + emit(&mut emitted, ctx, Inst::Char(canon(atom_unit))); + i = end; + } + } + } + ctx.atom = if atom { Some((start, before)) } else { None }; + } + _ => { + emit(&mut emitted, ctx, Inst::Char(canon(u))); + ctx.atom = Some((start, before)); + i += 1; + } + } + } + if stack.len() != 1 { + return Err("unterminated group".into()); + } + let Some(mut top) = stack.pop() else { return Err("unterminated group".into()) }; + let cur = std::mem::take(&mut top.cur); + top.alts.push(cur); + // Joining adds a fragment for each alternative, and flattening writes every instruction. + if compile_bytes(emitted + 1, frags.items.len() + joined + 1, sets_size) > room { + return Err(CompileFail::Abrupt(Abrupt::Limit(LimitKind::Heap))); + } + let top = join(top.alts, &mut frags)?; + let mut insts = frags.flatten(&top); + insts.push(Inst::Match); + charge(work + insts.len() as u64 / 16).map_err(CompileFail::Abrupt)?; + Ok(Regex { insts, sets, groups: groups as usize, loops: loops as usize, ignore_case: flags.ignore_case, multiline: flags.multiline }) +} + +// ---- matching ---- + +/// An entry on the matcher's stack: a choice point to go back to, or a value to restore when +/// going back past it. +#[derive(Clone, Copy, Debug)] +enum Bt { + Retry { + pc: usize, + pos: usize, + }, + /// Runs another iteration of the quantifier at `pc`. + EnterRep { + pc: usize, + pos: usize, + }, + Cap(usize, usize), + Open(usize, usize), + Count(usize, u64), + Mark(usize, usize), + Look { + pos: usize, + negative: bool, + resume: usize, + }, +} + +impl Bt { + fn is_undo(&self) -> bool { + matches!(self, Bt::Cap(..) | Bt::Open(..) | Bt::Count(..) | Bt::Mark(..)) + } +} + +/// Takes `n` units of fuel, checking the interrupt flag. +pub(crate) fn use_fuel_n(interp: &mut Interp, n: u64) -> JsResult<()> { + if n == 0 { + return Ok(()); + } + if interp.fuel < n { + interp.fuel = 0; + return Err(Abrupt::Limit(LimitKind::Fuel)); + } + interp.fuel -= n - 1; + interp.use_fuel() +} + +/// The state of one match attempt, kept between attempts to reuse its buffers. +pub(crate) struct Matcher<'r> { + re: &'r Regex, + /// Two positions per group, group 0 first: [`NONE`] where a group did not take part. + pub(crate) caps: Vec<usize>, + open: Vec<usize>, + counts: Vec<u64>, + marks: Vec<usize>, + stack: Vec<Bt>, + max_stack: usize, + steps: u64, +} + +impl<'r> Matcher<'r> { + /// A matcher for `re`. Its capture and quantifier state, and its stack, fit in the heap room + /// left under the limit; a run without room for the state ends with the heap limit. When the + /// room would not hold the state and 64 stack entries, a collection runs first, so everything + /// the caller still needs must be reachable from the roots. + pub(crate) fn new(re: &'r Regex, interp: &mut Interp) -> JsResult<Self> { + let state = 8usize.saturating_mul(re.groups.saturating_add(1).saturating_mul(3).saturating_add(re.loops.saturating_mul(2))); + let wanted = state.saturating_add(64 * size_of::<Bt>()); + if interp.heap.bytes.saturating_add(wanted) > interp.limits.heap_bytes { + interp.collect_for_room(); + } + let room = interp.limits.heap_bytes.saturating_sub(interp.heap.bytes); + if state > room { + return Err(Abrupt::Limit(LimitKind::Heap)); + } + Ok(Self { + re, + caps: vec![NONE; 2 * (re.groups + 1)], + open: vec![NONE; re.groups + 1], + counts: vec![0; re.loops], + marks: vec![NONE; re.loops], + stack: Vec::new(), + max_stack: (room - state) / size_of::<Bt>(), + steps: 0, + }) + } + + fn push(&mut self, b: Bt) -> JsResult<()> { + if self.stack.len() >= self.max_stack { + return Err(Abrupt::Limit(LimitKind::Heap)); + } + self.stack.push(b); + Ok(()) + } + + fn step(&mut self, interp: &mut Interp) -> JsResult<()> { + self.steps += 1; + if self.steps >= FUEL_BATCH { + let n = std::mem::take(&mut self.steps); + use_fuel_n(interp, n)?; + } + Ok(()) + } + + /// Takes the fuel for the steps not yet charged. + fn settle(&mut self, interp: &mut Interp) -> JsResult<()> { + let n = std::mem::take(&mut self.steps); + use_fuel_n(interp, n) + } + + fn set_cap(&mut self, slot: usize, v: usize) -> JsResult<()> { + let Some(old) = self.caps.get(slot).copied() else { return Ok(()) }; + if old != v { + self.push(Bt::Cap(slot, old))?; + if let Some(c) = self.caps.get_mut(slot) { + *c = v; + } + } + Ok(()) + } + + fn enter_rep(&mut self, pc: usize, pos: usize) -> JsResult<()> { + let Some(Inst::Rep { reg, caps: (lo, hi), .. }) = self.re.insts.get(pc).copied() else { return Ok(()) }; + let reg = reg as usize; + let old = self.marks.get(reg).copied().unwrap_or(NONE); + self.push(Bt::Mark(reg, old))?; + if let Some(m) = self.marks.get_mut(reg) { + *m = pos; + } + for g in lo as usize..hi as usize { + self.set_cap(2 * g, NONE)?; + self.set_cap(2 * g + 1, NONE)?; + } + Ok(()) + } + + /// Restores the value an undo entry saved; other entries are ignored. + fn undo(&mut self, b: Bt) { + let (slot, old) = match b { + Bt::Cap(s, old) => (self.caps.get_mut(s), old), + Bt::Open(g, old) => (self.open.get_mut(g), old), + Bt::Mark(r, old) => (self.marks.get_mut(r), old), + Bt::Count(r, old) => { + if let Some(c) = self.counts.get_mut(r) { + *c = old; + } + return; + } + _ => return, + }; + if let Some(slot) = slot { + *slot = old; + } + } + + /// Goes back to the last choice point, restoring what was changed since. False when there is + /// none left. + fn backtrack(&mut self, interp: &mut Interp, pc: &mut usize, pos: &mut usize) -> JsResult<bool> { + while let Some(b) = self.stack.pop() { + self.step(interp)?; + match b { + Bt::Retry { pc: p, pos: q } => { + (*pc, *pos) = (p, q); + return Ok(true); + } + Bt::EnterRep { pc: p, pos: q } => { + *pos = q; + self.enter_rep(p, q)?; + *pc = p + 1; + return Ok(true); + } + Bt::Cap(..) | Bt::Open(..) | Bt::Count(..) | Bt::Mark(..) => self.undo(b), + // A negative lookahead whose pattern failed succeeds. + Bt::Look { pos: q, negative: true, resume } => { + (*pc, *pos) = (resume, q); + return Ok(true); + } + Bt::Look { negative: false, .. } => {} + } + } + Ok(false) + } + + /// Tries to match at `start` only. On success `caps` holds the match. + pub(crate) fn match_at(&mut self, interp: &mut Interp, input: &[u16], start: usize) -> JsResult<bool> { + let r = self.run(interp, input, start); + self.settle(interp)?; + r + } + + /// The first match at `from` or after. + pub(crate) fn search(&mut self, interp: &mut Interp, input: &[u16], from: usize) -> JsResult<bool> { + let mut start = from; + while start <= input.len() { + if self.match_at(interp, input, start)? { + return Ok(true); + } + start += 1; + } + Ok(false) + } + + fn reset(&mut self) { + self.caps.fill(NONE); + self.open.fill(NONE); + self.counts.fill(0); + self.marks.fill(NONE); + self.stack.clear(); + } + + fn run(&mut self, interp: &mut Interp, input: &[u16], start: usize) -> JsResult<bool> { + self.reset(); + let re = self.re; + let mut pc = 0usize; + let mut pos = start; + let at = |pc: usize, off: i32| -> usize { pc.wrapping_add_signed(off as isize) }; + loop { + self.step(interp)?; + let Some(inst) = re.insts.get(pc).copied() else { return Err(Abrupt::Internal("regular expression jumped out of its code".into())) }; + let ok = match inst { + Inst::Char(c) => { + let hit = input.get(pos).is_some_and(|&u| re.canon(u) == c); + if hit { + pos += 1; + pc += 1; + } + hit + } + Inst::Any => { + let hit = input.get(pos).is_some_and(|&u| !is_line_terminator(u)); + if hit { + pos += 1; + pc += 1; + } + hit + } + Inst::Set(k) => { + let hit = match (input.get(pos), re.sets.get(k as usize)) { + (Some(&u), Some(set)) => re.set_matches(set, u), + _ => false, + }; + if hit { + pos += 1; + pc += 1; + } + hit + } + Inst::LineStart => { + let hit = pos == 0 || (re.multiline && input.get(pos - 1).is_some_and(|&u| is_line_terminator(u))); + pc += 1; + hit + } + Inst::LineEnd => { + let hit = pos == input.len() || (re.multiline && input.get(pos).is_some_and(|&u| is_line_terminator(u))); + pc += 1; + hit + } + Inst::WordBoundary(want) => { + let a = pos > 0 && input.get(pos - 1).is_some_and(|&u| is_word_char(u)); + let b = input.get(pos).is_some_and(|&u| is_word_char(u)); + pc += 1; + (a != b) == want + } + Inst::Split(first, second) => { + self.push(Bt::Retry { pc: at(pc, second), pos })?; + pc = at(pc, first); + true + } + Inst::Jump(off) => { + pc = at(pc, off); + true + } + Inst::GroupStart(g) => { + let g = g as usize; + let old = self.open.get(g).copied().unwrap_or(NONE); + self.push(Bt::Open(g, old))?; + if let Some(o) = self.open.get_mut(g) { + *o = pos; + } + pc += 1; + true + } + Inst::GroupEnd(g) => { + let g = g as usize; + let s = self.open.get(g).copied().unwrap_or(NONE); + self.set_cap(2 * g, s)?; + self.set_cap(2 * g + 1, pos)?; + pc += 1; + true + } + Inst::RepInit(r) => { + let r = r as usize; + let old = self.counts.get(r).copied().unwrap_or(0); + self.push(Bt::Count(r, old))?; + if let Some(c) = self.counts.get_mut(r) { + *c = 0; + } + pc += 1; + true + } + Inst::Rep { reg, min, max, greedy, exit, .. } => { + let count = self.counts.get(reg as usize).copied().unwrap_or(0); + let exit_pc = at(pc, exit); + if count >= max { + pc = exit_pc; + } else if count < min { + self.enter_rep(pc, pos)?; + pc += 1; + } else if greedy { + self.push(Bt::Retry { pc: exit_pc, pos })?; + self.enter_rep(pc, pos)?; + pc += 1; + } else { + self.push(Bt::EnterRep { pc, pos })?; + pc = exit_pc; + } + true + } + Inst::RepEnd { reg, head } => { + let head_pc = at(pc, head); + let min = match re.insts.get(head_pc) { + Some(Inst::Rep { min, .. }) => *min, + _ => return Err(Abrupt::Internal("quantifier end without its head".into())), + }; + let r = reg as usize; + let count = self.counts.get(r).copied().unwrap_or(0); + // An iteration past the minimum that matched nothing fails (section + // 15.10.2.5, RepeatMatcher step 2.a). + if count >= min && self.marks.get(r).copied() == Some(pos) { + false + } else { + self.push(Bt::Count(r, count))?; + if let Some(c) = self.counts.get_mut(r) { + *c = count + 1; + } + pc = head_pc; + true + } + } + Inst::Look { negative, end } => { + self.push(Bt::Look { pos, negative, resume: at(pc, end) })?; + pc += 1; + true + } + Inst::LookEnd => { + let Some(b) = self.stack.iter().rposition(|e| matches!(e, Bt::Look { .. })) else { + return Err(Abrupt::Internal("lookahead end without its start".into())); + }; + let Some(Bt::Look { pos: q, negative, resume }) = self.stack.get(b).copied() else { + return Err(Abrupt::Internal("lookahead end without its start".into())); + }; + if negative { + // The pattern matched, so the negative lookahead fails: restore what it + // changed, drop its choice points and go back past it. + while self.stack.len() > b + 1 { + if let Some(e) = self.stack.pop() { + self.undo(e); + } + } + self.stack.truncate(b); + false + } else { + // A lookahead is atomic: its choice points are dropped, and what it set is + // kept, with the entries that restore it. + let kept: Vec<Bt> = self.stack.drain(b..).skip(1).filter(Bt::is_undo).collect(); + self.stack.extend(kept); + pos = q; + pc = resume; + true + } + } + Inst::BackRef(g) => { + let g = g as usize; + let (s, e) = (self.caps.get(2 * g).copied().unwrap_or(NONE), self.caps.get(2 * g + 1).copied().unwrap_or(NONE)); + pc += 1; + if s == NONE || e == NONE || e < s { + true + } else { + let len = e - s; + let hit = pos + len <= input.len() + && (0..len).all(|k| match (input.get(s + k), input.get(pos + k)) { + (Some(&a), Some(&b)) => re.canon(a) == re.canon(b), + _ => false, + }); + self.steps += len as u64 / 16; + if hit { + pos += len; + } + hit + } + } + Inst::Match => { + self.set_cap(0, start)?; + self.set_cap(1, pos)?; + return Ok(true); + } + }; + if !ok && !self.backtrack(interp, &mut pc, &mut pos)? { + return Ok(false); + } + } + } +} diff --git a/crates/js/src/string_builtins.rs b/crates/js/src/string_builtins.rs new file mode 100644 index 000000000..7d9f56a25 --- /dev/null +++ b/crates/js/src/string_builtins.rs @@ -0,0 +1,699 @@ +//! `String` (ECMA-262 5.1 section 15.5): the constructor, `String.fromCharCode` and the methods of +//! `String.prototype`, with `substr` from Annex B. +//! +//! The methods read a string's code units once (flattening a rope), take a unit of fuel for every +//! [`UNITS_PER_FUEL`] code units they read or write, and reserve each new string's bytes on the +//! heap before building it, so a method that would make a string past the heap limit ends the +//! run instead of allocating it. + +use std::rc::Rc; + +use crate::LimitKind; +use crate::builtins::{arg, constructor, method, native}; +use crate::heap::{Class, NativeKind, OPEN, ObjId, Object, PROP_BYTES, Prop, string_bytes}; +use crate::interp::{Abrupt, Interp, JsResult}; +use crate::regexp_builtins::{exec_raw, new_regexp_from, regexp_of}; +use crate::regexp_vm::{Matcher, NONE, use_fuel_n}; +use crate::value::{Str, Value, index_key, is_str_space, to_uint32}; + +/// Code units a unit of fuel pays for when a built-in reads or writes text. +pub(crate) const UNITS_PER_FUEL: usize = 16; + +pub(crate) fn install(interp: &mut Interp) -> JsResult<()> { + let proto = interp.intrinsics.string_proto; + let ctor = native(interp, "String", string_ctor, 1, NativeKind::Plain, true)?; + constructor(interp, "String", ctor, proto)?; + method(interp, ctor, "fromCharCode", from_char_code, 1)?; + let methods: [(&'static str, crate::heap::NativeFn, u32); 20] = [ + ("toString", to_string, 0), + ("valueOf", to_string, 0), + ("charAt", char_at, 1), + ("charCodeAt", char_code_at, 1), + ("concat", concat, 1), + ("indexOf", index_of, 1), + ("lastIndexOf", last_index_of, 1), + ("localeCompare", locale_compare, 1), + ("match", match_, 1), + ("replace", replace, 2), + ("search", search, 1), + ("slice", slice, 2), + ("split", split, 2), + ("substring", substring, 2), + ("substr", substr, 2), + ("toLowerCase", to_lower_case, 0), + ("toLocaleLowerCase", to_lower_case, 0), + ("toUpperCase", to_upper_case, 0), + ("toLocaleUpperCase", to_upper_case, 0), + ("trim", trim, 0), + ]; + for (name, f, len) in methods { + method(interp, proto, name, f, len)?; + } + Ok(()) +} + +// ---- helpers the other text built-ins share ---- + +/// ToInteger (section 9.4) applied to a number. +pub(crate) fn to_integer(n: f64) -> f64 { + if n.is_nan() { + 0.0 + } else if n.is_infinite() { + n + } else { + n.trunc() + 0.0 + } +} + +/// Takes the fuel for reading or writing `n` code units. +pub(crate) fn charge_units(interp: &mut Interp, n: usize) -> JsResult<()> { + use_fuel_n(interp, (n / UNITS_PER_FUEL) as u64 + 1) +} + +/// Reserves the heap bytes of a new string of `len` code units before it is built, ending the +/// run with the heap limit when it would not fit. Everything the caller still needs must be +/// reachable from the roots. +pub(crate) fn reserve_units(interp: &mut Interp, len: usize) -> JsResult<()> { + if len > interp.limits.heap_bytes / 4 { + return Err(Abrupt::Limit(LimitKind::Heap)); + } + let bytes = string_bytes(len); + interp.reserve_heap(bytes)?; + interp.heap.bytes = interp.heap.bytes.saturating_add(bytes); + charge_units(interp, len) +} + +/// A new string holding `units[start..end]`. +pub(crate) fn substring_value(interp: &mut Interp, units: &[u16], start: usize, end: usize) -> JsResult<Value> { + let part = units.get(start..end.max(start)).unwrap_or_default(); + reserve_units(interp, part.len())?; + Ok(Value::String(Str::from_units(part.to_vec()))) +} + +/// Runs `f`, then drops the temporary roots it added. +pub(crate) fn rooted<T>(interp: &mut Interp, f: impl FnOnce(&mut Interp) -> JsResult<T>) -> JsResult<T> { + let mark = interp.temp_roots.len(); + let r = f(interp); + interp.temp_roots.truncate(mark); + r +} + +/// A new, empty array, kept in the temporary roots: call it inside [`rooted`]. +pub(crate) fn new_array(interp: &mut Interp) -> ObjId { + let proto = interp.intrinsics.array_proto; + let a = interp.heap.alloc_object(Object::new(Class::Array { length: 0, length_writable: true }, Some(proto))); + interp.temp_roots.push(Value::Object(a)); + a +} + +/// Sets element `i` of an array made by [`new_array`], reserving its bytes first. +pub(crate) fn set_element(interp: &mut Interp, a: ObjId, i: usize, v: Value) -> JsResult<()> { + let i = u32::try_from(i).map_err(|_| Abrupt::Limit(LimitKind::Heap))?; + interp.temp_roots.push(v.clone()); + let reserved = interp.reserve_heap(PROP_BYTES + string_bytes(10)); + interp.temp_roots.pop(); + reserved?; + interp.use_fuel()?; + interp.define(a, index_key(i), Prop::Data { value: v, flags: OPEN }) +} + +/// CheckObjectCoercible on `this`, then ToString. +pub(crate) fn this_str(interp: &mut Interp, this: &Value, method: &str) -> JsResult<Str> { + if this.is_nullish() { + let message = format!("String.prototype.{method} was called on null or undefined"); + return Err(interp.type_error(&message)); + } + interp.to_str(this) +} + +/// ToInteger of an argument. +fn int_arg(interp: &mut Interp, args: &[Value], i: usize) -> JsResult<f64> { + let n = interp.to_number(&arg(args, i))?; + Ok(to_integer(n)) +} + +/// Clamps an integer to `0..=len`. +fn clamp(n: f64, len: usize) -> usize { + if n <= 0.0 { + 0 + } else if n >= len as f64 { + len + } else { + n as usize + } +} + +/// The first index at `from` or after where `needle` occurs in `hay`. Every position tried takes +/// fuel. +fn find(interp: &mut Interp, hay: &[u16], needle: &[u16], from: usize) -> JsResult<Option<usize>> { + if needle.len() > hay.len() { + return Ok(None); + } + let last = hay.len() - needle.len(); + let per = (needle.len() / UNITS_PER_FUEL) as u64 + 1; + let mut steps = 0u64; + let mut i = from; + let mut found = None; + while i <= last { + steps += per; + if steps >= 1024 { + use_fuel_n(interp, std::mem::take(&mut steps))?; + } + if hay.get(i..i + needle.len()) == Some(needle) { + found = Some(i); + break; + } + i += 1; + } + use_fuel_n(interp, steps)?; + Ok(found) +} + +// ---- the constructor ---- + +/// `String(value)` and `new String(value)` (sections 15.5.1 and 15.5.2). +fn string_ctor(interp: &mut Interp, _: Value, args: &[Value], construct: bool) -> JsResult<Value> { + let s = match args.first() { + Some(v) => interp.to_str(v)?, + None => Str::from(""), + }; + if !construct { + return Ok(Value::String(s)); + } + let proto = interp.intrinsics.string_proto; + Ok(Value::Object(interp.heap.alloc_object(Object::new(Class::String(s), Some(proto))))) +} + +/// `String.fromCharCode(…)` (section 15.5.3.2). +fn from_char_code(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> { + let mut units = Vec::with_capacity(args.len()); + for a in args { + let n = interp.to_number(a)?; + units.push(to_uint32(n) as u16); + } + reserve_units(interp, units.len())?; + Ok(Value::String(Str::from_units(units))) +} + +// ---- String.prototype ---- + +/// `toString` and `valueOf` (sections 15.5.4.2 and 15.5.4.3). +fn to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> { + match &this { + Value::String(_) => Ok(this), + Value::Object(o) => match interp.heap.object(*o).map(|o| &o.class) { + Some(Class::String(s)) => Ok(Value::String(s.clone())), + _ => Err(interp.type_error("String.prototype.toString was called on a value that is not a string")), + }, + _ => Err(interp.type_error("String.prototype.toString was called on a value that is not a string")), + } +} + +fn char_at(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> { + let s = this_str(interp, &this, "charAt")?; + let pos = int_arg(interp, args, 0)?; + if pos < 0.0 || pos >= s.len() as f64 { + return Ok(Value::str("")); + } + let unit = s.flat().get(pos as usize).copied().unwrap_or(0); + Ok(Value::String(Str::from_units(vec![unit]))) +} + +fn char_code_at(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> { + let s = this_str(interp, &this, "charCodeAt")?; + let pos = int_arg(interp, args, 0)?; + if pos < 0.0 || pos >= s.len() as f64 { + return Ok(Value::Number(f64::NAN)); + } + Ok(Value::Number(f64::from(s.flat().get(pos as usize).copied().unwrap_or(0)))) +} + +fn concat(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> { + let mut s = this_str(interp, &this, "concat")?; + for a in args { + let r = interp.to_str(a)?; + s = interp.concat(&s, &r)?; + } + Ok(Value::String(s)) +} + +fn index_of(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> { + let s = this_str(interp, &this, "indexOf")?; + let search = interp.to_str(&arg(args, 0))?; + let pos = int_arg(interp, args, 1)?; + let (hay, needle) = (s.flat(), search.flat()); + let start = clamp(pos, hay.len()); + let found = find(interp, &hay, &needle, start)?; + Ok(Value::Number(found.map_or(-1.0, |i| i as f64))) +} + +fn last_index_of(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> { + let s = this_str(interp, &this, "lastIndexOf")?; + let search = interp.to_str(&arg(args, 0))?; + let n = interp.to_number(&arg(args, 1))?; + let pos = if n.is_nan() { f64::INFINITY } else { to_integer(n) }; + let (hay, needle) = (s.flat(), search.flat()); + let start = clamp(pos, hay.len()); + if needle.len() > hay.len() { + return Ok(Value::Number(-1.0)); + } + let mut k = start.min(hay.len() - needle.len()); + let per = (needle.len() / UNITS_PER_FUEL) as u64 + 1; + loop { + use_fuel_n(interp, per)?; + if hay.get(k..k + needle.len()) == Some(&needle[..]) { + return Ok(Value::Number(k as f64)); + } + if k == 0 { + return Ok(Value::Number(-1.0)); + } + k -= 1; + } +} + +/// `localeCompare` (section 15.5.4.9): code unit order. +fn locale_compare(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> { + let s = this_str(interp, &this, "localeCompare")?; + let that = interp.to_str(&arg(args, 0))?; + let (a, b) = (s.flat(), that.flat()); + charge_units(interp, a.len().min(b.len()))?; + Ok(Value::Number(match a.cmp(&b) { + std::cmp::Ordering::Less => -1.0, + std::cmp::Ordering::Equal => 0.0, + std::cmp::Ordering::Greater => 1.0, + })) +} + +fn slice(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> { + let s = this_str(interp, &this, "slice")?; + let len = s.len() as f64; + let start = int_arg(interp, args, 0)?; + let end = if matches!(arg(args, 1), Value::Undefined) { len } else { int_arg(interp, args, 1)? }; + let from = if start < 0.0 { (len + start).max(0.0) } else { start.min(len) }; + let to = if end < 0.0 { (len + end).max(0.0) } else { end.min(len) }; + if to <= from { + return Ok(Value::str("")); + } + substring_value(interp, &s.flat(), from as usize, to as usize) +} + +fn substring(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> { + let s = this_str(interp, &this, "substring")?; + let len = s.len(); + let start = int_arg(interp, args, 0)?; + let end = if matches!(arg(args, 1), Value::Undefined) { len as f64 } else { int_arg(interp, args, 1)? }; + let (a, b) = (clamp(start, len), clamp(end, len)); + substring_value(interp, &s.flat(), a.min(b), a.max(b)) +} + +/// `substr` (Annex B.2.3). Its first step applies ToString to `this` with no object-coercible +/// check, so `null` and `undefined` give substrings of `"null"` and `"undefined"`. +fn substr(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> { + let s = interp.to_str(&this)?; + let len = s.len() as f64; + let start = int_arg(interp, args, 0)?; + let length = if matches!(arg(args, 1), Value::Undefined) { f64::INFINITY } else { int_arg(interp, args, 1)? }; + let start = if start >= 0.0 { start } else { (len + start).max(0.0) }; + let count = length.max(0.0).min(len - start); + if count <= 0.0 { + return Ok(Value::str("")); + } + substring_value(interp, &s.flat(), start as usize, (start + count) as usize) +} + +/// The UTF-16 length of `c` mapped to lower or upper case. +fn mapped_len(c: char, upper: bool) -> usize { + if upper { c.to_uppercase().map(char::len_utf16).sum() } else { c.to_lowercase().map(char::len_utf16).sum() } +} + +/// Maps the characters of `units` to lower or upper case (Unicode's full mappings, final sigma +/// included), passing lone surrogates through. The result's length is reserved before it is built. +fn map_case(interp: &mut Interp, units: &[u16], upper: bool) -> JsResult<Vec<u16>> { + let len: usize = char::decode_utf16(units.iter().copied()).map(|r| r.map_or(1, |c| mapped_len(c, upper))).sum(); + reserve_units(interp, len)?; + let mut out = Vec::with_capacity(len); + let mut run = String::new(); + let flush = |run: &mut String, out: &mut Vec<u16>| { + let mapped = if upper { run.to_uppercase() } else { run.to_lowercase() }; + out.extend(mapped.encode_utf16()); + run.clear(); + }; + for r in char::decode_utf16(units.iter().copied()) { + match r { + Ok(c) => run.push(c), + Err(e) => { + flush(&mut run, &mut out); + out.push(e.unpaired_surrogate()); + } + } + } + flush(&mut run, &mut out); + Ok(out) +} + +fn to_lower_case(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> { + let s = this_str(interp, &this, "toLowerCase")?; + let out = map_case(interp, &s.flat(), false)?; + Ok(Value::String(Str::from_units(out))) +} + +fn to_upper_case(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> { + let s = this_str(interp, &this, "toUpperCase")?; + let out = map_case(interp, &s.flat(), true)?; + Ok(Value::String(Str::from_units(out))) +} + +fn trim(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> { + let s = this_str(interp, &this, "trim")?; + let units = s.flat(); + // The scan reads every unit once at most. + charge_units(interp, units.len())?; + let start = units.iter().position(|&u| !is_str_space(u)).unwrap_or(units.len()); + let end = units.iter().rposition(|&u| !is_str_space(u)).map_or(start, |i| i + 1); + if start == 0 && end == units.len() { + return Ok(Value::String(s)); + } + substring_value(interp, &units, start, end) +} + +// ---- the methods that take a regular expression ---- + +/// The regular expression argument of `match` and `search`: a RegExp object, or a new one made +/// from the value as `new RegExp(value)` would. +fn regexp_arg(interp: &mut Interp, v: &Value) -> JsResult<ObjId> { + if let Some((o, _)) = regexp_of(interp, v) { + return Ok(o); + } + let o = new_regexp_from(interp, v, &Value::Undefined)?; + interp.temp_roots.push(Value::Object(o)); + Ok(o) +} + +/// `match` (section 15.5.4.10). +fn match_(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> { + rooted(interp, |interp| { + let s = this_str(interp, &this, "match")?; + let rx = regexp_arg(interp, &arg(args, 0))?; + let global = interp.get_value(&Value::Object(rx), &crate::heap::key("global"))?.truthy(); + if !global { + return crate::regexp_builtins::exec_value(interp, rx, &s); + } + let last_index = crate::heap::key("lastIndex"); + interp.put(rx, last_index.clone(), Value::Number(0.0), true)?; + let a = new_array(interp); + let mut n = 0usize; + let mut previous = 0.0; + let units = s.flat(); + while let Some(caps) = exec_raw(interp, rx, &s)? { + let this_index = interp.get_value(&Value::Object(rx), &last_index)?; + let this_index = interp.to_number(&this_index)?; + if this_index == previous { + previous = this_index + 1.0; + interp.put(rx, last_index.clone(), Value::Number(previous), true)?; + } else { + previous = this_index; + } + let m = substring_value(interp, &units, caps.first().copied().unwrap_or(0), caps.get(1).copied().unwrap_or(0))?; + set_element(interp, a, n, m)?; + n += 1; + } + Ok(if n == 0 { Value::Null } else { Value::Object(a) }) + }) +} + +/// `search` (section 15.5.4.12): the index of the first match from the start, ignoring +/// `lastIndex` and `global`. +fn search(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> { + rooted(interp, |interp| { + let s = this_str(interp, &this, "search")?; + let rx = regexp_arg(interp, &arg(args, 0))?; + let Some((_, re)) = regexp_of(interp, &Value::Object(rx)) else { return Err(interp.type_error("not a regular expression")) }; + let units = s.flat(); + let mut m = Matcher::new(&re, interp)?; + let found = m.search(interp, &units, 0)?; + Ok(Value::Number(if found { m.caps.first().copied().unwrap_or(0) as f64 } else { -1.0 })) + }) +} + +/// A piece of a result string: a range of the input, or other text. +enum Piece { + Range(usize, usize), + Text(Str), +} + +impl Piece { + fn len(&self) -> usize { + match self { + Piece::Range(a, b) => b.saturating_sub(*a), + Piece::Text(s) => s.len(), + } + } +} + +/// Joins pieces into a new string, reserving its length first. +fn build(interp: &mut Interp, units: &[u16], pieces: &[Piece]) -> JsResult<Value> { + let total = pieces.iter().map(Piece::len).fold(0usize, usize::saturating_add); + reserve_units(interp, total)?; + let mut out = Vec::with_capacity(total); + for p in pieces { + match p { + Piece::Range(a, b) => out.extend_from_slice(units.get(*a..*b).unwrap_or_default()), + Piece::Text(s) => out.extend_from_slice(&s.flat()), + } + } + Ok(Value::String(Str::from_units(out))) +} + +/// The pieces a replacement pattern (section 15.5.4.11, Table 22) gives for one match: `caps` +/// holds the match and its groups as pairs of positions in `units`. +fn expand(template: &[u16], units: &[u16], caps: &[usize], out: &mut Vec<Piece>) { + let groups = caps.len() / 2 - 1; + let (start, end) = (caps.first().copied().unwrap_or(0), caps.get(1).copied().unwrap_or(0)); + let digit = |i: usize| template.get(i).filter(|&&u| (0x30..=0x39).contains(&u)).map(|&u| usize::from(u - 0x30)); + let group = |n: usize, out: &mut Vec<Piece>| { + let (s, e) = (caps.get(2 * n).copied().unwrap_or(NONE), caps.get(2 * n + 1).copied().unwrap_or(NONE)); + if s != NONE && e != NONE { + out.push(Piece::Range(s, e)); + } + }; + let mut lit = 0; + let mut i = 0; + while let Some(&u) = template.get(i) { + if u != 0x24 { + i += 1; + continue; + } + let flush = |out: &mut Vec<Piece>, to: usize| { + if to > lit { + out.push(Piece::Text(Str::from_units(template.get(lit..to).unwrap_or_default().to_vec()))); + } + }; + let taken = match template.get(i + 1) { + Some(0x24) => { + flush(out, i + 1); + lit = i + 2; + i += 2; + continue; + } + Some(0x26) => { + flush(out, i); + out.push(Piece::Range(start, end)); + 2 + } + Some(0x60) => { + flush(out, i); + out.push(Piece::Range(0, start)); + 2 + } + Some(0x27) => { + flush(out, i); + out.push(Piece::Range(end, units.len())); + 2 + } + _ => match (digit(i + 1), digit(i + 2)) { + (Some(a), Some(b)) if (1..=groups).contains(&(a * 10 + b)) => { + flush(out, i); + group(a * 10 + b, out); + 3 + } + (Some(a), _) if (1..=groups).contains(&a) => { + flush(out, i); + group(a, out); + 2 + } + _ => { + i += 1; + continue; + } + }, + }; + i += taken; + lit = i; + } + if template.len() > lit { + out.push(Piece::Text(Str::from_units(template.get(lit..).unwrap_or_default().to_vec()))); + } +} + +/// `replace` (section 15.5.4.11). +fn replace(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> { + rooted(interp, |interp| { + let s = this_str(interp, &this, "replace")?; + let search_value = arg(args, 0); + let replace_value = arg(args, 1); + let units = s.flat(); + // Each match as the positions of the match and its groups. + let mut matches: Vec<Vec<usize>> = Vec::new(); + if let Some((rx, _)) = regexp_of(interp, &search_value) { + let global = interp.get_value(&search_value, &crate::heap::key("global"))?.truthy(); + if global { + let last_index = crate::heap::key("lastIndex"); + interp.put(rx, last_index.clone(), Value::Number(0.0), true)?; + let mut previous = 0.0; + // The bytes the stored matches and the pieces of the result made from them hold, + // kept within the heap room left. + let mut stored = 0usize; + while let Some(caps) = exec_raw(interp, rx, &s)? { + stored = stored.saturating_add(24 + size_of::<usize>() * caps.len() + 2 * size_of::<Piece>()); + interp.reserve_heap(stored)?; + let this_index = interp.get_value(&search_value, &last_index)?; + let this_index = interp.to_number(&this_index)?; + if this_index == previous { + previous = this_index + 1.0; + interp.put(rx, last_index.clone(), Value::Number(previous), true)?; + } else { + previous = this_index; + } + matches.push(caps); + } + } else if let Some(caps) = exec_raw(interp, rx, &s)? { + matches.push(caps); + } + } else { + let search = interp.to_str(&search_value)?; + let needle = search.flat(); + if let Some(i) = find(interp, &units, &needle, 0)? { + matches.push(vec![i, i + needle.len()]); + } + } + let replacer = interp.is_callable(&replace_value).then(|| replace_value.clone()); + let template = match &replacer { + Some(_) => None, + None => Some(interp.to_str(&replace_value)?.flat()), + }; + if matches.is_empty() { + return Ok(Value::String(s)); + } + let mut pieces = Vec::new(); + let mut last = 0usize; + for caps in &matches { + let (start, end) = (caps.first().copied().unwrap_or(0), caps.get(1).copied().unwrap_or(0)); + pieces.push(Piece::Range(last, start)); + match (&replacer, &template) { + (Some(f), _) => { + let mut call_args = Vec::with_capacity(caps.len() / 2 + 2); + for pair in caps.chunks(2) { + let v = match pair { + [a, b] if *a != NONE && *b != NONE => substring_value(interp, &units, *a, *b)?, + _ => Value::Undefined, + }; + call_args.push(v); + } + call_args.push(Value::Number(start as f64)); + call_args.push(Value::String(s.clone())); + let r = interp.call(f, &Value::Undefined, &call_args)?; + let text = interp.to_str(&r)?; + pieces.push(Piece::Text(text)); + } + (None, Some(t)) => expand(t, &units, caps, &mut pieces), + (None, None) => {} + } + use_fuel_n(interp, 1)?; + last = end; + } + pieces.push(Piece::Range(last, units.len())); + build(interp, &units, &pieces) + }) +} + +/// `split` (section 15.5.4.14). +fn split(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> { + rooted(interp, |interp| { + let s = this_str(interp, &this, "split")?; + let separator = arg(args, 0); + let limit = arg(args, 1); + let a = new_array(interp); + let lim = if matches!(limit, Value::Undefined) { u32::MAX } else { to_uint32(interp.to_number(&limit)?) }; + let rx = regexp_of(interp, &separator); + let sep = match &rx { + Some(_) => None, + None => Some(interp.to_str(&separator)?.flat()), + }; + if lim == 0 { + return Ok(Value::Object(a)); + } + if matches!(separator, Value::Undefined) { + set_element(interp, a, 0, Value::String(s))?; + return Ok(Value::Object(a)); + } + let units = s.flat(); + let size = units.len(); + let re: Option<Rc<crate::regexp_vm::Regex>> = rx.map(|(_, re)| re); + let mut matcher = re.as_deref().map(|re| Matcher::new(re, interp)).transpose()?; + // SplitMatch (section 15.5.4.14): the end of a separator match at `q` and the groups. + let mut split_match = |interp: &mut Interp, q: usize| -> JsResult<Option<(usize, Vec<usize>)>> { + match (&mut matcher, &sep) { + (Some(m), _) => { + if m.match_at(interp, &units, q)? { + Ok(Some((m.caps.get(1).copied().unwrap_or(q), m.caps.get(2..).map(<[usize]>::to_vec).unwrap_or_default()))) + } else { + Ok(None) + } + } + (None, Some(r)) => { + use_fuel_n(interp, (r.len() / UNITS_PER_FUEL) as u64 + 1)?; + if units.get(q..q + r.len()) == Some(&r[..]) { Ok(Some((q + r.len(), Vec::new()))) } else { Ok(None) } + } + (None, None) => Ok(None), + } + }; + if size == 0 { + if split_match(interp, 0)?.is_none() { + set_element(interp, a, 0, Value::String(s.clone()))?; + } + return Ok(Value::Object(a)); + } + let mut n = 0usize; + let (mut p, mut q) = (0usize, 0usize); + while q != size { + match split_match(interp, q)? { + None => q += 1, + Some((e, _)) if e == p => q += 1, + Some((e, caps)) => { + let t = substring_value(interp, &units, p, q)?; + set_element(interp, a, n, t)?; + n += 1; + if n as u64 == u64::from(lim) { + return Ok(Value::Object(a)); + } + p = e; + for pair in caps.chunks(2) { + let v = match pair { + [x, y] if *x != NONE && *y != NONE => substring_value(interp, &units, *x, *y)?, + _ => Value::Undefined, + }; + set_element(interp, a, n, v)?; + n += 1; + if n as u64 == u64::from(lim) { + return Ok(Value::Object(a)); + } + } + q = p; + } + } + } + let t = substring_value(interp, &units, p, size)?; + set_element(interp, a, n, t)?; + Ok(Value::Object(a)) + }) +} diff --git a/crates/js/src/text_tests.rs b/crates/js/src/text_tests.rs new file mode 100644 index 000000000..39fec2dc9 --- /dev/null +++ b/crates/js/src/text_tests.rs @@ -0,0 +1,441 @@ +//! Tests of `String`, `Number`, `RegExp` and `Date` run through a realm. + +use proptest::prelude::*; + +use crate::{JsValue, LimitKind, Limits, Options, Realm, RunError, RunErrorKind}; + +fn run_in(realm: &mut Realm, src: &str) -> JsValue { + match realm.run(src) { + Ok(v) => v, + Err(e) => panic!("{src}\n{e:?}"), + } +} + +fn run(src: &str) -> JsValue { + run_in(&mut Realm::new(), src) +} + +fn string(src: &str) -> String { + match run(src) { + JsValue::String(s) => s, + other => panic!("{src}: {other:?}"), + } +} + +/// Script helpers that need no `Array.prototype` method: `show(a, sep)` joins an array (`null` +/// shows as `null`, `undefined` elements as nothing), `nan(n)` tells NaN apart. +const HELPERS: &str = "function show(a, sep) { if (a === null) return 'null'; var s = ''; \ + for (var i = 0; i < a.length; i++) s += (i ? (sep === undefined ? ',' : sep) : '') + (a[i] === undefined ? '' : a[i]); return s; } \ + function nan(n) { return n !== n ? 'NaN' : 'num'; }\n"; + +/// Runs `src` after [`HELPERS`] and checks it gives the string `want`. +fn shows(src: &str, want: &str) { + match run(&format!("{HELPERS}{src}")) { + JsValue::String(s) => assert_eq!(s, want, "{src}"), + other => panic!("{src}: {other:?}"), + } +} + +fn truth(src: &str) { + assert_eq!(run(src), JsValue::Bool(true), "{src}"); +} + +fn fail_with(mut realm: Realm, src: &str) -> RunError { + match realm.run(src) { + Ok(v) => panic!("{src} completed with {v:?}"), + Err(e) => e, + } +} + +fn thrown(src: &str) -> String { + let e = fail_with(Realm::new(), src); + assert_eq!(e.kind, RunErrorKind::Thrown, "{src}: {e:?}"); + e.message +} + +// ---- String ---- + +#[test] +fn string_converts_and_wraps() { + assert_eq!(string("String(12.5) + String() + String(null)"), "12.5null"); + truth("var s = new String('ab'); typeof s === 'object' && s.length === 2 && s[1] === 'b' && s.valueOf() === 'ab'"); + assert_eq!(string("String.fromCharCode(104, 105, 65536 + 33)"), "hi!"); + assert!(thrown("String.prototype.toString.call({})").starts_with("TypeError")); + assert!(thrown("String.prototype.trim.call(null)").starts_with("TypeError")); +} + +#[test] +fn string_methods_follow_section_15_5_4() { + let cases: &[(&str, &str)] = &[ + ("'abc'.charAt(1) + 'abc'.charAt(5) + 'abc'.charAt(-1)", "b"), + ("'' + 'abc'.charCodeAt(0) + nan('abc'.charCodeAt(3))", "97NaN"), + ("'a'.concat('b', 1, null)", "ab1null"), + ("'' + 'abcabc'.indexOf('c') + 'abcabc'.indexOf('c', 3) + 'abc'.indexOf('') + 'abc'.indexOf('', 9)", "2503"), + ("'' + 'abcabc'.lastIndexOf('c') + 'abcabc'.lastIndexOf('c', 4) + 'abc'.lastIndexOf('x')", "52-1"), + ("'' + 'a'.localeCompare('b') + 'b'.localeCompare('a') + 'a'.localeCompare('a')", "-110"), + ("'abcdef'.slice(1, -1) + '|' + 'abcdef'.slice(-2) + '|' + 'abc'.slice(2, 1)", "bcde|ef|"), + ("'abcdef'.substring(4, 1) + '|' + 'abcdef'.substring(-3, 2) + '|' + 'abc'.substring(1)", "bcd|ab|bc"), + ("'abcdef'.substr(-3, 2) + '|' + 'abcdef'.substr(1) + '|' + 'abc'.substr(1, -1)", "de|bcdef|"), + ("'\\u00dfa\\u03a3'.toUpperCase() + '|' + 'A\\u03a3'.toLowerCase() + '|' + 'A\\u03a3b'.toLowerCase()", "SSA\u{3a3}|a\u{3c2}|a\u{3c3}b"), + ("'[' + ' \\t\\n\\u00a0\\ufeffx y\\u2028 '.trim() + ']'", "[x y]"), + ("show('a,b,,c'.split(','), '|')", "a|b||c"), + ("show('abc'.split(''), '|') + ';' + 'abc'.split().length + ';' + show('abc'.split('', 2), '|')", "a|b|c;1;a|b"), + ("''.split('').length + ';' + ''.split('x').length", "0;1"), + ("show('a1b22c'.split(/(\\d)+/), '|')", "a|1|b|2|c"), + ("show('ab'.split(/(?:)/), '|') + ';' + show('ab'.split(/a*?/), '|')", "a|b;a|b"), + ("'aaa'.replace('a', '$&$&') + ';' + 'abc'.replace('b', '[$`|$\\'|$$]')", "aaaa;a[a|c|$]c"), + ("'John Smith'.replace(/(\\w+)\\s(\\w+)/, '$2, $1') + ';' + 'x'.replace(/x/, '$1$01$10')", "Smith, John;$1$01$10"), + ("'a-b-c'.replace(/-/g, function (m, i, s) { return '[' + i + s.length + ']'; })", "a[15]b[35]c"), + ("'aaa'.replace(/a*?/g, '-')", "-a-a-a-"), + ("'abc'.match(/b/).index + ';' + show('abab'.match(/a(b)/g), '|') + ';' + ('x'.match(/y/g) === null)", "1;ab|ab;true"), + ("'' + 'abc'.search(/c/) + 'abc'.search('b') + 'abc'.search(/x/)", "21-1"), + ]; + for (src, want) in cases { + shows(src, want); + } +} + +#[test] +fn string_methods_count_against_the_heap_before_allocating() { + // Doubling with `concat` and `replace` stops at the heap limit, not at an allocation failure. + let limits = Limits { heap_bytes: 8 << 20, ..Limits::default() }; + for src in [ + "var s = 'x'; for (;;) s = s.concat(s);", + "var s = 'xx'; for (;;) s = s.replace(/x/g, 'xx');", + "var s = 'x'; for (;;) s = (s + s).toUpperCase();", + "var s = 'x'; for (;;) s = (s + s + s).slice(0) + s.substr(0);", + ] { + let e = fail_with(Realm::with_options(Options::default(), limits.clone()), src); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{src}: {e:?}"); + } + // A replacement that would build a string past the heap limit is refused before it is built. + let limits = Limits { heap_bytes: 4 << 20, ..Limits::default() }; + let e = fail_with( + Realm::with_options(Options::default(), limits), + "var big = 'yyyyyyyyyyyyyyyy'; for (var i = 0; i < 15; i++) big = big + big; 'xxxxxxxxxx'.replace(/x/g, big)", + ); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{e:?}"); +} + +// ---- Number ---- + +#[test] +fn numbers_format_as_section_15_7_4_says() { + let cases: &[(&str, &str)] = &[ + ("(255).toString(16) + ' ' + (-255).toString(2) + ' ' + (0.5).toString(2) + ' ' + (1e21).toString(36)", "ff -11111111 0.1 5v1j4f4ds7c000"), + ("(123.456).toString() + ' ' + (1e21).toString(10) + ' ' + (0.000001).toString()", "123.456 1e+21 0.000001"), + ("(0.5).toFixed(0) + ' ' + (1.255).toFixed(2) + ' ' + (0.1).toFixed(20) + ' ' + (1e21).toFixed(2)", "1 1.25 0.10000000000000000555 1e+21"), + ("(123456).toExponential(2) + ' ' + (123456).toExponential() + ' ' + (0).toExponential(2)", "1.23e+5 1.23456e+5 0.00e+0"), + ( + "(123.456).toPrecision(4) + ' ' + (0.0000001234).toPrecision(2) + ' ' + (123456).toPrecision(2) + ' ' + (5).toPrecision()", + "123.5 1.2e-7 1.2e+5 5", + ), + ( + "(-1.5).toFixed(0) + ' ' + (NaN).toFixed(2) + ' ' + (Infinity).toExponential(50) + ' ' + (-Infinity).toPrecision(50)", + "-2 NaN Infinity -Infinity", + ), + ("new Number(5).toString(2) + ' ' + Number('0x10') + ' ' + Number() + ' ' + Number.MIN_VALUE", "101 16 0 5e-324"), + ]; + for (src, want) in cases { + assert_eq!(string(src), *want, "{src}"); + } + truth("Number.MAX_VALUE === 1.7976931348623157e308 && Number.POSITIVE_INFINITY === 1/0 && Number.NaN !== Number.NaN"); + truth( + "typeof new Number(1) === 'object' && new Number(7).valueOf() === 7 && Object.prototype.toString.call(Number.prototype) === '[object Number]'", + ); + for src in [ + "(1).toFixed(21)", + "(1).toFixed(-1)", + "(1).toExponential(21)", + "(1).toPrecision(0)", + "(1).toPrecision(22)", + "(1).toString(1)", + "(1).toString(37)", + ] { + assert!(thrown(src).starts_with("RangeError"), "{src}"); + } + assert!(thrown("Number.prototype.toFixed.call('1', 2)").starts_with("TypeError")); +} + +// ---- RegExp ---- + +#[test] +fn regular_expressions_match_as_section_15_10_2_says() { + let cases: &[(&str, &str)] = &[ + ("show(/a(b)?c/.exec('ac'))", "ac,"), + ("show(/(z)((a+)?(b+)?(c))*/.exec('zaacbbbcac'))", "zaacbbbcac,z,ac,a,,c"), + ("show(/(a*)*/.exec('b'))", ","), + ("show(/(a*)b\\1+/.exec('baaaac'))", "b,"), + ("show(/(?=(a+))/.exec('baaabac'))", ",aaa"), + ("show(/(?=(a+))a*b\\1/.exec('baaabac'))", "aba,a"), + ("show(/(.*?)a(?!(a+)b\\2c)\\2(.*)/.exec('baaabaac'))", "baaabaac,ba,,abaac"), + ("show(/((a)|b)+/.exec('ab'))", "ab,b,"), + ("show(/a[a-z]{2,4}/.exec('abcdefghi'))", "abcde"), + ("show(/a[a-z]{2,4}?/.exec('abcdefghi'))", "abc"), + ("show(/(aa|aabaac|ba|b|c)*/.exec('aabaac'))", "aaba,ba"), + ("show(/[^\\d\\s]+/.exec('12 ab3'))", "ab"), + ("show(/\\bfoo\\B/.exec('a foox'))", "foo"), + ("show(/^b/m.exec('a\\nb')) + /^b/.test('a\\nb')", "bfalse"), + ("/ß/i.test('SS') + ' ' + show(/[a-z]+/i.exec('HeLLo')) + ' ' + /\\u017f/i.test('s')", "false HeLLo false"), + ("/[\\u017f]/i.test('S') + ' ' + /k/i.test('\\u212a')", "false false"), + ("show(/\\1(a)/.exec('aa')) + ';' + show(/(a\\1)/.exec('aa'))", "a,a;a,a"), + ("/\\0\\x41\\u0042\\cJ\\101/.test('\\0AB\\nA') + ' ' + /[\\b]/.test('\\b') + ' ' + /a{,2}/.test('a{,2}')", "true true true"), + ("/]/.test(']') + ' ' + show(/[\\d-z]+/.exec('5-z')) + ' ' + /\\8/.test('8')", "true 5-z true"), + ("/(?:a|b)*c/.exec('ababc').index + ' ' + /x*/.exec('yyy').index", "0 0"), + ("/[]/.test('a') + ' ' + /[^]/.test('\\n')", "false true"), + ]; + for (src, want) in cases { + shows(src, want); + } +} + +#[test] +fn regexp_objects_follow_sections_15_10_4_to_15_10_7() { + truth("var r = /a/g; r.lastIndex === 0 && r.global && !r.ignoreCase && !r.multiline && r.source === 'a'"); + truth( + "var r = /a/g; r.exec('aa'); var i = r.lastIndex; r.exec('aa'); var j = r.lastIndex; r.exec('aa'); i === 1 && j === 2 && r.lastIndex === 0", + ); + truth("var r = /a/; r.lastIndex = 5; r.test('a') && r.lastIndex === 5"); + truth("var r = new RegExp('a/b', 'im'); r.source === 'a\\\\/b' && r.ignoreCase && r.multiline && String(r) === '/a\\\\/b/im'"); + truth("String(new RegExp()) === '/(?:)/' && new RegExp('').test('x')"); + truth("var r = new RegExp('[/]'); String(r) === '/[/]/' && eval(String(r)).test('/') && eval(String(new RegExp('a/'))).source === 'a\\\\/'"); + truth("var r = /x/g; RegExp(r) === r && new RegExp(r) !== r && new RegExp(r).global"); + truth("var m = /(a)|(b)/.exec('b'); m.length === 3 && m[1] === undefined && m[2] === 'b' && m.index === 0 && m.input === 'b'"); + truth("Object.prototype.toString.call(RegExp.prototype) === '[object RegExp]' && Object.prototype.toString.call(/x/) === '[object RegExp]'"); + truth("var p = RegExp.prototype; p.hasOwnProperty('global') && p.hasOwnProperty('lastIndex') && p.source === '(?:)' && p.global === false"); + for src in ["new RegExp('(')", "new RegExp('a', 'gg')", "new RegExp('a', 'x')", "new RegExp('a**')", "new RegExp('[b-a]')"] { + assert!(thrown(src).starts_with("SyntaxError"), "{src}"); + } + assert!(thrown("new RegExp(/a/, 'g')").starts_with("TypeError")); + assert!(thrown("RegExp.prototype.exec.call({}, 'a')").starts_with("TypeError")); +} + +#[test] +fn a_catastrophic_regular_expression_ends_by_fuel() { + let limits = Limits { fuel: 2_000_000, ..Limits::default() }; + let src = "var s = ''; for (var i = 0; i < 40; i++) s += 'a'; /(a+)+$/.test(s + 'b')"; + let e = fail_with(Realm::with_options(Options::default(), limits.clone()), src); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Fuel), "{e:?}"); + // The interrupt flag stops it too. + let realm = Realm::with_options(Options::default(), Limits { fuel: u64::MAX, ..Limits::default() }); + realm.interrupt().set(); + let e = fail_with(realm, src); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Interrupted), "{e:?}"); +} + +#[test] +fn trim_and_date_parsing_take_fuel_for_the_units_they_scan() { + let limits = Limits { fuel: 40_000, ..Limits::default() }; + let build = "var s = ' '; for (var i = 0; i < 20; i++) s = s + s; s.charAt(0)"; + assert!(Realm::with_options(Options::default(), limits.clone()).run(build).is_ok()); + for scan in ["s.trim()", "Date.parse(s)", "new Date(s)"] { + let e = fail_with(Realm::with_options(Options::default(), limits.clone()), &format!("{build}; {scan}")); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Fuel), "{scan}: {e:?}"); + } +} + +#[test] +fn global_replace_counts_its_matches_against_the_heap() { + let src = "var s = 'ab'; for (var i = 0; i < 17; i++) s = s + s; s.replace(/./g, '').length"; + let e = fail_with(Realm::with_options(Options::default(), Limits { heap_bytes: 4 << 20, ..Limits::default() }), src); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{e:?}"); + assert_eq!(run(src), JsValue::Number(0.0)); +} + +#[test] +fn compiling_a_pattern_counts_against_the_heap() { + let src = "var s = '[\\\\s\\\\S]a*'; for (var i = 0; i < 14; i++) s = s + s; new RegExp(s).source.length"; + let e = fail_with(Realm::with_options(Options::default(), Limits { heap_bytes: 2 << 20, ..Limits::default() }), src); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{e:?}"); + assert_eq!(run(src), JsValue::Number(131_072.0)); + // The compiler stops while it reads the pattern, before the code it builds passes the room. + let pattern: Vec<u16> = "[\\s\\S]a*".repeat(1000).encode_utf16().collect(); + let flags = crate::regexp_vm::parse_flags(&[]).unwrap(); + let room = crate::regexp_vm::compile_bytes(400, 0, 0); + let result = crate::regexp_vm::compile(&pattern, flags, 200, room, &mut |_| Ok(())); + assert!(matches!(result, Err(crate::regexp_vm::CompileFail::Abrupt(crate::interp::Abrupt::Limit(LimitKind::Heap))))); + assert!(crate::regexp_vm::compile(&pattern, flags, 200, usize::MAX, &mut |_| Ok(())).is_ok()); + // A literal pattern takes what its instructions hold, not the most a unit can take. + let literal: Vec<u16> = "a".repeat(10_000).encode_utf16().collect(); + let room = crate::regexp_vm::compile_bytes(20_000, 0, 0); + assert!(crate::regexp_vm::compile(&literal, flags, 200, room, &mut |_| Ok(())).is_ok()); + // A class's ranges count while they are read, even when they collapse into one range. + let class: Vec<u16> = format!("[{}]", "a".repeat(10_000)).encode_utf16().collect(); + let result = crate::regexp_vm::compile(&class, flags, 200, 1000, &mut |_| Ok(())); + assert!(matches!(result, Err(crate::regexp_vm::CompileFail::Abrupt(crate::interp::Abrupt::Limit(LimitKind::Heap))))); + // The joining and flattening after the last unit count too. + let ab: Vec<u16> = "ab".encode_utf16().collect(); + let room = crate::regexp_vm::compile_bytes(1, 0, 0); + let result = crate::regexp_vm::compile(&ab, flags, 200, room, &mut |_| Ok(())); + assert!(matches!(result, Err(crate::regexp_vm::CompileFail::Abrupt(crate::interp::Abrupt::Limit(LimitKind::Heap))))); +} + +#[test] +fn compiling_a_pattern_collects_garbage_when_the_heap_is_full() { + let mut realm = Realm::with_options(Options::default(), Limits { heap_bytes: 256 << 10, ..Limits::default() }); + let src = "var n = 0; for (var i = 0; i < 5000; i++) { var junk = 'x' + i + 'y' + i; new RegExp('(a|b)c[d-f]'); n++; } n"; + assert_eq!(run_in(&mut realm, src), JsValue::Number(5000.0)); +} + +#[test] +fn a_constructed_source_counts_against_the_heap_before_it_is_built() { + let mut realm = Realm::new(); + let interp = realm.interp(); + interp.collect_for_room(); + let live = interp.heap.bytes; + let pattern = vec![0x2028u16; 10_000]; + interp.limits.heap_bytes = live + 50_000; + assert!(matches!(crate::regexp_builtins::source_text(interp, &pattern), Err(crate::interp::Abrupt::Limit(LimitKind::Heap)))); + interp.limits.heap_bytes = live + (1 << 20); + let Ok(source) = crate::regexp_builtins::source_text(interp, &pattern) else { panic!("the source fits") }; + assert_eq!(source.flat().len(), 60_000); + assert_eq!(string("String(new RegExp('a/\\\\/[/]\\n\\r\\u2028\\u2029'))"), "/a\\/\\/[/]\\n\\r\\u2028\\u2029/"); +} + +#[test] +fn the_matcher_state_counts_against_the_heap() { + let mut realm = Realm::with_options(Options::default(), Limits { heap_bytes: 1 << 20, ..Limits::default() }); + let pattern: Vec<u16> = "(a)".repeat(1000).encode_utf16().collect(); + let flags = crate::regexp_vm::parse_flags(&[]).unwrap(); + let Ok(re) = crate::regexp_vm::compile(&pattern, flags, 200, usize::MAX, &mut |_| Ok(())) else { panic!("the pattern compiles") }; + let interp = realm.interp(); + interp.collect_for_room(); + let live = interp.heap.bytes; + interp.limits.heap_bytes = live + 1000; + assert!(matches!(crate::regexp_vm::Matcher::new(&re, interp), Err(crate::interp::Abrupt::Limit(LimitKind::Heap)))); + interp.limits.heap_bytes = 1 << 20; + assert!(crate::regexp_vm::Matcher::new(&re, interp).is_ok()); + // The stack takes only the room left: with room for the state and no more, backtracking ends + // with the heap limit. + let pattern: Vec<u16> = "a*b".encode_utf16().collect(); + let Ok(re) = crate::regexp_vm::compile(&pattern, flags, 200, usize::MAX, &mut |_| Ok(())) else { panic!("the pattern compiles") }; + let input: Vec<u16> = "aaaaaaaaaa".encode_utf16().collect(); + interp.limits.heap_bytes = live + 64; + let Ok(mut m) = crate::regexp_vm::Matcher::new(&re, interp) else { panic!("the state fits") }; + assert!(matches!(m.search(interp, &input, 0), Err(crate::interp::Abrupt::Limit(LimitKind::Heap)))); + interp.limits.heap_bytes = 1 << 20; + let Ok(mut m) = crate::regexp_vm::Matcher::new(&re, interp) else { panic!("the state fits") }; + assert!(matches!(m.search(interp, &input, 0), Ok(false))); +} + +#[test] +fn deeply_nested_groups_give_a_syntax_error_and_never_overflow_the_stack() { + let deep = |n: usize| format!("{}a{}", "(".repeat(n), ")".repeat(n)); + let src = format!("try {{ new RegExp('{}'); 'compiled' }} catch (e) {{ e.name }}", deep(50_000)); + assert_eq!(run(&src), JsValue::String("SyntaxError".into())); + // With the depth limit raised, the same nesting compiles and matches on a small stack: the + // compiler and the matcher keep their state on the heap. + let handle = std::thread::Builder::new() + .stack_size(256 << 10) + .spawn(move || { + let options = Options { max_depth: 60_000, ..Options::default() }; + let mut realm = Realm::with_options(options, Limits::default()); + let src = format!("var m = new RegExp('{}').exec('xa'); m.length + ':' + m[50000]", deep(50_000)); + realm.run(&src) + }) + .unwrap(); + assert_eq!(handle.join().unwrap(), Ok(JsValue::String("50001:a".into()))); +} + +#[test] +fn the_matcher_stack_counts_against_the_heap() { + let limits = Limits { heap_bytes: 4 << 20, ..Limits::default() }; + let src = "var s = 'ab'; for (var i = 0; i < 18; i++) s = s + s; /(?:a|b)*c/.test(s)"; + let e = fail_with(Realm::with_options(Options::default(), limits), src); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{e:?}"); +} + +proptest! { + #[test] + fn arbitrary_patterns_and_inputs_never_panic(pattern in "[ab()|*+?{}\\[\\]^$.\\\\dws0-9,:=!-]{0,24}", input in "[ab\\n0-9 ]{0,24}", flags in "[gim]{0,2}") { + let mut realm = Realm::with_options(Options::default(), Limits { fuel: 200_000, ..Limits::default() }); + let quote = |s: &str| s.replace('\\', "\\\\").replace('\'', "\\'").replace('\n', "\\n"); + let src = format!( + "try {{ var r = new RegExp('{}', '{}'); var s = '{}'; [r.exec(s), s.replace(r, '$1'), s.split(r), s.match(r), s.search(r)].length }} catch (e) {{ e.name }}", + quote(&pattern), flags, quote(&input) + ); + match realm.run(&src) { + Ok(_) => {} + Err(e) => prop_assert!(matches!(e.kind, RunErrorKind::Limit(_)), "{src}: {e:?}"), + } + } +} + +// ---- Date ---- + +proptest! { + #[test] + fn arbitrary_date_strings_never_panic(text in "[0-9TZ:.+ ,()a-zA-Z-]{0,32}") { + let mut realm = Realm::new(); + let src = format!("var t = Date.parse('{text}'); new Date('{text}').toString(); t"); + prop_assert!(realm.run(&src).is_ok(), "{src}"); + } +} + +fn date_realm(now: f64, offset_minutes: f64) -> Realm { + let mut realm = Realm::new(); + realm.set_clock(move || now); + realm.set_time_zone(move |_| offset_minutes * 60_000.0); + realm +} + +#[test] +fn dates_read_the_host_clock_and_time_zone() { + // 2026-11-07T12:34:56.789Z. + let now = 1_794_054_896_789.0; + let mut utc = date_realm(now, 0.0); + assert_eq!(run_in(&mut utc, "Date.now()"), JsValue::Number(now)); + assert_eq!(run_in(&mut utc, "new Date().getTime()"), JsValue::Number(now)); + assert_eq!(run_in(&mut utc, "new Date().toISOString()"), JsValue::String("2026-11-07T12:34:56.789Z".into())); + assert_eq!(run_in(&mut utc, "String(new Date())"), JsValue::String("Sat Nov 07 2026 12:34:56 GMT+0000".into())); + assert_eq!(run_in(&mut utc, "Date()"), JsValue::String("Sat Nov 07 2026 12:34:56 GMT+0000".into())); + assert_eq!(run_in(&mut utc, "new Date().toUTCString()"), JsValue::String("Sat, 07 Nov 2026 12:34:56 GMT".into())); + let mut east = date_realm(now, 90.0); + assert_eq!(run_in(&mut east, "String(new Date())"), JsValue::String("Sat Nov 07 2026 14:04:56 GMT+0130".into())); + assert_eq!( + run_in(&mut east, "var d = new Date(); d.getHours() + ',' + d.getUTCHours() + ',' + d.getMinutes() + ',' + d.getTimezoneOffset()"), + JsValue::String("14,12,4,-90".into()) + ); + assert_eq!(run_in(&mut east, "new Date(2026, 10, 7, 14, 4, 56, 789).getTime()"), JsValue::Number(now)); + // An offset of a day or more, or one that is not finite, counts as 0. + for offset in [f64::MAX, 86_400_000.0, f64::NAN] { + let mut odd = Realm::new(); + odd.set_clock(move || now); + odd.set_time_zone(move |_| offset); + assert_eq!(run_in(&mut odd, "var d = new Date(); d.getHours() + ',' + d.getTimezoneOffset()"), JsValue::String("12,0".into()), "{offset}"); + } + let mut west = date_realm(now, -300.0); + assert_eq!(run_in(&mut west, "new Date(2026, 0, 31).toDateString()"), JsValue::String("Sat Jan 31 2026".into())); +} + +#[test] +fn dates_round_trip_through_their_strings() { + let mut realm = date_realm(1_794_054_896_789.0, -420.0); + for src in [ + "var d = new Date(); Date.parse(d.toString()) === Math_floor(d.getTime())", + "var d = new Date(); Date.parse(d.toUTCString()) === Math_floor(d.getTime())", + "var d = new Date(); Date.parse(d.toISOString()) === d.getTime()", + "var d = new Date(-1e14); Date.parse(d.toISOString()) === d.getTime() && Date.parse(d.toString()) === d.getTime()", + "new Date(new Date(5).toISOString()).getTime() === 5", + "Date.UTC(2000, 1, 29) === Date.parse('2000-02-29') && Date.UTC(99, 0) === Date.UTC(1999, 0)", + "Date.UTC(2000) === Date.UTC(2000, 0) && Date.UTC(1970) === 0 && isNaN_(Date.UTC())", + "isNaN_(Date.parse('nonsense')) && isNaN_(new Date(8.64e15 + 1).getTime()) && new Date(8.64e15).getTime() === 8.64e15", + "var d = new Date(2000, 0, 31); d.setMonth(1); d.getMonth() === 2 && d.getDate() === 2", + "var d = new Date(NaN); d.setFullYear(2001); d.getFullYear() === 2001 && d.getMonth() === 0", + "var d = new Date(0); d.setUTCHours(25, 61, 61, 1001); d.toISOString() === '1970-01-02T02:02:02.001Z'", + "var d = new Date(0); d.setMinutes(); isNaN_(d.getTime())", + "var d = new Date(2000, 0, 1); d.setYear(99); d.getFullYear() === 1999 && d.getYear() === 99", + "Date.prototype.toGMTString === Date.prototype.toUTCString && String(new Date(NaN)) === 'Invalid Date'", + "new Date(0).toJSON() === '1970-01-01T00:00:00.000Z' && new Date(NaN).toJSON() === null", + "Object.prototype.toString.call(Date.prototype) === '[object Date]' && isNaN_(Date.prototype.getTime())", + "var d = new Date(0); d + d === d.toString() + d.toString() && d - d === 0 && d == d.toString()", + "isNaN_(Date.parse('1970-00-01')) && isNaN_(Date.parse('1970-13-01')) && isNaN_(Date.parse('1970-02-30'))", + ] { + let full = format!("function Math_floor(t) {{ return t - t % 1000; }} function isNaN_(n) {{ return n !== n; }} {src}"); + assert_eq!(run_in(&mut realm, &full), JsValue::Bool(true), "{src}"); + } + let e = fail_with(date_realm(0.0, 0.0), "new Date(NaN).toISOString()"); + assert!(e.message.starts_with("RangeError"), "{e:?}"); + let e = fail_with(date_realm(0.0, 0.0), "Date.prototype.getTime.call({})"); + assert!(e.message.starts_with("TypeError"), "{e:?}"); +} diff --git a/crates/js/tests/test262.rs b/crates/js/tests/test262.rs index 8c87bb19d..f090e7273 100644 --- a/crates/js/tests/test262.rs +++ b/crates/js/tests/test262.rs @@ -19,6 +19,11 @@ //! passes when it completes, or, for `negative: phase: runtime`, when it throws an object whose //! `constructor` is the global its `type:` names. Its counts are printed apart from the parse //! counts, and its failures go to `run-failures.txt`. +//! +//! A third test runs the tests under `test/built-ins/String`, `Number`, `RegExp` and `Date` the same +//! way, counting the tests that name an ES5 section apart from the other tests without a feature +//! tag; its failures go to `builtins-failures.txt`. The interpreter tests fail only when the +//! interpreter panics. use std::collections::HashSet; use std::path::{Path, PathBuf}; @@ -203,8 +208,20 @@ const MISSING_BUILTINS: &str = "Array String Number Boolean Math JSON Date RegEx fromCharCode substring substr split replace match search exec test toUpperCase toLowerCase trim toFixed toPrecision toExponential \ bind now isArray compareArray verifyProperty fnGlobalObject"; -/// The stand-in for `String` that the harness's failure messages call. -const HARNESS_STRING: &str = "function String(value) { return '' + value; }"; +/// The names in [`MISSING_BUILTINS`] that `String`, `Number`, `RegExp` and `Date` provide. +const TEXT_BUILTINS: &str = "String Number Date RegExp charAt charCodeAt fromCharCode substring substr split replace match search exec test \ + toUpperCase toLowerCase trim toFixed toPrecision toExponential now"; + +/// String methods whose names `Array.prototype` also uses: provided for the tests under +/// `built-ins/String/`, and still missing elsewhere, where the name usually means the array method. +const STRING_METHODS_SHARED_WITH_ARRAY: &str = "indexOf lastIndexOf concat slice"; + +/// The names in [`MISSING_BUILTINS`] the interpreter still lacks, without those in each of +/// `provided`. +fn missing_builtins<'a>(provided: &[&str]) -> HashSet<&'a str> { + let provided: HashSet<&str> = provided.iter().flat_map(|p| p.split_whitespace()).collect(); + MISSING_BUILTINS.split_whitespace().filter(|name| !provided.contains(name)).collect() +} /// The source names a missing built-in, as an identifier, a property name or a word inside a /// string literal (code passed to `eval` or `Function`), or does not parse as ES5 (the tests that @@ -249,32 +266,104 @@ fn es5_language_subset_runs() { handle.join().unwrap(); } +/// The counts of one run of a set of tests through the interpreter. +#[derive(Default)] +struct RunCounts { + tests: usize, + skipped: usize, + passed: usize, + failed: usize, + panicked: usize, + failures: String, +} + +impl RunCounts { + fn summary(&self) -> String { + format!( + "{} tests that need only the built-ins present, {} runs passed, {} failed ({} panicked), {} skipped", + self.tests, self.passed, self.failed, self.panicked, self.skipped + ) + } +} + fn run_language_subset(dir: &Path) { let language = dir.join("test").join("language"); + let missing = missing_builtins(&[TEXT_BUILTINS]); + let (es5, _) = run_tests(dir, &language, &missing, false); + let report = dir.join("run-failures.txt"); + if let Err(e) = std::fs::write(&report, &es5.failures) { + println!("could not write {}: {e}", report.display()); + } + println!( + "test262 ES5 language subset through the interpreter: {} tests that need only the built-ins present, {} runs passed, \ + {} failed ({} panicked). Skipped {} tests that need other built-ins, use syntax after ES5 or are asynchronous. Failures: {}", + es5.tests, + es5.passed, + es5.failed, + es5.panicked, + es5.skipped, + report.display() + ); + assert_eq!(es5.panicked, 0, "the interpreter panicked; see {}", report.display()); +} + +#[test] +fn es5_text_builtins_run() { + let Some(dir) = suite_dir() else { + println!("test262 built-ins: SKIPPED (no suite; run cargo xtask test262)"); + return; + }; + let handle = std::thread::Builder::new().stack_size(64 << 20).spawn(move || run_text_builtins(&dir)).unwrap(); + handle.join().unwrap(); +} + +/// Runs the tests under `test/built-ins/` for `String`, `Number`, `RegExp` and `Date`: those that +/// name an ES5 section and the other tests without a feature tag, counted apart. +fn run_text_builtins(dir: &Path) { + let mut report = String::new(); + let mut panicked = 0; + for name in ["String", "Number", "RegExp", "Date"] { + let root = dir.join("test").join("built-ins").join(name); + assert!(root.is_dir(), "{} is not a test262 checkout", dir.display()); + let missing = + if name == "String" { missing_builtins(&[TEXT_BUILTINS, STRING_METHODS_SHARED_WITH_ARRAY]) } else { missing_builtins(&[TEXT_BUILTINS]) }; + let (es5, later) = run_tests(dir, &root, &missing, true); + println!("test262 built-ins/{name}, ES5 tests: {}. Other tests without a feature tag: {}.", es5.summary(), later.summary()); + report.push_str(&format!("# built-ins/{name}, ES5 tests\n{}\n# built-ins/{name}, other tests\n{}\n", es5.failures, later.failures)); + panicked += es5.panicked + later.panicked; + } + let path = dir.join("builtins-failures.txt"); + if let Err(e) = std::fs::write(&path, &report) { + println!("could not write {}: {e}", path.display()); + } + println!("test262 built-ins failures: {}", path.display()); + assert_eq!(panicked, 0, "the interpreter panicked; see {}", path.display()); +} + +/// Runs the tests under `root` that have no feature tag and are not module, asynchronous or +/// negative parse tests, each in a new realm after the harness. Returns the counts for the tests +/// that name an ES5 section and, when `later` is set, for the others (otherwise those are left out). +fn run_tests(dir: &Path, root: &Path, missing: &HashSet<&str>, later: bool) -> (RunCounts, RunCounts) { let harness_dir = dir.join("harness"); // A file that cannot be read gives an empty source. let read = |p: &Path| String::from_utf8_lossy(&std::fs::read(p).unwrap_or_default()).into_owned(); - // `assert.js` formats a failed assertion's message with the global `String`, which the - // interpreter does not provide yet; the stand-in converts with `+` so a failure reports the - // assertion's message instead of a ReferenceError. - let prelude = format!("{HARNESS_STRING}\n{}\n{}\n", read(&harness_dir.join("assert.js")), read(&harness_dir.join("sta.js"))); - let missing: HashSet<&str> = MISSING_BUILTINS.split_whitespace().collect(); + let prelude = format!("{}\n{}\n", read(&harness_dir.join("assert.js")), read(&harness_dir.join("sta.js"))); let verbose = std::env::var_os("VECTORCRAFT_TEST262_VERBOSE").is_some(); - let (mut tests, mut skipped, mut passed, mut failed, mut panicked) = (0usize, 0usize, 0usize, 0usize, 0usize); - let mut failures = String::new(); - for path in test_files(&language) { + let (mut es5_counts, mut later_counts) = (RunCounts::default(), RunCounts::default()); + for path in test_files(root) { let source = read(&path); let meta = meta(&source); let negative_parse = matches!(meta.negative_phase.as_deref(), Some("parse" | "resolution")); - if !meta.es5 || !meta.features.is_empty() || negative_parse || meta.flags.iter().any(|f| f == "module") { + if (!meta.es5 && !later) || !meta.features.is_empty() || negative_parse || meta.flags.iter().any(|f| f == "module") { continue; } + let counts = if meta.es5 { &mut es5_counts } else { &mut later_counts }; let includes: String = meta.includes.iter().map(|i| read(&harness_dir.join(i)) + "\n").collect(); - if meta.flags.iter().any(|f| f == "async") || needs_missing_builtin(&source, &missing) || needs_missing_builtin(&includes, &missing) { - skipped += 1; + if meta.flags.iter().any(|f| f == "async") || needs_missing_builtin(&source, missing) || needs_missing_builtin(&includes, missing) { + counts.skipped += 1; continue; } - tests += 1; + counts.tests += 1; let has = |flag: &str| meta.flags.iter().any(|f| f == flag); let modes: &[bool] = if has("raw") || has("noStrict") { &[false] @@ -294,27 +383,18 @@ fn run_language_subset(dir: &Path) { let negative_type = meta.negative_type.clone().filter(|_| meta.negative_phase.as_deref() == Some("runtime")); let outcome = std::panic::catch_unwind(|| run_one(&script, negative_type.as_deref())); match outcome { - Ok(Ok(())) => passed += 1, + Ok(Ok(())) => counts.passed += 1, Ok(Err(why)) => { - failed += 1; - failures.push_str(&format!("{rel} ({mode}): {why}\n")); + counts.failed += 1; + counts.failures.push_str(&format!("{rel} ({mode}): {why}\n")); } Err(_) => { - failed += 1; - panicked += 1; - failures.push_str(&format!("{rel} ({mode}): PANIC\n")); + counts.failed += 1; + counts.panicked += 1; + counts.failures.push_str(&format!("{rel} ({mode}): PANIC\n")); } } } } - let report = dir.join("run-failures.txt"); - if let Err(e) = std::fs::write(&report, &failures) { - println!("could not write {}: {e}", report.display()); - } - println!( - "test262 ES5 language subset through the interpreter: {tests} tests that need only the built-ins present, {passed} runs passed, \ - {failed} failed ({panicked} panicked). Skipped {skipped} tests that need other built-ins, use syntax after ES5 or are asynchronous. Failures: {}", - report.display() - ); - assert_eq!(panicked, 0, "the interpreter panicked; see {}", report.display()); + (es5_counts, later_counts) } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 27e8b3d79..6f64db618 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -655,6 +655,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files. - `vectorcraft-js` parses JavaScript as ECMAScript 5.1 defines it, with the legacy octal forms, block functions and lenient regular expressions of Annex B, into a syntax tree with source spans; it reads the `#target`, `#include` and other preprocessor lines as directives without running them, and a script that breaks the grammar or an early-error rule gives a `SyntaxError` with its line and column. Nesting past the parser's depth limit (`Options::max_depth`, 200 by default) gives a `SyntaxError` instead of exhausting the stack. `cargo xtask test262` fetches the conformance suite at a pinned commit and parses its ES5 tests: 5,411 of 5,418 runs pass (the 7 others use `let` and `const`). Nothing runs scripts yet. Tests: `crates/js/src/tests.rs`, `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs`. - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap. Built-ins are limited to `Object`, `Function` with `call` and `apply`, `eval` and the error types. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only these built-ins: 3,074 of 3,078 runs pass (the 4 others expect the completion values of later editions). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. + - `vectorcraft-js` has `String`, `Number`, `RegExp` and `Date` as ECMAScript 5.1 defines them (sections 15.5, 15.7, 15.9 and 15.10, https://262.ecma-international.org/5.1/), with Annex B's `substr`, `getYear`, `setYear` and `toGMTString`. `toFixed`, `toExponential`, `toPrecision` and `toString(radix)` format a number from its exact decimal value. Regular expressions (flags `g`, `i` and `m`) run on a backtracking matcher that keeps its choice points on the heap and takes fuel for every step, so a catastrophic pattern stops at the fuel limit and 50,000 nested groups give a `SyntaxError` at the default depth limit. A string method that would build a string past the heap limit stops before it allocates. `Date` reads the clock and time zone the host sets (`Realm::set_clock`, `Realm::set_time_zone`; local time is UTC by default). `cargo xtask test262` runs the suite's `built-ins` tests for the four (https://github.com/tc39/test262): of the runs of the ES5 tests that need only the built-ins present, 1,113 of 1,169 pass for `String`, 262 of 262 for `Number`, 878 of 908 for `RegExp` and 6 of 6 for `Date`. The others need `Array` and `Boolean` methods, or expect later editions' rules (built-in functions whose `length` can be deleted, flags given with a RegExp object, a negative `lastIndex`, `RegExp.prototype` as a plain object). The ES5 language tests that can now run give 3,742 of 3,746 passing runs. Tests: `crates/js/src/text_tests.rs`, `crates/js/tests/test262.rs`. - **UI:** Illustrator 2026 layout restyled to measured values: - Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs. - Hint bar, contextual task bar, 19 dock panels with ≡ menus. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index bfd6172ac..0324964c5 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -44,7 +44,8 @@ commands: stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) test262 fetch the pinned ECMAScript conformance suite into corpus/test262 (sha256 verified), parse its ES5 subset with vectorcraft-js and run the ES5 language - tests its interpreter supports, printing the counts + tests its interpreter supports and the String, Number, RegExp and Date + built-ins tests, printing the counts web-fonts copy the craft-fonts faces in crates/text/web-fonts.txt into the web site (the Trunk post_build hook; a no-op without CRAFT_FONTS_DIR) "; diff --git a/xtask/src/test262.rs b/xtask/src/test262.rs index 30f6f2472..88cbef395 100644 --- a/xtask/src/test262.rs +++ b/xtask/src/test262.rs @@ -2,7 +2,8 @@ //! BSD-3-Clause) at a pinned commit, fetched as GitHub's archive of that commit, checked against //! its SHA-256 and size, and unpacked into `corpus/test262/` (gitignored, never committed). It then //! runs `vectorcraft-js`'s `test262` tests, which parse the ES5 parse subset, run the ES5 -//! `test/language` tests that need only the interpreter's built-ins, and print each one's counts. +//! `test/language` tests that need only the interpreter's built-ins, run the `test/built-ins` tests +//! for `String`, `Number`, `RegExp` and `Date`, and print each one's counts. //! //! Moving the pin: change [`COMMIT`], run the command once with the new commit to see the //! archive's SHA-256 in the error, check the archive, and update [`SHA256`]. From 14ea556e92a9efb9097e68c24f3b7d2f7c8a7b8a Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 18:47:22 -0800 Subject: [PATCH 104/160] Plug-ins: a malformed ids is refused, and a command out of budget changes nothing plugin.run and plug-in commands read `ids` strictly: a value that is not a list, or an entry that is not a non-negative integer, is an error naming `ids` ("ids must be an array of non-negative integer object ids"). Left out or null, `ids` is still the selection. A new test shows that a plug-in command that runs out of its instruction budget leaves the document and the undo history unchanged, and that the error names the plug-in and the command. docs/plugins.md and docs/mcp.md name MCP's `command_list` and `list_commands` as the listings that show plug-in commands, and the params text, docs and progress line describe the `ids` rule. --- crates/engine/src/cmd/plugin.rs | 22 +++++++++-- crates/engine/src/tests/plugins.rs | 60 ++++++++++++++++++++++++++++++ docs/mcp.md | 7 ++-- docs/plugins.md | 12 +++--- docs/target-app-parity.md | 2 +- 5 files changed, 90 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 427a03f98..b8d2ada2a 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -33,7 +33,7 @@ use serde_json::{Map, Value, json}; use vectorcraft_doc::{Effect, Node}; use vectorcraft_plugins::{Budget, Command, Kind, Plugin, effect, export, objects, registry}; -use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, has_doc, ids_param, str_param, switch_or}; +use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, has_doc, str_param, switch_or}; use vectorcraft_plugins::permissions::{self, Grant, Permission}; @@ -94,6 +94,19 @@ fn plugin_params(p: &Value) -> Value { } } +/// The objects command `c`'s params `p` give as `ids`, or `None` when `p` leaves it out or gives +/// null. A value that is not a list, or an entry that is not a non-negative integer, is an error +/// naming `ids`: a malformed list is never read as the selection. +fn given_ids(c: &str, p: &Value) -> Result<Option<Vec<NodeId>>> { + let key = "ids"; + let malformed = || bad(c, format!("{key} must be an array of non-negative integer object ids")); + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::Array(a)) => a.iter().map(|v| v.as_u64().map(NodeId).ok_or_else(malformed)).collect::<Result<Vec<_>>>().map(Some), + Some(_) => Err(malformed()), + } +} + fn plugin_by_id(cmd: &str, p: &Value) -> Result<Arc<Plugin>> { let id = str_param(p, "id").ok_or_else(|| bad(cmd, "missing `id` (see plugin.list)"))?; registry::get(id).ok_or_else(|| err(vectorcraft_plugins::Error::NotFound(id.into()))) @@ -164,7 +177,7 @@ impl FilterRun { let mut params = Value::Object(resolved.map_err(|e| bad(c, e.to_string()))?); let may = m.permissions(); let st = s.doc()?; - let selected = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.clone()); + let selected = given_ids(c, p)?.unwrap_or_else(|| st.selection.objects.clone()); // Without `doc.read` a filter is given no objects: it can only add new ones. let inputs = if may.contains(&Permission::DocRead) { objects::filter_targets(&st.doc, &selected) } else { Vec::new() }; let parent = st.insertion_parent(); @@ -362,7 +375,8 @@ fn spec_of(p: &Plugin, c: &Command) -> &'static CommandSpec { let label = c.shown_label(); let top = if m.kind == Kind::Effect { "Effect" } else { "Object" }; let name = permissions::display_text(m.name.trim(), permissions::MAX_SHOWN_NAME); - let mut fields = String::from("ids?: [..] (default: the selection), params?: {…} (its parameters; or give them as top-level keys)"); + let mut fields = + String::from("ids?: [object id] (left out or null: the selection), params?: {…} (its parameters; or give them as top-level keys)"); let doc = m.for_command(c).params_doc(); if let Some(own) = doc.strip_prefix('{').and_then(|d| d.strip_suffix('}')).filter(|d| !d.is_empty()) { fields.push_str(", "); @@ -692,7 +706,7 @@ pub fn specs() -> Vec<CommandSpec> { "Run Plug-in", [], None, - "{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}", + "{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [object id] (left out or null: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}", has_doc, run ), diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 2dfd61b48..fc46cfcd7 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -966,6 +966,66 @@ fn a_failing_plugin_command_changes_nothing_and_names_itself() { run(&mut s, "plugin.remove", json!({"id": id})); } +/// A command that runs out of its instruction budget leaves the document and its history as they +/// were, and the error names the plug-in and the command. +#[test] +fn a_plugin_command_out_of_budget_changes_nothing_and_names_itself() { + let mut s = session(); + rect(&mut s, 10.0, "#ff0000"); + run(&mut s, "select.all", json!({})); + let id = "test.engine.cmds.spin"; + let triangle = r#"{"objects":[{"path":{"subpaths":[{"closed":true,"anchors":[{"p":[0,0]},{"p":[5,0]},{"p":[5,5]}]}]}}]}"#; + // More steps than a light budget pays for (as the plug-in crate's budget test counts them). + install_wat(&mut s, &tpl::spinning(&with_commands(id, "filter", r#"[{"id":"spin","label":"Spin"}]"#), 100_000_000, triangle)); + let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let e = s.execute(&crate::cmd::plugin::command_id(id, "spin"), &json!({})).unwrap_err().to_string(); + assert!(e.contains("“Cmd Test” › Spin: ") && e.contains("instruction budget"), "{e}"); + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + run(&mut s, "plugin.remove", json!({"id": id})); +} + +/// An `ids` that is not a list, or that holds an entry that is not an object id, is an error naming +/// `ids` for a plug-in command and for `plugin.run`, and is never read as the selection; left out +/// or null, `ids` is the selection. +#[test] +fn a_malformed_ids_is_refused_not_read_as_the_selection() { + let mut s = session(); + let id = "test.engine.cmds.ids"; + let grow = crate::cmd::plugin::command_id(id, "grow"); + install_wat(&mut s, &grower(id)); + rect(&mut s, 10.0, "#ff0000"); + run(&mut s, "select.all", json!({})); + let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let cases = [ + (grow.as_str(), json!(5)), + (grow.as_str(), json!([1, "x"])), + (grow.as_str(), json!([-1])), + ("plugin.run", json!("all")), + ("plugin.run", json!([1.5])), + ]; + for (cmd, ids) in cases { + let mut p = json!({"ids": ids}); + if cmd == "plugin.run" { + p["id"] = json!(id); + } + let e = s.execute(cmd, &p).unwrap_err(); + assert!(matches!(&e, EngineError::BadParams { cmd: c, .. } if c == cmd), "{cmd} {ids}: {e}"); + assert!(e.to_string().contains("ids must be an array of non-negative integer object ids"), "{cmd} {ids}: {e}"); + assert_eq!(s.doc().unwrap().doc, before, "{cmd} {ids}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{cmd} {ids}"); + } + // Left out or null, the command runs on the selection. + for p in [json!({}), json!({"ids": null})] { + let r = run(&mut s, &grow, p.clone()); + assert_eq!(r["command"], "grow", "{p}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{p}"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc().unwrap().doc, before, "{p}"); + } + run(&mut s, "plugin.remove", json!({"id": id})); +} + /// A live effect's commands are listed under Effect › Plug-ins, run under its permissions (it may /// not change the document), and labels show bidirectional characters escaped. A malformed /// `commands` entry is refused at install, and the error names it. diff --git a/docs/mcp.md b/docs/mcp.md index 0945d7070..280e26086 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5926,11 +5926,12 @@ permission names, and a `hash` that is not the module's SHA-256. `prefs.set` ret changes nothing when a new `pluginsFolder` is missing, is not a folder or can't be listed. It does not check an empty value or the folder the preference already holds, and the web build does not check folders. A filter or live effect may declare commands of its own ([plugins.md](plugins.md#commands)): each is the command -`plugin.<id>.<command>` while the plug-in is installed, listed by `command.list` and in its `plugin.list` entry +`plugin.<id>.<command>` while the plug-in is installed, listed by `command_list` and in its `plugin.list` entry (`commands: [{id, command, label, params, paramsSchema}]`). Run it with `{ids?, params?}` (its parameters may also be top-level keys): the parameters are checked before the plug-in runs, a run is one undo step named after the -command, and a failing run returns an error that starts with the plug-in's name and the command's label and leaves -the document as it was. +command, and a run that traps or runs out of its instruction budget returns an error that starts with the plug-in's +name and the command's label and leaves the document and the undo history as they were. For `plugin.run` and plug-in +commands, `ids` left out or null means the selection, and an `ids` that is not a list of object ids is an error. ```json {"name":"run_command","arguments":{"command":"plugin.install","params":{"path":"/plugins/desaturate.wasm"}}} diff --git a/docs/plugins.md b/docs/plugins.md index d44a1b291..777dc69e8 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -25,7 +25,7 @@ This page is the contract for plug-in authors and the reference for the host (`c |---|---|---| | `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, commands?, size, source, permissions, hash}], "folder": report}` (`budget: "heavy"` for a [heavy filter](#heavy-filters); `commands` lists its [commands](#commands); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256) | | `plugin.info` | `{"id"}` | One plug-in: the `plugin.list` entry plus `manifest`, `defaults` and `lastError` (a live effect's last failure). | -| `plugin.run` | `{"id", "params"?: {…}, "ids"?: [...]}` (plug-in params may also be top-level keys) | Runs an object filter on the selection (or `ids`). One undo step; the result is selected, except the objects it locked or made guides. | +| `plugin.run` | `{"id", "params"?: {…}, "ids"?: [...]}` (plug-in params may also be top-level keys) | Runs an object filter on the selection (or `ids`; an `ids` that is not a list of object ids is an error). One undo step; the result is selected, except the objects it locked or made guides. | | `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace": bool = true`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`, once the user agreed. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, once the user agreed. With `hash`, a module whose SHA-256 differs is refused. | | `plugin.inspect` | as `plugin.install`: `{"path"}` or `{"dataBase64", "name"?}` | Validates a module without installing it; returns the `plugin.list` entry it would have, plus `needsApproval` (true when `plugin.install` needs `grant`). | | `plugin.remove` | `{"id"}` | Uninstalls, and deletes it from the `Plug-ins` folder. | @@ -64,14 +64,16 @@ replaced by a module without it. shortcut. A manifest cannot give a command a shortcut. - **Actions and agents.** A command run is recorded in an action like any other command and plays back with its parameters. `run`, MCP and `command.batch` call it as `plugin.<id>.<command>` with `{"ids"?: [...], "params"?: {…}}` - (its parameters may also be top-level keys). `command.list` lists it, and `plugin.list` lists a plug-in's commands - as `commands: [{id, command, label, params, paramsSchema}]`. + (its parameters may also be top-level keys). MCP's `command_list` and `list_commands` list it, and `plugin.list` + lists a plug-in's commands as `commands: [{id, command, label, params, paramsSchema}]`. - **Runs.** The parameters are checked against the command's declarations before the plug-in runs. The plug-in runs through `vc_run` on the selection (or `ids`) as a filter run does, with `_context.mode` `"command"` and `_context.command` set to the command's id ([Parameters](#parameters)), under the plug-in's [permissions](#permissions): a live effect's command has no `doc.write`, so an output that changes the document is - refused. One run is one undo step named after the command. A run that traps or fails leaves the document as it was, - and the error starts with the plug-in's name and the command's label (`“Cmd Test” › Grow: …`). + refused. `ids` left out or null means the selection; an `ids` that is not a list of object ids is an error. One run + is one undo step named after the command. A run that traps, runs out of its instruction budget or fails leaves the + document and the undo history as they were, and the error starts with the plug-in's name and the command's label + (`“Cmd Test” › Grow: …`). **Preference folder.** Edit › Preferences › Plug-ins & Scratch Disks › *Additional Plug-ins Folder*: when set, every `*.wasm` in that folder (not recursive) is installed at startup and whenever the preference changes (desktop builds; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 04f4a6433..52d8aa2b9 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1429,7 +1429,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Plug-in data kept with the document (G17):** a filter writes JSON of at most 1 MiB with `documentData` (one undo step with its objects; `null` removes it) and reads it back in `_context.documentData`, which no other filter is given. It is the document's foreign data (`unknown.pluginData`, by plug-in id), as the perspective grid is, so the native file keeps it with no section of its own; an exporter is given the whole document, so it can read every plug-in's. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_export.rs` (the exporter), `crates/doc/src/plugin_data.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged. - **Heavy filter plug-ins (G17):** a filter's manifest may ask for `"budget": "heavy"`: 1 G instructions (20 times a light run's) and 300 s a run, for routing, packing or simulation. Install Plug-in… and File › Open ask the user first, `plugin.install` needs `allowHeavy: true` (`plugin.inspect` tells beforehand), the Additional Plug-ins Folder skips such filters, and the plug-in store keeps the answer. Its dialog has no Preview; the app runs it on a thread of its own with a progress window (Cancel, Continue in Background) and applies the result as `plugin.run` with the same parameters, one undo step, unless the document or the plug-in changed meanwhile. Regressions: `crates/plugins/tests/abi.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. -- **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin.<id>.<command>` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions, is one undo step named after the command, and on failure leaves the document as it was with an error naming the plug-in and the command. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. +- **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin.<id>.<command>` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions, is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one. From c7baa679d91449bb9be925316f4851ec3e80209d Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 19:02:53 -0800 Subject: [PATCH 105/160] JavaScript: apply takes fuel for each argument it reads Function.prototype.apply reads up to 65,536 elements from an array-like object. Each element read now takes a unit of fuel, so a long list ends the run with the fuel limit as other loops in the interpreter do. --- crates/js/src/interp.rs | 3 ++- crates/js/src/interp_tests.rs | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 1740c5733..9d16046d0 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -1550,7 +1550,8 @@ impl Interp { let saved = self.temp_roots.len(); let mut out = Vec::with_capacity(n); for i in 0..n { - match self.get_from(*o, &index_key(u32::try_from(i).unwrap_or(u32::MAX)), list) { + // Each element read takes fuel, so a long array-like list stays within the run's limits. + match self.use_fuel().and_then(|()| self.get_from(*o, &index_key(u32::try_from(i).unwrap_or(u32::MAX)), list)) { Ok(v) => { self.temp_roots.push(v.clone()); out.push(v); diff --git a/crates/js/src/interp_tests.rs b/crates/js/src/interp_tests.rs index 8dff13050..03d9e6639 100644 --- a/crates/js/src/interp_tests.rs +++ b/crates/js/src/interp_tests.rs @@ -391,6 +391,13 @@ fn shrinking_an_array_takes_fuel_for_each_property() { assert_eq!(realm.run("a.length = 1; a.length + a[0] + (99999 in a ? 1 : 0)"), Ok(JsValue::Number(1.0))); } +#[test] +fn apply_takes_fuel_for_each_argument_it_reads() { + let mut realm = small(Limits { fuel: 1000, ..Limits::default() }); + assert_eq!(limit(&mut realm, "(function () {}).apply(null, {length: 65536});"), LimitKind::Fuel); + assert_eq!(realm.run("(function () { return arguments.length; }).apply(null, {length: 100})"), Ok(JsValue::Number(100.0))); +} + #[test] fn declarations_check_the_heap_before_they_are_bound() { // Each script declares more than fits in the heap. The run ends with the heap limit before From 396784777227b93479cc8b8b9cac91a0a56f3a01 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 19:18:10 -0800 Subject: [PATCH 106/160] .ai save: the large-canvas warning says geometry effects are applied to the art On a large canvas no art style is written, so the editing data holds an object's geometry effects only as the artwork they made: the effects can no longer be edited when the file is opened again. The warning now says that the geometry effects are applied to the artwork and can no longer be edited, and that the object's other effects are left out. --- crates/eps/src/ai_editing/writer/styles.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index b33805754..1f10bb4c8 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -19,7 +19,7 @@ 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, no art styles or graphic styles are written, and objects with live effects keep only their geometry effects in the editing data for other apps"; +pub(in crate::ai_editing) const LARGE_CANVAS_STYLES: &str = "on a large canvas, no art styles or graphic styles are written: objects with live effects are written with their geometry effects applied to the artwork, where they can no longer be edited, and their other effects are left out of the editing data for other apps"; pub(in crate::ai_editing) const PANEL_STYLES: &str = "graphic styles with gradient or pattern fills, with fills, strokes or effects the editing data can't hold as a graphic style, with a name another style has, with names over 1,024 bytes, or past 4,096 styles, are left out of the editing data for other apps"; pub(in crate::ai_editing) const PANEL_EMPTY: &str = "a Graphic Styles panel with no style written opens from the editing data with a new document's styles"; From ab9612c4e6cb1fafcdfa69ded31645481af0d3b4 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 19:24:19 -0800 Subject: [PATCH 107/160] Keyboard Shortcuts: lists made for removed plug-in commands are freed The Keyboard Shortcuts entries and the default keymap were made again for each set of installed plug-in commands and kept for the rest of the process. They are now shared values kept only for the current set of plug-in commands, so an older one is freed once nothing holds it. The test that compares the keymap's catalog with the entry list reads both for one set of plug-in commands, so a test that installs a plug-in alongside it no longer makes it fail. --- crates/ui-egui/src/dialogs/shortcut_import.rs | 31 +++--- crates/ui-egui/src/keymap.rs | 98 ++++++++++++++----- crates/ui-egui/src/shortcut_editor.rs | 66 ++++++------- crates/ui-egui/src/shortcuts/tests.rs | 4 +- crates/ui-egui/src/tests/plugins.rs | 3 +- 5 files changed, 123 insertions(+), 79 deletions(-) diff --git a/crates/ui-egui/src/dialogs/shortcut_import.rs b/crates/ui-egui/src/dialogs/shortcut_import.rs index 7360b3a32..fd638f971 100644 --- a/crates/ui-egui/src/dialogs/shortcut_import.rs +++ b/crates/ui-egui/src/dialogs/shortcut_import.rs @@ -377,12 +377,12 @@ pub fn candidates() -> Vec<Candidate> { /// Keyboard Shortcuts' entry `key`. The commands of installed plug-ins are looked up in /// [`shortcut_editor::entries`] as it is now. -fn entry(key: &str) -> Option<&'static Entry> { - static BY_KEY: OnceLock<HashMap<&'static str, &'static Entry>> = OnceLock::new(); +fn entry(key: &str) -> Option<Entry> { + static BY_KEY: OnceLock<HashMap<String, Entry>> = OnceLock::new(); if vectorcraft_engine::cmd::plugin::is_command_id(key) { - return shortcut_editor::entries().iter().find(|e| e.key == key); + return shortcut_editor::entry(key); } - BY_KEY.get_or_init(|| shortcut_editor::entries().iter().map(|e| (e.key.as_str(), e)).collect()).get(key).copied() + BY_KEY.get_or_init(|| shortcut_editor::entries().iter().map(|e| (e.key.clone(), e.clone())).collect()).get(key).cloned() } /// A tool, command or panel that Keyboard Shortcuts can give a shortcut. @@ -400,7 +400,7 @@ fn target_label(target: Option<&str>) -> String { match target { None => tl!("Choose…").to_string(), Some("") => tl!("No equivalent").to_string(), - Some(t) => entry(t).map_or_else(|| t.to_string(), shown), + Some(t) => entry(t).map_or_else(|| t.to_string(), |e| shown(&e)), } } @@ -948,22 +948,27 @@ pub(super) fn target_picker(ui: &mut egui::Ui, i: usize, r: &Row) -> Option<Stri } let q = widgets::search_field(ui, field, tl!("Search")).to_lowercase(); let mut picked = option(ui, "", tl!("No equivalent")); - let list: Vec<&Entry> = if q.is_empty() { + let all = shortcut_editor::entries(); + let list: Vec<usize> = if q.is_empty() { for e in r.choices.iter().filter_map(|c| entry(c)) { - picked = option(ui, &e.key, &shown(e)).or(picked); + picked = option(ui, &e.key, &shown(&e)).or(picked); } ui.separator(); - shortcut_editor::entries().iter().collect() + (0..all.len()).collect() } else { - shortcut_editor::entries().iter().filter(|e| shown(e).to_lowercase().contains(&q) || e.key.to_lowercase().contains(&q)).collect() + all.iter() + .enumerate() + .filter(|(_, e)| shown(e).to_lowercase().contains(&q) || e.key.to_lowercase().contains(&q)) + .map(|(i, _)| i) + .collect() }; ui.data_mut(|d| d.insert_temp(header_id, ui.min_rect().height())); - (list, picked) + (all, list, picked) }, ROW_H, - |(list, _)| list.len(), - |ui, range, (list, mut picked)| { - for e in range.filter_map(|k| list.get(k)) { + |(_, list, _)| list.len(), + |ui, range, (all, list, mut picked)| { + for e in range.filter_map(|k| list.get(k).and_then(|&i| all.get(i))) { picked = option(ui, &e.key, &shown(e)).or(picked); } if picked.is_some() { diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 844db0a98..94a26f001 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -7,7 +7,7 @@ //! outside it are VectorCraft's too (`Cmd+Shift+]`, `Comma`, `Period`, `~` for the backtick key, //! `=` for Plus with modifiers). Inside an [`Overrides`] keys and texts are the crate's. -use std::sync::{Mutex, OnceLock, RwLock}; +use std::sync::{Arc, Mutex, OnceLock, RwLock}; use craft_keymap::{Bindable, Binding, Chord, CommandRef, Key, Keymap, Kind, Lookup, Mods, Overrides, Platform, Scope, Scopes}; use serde_json::{Value, json}; @@ -142,7 +142,8 @@ fn catalogue() -> Vec<Bindable> { 0 } }; - let mut entries: Vec<&Entry> = shortcut_editor::entries().iter().collect(); + let all = shortcut_editor::entries(); + let mut entries: Vec<&Entry> = all.iter().collect(); entries.sort_by_key(|e| rank(e)); entries .into_iter() @@ -212,25 +213,43 @@ pub(crate) fn per_platform<T>(cells: &'static [OnceLock<T>; 3], p: Platform, mak cell.get_or_init(make) } -/// The keymap with the default shortcuts for [`platform`], built once per platform, and again for -/// each set of plug-in commands installed ([`vectorcraft_engine::cmd::plugin::commands_generation`]). -pub fn template() -> &'static Keymap { - static TEMPLATES: [OnceLock<Keymap>; 3] = [OnceLock::new(), OnceLock::new(), OnceLock::new()]; - static WITH_PLUGINS: Mutex<Vec<(Platform, u64, &'static Keymap)>> = Mutex::new(Vec::new()); +/// Values made for one set of installed plug-in commands +/// ([`vectorcraft_engine::cmd::plugin::commands_generation`]), one for each key. Only the values of +/// the generation asked for last are kept; an older one is freed once its last holder drops it. +pub(crate) struct Latest<K, T: ?Sized> { + made: Mutex<Vec<(K, u64, Arc<T>)>>, +} + +impl<K: PartialEq, T: ?Sized> Latest<K, T> { + pub(crate) const fn new() -> Self { + Latest { made: Mutex::new(Vec::new()) } + } + + /// The value for `key` at `generation`, made with `make` when there is none yet. + pub(crate) fn get(&self, key: K, generation: u64, make: impl FnOnce() -> Arc<T>) -> Arc<T> { + let mut made = self.made.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((_, _, v)) = made.iter().find(|(k, g, _)| *k == key && *g == generation) { + return v.clone(); + } + made.retain(|(_, g, _)| *g == generation); + let v = make(); + made.push((key, generation, v.clone())); + v + } +} + +/// The keymap with the default shortcuts for [`platform`], built once per platform, and again when +/// the installed plug-in commands change ([`vectorcraft_engine::cmd::plugin::commands_generation`]). +/// Only the keymaps of the current plug-in commands are kept ([`Latest`]). +pub fn template() -> Arc<Keymap> { + static TEMPLATES: [OnceLock<Arc<Keymap>>; 3] = [OnceLock::new(), OnceLock::new(), OnceLock::new()]; + static WITH_PLUGINS: Latest<Platform, Keymap> = Latest::new(); let p = platform(); let generation = vectorcraft_engine::cmd::plugin::commands_generation(); if generation == 0 { - return per_platform(&TEMPLATES, p, || keymap_for(p)); + return per_platform(&TEMPLATES, p, || Arc::new(keymap_for(p))).clone(); } - let mut made = WITH_PLUGINS.lock().unwrap_or_else(|e| e.into_inner()); - if let Some((_, _, km)) = made.iter().find(|(q, g, _)| *q == p && *g == generation) { - return km; - } - made.retain(|(_, g, _)| *g == generation); - // Kept for the rest of the process: one keymap for each set of plug-in commands installed. - let km: &'static Keymap = Box::leak(Box::new(keymap_for(p))); - made.push((p, generation, km)); - km + WITH_PLUGINS.get(p, generation, || Arc::new(keymap_for(p))) } /// The default keymap of `p`. The chords the operating system takes can't be assigned. @@ -240,7 +259,7 @@ fn keymap_for(p: Platform) -> Keymap { /// The keymap with `overrides` applied. pub fn keymap_with(overrides: &Overrides) -> Keymap { - template().clone().with_overrides(overrides.clone()) + Keymap::clone(&template()).with_overrides(overrides.clone()) } /// The command a chord runs, the first for tools sharing it. @@ -409,7 +428,8 @@ mod tests { #[test] fn curvature_keeps_shift_tilde_and_plus_is_one_key() { - let km = template(); + let template = template(); + let km = &*template; let curvature = vc_chord("Shift+~").unwrap(); assert_eq!(curvature, Chord::parse("Shift+`").unwrap()); assert_eq!(lookup(km, &curvature), Some(CommandRef::new("tool:curvature"))); @@ -431,9 +451,17 @@ mod tests { fn the_default_shortcuts_do_not_conflict() { assert_eq!(template().conflicts(), vec![]); assert_eq!(template().problems(), vec![]); - assert_eq!(template().catalog().len(), shortcut_editor::entries().len(), "every entry is bindable"); + // Both lists for one set of plug-in commands: a test running alongside may install one. + let (km, entries) = loop { + let before = vectorcraft_engine::cmd::plugin::commands_generation(); + let both = (template(), shortcut_editor::entries()); + if vectorcraft_engine::cmd::plugin::commands_generation() == before { + break both; + } + }; + assert_eq!(km.catalog().len(), entries.len(), "every entry is bindable"); // A default the bridge can't read would show in the menus but never run. - let unbound: Vec<(&str, &str)> = shortcut_editor::entries() + let unbound: Vec<(&str, &str)> = entries .iter() .filter_map(|e| shortcut_editor::default_of(&e.key).map(|d| (e.key.as_str(), d))) .filter(|(key, d)| { @@ -445,6 +473,22 @@ mod tests { assert_eq!(unbound, vec![], "every default text becomes the entry's binding"); } + /// A value made for an older set of plug-in commands is freed once a newer one is asked for; + /// within one set each key keeps its own value, made once. + #[test] + fn only_the_latest_plugin_commands_are_kept() { + let cache: Latest<u8, str> = Latest::new(); + let first = cache.get(0, 1, || Arc::from("a")); + assert!(Arc::ptr_eq(&first, &cache.get(0, 1, || Arc::from("b"))), "made once"); + let other = Arc::downgrade(&cache.get(1, 1, || Arc::from("c"))); + assert!(other.upgrade().is_some(), "each key has its own"); + let old = Arc::downgrade(&first); + drop(first); + assert_eq!(&*cache.get(0, 2, || Arc::from("d")), "d"); + assert!(old.upgrade().is_none(), "the older value is freed"); + assert!(other.upgrade().is_none(), "the older value of the other key is freed"); + } + #[test] fn paint_defaults_have_two_bindings_and_reset_restores_both() { for (key, plain, shifted) in [("paint.lastColor", ",", "Shift+,"), ("paint.lastGradient", ".", "Shift+.")] { @@ -452,10 +496,10 @@ mod tests { let defaults = template().default_bindings(&command); assert_eq!(defaults, vec![vc_binding(plain).unwrap(), vc_binding(shifted).unwrap()], "{key}"); assert_eq!( - shortcut_editor::entry(key).map(|e| e.label.as_str()), - Some(if key == "paint.lastColor" { "Apply Last Color" } else { "Apply Last Gradient" }) + shortcut_editor::entry(key).map(|e| e.label), + Some(if key == "paint.lastColor" { "Apply Last Color" } else { "Apply Last Gradient" }.to_string()) ); - assert_eq!(effective_with(template(), key).as_deref(), Some(plain)); + assert_eq!(effective_with(&template(), key).as_deref(), Some(plain)); let mut overrides = Overrides::new(); set_override(&mut overrides, key, "Shift+;"); assert_eq!(keymap_with(&overrides).bindings(&command), vec![vc_binding("Shift+;").unwrap()]); @@ -494,7 +538,8 @@ mod tests { assert_eq!(entry_key(&c), "panel:layers"); assert_eq!(entry_of_stored(&stored_key("panel:layers")), "panel:layers"); assert_eq!(stored_key("tool:pen"), "tool:pen"); - let paths = &template().bindable(&c).unwrap().paths; + let template = template(); + let paths = &template.bindable(&c).unwrap().paths; assert_eq!(paths, &vec![vec!["Window".to_string(), "Layers".to_string()]], "import matches panels by name"); } @@ -506,8 +551,7 @@ mod tests { for id in TOGGLES { let label = crate::menus::dynamic_label(&app, id, ""); assert!(label.starts_with("Show ") || label.starts_with("Hide "), "{id}: {label}"); - let listed = template().bindable(&CommandRef::new(*id)); - assert!(listed.is_none_or(|b| b.is_toggle), "{id}"); + assert!(template().bindable(&CommandRef::new(*id)).is_none_or(|b| b.is_toggle), "{id}"); } } } diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index 07a6af693..39c7caf55 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -73,7 +73,7 @@ pub(crate) struct Mirror { impl Mirror { fn new(overrides: Overrides) -> Mirror { let plugins = vectorcraft_engine::cmd::plugin::commands_generation(); - let keymap = crate::keymap::template().clone().with_overrides(overrides); + let keymap = Keymap::clone(&crate::keymap::template()).with_overrides(overrides); let text = keymap .overrides() .iter() @@ -360,36 +360,30 @@ pub struct Entry { /// Every tool, command and panel the editor can give a shortcut, followed by the commands of the /// installed plug-ins; [`listed`] says which it lists. The list is made again when the plug-in -/// commands change ([`vectorcraft_engine::cmd::plugin::commands_generation`]). -pub fn entries() -> &'static [Entry] { - static WITH_PLUGINS: Mutex<Option<(u64, &'static [Entry])>> = Mutex::new(None); +/// commands change ([`vectorcraft_engine::cmd::plugin::commands_generation`]). Only the list of the +/// current plug-in commands is kept ([`crate::keymap::Latest`]). +pub fn entries() -> Arc<[Entry]> { + static WITH_PLUGINS: crate::keymap::Latest<(), [Entry]> = crate::keymap::Latest::new(); let base = built_in_entries(); let generation = vectorcraft_engine::cmd::plugin::commands_generation(); if generation == 0 { - return base; + return base.clone(); } - let mut with = WITH_PLUGINS.lock().unwrap_or_else(|e| e.into_inner()); - if let Some((g, e)) = *with - && g == generation - { - return e; - } - let mut v = base.to_vec(); - v.extend(vectorcraft_engine::cmd::plugin::command_specs().into_iter().map(|c| Entry { - key: c.id.into(), - label: c.label.into(), - group: c.menu.join(" › "), - is_tool: false, - })); - // Kept for the rest of the process: one list for each set of plug-in commands installed. - let e: &'static [Entry] = Box::leak(v.into_boxed_slice()); - *with = Some((generation, e)); - e + WITH_PLUGINS.get((), generation, || { + let mut v = base.to_vec(); + v.extend(vectorcraft_engine::cmd::plugin::command_specs().into_iter().map(|c| Entry { + key: c.id.into(), + label: c.label.into(), + group: c.menu.join(" › "), + is_tool: false, + })); + v.into() + }) } /// [`entries`] without the plug-in commands. -fn built_in_entries() -> &'static [Entry] { - static E: OnceLock<Vec<Entry>> = OnceLock::new(); +fn built_in_entries() -> &'static Arc<[Entry]> { + static E: OnceLock<Arc<[Entry]>> = OnceLock::new(); E.get_or_init(|| { let mut v: Vec<Entry> = vectorcraft_tools::catalog::all_tools() .map(|t| Entry { key: format!("tool:{}", t.id), label: t.label.to_string(), group: "Tools".into(), is_tool: true }) @@ -430,9 +424,8 @@ fn built_in_entries() -> &'static [Entry] { } v.push(Entry { key: id.to_string(), label: label.to_string(), group: id_group(id), is_tool: false }); } - v + v.into() }) - .as_slice() } /// One Effect menu item a shortcut can be given (#3296): the effect's id, the command its item runs @@ -621,8 +614,8 @@ fn names_a_required_field(params: &str) -> bool { false } -pub fn entry(key: &str) -> Option<&'static Entry> { - entries().iter().find(|e| e.key == key) +pub fn entry(key: &str) -> Option<Entry> { + entries().iter().find(|e| e.key == key).cloned() } /// Entries (other than `except`) whose effective shortcut equals `chord`. @@ -1217,7 +1210,7 @@ fn export_from_dialog(app: &mut VectorcraftApp) { fn drop_hidden(d: &mut Dialog, overrides: &Overrides) { for field in ["__recording", "__selected", "__conflict"] { let key = d.str(field); - if !key.is_empty() && entry(&key).is_none_or(|e| !listed(overrides, e)) { + if !key.is_empty() && entry(&key).is_none_or(|e| !listed(overrides, &e)) { d.fields.insert(field.into(), json!("")); } } @@ -1259,7 +1252,7 @@ mod tests { #[test] fn format_parse_round_trip_for_every_default() { - for e in entries() { + for e in entries().iter() { if let Some(s) = default_of(&e.key) && let Some(sc) = crate::shortcuts::parse(s) { @@ -1574,7 +1567,7 @@ mod tests { #[test] fn duplicate_can_be_given_a_shortcut() { // Duplicate has no default chord (#1539): it must still be listed so one can be set. - assert_eq!(entry("edit.duplicate").map(|e| e.group.as_str()), Some("Edit")); + assert_eq!(entry("edit.duplicate").map(|e| e.group).as_deref(), Some("Edit")); let mut ov = Overrides::new(); assign(&mut ov, "edit.duplicate", Some("Cmd+Alt+Shift+F8"), false).unwrap(); assert_eq!(effective_in(&ov, "edit.duplicate").as_deref(), Some("Cmd+Alt+Shift+F8")); @@ -1647,10 +1640,11 @@ mod tests { // whose params are all optional, one the menu bar runs without params (Rotate… opens its // dialog) and one that opens a dialog for its values (New Variable). let none = Overrides::new(); - let dropped: Vec<&str> = entries().iter().filter(|e| default_of(&e.key).is_some() && !listed(&none, e)).map(|e| e.key.as_str()).collect(); + let all = entries(); + let dropped: Vec<&str> = all.iter().filter(|e| default_of(&e.key).is_some() && !listed(&none, e)).map(|e| e.key.as_str()).collect(); assert!(dropped.is_empty(), "{dropped:?}"); for id in ["edit.duplicate", "object.pathfinder.unite", "object.rotate", "variable.define", "object.distributeSpacing"] { - assert!(entry(id).is_some_and(|e| listed(&none, e)), "{id}"); + assert!(entry(id).is_some_and(|e| listed(&none, &e)), "{id}"); } for (doc, required) in [ ("{}", false), @@ -1679,12 +1673,12 @@ mod tests { let others = ["swatch.unused", "graphicStyle.unused", "document.info", "view.proofSetup", "magicWand.set", "edit.findNext"]; for id in NEEDS_A_PARAM.iter().chain(&others) { let e = entry(id).unwrap_or_else(|| panic!("{id} has an entry")); - assert!(!listed(&none, e), "{id}"); + assert!(!listed(&none, &e), "{id}"); assert_eq!(default_of(id), None, "{id}"); } let mut ov = Overrides::new(); crate::keymap::set_override(&mut ov, "transparency.set", "Cmd+Alt+Shift+F10"); - assert!(entry("transparency.set").is_some_and(|e| listed(&ov, e))); + assert!(entry("transparency.set").is_some_and(|e| listed(&ov, &e))); let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); for id in ["transparency.set", "transparency.setOpacityMask"] { let list = run_command(&mut app, "shortcuts.list", &json!({ "query": id })).unwrap().unwrap(); @@ -1699,7 +1693,7 @@ mod tests { "addon.color.printFixes", "edit.findReplace", ] { - assert!(entry(id).is_some_and(|e| listed(&none, e)), "{id}"); + assert!(entry(id).is_some_and(|e| listed(&none, &e)), "{id}"); } assert_eq!(menu_target("object.path.cleanUp").map(|(id, _)| id).as_deref(), Some("ui.paramDialog")); assert_eq!( diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index 5fbffb533..bdbeb543c 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -510,7 +510,7 @@ fn cmd_alt_k_and_cmd_alt_shift_f_highlight_the_character_fields() { assert!(app.ui.dialog.is_none(), "not Preferences"); // Keyboard Shortcuts lists all three under Type; Highlight Tracking has no default key. for id in ["type.highlightFontName", "type.highlightKerning", "type.highlightTracking"] { - assert_eq!(crate::shortcut_editor::entry(id).map(|e| e.group.as_str()), Some("Type"), "{id}"); + assert_eq!(crate::shortcut_editor::entry(id).map(|e| e.group).as_deref(), Some("Type"), "{id}"); } assert_eq!(crate::menus::shortcut_of("type.highlightTracking"), None); } @@ -2347,7 +2347,7 @@ fn all_registered_shortcuts_parse() { /// dialog for the selection. #[test] fn an_effect_menu_item_takes_a_shortcut_of_its_own() { - let entry = crate::shortcut_editor::entries().iter().find(|e| e.key == "effect:distort.transform").expect("Transform is listed"); + let entry = crate::shortcut_editor::entry("effect:distort.transform").expect("Transform is listed"); assert_eq!(entry.group, "Effect › Distort & Transform"); assert_eq!(crate::keymap::command_of("effect:distort.transform").id, "effect.dialog"); let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); diff --git a/crates/ui-egui/src/tests/plugins.rs b/crates/ui-egui/src/tests/plugins.rs index 3f898f49f..e6f3cf74a 100644 --- a/crates/ui-egui/src/tests/plugins.rs +++ b/crates/ui-egui/src/tests/plugins.rs @@ -220,7 +220,7 @@ fn plugin_commands_are_menu_items_and_shortcut_entries() { assert!(!entries.iter().any(|e| e.command.as_deref() == Some(size) && e.path.first().map(String::as_str) != Some("Object")), "not in File"); assert!(!menus::menu_strings().contains("Cmd Test"), "the plug-in's name is not text to translate"); - let listed = crate::shortcut_editor::entries().iter().find(|e| e.key == grow).expect("in Keyboard Shortcuts"); + let listed = crate::shortcut_editor::entry(grow).expect("in Keyboard Shortcuts"); assert_eq!((listed.label.as_str(), listed.group.as_str()), ("Grow", "Object › Plug-ins › Cmd Test")); assert_eq!(crate::shortcut_editor::default_of(grow), None, "a manifest gives no shortcut"); assert!(crate::keymap::template().bindable(&crate::keymap::command_of(grow)).is_some(), "a shortcut can be given to it"); @@ -238,6 +238,7 @@ fn plugin_commands_are_menu_items_and_shortcut_entries() { app.run("plugin.remove", json!({"id": id})).unwrap(); assert!(menus::menu_entries(&app).iter().all(|e| e.command.as_deref() != Some(grow))); assert!(crate::shortcut_editor::entries().iter().all(|e| e.key != grow)); + assert!(crate::keymap::template().bindable(&crate::keymap::command_of(grow)).is_none()); } /// A shortcut the user gives a plug-in command runs it. From cea958b782a7023af60f51590855fc8533f25cfb Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 19:29:53 -0800 Subject: [PATCH 108/160] JavaScript: text built-ins hold their working memory against the heap limit Native code now holds the Rust memory it works in against the heap limit until it is done with it: a matcher's capture state and stack, a pattern being compiled, the matches and pieces of a replacement, and the text Date.parse reads. A collection counts these bytes as live while they are held. - replace reads its replacement pattern once into parts that refer to ranges of the pattern, so literal text is not copied for each match. Each piece takes fuel for its units, and the result's length is checked against the heap as each piece is added. - The regular expression compiler charges heap through its callback as it builds, including the ranges of a character class, and each class atom and range takes fuel. A compiled form counts the capacity of its lists. - Clearing the groups inside a quantifier and clearing the matcher's state before each attempt take fuel. - Flattening a rope in a String, RegExp or Date method reserves the flat copy on the heap and takes fuel for it. - A RegExp object's bytes are reserved together with its compiled form, so a collection between the two does not drop the compiled form from the heap count. --- crates/js/src/date_builtins.rs | 15 +- crates/js/src/interp.rs | 50 +++++- crates/js/src/regexp_builtins.rs | 59 +++---- crates/js/src/regexp_vm.rs | 275 ++++++++++++++++++---------- crates/js/src/string_builtins.rs | 295 ++++++++++++++++++++----------- crates/js/src/text_tests.rs | 140 ++++++++++++--- 6 files changed, 582 insertions(+), 252 deletions(-) diff --git a/crates/js/src/date_builtins.rs b/crates/js/src/date_builtins.rs index b453910ff..e81f73ee9 100644 --- a/crates/js/src/date_builtins.rs +++ b/crates/js/src/date_builtins.rs @@ -13,8 +13,8 @@ use chrono::{Datelike, NaiveDate}; use crate::builtins::{arg, constructor, data, method, native}; use crate::heap::{Class, NativeFn, NativeKind, Object, key}; -use crate::interp::{ErrorType, Hint, Interp, JsResult}; -use crate::string_builtins::{charge_units, reserve_units, to_integer}; +use crate::interp::{ErrorType, HeldBytes, Hint, Interp, JsResult}; +use crate::string_builtins::{charge_units, flat_charged, reserve_units, to_integer}; use crate::value::{Str, Value}; /// The clock and time zone a realm's `Date` reads. @@ -425,10 +425,15 @@ fn parse_text(interp: &Interp, s: &str) -> Option<f64> { Some(time_clip(t)) } -/// Takes fuel for the units of `s` the parsers read. +/// Takes fuel for the units of `s` the parsers read. The text the parsers read is held against +/// the heap limit while they read it. fn parse_date(interp: &mut Interp, s: &Str) -> JsResult<f64> { - charge_units(interp, s.len())?; - let text = s.to_rust_string(); + let units = flat_charged(interp, s)?; + charge_units(interp, units.len())?; + let mut held = HeldBytes::new(interp); + // Each code unit becomes at most three UTF-8 bytes. + held.add(interp, units.len().saturating_mul(3))?; + let text = String::from_utf16_lossy(&units); let trimmed = text.trim(); Ok(parse_iso(trimmed.as_bytes()).or_else(|| parse_text(interp, trimmed)).unwrap_or(f64::NAN)) } diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 974e0c181..ed78aa209 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -166,6 +166,50 @@ pub(crate) struct Interp { pub(crate) last_thrown: Value, /// The clock and time zone `Date` reads. pub(crate) date_host: crate::date_builtins::DateHost, + /// The bytes native code holds outside the heap's cells through [`HeldBytes`]. + pub(crate) held: Rc<HeldCount>, +} + +/// The bytes all live [`HeldBytes`] hold. +#[derive(Default)] +pub(crate) struct HeldCount { + now: std::cell::Cell<usize>, +} + +/// Bytes that native code holds in Rust memory while it works (a matcher's state and stack, a +/// replacement being built, a pattern being compiled), counted against the heap limit. A +/// collection adds them to the live total until the holder is dropped. +pub(crate) struct HeldBytes { + count: Rc<HeldCount>, + bytes: usize, +} + +impl HeldBytes { + pub(crate) fn new(interp: &Interp) -> Self { + Self { count: interp.held.clone(), bytes: 0 } + } + + /// The bytes this holder holds. + #[cfg(test)] + pub(crate) fn bytes(&self) -> usize { + self.bytes + } + + /// Holds `n` more bytes, ending the run with the heap limit when they would not fit after a + /// collection. Everything the caller still needs must be reachable from the roots. + pub(crate) fn add(&mut self, interp: &mut Interp, n: usize) -> JsResult<()> { + interp.reserve_heap(n)?; + interp.heap.bytes = interp.heap.bytes.saturating_add(n); + self.bytes = self.bytes.saturating_add(n); + self.count.now.set(self.count.now.get().saturating_add(n)); + Ok(()) + } +} + +impl Drop for HeldBytes { + fn drop(&mut self) { + self.count.now.set(self.count.now.get().saturating_sub(self.bytes)); + } } /// The least heap size a collection waits for. @@ -292,6 +336,7 @@ impl Interp { throw_site: None, last_thrown: Value::Undefined, date_host: crate::date_builtins::DateHost::default(), + held: Rc::default(), }; crate::builtins::install(&mut interp); interp.gc_threshold = interp.next_threshold(); @@ -418,8 +463,8 @@ impl Interp { Ok(()) } - /// Collects garbage now, for a caller whose work ran out of heap room, and sets when the - /// next collection runs. + /// Collects garbage now and sets when the next collection runs. + #[cfg(test)] pub(crate) fn collect_for_room(&mut self) { self.collect_garbage(); self.gc_threshold = self.next_threshold(); @@ -452,6 +497,7 @@ impl Interp { roots.objects.extend(self.intrinsics.all()); roots.envs.push(self.global_env); self.heap.collect(&roots); + self.heap.bytes = self.heap.bytes.saturating_add(self.held.now.get()); } /// Runs frames until the boundary frame on top returns, and returns its value. diff --git a/crates/js/src/regexp_builtins.rs b/crates/js/src/regexp_builtins.rs index a882efd3a..2ecadafd8 100644 --- a/crates/js/src/regexp_builtins.rs +++ b/crates/js/src/regexp_builtins.rs @@ -3,18 +3,17 @@ use std::rc::Rc; -use crate::LimitKind; use crate::builtins::{arg, constructor, method, native}; use crate::heap::{Class, NativeKind, OBJECT_BYTES, OPEN, ObjId, Object, PROP_BYTES, Prop, WRITABLE, key, string_bytes}; -use crate::interp::{Abrupt, ErrorType, Interp, JsResult}; -use crate::regexp_vm::{CompileFail, Flags, Matcher, Regex, compile, parse_flags, use_fuel_n}; -use crate::string_builtins::{new_array, reserve_units, rooted, set_element, substring_value, to_integer}; +use crate::interp::{ErrorType, HeldBytes, Interp, JsResult}; +use crate::regexp_vm::{CompileFail, Cost, Flags, Matcher, Regex, compile, parse_flags, use_fuel_n}; +use crate::string_builtins::{flat_charged, new_array, reserve_units, rooted, set_element, substring_value, to_integer}; use crate::value::{Str, Value}; pub(crate) fn install(interp: &mut Interp) -> JsResult<()> { let proto = interp.intrinsics.regexp_proto; // The prototype is itself a regular expression object, matching the empty string. - let empty = compile_regex(interp, &[], &[])?; + let empty = compile_regex(interp, &[], &[], 0)?; if let Some(p) = interp.heap.object_mut(proto) { p.class = Class::RegExp(empty); } @@ -36,8 +35,10 @@ pub(crate) fn regexp_of(interp: &Interp, v: &Value) -> Option<(ObjId, Rc<Regex>) } /// Checks and compiles a pattern, throwing a `SyntaxError` for a pattern or flags that are not -/// valid. The compiled form's bytes are reserved on the heap. -fn compile_regex(interp: &mut Interp, pattern: &[u16], flags: &[u16]) -> JsResult<Rc<Regex>> { +/// valid. What compiling holds is held against the heap limit as it grows. The compiled form's +/// bytes are then reserved together with `extra` more and added to the heap, so a caller that +/// stores the result in an object it allocates next reserves that object here. +fn compile_regex(interp: &mut Interp, pattern: &[u16], flags: &[u16], extra: usize) -> JsResult<Rc<Regex>> { let flags: Flags = match parse_flags(flags) { Ok(f) => f, Err(m) => return Err(interp.throw(ErrorType::Syntax, &m)), @@ -46,30 +47,26 @@ fn compile_regex(interp: &mut Interp, pattern: &[u16], flags: &[u16]) -> JsResul if let Err(m) = crate::regexp::check(pattern, depth) { return Err(interp.throw(ErrorType::Syntax, &format!("invalid regular expression: {m}"))); } - // A compile that runs out of room collects garbage and tries once more with the room it frees. - let mut collected = false; - let re = loop { - let room = interp.limits.heap_bytes.saturating_sub(interp.heap.bytes); - match compile(pattern, flags, depth, room, &mut |n| use_fuel_n(interp, n)) { - Ok(re) => break re, - Err(CompileFail::Abrupt(Abrupt::Limit(LimitKind::Heap))) if !collected => { - interp.collect_for_room(); - collected = true; - } - Err(CompileFail::Syntax(m)) => return Err(interp.throw(ErrorType::Syntax, &format!("invalid regular expression: {m}"))), - Err(CompileFail::Abrupt(a)) => return Err(a), - } + let mut held = HeldBytes::new(interp); + let compiled = compile(pattern, flags, depth, &mut |cost| match cost { + Cost::Fuel(n) => use_fuel_n(interp, n), + Cost::Heap(n) => held.add(interp, n), + }); + drop(held); + let re = match compiled { + Ok(re) => re, + Err(CompileFail::Syntax(m)) => return Err(interp.throw(ErrorType::Syntax, &format!("invalid regular expression: {m}"))), + Err(CompileFail::Abrupt(a)) => return Err(a), }; let bytes = re.heap_bytes(); - interp.reserve_heap(bytes)?; + interp.reserve_heap(bytes.saturating_add(extra))?; interp.heap.bytes = interp.heap.bytes.saturating_add(bytes); Ok(Rc::new(re)) } /// A new RegExp object whose `source` property is `source` (section 15.10.4.1, steps 7 to 12). fn make(interp: &mut Interp, source: Str, pattern: &[u16], flags: &[u16]) -> JsResult<ObjId> { - let re = compile_regex(interp, pattern, flags)?; - interp.reserve_heap(OBJECT_BYTES + 5 * (PROP_BYTES + string_bytes(10)))?; + let re = compile_regex(interp, pattern, flags, OBJECT_BYTES + 5 * (PROP_BYTES + string_bytes(10)))?; let (g, i, m) = (parse_flags(flags).unwrap_or_default().global, re.ignore_case, re.multiline); let proto = interp.intrinsics.regexp_proto; let r = interp.heap.alloc_object(Object::new(Class::RegExp(re), Some(proto))); @@ -91,8 +88,9 @@ fn define_flags(interp: &mut Interp, r: ObjId, source: Str, g: bool, i: bool, m: /// The object a regular expression literal evaluates to (section 7.8.5): a new RegExp object /// each time. pub(crate) fn new_regexp(interp: &mut Interp, source: &Str, flags: &Str) -> JsResult<ObjId> { - let pattern = source.flat(); - make(interp, source.clone(), &pattern, &flags.flat()) + let pattern = flat_charged(interp, source)?; + let flags = flat_charged(interp, flags)?; + make(interp, source.clone(), &pattern, &flags) } /// The `source` text for a pattern given to the constructor: `(?:)` for the empty pattern, and @@ -153,15 +151,16 @@ pub(crate) fn new_regexp_from(interp: &mut Interp, pattern: &Value, flags: &Valu f.push(c); } } - let units = source.flat(); + let units = flat_charged(interp, &source)?; let pattern: &[u16] = if is_empty_group(&units) { &[] } else { &units }; return make(interp, source.clone(), pattern, &f.encode_utf16().collect::<Vec<u16>>()); } let p = if matches!(pattern, Value::Undefined) { Str::from("") } else { interp.to_str(pattern)? }; let f = if matches!(flags, Value::Undefined) { Str::from("") } else { interp.to_str(flags)? }; - let units = p.flat(); + let units = flat_charged(interp, &p)?; + let flags = flat_charged(interp, &f)?; let source = source_text(interp, &units)?; - make(interp, source, &units, &f.flat()) + make(interp, source, &units, &flags) } fn is_empty_group(units: &[u16]) -> bool { @@ -194,7 +193,7 @@ pub(crate) fn exec_raw(interp: &mut Interp, rx: ObjId, s: &Str) -> JsResult<Opti let li = interp.get_value(&rv, &last_index)?; let li = to_integer(interp.to_number(&li)?); let global = interp.get_value(&rv, &key("global"))?.truthy(); - let units = s.flat(); + let units = flat_charged(interp, s)?; let i = if global { li } else { 0.0 }; let found = if i < 0.0 || i > units.len() as f64 { None @@ -221,7 +220,7 @@ pub(crate) fn exec_raw(interp: &mut Interp, rx: ObjId, s: &Str) -> JsResult<Opti pub(crate) fn exec_value(interp: &mut Interp, rx: ObjId, s: &Str) -> JsResult<Value> { rooted(interp, |interp| { let Some(caps) = exec_raw(interp, rx, s)? else { return Ok(Value::Null) }; - let units = s.flat(); + let units = flat_charged(interp, s)?; let a = new_array(interp); interp.reserve_heap(2 * (PROP_BYTES + string_bytes(5)))?; let index = caps.first().copied().unwrap_or(0); diff --git a/crates/js/src/regexp_vm.rs b/crates/js/src/regexp_vm.rs index 8f379d1c0..26b0b37ef 100644 --- a/crates/js/src/regexp_vm.rs +++ b/crates/js/src/regexp_vm.rs @@ -5,14 +5,15 @@ //! pattern with a loop and a stack of open groups, and the matcher keeps its choice points and the //! values it must restore on its own stack, so neither recurses however deeply the groups nest. //! Quantifiers count their iterations in registers instead of repeating their atom's code. Every -//! instruction run and every choice point taken back costs fuel, and the matcher's stack is -//! charged against the heap limit, so a pattern that backtracks without end stops at a limit. +//! instruction run, every capture slot cleared and every choice point taken back costs fuel, and +//! the compiler's work, the matcher's state and its stack are held against the heap limit, so a +//! pattern that backtracks without end stops at a limit. use std::collections::HashMap; use std::sync::OnceLock; use crate::LimitKind; -use crate::interp::{Abrupt, Interp, JsResult}; +use crate::interp::{Abrupt, HeldBytes, Interp, JsResult}; /// A capture position that is not set. pub(crate) const NONE: usize = usize::MAX; @@ -131,10 +132,21 @@ impl From<&str> for CompileFail { } } +/// What compiling takes as it goes: fuel for the work done, or heap bytes for what it holds. +#[derive(Clone, Copy, Debug)] +pub(crate) enum Cost { + Fuel(u64), + Heap(usize), +} + impl Regex { - /// The estimated bytes the compiled form holds. + /// The bytes the compiled form holds, counting the capacity of each list. pub(crate) fn heap_bytes(&self) -> usize { - 64 + size_of::<Inst>() * self.insts.len() + self.sets.iter().map(set_bytes).sum::<usize>() + let sets = self.sets.iter().map(set_bytes).fold(0usize, usize::saturating_add); + size_of::<Regex>() + .saturating_add(size_of::<Inst>().saturating_mul(self.insts.capacity())) + .saturating_add(size_of::<CharSet>().saturating_mul(self.sets.capacity())) + .saturating_add(sets) } fn canon(&self, u: u16) -> u16 { @@ -239,6 +251,7 @@ fn complement(ranges: &[(u16, u16)]) -> Vec<(u16, u16)> { if next <= 0xFFFF { out.push((next as u16, 0xFFFF)); } + out.shrink_to_fit(); out } @@ -252,6 +265,7 @@ fn normalize(mut ranges: Vec<(u16, u16)>) -> Vec<(u16, u16)> { _ => out.push((lo, hi)), } } + out.shrink_to_fit(); out } @@ -313,9 +327,10 @@ impl Frags { Item::Frag(self.items.len() - 1) } - /// The instructions of `top` with every fragment in place, written with a loop. + /// The instructions of `top` with every fragment in place, written with a loop, with room for + /// one more. fn flatten(&self, top: &[Item]) -> Vec<Inst> { - let mut out = Vec::with_capacity(self.len_of(top)); + let mut out = Vec::with_capacity(self.len_of(top).saturating_add(1)); let mut stack: Vec<(&[Item], usize)> = vec![(top, 0)]; while let Some((items, i)) = stack.pop() { let Some(item) = items.get(i) else { continue }; @@ -493,22 +508,64 @@ fn class_atom(pattern: &[u16], i: usize) -> Result<(ClassAtom, usize), CompileFa } } -/// The character class whose `[` is at `i`, and the position after its `]`. -fn class(pattern: &[u16], mut i: usize, room: usize) -> Result<(CharSet, usize), CompileFail> { +/// Counts `n` units of work in `work`, taking them from the fuel once a batch is full. +fn add_work(work: &mut u64, n: u64, charge: &mut dyn FnMut(Cost) -> JsResult<()>) -> Result<(), CompileFail> { + *work = work.saturating_add(n); + if *work >= FUEL_BATCH { + charge(Cost::Fuel(std::mem::take(work))).map_err(CompileFail::Abrupt)?; + } + Ok(()) +} + +/// The ranges of a class being read, with the bytes charged for them so far. +struct ClassRanges { + ranges: Vec<(u16, u16)>, + held: usize, +} + +impl ClassRanges { + /// Adds the ranges of an atom. + fn add_atom(&mut self, atom: &ClassAtom, work: &mut u64, charge: &mut dyn FnMut(Cost) -> JsResult<()>) -> Result<(), CompileFail> { + match atom { + ClassAtom::Unit(u) => self.add(&[(*u, *u)], work, charge), + ClassAtom::Ranges(r) => self.add(r, work, charge), + } + } + + /// Adds `part`. Each range counts as work, and when the list must grow, the bytes of its new + /// capacity and of the copy `normalize` makes of it are charged first. + fn add(&mut self, part: &[(u16, u16)], work: &mut u64, charge: &mut dyn FnMut(Cost) -> JsResult<()>) -> Result<(), CompileFail> { + let extra = part.len(); + add_work(work, extra as u64, charge)?; + let len = self.ranges.len().saturating_add(extra); + if len > self.ranges.capacity() { + let cap = len.max(self.ranges.capacity().saturating_mul(2)); + let need = cap.saturating_mul(2 * size_of::<(u16, u16)>()); + if need > self.held { + charge(Cost::Heap(need - self.held)).map_err(CompileFail::Abrupt)?; + self.held = need; + } + self.ranges.reserve_exact(cap - self.ranges.len()); + } + self.ranges.extend_from_slice(part); + Ok(()) + } +} + +/// The character class whose `[` is at `i`, and the position after its `]`. Each atom read and +/// each range added counts as work in `work`, and the class's ranges are charged as they grow. +fn class(pattern: &[u16], mut i: usize, work: &mut u64, charge: &mut dyn FnMut(Cost) -> JsResult<()>) -> Result<(CharSet, usize), CompileFail> { i += 1; let negated = pattern.get(i) == Some(&0x5E); if negated { i += 1; } - let mut ranges = Vec::new(); + let mut list = ClassRanges { ranges: Vec::new(), held: 0 }; loop { - // The ranges and the copy `normalize` makes of them stay within `room`. - if ranges.len().saturating_mul(2 * size_of::<(u16, u16)>()) > room { - return Err(CompileFail::Abrupt(Abrupt::Limit(LimitKind::Heap))); - } + add_work(work, 1, charge)?; let u = *pattern.get(i).ok_or("unterminated character class")?; if u == 0x5D { - return Ok((CharSet { ranges: normalize(ranges), negated }, i + 1)); + return Ok((CharSet { ranges: normalize(list.ranges), negated }, i + 1)); } let (atom, next) = class_atom(pattern, i)?; i = next; @@ -520,31 +577,28 @@ fn class(pattern: &[u16], mut i: usize, room: usize) -> Result<(CharSet, usize), if lo > hi { return Err("range out of order in character class".into()); } - ranges.push((lo, hi)); + list.add(&[(lo, hi)], work, charge)?; } // Annex B: a range with a class escape at one end is its atoms and a `-`. (lo, hi) => { for a in [lo, ClassAtom::Unit(0x2D), hi] { - match a { - ClassAtom::Unit(u) => ranges.push((u, u)), - ClassAtom::Ranges(r) => ranges.extend(r), - } + list.add_atom(&a, work, charge)?; } } } i = after; } else { - match atom { - ClassAtom::Unit(u) => ranges.push((u, u)), - ClassAtom::Ranges(r) => ranges.extend(r), - } + list.add_atom(&atom, work, charge)?; } } } -/// The bytes a character class holds. +/// At least the bytes a class escape's set (`\d`, `\S`, ...) holds. +const ESCAPE_SET_BYTES: usize = size_of::<CharSet>() + 16 * size_of::<(u16, u16)>(); + +/// The bytes a character class's ranges hold. fn set_bytes(set: &CharSet) -> usize { - 32 + size_of::<(u16, u16)>() * set.ranges.len() + size_of::<(u16, u16)>().saturating_mul(set.ranges.capacity()) } /// The bytes compiling holds: each instruction as an item and in the flattened code, each @@ -556,16 +610,11 @@ pub(crate) fn compile_bytes(instructions: usize, fragments: usize, sets_size: us .saturating_add(sets_size) } -/// Compiles `pattern`. Groups may nest `max_depth` deep; `charge` takes fuel for the work done. -/// Compiling stops with the heap limit when what it holds (instructions, fragments and classes -/// made so far, with the two instructions each `|` adds) would pass `room` bytes. -pub(crate) fn compile( - pattern: &[u16], - flags: Flags, - max_depth: usize, - room: usize, - charge: &mut dyn FnMut(u64) -> JsResult<()>, -) -> Result<Regex, CompileFail> { +/// Compiles `pattern`. Groups may nest `max_depth` deep. `charge` takes fuel for the work done, and +/// heap bytes for what compiling holds before it grows: the instructions, fragments and classes +/// made so far (with the two instructions each `|` adds), the groups still open and the ranges of +/// the class being read. An error from `charge` stops compiling. +pub(crate) fn compile(pattern: &[u16], flags: Flags, max_depth: usize, charge: &mut dyn FnMut(Cost) -> JsResult<()>) -> Result<Regex, CompileFail> { let total_groups = count_groups(pattern); let mut sets: Vec<CharSet> = Vec::new(); let mut sets_size = 0usize; @@ -579,15 +628,19 @@ pub(crate) fn compile( let mut emitted = 0usize; // The fragments the joins of the groups still open will add, one for each `|` read in them. let mut joined = 0usize; + // The heap bytes charged so far. + let mut held = 0usize; let canon = |u: u16| if flags.ignore_case { canonicalize(u) } else { u }; while let Some(&u) = pattern.get(i) { - work += 1; - if work >= FUEL_BATCH { - charge(work).map_err(CompileFail::Abrupt)?; - work = 0; - } - if compile_bytes(emitted, frags.items.len() + joined, sets_size) > room { - return Err(CompileFail::Abrupt(Abrupt::Limit(LimitKind::Heap))); + add_work(&mut work, 1, charge)?; + // One step adds at most three instructions, two fragments, one open group and one class + // escape's set (a class charges its own ranges), so their bytes are charged before the + // step makes them. + let now = compile_bytes(emitted + 3, frags.items.len() + joined + 2, sets_size.saturating_add(ESCAPE_SET_BYTES)) + .saturating_add(size_of::<Ctx>().saturating_mul(stack.len() + 1)); + if now > held { + charge(Cost::Heap(now - held)).map_err(CompileFail::Abrupt)?; + held = now; } let ctx = stack.last_mut().ok_or("unmatched ')'")?; let start = ctx.cur.len(); @@ -612,9 +665,9 @@ pub(crate) fn compile( i += 1; } 0x5B => { - let (set, next) = class(pattern, i, room.saturating_sub(compile_bytes(emitted, frags.items.len() + joined, sets_size)))?; + let (set, next) = class(pattern, i, &mut work, charge)?; emit(&mut emitted, ctx, Inst::Set(u32::try_from(sets.len()).map_err(|_| "too many classes")?)); - sets_size = sets_size.saturating_add(set_bytes(&set)); + sets_size = sets_size.saturating_add(size_of::<CharSet>() + set_bytes(&set)); sets.push(set); ctx.atom = Some((start, before)); i = next; @@ -746,7 +799,7 @@ pub(crate) fn compile( if let Some(r) = escape_ranges(next) { emit(&mut emitted, ctx, Inst::Set(u32::try_from(sets.len()).map_err(|_| "too many classes")?)); let set = CharSet { ranges: r, negated: false }; - sets_size = sets_size.saturating_add(set_bytes(&set)); + sets_size = sets_size.saturating_add(size_of::<CharSet>() + set_bytes(&set)); sets.push(set); i += 2; } else { @@ -775,13 +828,15 @@ pub(crate) fn compile( let cur = std::mem::take(&mut top.cur); top.alts.push(cur); // Joining adds a fragment for each alternative, and flattening writes every instruction. - if compile_bytes(emitted + 1, frags.items.len() + joined + 1, sets_size) > room { - return Err(CompileFail::Abrupt(Abrupt::Limit(LimitKind::Heap))); + let now = compile_bytes(emitted + 1, frags.items.len() + joined + 1, sets_size); + if now > held { + charge(Cost::Heap(now - held)).map_err(CompileFail::Abrupt)?; } let top = join(top.alts, &mut frags)?; let mut insts = frags.flatten(&top); insts.push(Inst::Match); - charge(work + insts.len() as u64 / 16).map_err(CompileFail::Abrupt)?; + sets.shrink_to_fit(); + charge(Cost::Fuel(work + insts.len() as u64 / 16)).map_err(CompileFail::Abrupt)?; Ok(Regex { insts, sets, groups: groups as usize, loops: loops as usize, ignore_case: flags.ignore_case, multiline: flags.multiline }) } @@ -830,7 +885,12 @@ pub(crate) fn use_fuel_n(interp: &mut Interp, n: u64) -> JsResult<()> { interp.use_fuel() } -/// The state of one match attempt, kept between attempts to reuse its buffers. +/// The stack entries a matcher's stack first grows by. +const STACK_CHUNK: usize = 64; + +/// The state of one match attempt, kept between attempts to reuse its buffers. Its capture and +/// quantifier state, and its stack's capacity, are held against the heap limit until it is +/// dropped. pub(crate) struct Matcher<'r> { re: &'r Regex, /// Two positions per group, group 0 first: [`NONE`] where a group did not take part. @@ -839,25 +899,20 @@ pub(crate) struct Matcher<'r> { counts: Vec<u64>, marks: Vec<usize>, stack: Vec<Bt>, - max_stack: usize, + /// The stack entries held. + stack_room: usize, + held: HeldBytes, steps: u64, } impl<'r> Matcher<'r> { - /// A matcher for `re`. Its capture and quantifier state, and its stack, fit in the heap room - /// left under the limit; a run without room for the state ends with the heap limit. When the - /// room would not hold the state and 64 stack entries, a collection runs first, so everything - /// the caller still needs must be reachable from the roots. + /// A matcher for `re`, ending the run with the heap limit when its state does not fit. Holding + /// the state, and later the stack, can run a collection, so everything the caller still needs + /// must be reachable from the roots while the matcher works. pub(crate) fn new(re: &'r Regex, interp: &mut Interp) -> JsResult<Self> { let state = 8usize.saturating_mul(re.groups.saturating_add(1).saturating_mul(3).saturating_add(re.loops.saturating_mul(2))); - let wanted = state.saturating_add(64 * size_of::<Bt>()); - if interp.heap.bytes.saturating_add(wanted) > interp.limits.heap_bytes { - interp.collect_for_room(); - } - let room = interp.limits.heap_bytes.saturating_sub(interp.heap.bytes); - if state > room { - return Err(Abrupt::Limit(LimitKind::Heap)); - } + let mut held = HeldBytes::new(interp); + held.add(interp, state)?; Ok(Self { re, caps: vec![NONE; 2 * (re.groups + 1)], @@ -865,19 +920,43 @@ impl<'r> Matcher<'r> { counts: vec![0; re.loops], marks: vec![NONE; re.loops], stack: Vec::new(), - max_stack: (room - state) / size_of::<Bt>(), + stack_room: 0, + held, steps: 0, }) } - fn push(&mut self, b: Bt) -> JsResult<()> { - if self.stack.len() >= self.max_stack { - return Err(Abrupt::Limit(LimitKind::Heap)); + /// The bytes the matcher holds. + #[cfg(test)] + pub(crate) fn held_bytes(&self) -> usize { + self.held.bytes() + } + + fn push(&mut self, interp: &mut Interp, b: Bt) -> JsResult<()> { + if self.stack.len() >= self.stack_room { + self.grow(interp)?; } self.stack.push(b); Ok(()) } + /// Holds more stack entries: as many as the stack has, at least [`STACK_CHUNK`], or when those + /// do not fit, what the heap room left holds. + fn grow(&mut self, interp: &mut Interp) -> JsResult<()> { + let size = size_of::<Bt>(); + let mut more = self.stack_room.max(STACK_CHUNK); + if self.held.add(interp, more.saturating_mul(size)).is_err() { + more = interp.limits.heap_bytes.saturating_sub(interp.heap.bytes) / size; + if more == 0 { + return Err(Abrupt::Limit(LimitKind::Heap)); + } + self.held.add(interp, more * size)?; + } + self.stack_room = self.stack_room.saturating_add(more); + self.stack.reserve_exact(self.stack_room - self.stack.len()); + Ok(()) + } + fn step(&mut self, interp: &mut Interp) -> JsResult<()> { self.steps += 1; if self.steps >= FUEL_BATCH { @@ -893,10 +972,10 @@ impl<'r> Matcher<'r> { use_fuel_n(interp, n) } - fn set_cap(&mut self, slot: usize, v: usize) -> JsResult<()> { + fn set_cap(&mut self, interp: &mut Interp, slot: usize, v: usize) -> JsResult<()> { let Some(old) = self.caps.get(slot).copied() else { return Ok(()) }; if old != v { - self.push(Bt::Cap(slot, old))?; + self.push(interp, Bt::Cap(slot, old))?; if let Some(c) = self.caps.get_mut(slot) { *c = v; } @@ -904,17 +983,20 @@ impl<'r> Matcher<'r> { Ok(()) } - fn enter_rep(&mut self, pc: usize, pos: usize) -> JsResult<()> { + /// Starts an iteration of the quantifier at `pc`, clearing the groups inside its atom. Each + /// group cleared counts as a step. + fn enter_rep(&mut self, interp: &mut Interp, pc: usize, pos: usize) -> JsResult<()> { let Some(Inst::Rep { reg, caps: (lo, hi), .. }) = self.re.insts.get(pc).copied() else { return Ok(()) }; let reg = reg as usize; let old = self.marks.get(reg).copied().unwrap_or(NONE); - self.push(Bt::Mark(reg, old))?; + self.push(interp, Bt::Mark(reg, old))?; if let Some(m) = self.marks.get_mut(reg) { *m = pos; } for g in lo as usize..hi as usize { - self.set_cap(2 * g, NONE)?; - self.set_cap(2 * g + 1, NONE)?; + self.step(interp)?; + self.set_cap(interp, 2 * g, NONE)?; + self.set_cap(interp, 2 * g + 1, NONE)?; } Ok(()) } @@ -950,7 +1032,7 @@ impl<'r> Matcher<'r> { } Bt::EnterRep { pc: p, pos: q } => { *pos = q; - self.enter_rep(p, q)?; + self.enter_rep(interp, p, q)?; *pc = p + 1; return Ok(true); } @@ -985,7 +1067,10 @@ impl<'r> Matcher<'r> { Ok(false) } + /// Clears the state for a new attempt. Each slot cleared counts as a step. fn reset(&mut self) { + let slots = self.caps.len() + self.open.len() + self.counts.len() + self.marks.len(); + self.steps = self.steps.saturating_add(slots as u64); self.caps.fill(NONE); self.open.fill(NONE); self.counts.fill(0); @@ -1047,7 +1132,7 @@ impl<'r> Matcher<'r> { (a != b) == want } Inst::Split(first, second) => { - self.push(Bt::Retry { pc: at(pc, second), pos })?; + self.push(interp, Bt::Retry { pc: at(pc, second), pos })?; pc = at(pc, first); true } @@ -1058,7 +1143,7 @@ impl<'r> Matcher<'r> { Inst::GroupStart(g) => { let g = g as usize; let old = self.open.get(g).copied().unwrap_or(NONE); - self.push(Bt::Open(g, old))?; + self.push(interp, Bt::Open(g, old))?; if let Some(o) = self.open.get_mut(g) { *o = pos; } @@ -1068,15 +1153,15 @@ impl<'r> Matcher<'r> { Inst::GroupEnd(g) => { let g = g as usize; let s = self.open.get(g).copied().unwrap_or(NONE); - self.set_cap(2 * g, s)?; - self.set_cap(2 * g + 1, pos)?; + self.set_cap(interp, 2 * g, s)?; + self.set_cap(interp, 2 * g + 1, pos)?; pc += 1; true } Inst::RepInit(r) => { let r = r as usize; let old = self.counts.get(r).copied().unwrap_or(0); - self.push(Bt::Count(r, old))?; + self.push(interp, Bt::Count(r, old))?; if let Some(c) = self.counts.get_mut(r) { *c = 0; } @@ -1089,14 +1174,14 @@ impl<'r> Matcher<'r> { if count >= max { pc = exit_pc; } else if count < min { - self.enter_rep(pc, pos)?; + self.enter_rep(interp, pc, pos)?; pc += 1; } else if greedy { - self.push(Bt::Retry { pc: exit_pc, pos })?; - self.enter_rep(pc, pos)?; + self.push(interp, Bt::Retry { pc: exit_pc, pos })?; + self.enter_rep(interp, pc, pos)?; pc += 1; } else { - self.push(Bt::EnterRep { pc, pos })?; + self.push(interp, Bt::EnterRep { pc, pos })?; pc = exit_pc; } true @@ -1114,7 +1199,7 @@ impl<'r> Matcher<'r> { if count >= min && self.marks.get(r).copied() == Some(pos) { false } else { - self.push(Bt::Count(r, count))?; + self.push(interp, Bt::Count(r, count))?; if let Some(c) = self.counts.get_mut(r) { *c = count + 1; } @@ -1123,7 +1208,7 @@ impl<'r> Matcher<'r> { } } Inst::Look { negative, end } => { - self.push(Bt::Look { pos, negative, resume: at(pc, end) })?; + self.push(interp, Bt::Look { pos, negative, resume: at(pc, end) })?; pc += 1; true } @@ -1131,6 +1216,8 @@ impl<'r> Matcher<'r> { let Some(b) = self.stack.iter().rposition(|e| matches!(e, Bt::Look { .. })) else { return Err(Abrupt::Internal("lookahead end without its start".into())); }; + // Each entry read above, and below, counts as a step. + self.steps = self.steps.saturating_add((self.stack.len() - b) as u64); let Some(Bt::Look { pos: q, negative, resume }) = self.stack.get(b).copied() else { return Err(Abrupt::Internal("lookahead end without its start".into())); }; @@ -1146,9 +1233,17 @@ impl<'r> Matcher<'r> { false } else { // A lookahead is atomic: its choice points are dropped, and what it set is - // kept, with the entries that restore it. - let kept: Vec<Bt> = self.stack.drain(b..).skip(1).filter(Bt::is_undo).collect(); - self.stack.extend(kept); + // kept, with the entries that restore it, moved down in place. + let mut kept = b; + for r in b + 1..self.stack.len() { + if let Some(e) = self.stack.get(r).copied().filter(Bt::is_undo) { + if let Some(slot) = self.stack.get_mut(kept) { + *slot = e; + } + kept += 1; + } + } + self.stack.truncate(kept); pos = q; pc = resume; true @@ -1175,8 +1270,8 @@ impl<'r> Matcher<'r> { } } Inst::Match => { - self.set_cap(0, start)?; - self.set_cap(1, pos)?; + self.set_cap(interp, 0, start)?; + self.set_cap(interp, 1, pos)?; return Ok(true); } }; diff --git a/crates/js/src/string_builtins.rs b/crates/js/src/string_builtins.rs index 7d9f56a25..b3758bdf5 100644 --- a/crates/js/src/string_builtins.rs +++ b/crates/js/src/string_builtins.rs @@ -1,17 +1,17 @@ //! `String` (ECMA-262 5.1 section 15.5): the constructor, `String.fromCharCode` and the methods of //! `String.prototype`, with `substr` from Annex B. //! -//! The methods read a string's code units once (flattening a rope), take a unit of fuel for every -//! [`UNITS_PER_FUEL`] code units they read or write, and reserve each new string's bytes on the -//! heap before building it, so a method that would make a string past the heap limit ends the -//! run instead of allocating it. +//! The methods read a string's code units once (flattening a rope, whose flat copy counts as a new +//! string), take a unit of fuel for every [`UNITS_PER_FUEL`] code units they read or write, and +//! reserve each new string's bytes on the heap before building it, so a method that would make a +//! string past the heap limit ends the run instead of allocating it. use std::rc::Rc; use crate::LimitKind; use crate::builtins::{arg, constructor, method, native}; use crate::heap::{Class, NativeKind, OPEN, ObjId, Object, PROP_BYTES, Prop, string_bytes}; -use crate::interp::{Abrupt, Interp, JsResult}; +use crate::interp::{Abrupt, HeldBytes, Interp, JsResult}; use crate::regexp_builtins::{exec_raw, new_regexp_from, regexp_of}; use crate::regexp_vm::{Matcher, NONE, use_fuel_n}; use crate::value::{Str, Value, index_key, is_str_space, to_uint32}; @@ -74,13 +74,47 @@ pub(crate) fn charge_units(interp: &mut Interp, n: usize) -> JsResult<()> { /// run with the heap limit when it would not fit. Everything the caller still needs must be /// reachable from the roots. pub(crate) fn reserve_units(interp: &mut Interp, len: usize) -> JsResult<()> { + reserve_string(interp, len)?; + charge_units(interp, len) +} + +/// [`reserve_units`] without the fuel, for a caller that took it as it read the units. +fn reserve_string(interp: &mut Interp, len: usize) -> JsResult<()> { + check_string_room(interp, len)?; + let bytes = string_bytes(len); + interp.heap.bytes = interp.heap.bytes.saturating_add(bytes); + Ok(()) +} + +/// Ends the run with the heap limit when a new string of `len` code units would not fit, after +/// a collection if one is needed. Everything the caller still needs must be reachable from the +/// roots. +fn check_string_room(interp: &mut Interp, len: usize) -> JsResult<()> { if len > interp.limits.heap_bytes / 4 { return Err(Abrupt::Limit(LimitKind::Heap)); } - let bytes = string_bytes(len); - interp.reserve_heap(bytes)?; - interp.heap.bytes = interp.heap.bytes.saturating_add(bytes); - charge_units(interp, len) + interp.reserve_heap(string_bytes(len)) +} + +/// Pushes `item` onto `list`, first holding the bytes of the list's new capacity when it must +/// grow. +fn push_held<T>(interp: &mut Interp, held: &mut HeldBytes, list: &mut Vec<T>, item: T) -> JsResult<()> { + if list.len() == list.capacity() { + let more = list.capacity().max(4); + held.add(interp, more.saturating_mul(size_of::<T>()))?; + list.reserve_exact(more); + } + list.push(item); + Ok(()) +} + +/// The code units of `s`. Flattening a rope makes a copy of its units, so for a rope that copy's +/// bytes are reserved and added to the heap, and its units charged as fuel, first. +pub(crate) fn flat_charged(interp: &mut Interp, s: &Str) -> JsResult<Rc<[u16]>> { + if s.is_rope() { + reserve_units(interp, s.len())?; + } + Ok(s.flat()) } /// A new string holding `units[start..end]`. @@ -215,7 +249,7 @@ fn char_at(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResul if pos < 0.0 || pos >= s.len() as f64 { return Ok(Value::str("")); } - let unit = s.flat().get(pos as usize).copied().unwrap_or(0); + let unit = flat_charged(interp, &s)?.get(pos as usize).copied().unwrap_or(0); Ok(Value::String(Str::from_units(vec![unit]))) } @@ -225,7 +259,7 @@ fn char_code_at(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> Js if pos < 0.0 || pos >= s.len() as f64 { return Ok(Value::Number(f64::NAN)); } - Ok(Value::Number(f64::from(s.flat().get(pos as usize).copied().unwrap_or(0)))) + Ok(Value::Number(f64::from(flat_charged(interp, &s)?.get(pos as usize).copied().unwrap_or(0)))) } fn concat(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> { @@ -241,7 +275,7 @@ fn index_of(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResu let s = this_str(interp, &this, "indexOf")?; let search = interp.to_str(&arg(args, 0))?; let pos = int_arg(interp, args, 1)?; - let (hay, needle) = (s.flat(), search.flat()); + let (hay, needle) = (flat_charged(interp, &s)?, flat_charged(interp, &search)?); let start = clamp(pos, hay.len()); let found = find(interp, &hay, &needle, start)?; Ok(Value::Number(found.map_or(-1.0, |i| i as f64))) @@ -252,7 +286,7 @@ fn last_index_of(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> J let search = interp.to_str(&arg(args, 0))?; let n = interp.to_number(&arg(args, 1))?; let pos = if n.is_nan() { f64::INFINITY } else { to_integer(n) }; - let (hay, needle) = (s.flat(), search.flat()); + let (hay, needle) = (flat_charged(interp, &s)?, flat_charged(interp, &search)?); let start = clamp(pos, hay.len()); if needle.len() > hay.len() { return Ok(Value::Number(-1.0)); @@ -275,7 +309,7 @@ fn last_index_of(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> J fn locale_compare(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> { let s = this_str(interp, &this, "localeCompare")?; let that = interp.to_str(&arg(args, 0))?; - let (a, b) = (s.flat(), that.flat()); + let (a, b) = (flat_charged(interp, &s)?, flat_charged(interp, &that)?); charge_units(interp, a.len().min(b.len()))?; Ok(Value::Number(match a.cmp(&b) { std::cmp::Ordering::Less => -1.0, @@ -294,7 +328,8 @@ fn slice(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult< if to <= from { return Ok(Value::str("")); } - substring_value(interp, &s.flat(), from as usize, to as usize) + let units = flat_charged(interp, &s)?; + substring_value(interp, &units, from as usize, to as usize) } fn substring(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> { @@ -303,7 +338,8 @@ fn substring(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsRes let start = int_arg(interp, args, 0)?; let end = if matches!(arg(args, 1), Value::Undefined) { len as f64 } else { int_arg(interp, args, 1)? }; let (a, b) = (clamp(start, len), clamp(end, len)); - substring_value(interp, &s.flat(), a.min(b), a.max(b)) + let units = flat_charged(interp, &s)?; + substring_value(interp, &units, a.min(b), a.max(b)) } /// `substr` (Annex B.2.3). Its first step applies ToString to `this` with no object-coercible @@ -318,7 +354,8 @@ fn substr(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult if count <= 0.0 { return Ok(Value::str("")); } - substring_value(interp, &s.flat(), start as usize, (start + count) as usize) + let units = flat_charged(interp, &s)?; + substring_value(interp, &units, start as usize, (start + count) as usize) } /// The UTF-16 length of `c` mapped to lower or upper case. @@ -353,19 +390,21 @@ fn map_case(interp: &mut Interp, units: &[u16], upper: bool) -> JsResult<Vec<u16 fn to_lower_case(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> { let s = this_str(interp, &this, "toLowerCase")?; - let out = map_case(interp, &s.flat(), false)?; + let units = flat_charged(interp, &s)?; + let out = map_case(interp, &units, false)?; Ok(Value::String(Str::from_units(out))) } fn to_upper_case(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> { let s = this_str(interp, &this, "toUpperCase")?; - let out = map_case(interp, &s.flat(), true)?; + let units = flat_charged(interp, &s)?; + let out = map_case(interp, &units, true)?; Ok(Value::String(Str::from_units(out))) } fn trim(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> { let s = this_str(interp, &this, "trim")?; - let units = s.flat(); + let units = flat_charged(interp, &s)?; // The scan reads every unit once at most. charge_units(interp, units.len())?; let start = units.iter().position(|&u| !is_str_space(u)).unwrap_or(units.len()); @@ -403,7 +442,7 @@ fn match_(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult let a = new_array(interp); let mut n = 0usize; let mut previous = 0.0; - let units = s.flat(); + let units = flat_charged(interp, &s)?; while let Some(caps) = exec_raw(interp, rx, &s)? { let this_index = interp.get_value(&Value::Object(rx), &last_index)?; let this_index = interp.to_number(&this_index)?; @@ -428,54 +467,106 @@ fn search(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult let s = this_str(interp, &this, "search")?; let rx = regexp_arg(interp, &arg(args, 0))?; let Some((_, re)) = regexp_of(interp, &Value::Object(rx)) else { return Err(interp.type_error("not a regular expression")) }; - let units = s.flat(); + let units = flat_charged(interp, &s)?; let mut m = Matcher::new(&re, interp)?; let found = m.search(interp, &units, 0)?; Ok(Value::Number(if found { m.caps.first().copied().unwrap_or(0) as f64 } else { -1.0 })) }) } -/// A piece of a result string: a range of the input, or other text. +/// A piece of a result string: a range of the input, a range of the replacement pattern, or the +/// units of a string a replacement function returned. enum Piece { Range(usize, usize), - Text(Str), + Template(usize, usize), + Units(Rc<[u16]>), } impl Piece { fn len(&self) -> usize { match self { - Piece::Range(a, b) => b.saturating_sub(*a), - Piece::Text(s) => s.len(), + Piece::Range(a, b) | Piece::Template(a, b) => b.saturating_sub(*a), + Piece::Units(u) => u.len(), } } } -/// Joins pieces into a new string, reserving its length first. -fn build(interp: &mut Interp, units: &[u16], pieces: &[Piece]) -> JsResult<Value> { - let total = pieces.iter().map(Piece::len).fold(0usize, usize::saturating_add); - reserve_units(interp, total)?; - let mut out = Vec::with_capacity(total); - for p in pieces { - match p { - Piece::Range(a, b) => out.extend_from_slice(units.get(*a..*b).unwrap_or_default()), - Piece::Text(s) => out.extend_from_slice(&s.flat()), - } - } - Ok(Value::String(Str::from_units(out))) +/// The pieces of a result string and the length they add up to. +#[derive(Default)] +struct Pieces { + list: Vec<Piece>, + len: usize, } -/// The pieces a replacement pattern (section 15.5.4.11, Table 22) gives for one match: `caps` -/// holds the match and its groups as pairs of positions in `units`. -fn expand(template: &[u16], units: &[u16], caps: &[usize], out: &mut Vec<Piece>) { - let groups = caps.len() / 2 - 1; - let (start, end) = (caps.first().copied().unwrap_or(0), caps.get(1).copied().unwrap_or(0)); +impl Pieces { + /// Adds `piece`, holding its bytes and taking fuel for its units, and ends the run with the + /// heap limit once the result would not fit. + fn add(&mut self, interp: &mut Interp, held: &mut HeldBytes, piece: Piece) -> JsResult<()> { + let n = piece.len(); + if n == 0 { + return Ok(()); + } + push_held(interp, held, &mut self.list, piece)?; + self.len = self.len.saturating_add(n); + charge_units(interp, n)?; + check_string_room(interp, self.len) + } + + /// Joins the pieces into a new string, reserving its length first. `units` is the input and + /// `template` the replacement pattern. + fn build(&self, interp: &mut Interp, units: &[u16], template: &[u16]) -> JsResult<Value> { + reserve_string(interp, self.len)?; + let mut out = Vec::with_capacity(self.len); + for p in &self.list { + match p { + Piece::Range(a, b) => out.extend_from_slice(units.get(*a..*b).unwrap_or_default()), + Piece::Template(a, b) => out.extend_from_slice(template.get(*a..*b).unwrap_or_default()), + Piece::Units(u) => out.extend_from_slice(u), + } + } + Ok(Value::String(Str::from_units(out))) + } +} + +/// A part of a replacement pattern (section 15.5.4.11, Table 22). +#[derive(Clone, Copy)] +enum Part { + /// The pattern's own text in a range. + Text(usize, usize), + /// `$&` + Matched, + /// `` $` `` + Before, + /// `$'` + After, + /// `$n` or `$nn` + Group(usize), +} + +impl Part { + /// The piece this part gives for one match: `caps` holds the match and its groups as pairs of + /// positions in an input of `len` units. + fn piece(self, caps: &[usize], len: usize) -> Option<Piece> { + let (start, end) = (caps.first().copied().unwrap_or(0), caps.get(1).copied().unwrap_or(0)); + match self { + Part::Text(a, b) => Some(Piece::Template(a, b)), + Part::Matched => Some(Piece::Range(start, end)), + Part::Before => Some(Piece::Range(0, start)), + Part::After => Some(Piece::Range(end, len)), + Part::Group(n) => { + let (s, e) = (caps.get(2 * n).copied().unwrap_or(NONE), caps.get(2 * n + 1).copied().unwrap_or(NONE)); + (s != NONE && e != NONE).then_some(Piece::Range(s, e)) + } + } + } +} + +/// Reads a replacement pattern once into its parts, for a regular expression with `groups` +/// groups. The parts are held against the heap limit, and the units read take fuel. +fn parse_template(interp: &mut Interp, held: &mut HeldBytes, template: &[u16], groups: usize) -> JsResult<Vec<Part>> { + charge_units(interp, template.len())?; let digit = |i: usize| template.get(i).filter(|&&u| (0x30..=0x39).contains(&u)).map(|&u| usize::from(u - 0x30)); - let group = |n: usize, out: &mut Vec<Piece>| { - let (s, e) = (caps.get(2 * n).copied().unwrap_or(NONE), caps.get(2 * n + 1).copied().unwrap_or(NONE)); - if s != NONE && e != NONE { - out.push(Piece::Range(s, e)); - } - }; + let mut parts = Vec::new(); let mut lit = 0; let mut i = 0; while let Some(&u) = template.get(i) { @@ -483,65 +574,49 @@ fn expand(template: &[u16], units: &[u16], caps: &[usize], out: &mut Vec<Piece>) i += 1; continue; } - let flush = |out: &mut Vec<Piece>, to: usize| { - if to > lit { - out.push(Piece::Text(Str::from_units(template.get(lit..to).unwrap_or_default().to_vec()))); - } - }; - let taken = match template.get(i + 1) { + let (part, taken) = match template.get(i + 1) { + // `$$` gives one `$`: the text up to and including the first. Some(0x24) => { - flush(out, i + 1); + push_held(interp, held, &mut parts, Part::Text(lit, i + 1))?; lit = i + 2; i += 2; continue; } - Some(0x26) => { - flush(out, i); - out.push(Piece::Range(start, end)); - 2 - } - Some(0x60) => { - flush(out, i); - out.push(Piece::Range(0, start)); - 2 - } - Some(0x27) => { - flush(out, i); - out.push(Piece::Range(end, units.len())); - 2 - } + Some(0x26) => (Part::Matched, 2), + Some(0x60) => (Part::Before, 2), + Some(0x27) => (Part::After, 2), _ => match (digit(i + 1), digit(i + 2)) { - (Some(a), Some(b)) if (1..=groups).contains(&(a * 10 + b)) => { - flush(out, i); - group(a * 10 + b, out); - 3 - } - (Some(a), _) if (1..=groups).contains(&a) => { - flush(out, i); - group(a, out); - 2 - } + (Some(a), Some(b)) if (1..=groups).contains(&(a * 10 + b)) => (Part::Group(a * 10 + b), 3), + (Some(a), _) if (1..=groups).contains(&a) => (Part::Group(a), 2), _ => { i += 1; continue; } }, }; + if i > lit { + push_held(interp, held, &mut parts, Part::Text(lit, i))?; + } + push_held(interp, held, &mut parts, part)?; i += taken; lit = i; } if template.len() > lit { - out.push(Piece::Text(Str::from_units(template.get(lit..).unwrap_or_default().to_vec()))); + push_held(interp, held, &mut parts, Part::Text(lit, template.len()))?; } + Ok(parts) } -/// `replace` (section 15.5.4.11). +/// `replace` (section 15.5.4.11). The matches, the parsed replacement pattern and the pieces of +/// the result are held against the heap limit while the result is put together, and the +/// result's length is checked against the heap as each piece is added. fn replace(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> { rooted(interp, |interp| { let s = this_str(interp, &this, "replace")?; let search_value = arg(args, 0); let replace_value = arg(args, 1); - let units = s.flat(); + let units = flat_charged(interp, &s)?; + let mut held = HeldBytes::new(interp); // Each match as the positions of the match and its groups. let mut matches: Vec<Vec<usize>> = Vec::new(); if let Some((rx, _)) = regexp_of(interp, &search_value) { @@ -550,12 +625,9 @@ fn replace(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResul let last_index = crate::heap::key("lastIndex"); interp.put(rx, last_index.clone(), Value::Number(0.0), true)?; let mut previous = 0.0; - // The bytes the stored matches and the pieces of the result made from them hold, - // kept within the heap room left. - let mut stored = 0usize; while let Some(caps) = exec_raw(interp, rx, &s)? { - stored = stored.saturating_add(24 + size_of::<usize>() * caps.len() + 2 * size_of::<Piece>()); - interp.reserve_heap(stored)?; + held.add(interp, size_of::<usize>().saturating_mul(caps.capacity()))?; + push_held(interp, &mut held, &mut matches, caps)?; let this_index = interp.get_value(&search_value, &last_index)?; let this_index = interp.to_number(&this_index)?; if this_index == previous { @@ -564,14 +636,13 @@ fn replace(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResul } else { previous = this_index; } - matches.push(caps); } } else if let Some(caps) = exec_raw(interp, rx, &s)? { matches.push(caps); } } else { let search = interp.to_str(&search_value)?; - let needle = search.flat(); + let needle = flat_charged(interp, &search)?; if let Some(i) = find(interp, &units, &needle, 0)? { matches.push(vec![i, i + needle.len()]); } @@ -579,18 +650,25 @@ fn replace(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResul let replacer = interp.is_callable(&replace_value).then(|| replace_value.clone()); let template = match &replacer { Some(_) => None, - None => Some(interp.to_str(&replace_value)?.flat()), + None => { + let t = interp.to_str(&replace_value)?; + Some(flat_charged(interp, &t)?) + } }; if matches.is_empty() { return Ok(Value::String(s)); } - let mut pieces = Vec::new(); + let template: &[u16] = template.as_deref().unwrap_or_default(); + // Every match has the same number of groups. + let groups = matches.first().map_or(0, |caps| (caps.len() / 2).saturating_sub(1)); + let parts = if replacer.is_none() { parse_template(interp, &mut held, template, groups)? } else { Vec::new() }; + let mut pieces = Pieces::default(); let mut last = 0usize; for caps in &matches { let (start, end) = (caps.first().copied().unwrap_or(0), caps.get(1).copied().unwrap_or(0)); - pieces.push(Piece::Range(last, start)); - match (&replacer, &template) { - (Some(f), _) => { + pieces.add(interp, &mut held, Piece::Range(last, start))?; + match &replacer { + Some(f) => { let mut call_args = Vec::with_capacity(caps.len() / 2 + 2); for pair in caps.chunks(2) { let v = match pair { @@ -603,16 +681,24 @@ fn replace(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResul call_args.push(Value::String(s.clone())); let r = interp.call(f, &Value::Undefined, &call_args)?; let text = interp.to_str(&r)?; - pieces.push(Piece::Text(text)); + let text = flat_charged(interp, &text)?; + pieces.add(interp, &mut held, Piece::Units(text))?; + } + None => { + // Reading the parts takes fuel even when they give empty pieces. + use_fuel_n(interp, (parts.len() / UNITS_PER_FUEL) as u64)?; + for part in &parts { + if let Some(p) = part.piece(caps, units.len()) { + pieces.add(interp, &mut held, p)?; + } + } } - (None, Some(t)) => expand(t, &units, caps, &mut pieces), - (None, None) => {} } use_fuel_n(interp, 1)?; last = end; } - pieces.push(Piece::Range(last, units.len())); - build(interp, &units, &pieces) + pieces.add(interp, &mut held, Piece::Range(last, units.len()))?; + pieces.build(interp, &units, template) }) } @@ -627,7 +713,10 @@ fn split(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult< let rx = regexp_of(interp, &separator); let sep = match &rx { Some(_) => None, - None => Some(interp.to_str(&separator)?.flat()), + None => { + let sep = interp.to_str(&separator)?; + Some(flat_charged(interp, &sep)?) + } }; if lim == 0 { return Ok(Value::Object(a)); @@ -636,7 +725,7 @@ fn split(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult< set_element(interp, a, 0, Value::String(s))?; return Ok(Value::Object(a)); } - let units = s.flat(); + let units = flat_charged(interp, &s)?; let size = units.len(); let re: Option<Rc<crate::regexp_vm::Regex>> = rx.map(|(_, re)| re); let mut matcher = re.as_deref().map(|re| Matcher::new(re, interp)).transpose()?; diff --git a/crates/js/src/text_tests.rs b/crates/js/src/text_tests.rs index 39fec2dc9..649924725 100644 --- a/crates/js/src/text_tests.rs +++ b/crates/js/src/text_tests.rs @@ -226,7 +226,7 @@ fn a_catastrophic_regular_expression_ends_by_fuel() { #[test] fn trim_and_date_parsing_take_fuel_for_the_units_they_scan() { let limits = Limits { fuel: 40_000, ..Limits::default() }; - let build = "var s = ' '; for (var i = 0; i < 20; i++) s = s + s; s.charAt(0)"; + let build = "var s = ' '; for (var i = 0; i < 20; i++) s = s + s; s.length"; assert!(Realm::with_options(Options::default(), limits.clone()).run(build).is_ok()); for scan in ["s.trim()", "Date.parse(s)", "new Date(s)"] { let e = fail_with(Realm::with_options(Options::default(), limits.clone()), &format!("{build}; {scan}")); @@ -242,6 +242,58 @@ fn global_replace_counts_its_matches_against_the_heap() { assert_eq!(run(src), JsValue::Number(0.0)); } +#[test] +fn replace_holds_its_pieces_and_checks_the_result_as_it_grows() { + // Each `$&` gives a piece for every match: the pieces count against the heap, so the run ends + // with the heap limit long before the 4,194,304 pieces would take the fuel. + let limits = Limits { heap_bytes: 4 << 20, fuel: 500_000, ..Limits::default() }; + let src = "var s = 'a'; for (var i = 0; i < 12; i++) s += s; var t = '$&'; for (var i = 0; i < 10; i++) t += t; s.replace(/a/g, t).length"; + let e = fail_with(Realm::with_options(Options::default(), limits), src); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{e:?}"); + // A long literal pattern is not copied for each match, and the result's length is checked as + // the pieces are added, so the run ends with the heap limit before the fuel for 16,384 + // copies of the pattern is spent. + let limits = Limits { heap_bytes: 4 << 20, fuel: 1_000_000, ..Limits::default() }; + let src = "var s = 'a'; for (var i = 0; i < 14; i++) s += s; var t = 'x'; for (var i = 0; i < 11; i++) t += t; s.replace(/a/g, t).length"; + let e = fail_with(Realm::with_options(Options::default(), limits), src); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{e:?}"); + // The parts of the pattern give the same result as before. + shows("'abcabc'.replace(/(b)(c)/g, '[$$|$&|$`|$\\'|$2$1|$3|$0|$10]')", "a[$|bc|a|abc|cb|$3|$0|b0]a[$|bc|abca||cb|$3|$0|b0]"); +} + +#[test] +fn flattening_a_rope_counts_against_the_heap() { + // Three ropes of about 524,288 units, each 1 MiB once flat, with a 3 MiB heap. + let build = "var s = 'x'; for (var i = 0; i < 19; i++) s = s + s; var t = s + 'y', u = s + 'z';"; + let limits = Limits { heap_bytes: 3 << 20, ..Limits::default() }; + let built = Realm::with_options(Options::default(), limits.clone()).run(&format!("{build} s.length + t.length + u.length")); + assert_eq!(built, Ok(JsValue::Number(1_572_866.0))); + for read in [ + "charAt(0)", + "charCodeAt(0)", + "indexOf('q')", + "lastIndexOf('q')", + "localeCompare('a')", + "slice(0, 1)", + "substring(0, 1)", + "substr(0, 1)", + "trim()", + "match(/q/)", + "search(/q/)", + "replace(/q/, '')", + "replace('q', '')", + ] { + let src = format!("{build} s.{read}; t.{read}; u.{read}; 0"); + let e = fail_with(Realm::with_options(Options::default(), limits.clone()), &src); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{read}: {e:?}"); + } + for call in ["/q/.exec(X)", "/q/.test(X)", "Date.parse(X)", "new RegExp(X)"] { + let src = format!("{build} {}; {}; {}; 0", call.replace('X', "s"), call.replace('X', "t"), call.replace('X', "u")); + let e = fail_with(Realm::with_options(Options::default(), limits.clone()), &src); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{call}: {e:?}"); + } +} + #[test] fn compiling_a_pattern_counts_against_the_heap() { let src = "var s = '[\\\\s\\\\S]a*'; for (var i = 0; i < 14; i++) s = s + s; new RegExp(s).source.length"; @@ -249,25 +301,47 @@ fn compiling_a_pattern_counts_against_the_heap() { assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{e:?}"); assert_eq!(run(src), JsValue::Number(131_072.0)); // The compiler stops while it reads the pattern, before the code it builds passes the room. - let pattern: Vec<u16> = "[\\s\\S]a*".repeat(1000).encode_utf16().collect(); - let flags = crate::regexp_vm::parse_flags(&[]).unwrap(); - let room = crate::regexp_vm::compile_bytes(400, 0, 0); - let result = crate::regexp_vm::compile(&pattern, flags, 200, room, &mut |_| Ok(())); - assert!(matches!(result, Err(crate::regexp_vm::CompileFail::Abrupt(crate::interp::Abrupt::Limit(LimitKind::Heap))))); - assert!(crate::regexp_vm::compile(&pattern, flags, 200, usize::MAX, &mut |_| Ok(())).is_ok()); + let pattern = "[\\s\\S]a*".repeat(1000); + assert!(stopped_by(&compile_within(&pattern, crate::regexp_vm::compile_bytes(400, 0, 0), u64::MAX).0, LimitKind::Heap)); + assert!(compile_within(&pattern, usize::MAX, u64::MAX).0.is_ok()); // A literal pattern takes what its instructions hold, not the most a unit can take. - let literal: Vec<u16> = "a".repeat(10_000).encode_utf16().collect(); - let room = crate::regexp_vm::compile_bytes(20_000, 0, 0); - assert!(crate::regexp_vm::compile(&literal, flags, 200, room, &mut |_| Ok(())).is_ok()); + assert!(compile_within(&"a".repeat(10_000), crate::regexp_vm::compile_bytes(20_000, 0, 0), u64::MAX).0.is_ok()); // A class's ranges count while they are read, even when they collapse into one range. - let class: Vec<u16> = format!("[{}]", "a".repeat(10_000)).encode_utf16().collect(); - let result = crate::regexp_vm::compile(&class, flags, 200, 1000, &mut |_| Ok(())); - assert!(matches!(result, Err(crate::regexp_vm::CompileFail::Abrupt(crate::interp::Abrupt::Limit(LimitKind::Heap))))); + assert!(stopped_by(&compile_within(&format!("[{}]", "a".repeat(10_000)), 1000, u64::MAX).0, LimitKind::Heap)); + // So do the ranges of class escapes, and each atom and range read takes fuel. + let escapes = format!("[{}]", "\\w".repeat(10_000)); + assert!(stopped_by(&compile_within(&escapes, 64 << 10, u64::MAX).0, LimitKind::Heap)); + assert!(stopped_by(&compile_within(&escapes, usize::MAX, 20_000).0, LimitKind::Fuel)); // The joining and flattening after the last unit count too. - let ab: Vec<u16> = "ab".encode_utf16().collect(); - let room = crate::regexp_vm::compile_bytes(1, 0, 0); - let result = crate::regexp_vm::compile(&ab, flags, 200, room, &mut |_| Ok(())); - assert!(matches!(result, Err(crate::regexp_vm::CompileFail::Abrupt(crate::interp::Abrupt::Limit(LimitKind::Heap))))); + assert!(stopped_by(&compile_within("ab", crate::regexp_vm::compile_bytes(1, 0, 0), u64::MAX).0, LimitKind::Heap)); +} + +/// Compiles `pattern` with no flags through a charge that allows `heap` bytes and `fuel` units, +/// and returns the result with the bytes and fuel the compiler asked for. +fn compile_within(pattern: &str, heap: usize, fuel: u64) -> (Result<crate::regexp_vm::Regex, crate::regexp_vm::CompileFail>, usize, u64) { + use crate::regexp_vm::Cost; + let units: Vec<u16> = pattern.encode_utf16().collect(); + let flags = crate::regexp_vm::parse_flags(&[]).unwrap(); + let (mut bytes, mut used) = (0usize, 0u64); + let result = crate::regexp_vm::compile(&units, flags, 200, &mut |cost| { + match cost { + Cost::Heap(n) => bytes = bytes.saturating_add(n), + Cost::Fuel(n) => used = used.saturating_add(n), + } + if bytes > heap { + return Err(crate::interp::Abrupt::Limit(LimitKind::Heap)); + } + if used > fuel { + return Err(crate::interp::Abrupt::Limit(LimitKind::Fuel)); + } + Ok(()) + }); + (result, bytes, used) +} + +/// Whether compiling stopped with the limit `kind`. +fn stopped_by(result: &Result<crate::regexp_vm::Regex, crate::regexp_vm::CompileFail>, kind: LimitKind) -> bool { + matches!(result, Err(crate::regexp_vm::CompileFail::Abrupt(crate::interp::Abrupt::Limit(k))) if *k == kind) } #[test] @@ -295,9 +369,7 @@ fn a_constructed_source_counts_against_the_heap_before_it_is_built() { #[test] fn the_matcher_state_counts_against_the_heap() { let mut realm = Realm::with_options(Options::default(), Limits { heap_bytes: 1 << 20, ..Limits::default() }); - let pattern: Vec<u16> = "(a)".repeat(1000).encode_utf16().collect(); - let flags = crate::regexp_vm::parse_flags(&[]).unwrap(); - let Ok(re) = crate::regexp_vm::compile(&pattern, flags, 200, usize::MAX, &mut |_| Ok(())) else { panic!("the pattern compiles") }; + let Ok(re) = compile_within(&"(a)".repeat(1000), usize::MAX, u64::MAX).0 else { panic!("the pattern compiles") }; let interp = realm.interp(); interp.collect_for_room(); let live = interp.heap.bytes; @@ -307,15 +379,39 @@ fn the_matcher_state_counts_against_the_heap() { assert!(crate::regexp_vm::Matcher::new(&re, interp).is_ok()); // The stack takes only the room left: with room for the state and no more, backtracking ends // with the heap limit. - let pattern: Vec<u16> = "a*b".encode_utf16().collect(); - let Ok(re) = crate::regexp_vm::compile(&pattern, flags, 200, usize::MAX, &mut |_| Ok(())) else { panic!("the pattern compiles") }; + let Ok(re) = compile_within("a*b", usize::MAX, u64::MAX).0 else { panic!("the pattern compiles") }; let input: Vec<u16> = "aaaaaaaaaa".encode_utf16().collect(); interp.limits.heap_bytes = live + 64; let Ok(mut m) = crate::regexp_vm::Matcher::new(&re, interp) else { panic!("the state fits") }; assert!(matches!(m.search(interp, &input, 0), Err(crate::interp::Abrupt::Limit(LimitKind::Heap)))); + drop(m); interp.limits.heap_bytes = 1 << 20; let Ok(mut m) = crate::regexp_vm::Matcher::new(&re, interp) else { panic!("the state fits") }; assert!(matches!(m.search(interp, &input, 0), Ok(false))); + // What the matcher holds is on the heap while it lives, so other allocations cannot use the + // same room, and a collection after it is dropped takes it off again. + let held = m.held_bytes(); + assert!(held > 0); + assert!(interp.heap.bytes >= live + held, "{} < {live} + {held}", interp.heap.bytes); + interp.collect_for_room(); + assert!(interp.heap.bytes >= live + held, "{} < {live} + {held}", interp.heap.bytes); + drop(m); + interp.collect_for_room(); + assert_eq!(interp.heap.bytes, live); +} + +#[test] +fn clearing_matcher_state_takes_fuel() { + let limits = Limits { fuel: 500_000, ..Limits::default() }; + let groups = "var p = ''; for (var i = 0; i < 1000; i++) p += '(x)'; var s = ''; for (var i = 0; i < 2000; i++) s += 'a';"; + // Each iteration of the `*` clears the 1000 groups inside it, though only `a` runs. + let src = format!("{groups} new RegExp('(?:a|' + p + ')*').test(s)"); + let e = fail_with(Realm::with_options(Options::default(), limits.clone()), &src); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Fuel), "{e:?}"); + // Each attempt at a new position clears the state of all 1001 groups. + let src = format!("{groups} new RegExp(p + 'z').test(s.replace(/a/g, 'b'))"); + let e = fail_with(Realm::with_options(Options::default(), limits), &src); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Fuel), "{e:?}"); } #[test] From b6fdecc2e0fe3c39579ae71b9992cea80f0308d3 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 19:42:24 -0800 Subject: [PATCH 109/160] Graphs: Appearance, Default Fill and Stroke and graphic style help names the stored opacity and blend mode The help text of appearance.addFill, addStroke, clear, reduceToBasic, removeItem, duplicateItem, moveItem, copyFrom, showAllHidden and transfer, paint.default, and graphicStyle.apply and redefine said the graph stores only the paint and weight of the topmost visible fill and stroke. These commands also store that row's opacity and blend mode, as appearance.setItem's help and docs/mcp.md already say. --- crates/engine/src/cmd/appearance.rs | 20 ++++++++++---------- crates/engine/src/cmd/paint.rs | 2 +- crates/engine/src/cmd/style.rs | 4 ++-- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 4c4873447..83e11a3cc 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> { "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. 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?: \"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, weight, opacity and blend mode 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<CommandSpec> { "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. 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?} 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, weight, opacity and blend mode 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<CommandSpec> { "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 and the object's opacity and blend mode (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, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md)", has_doc, clear_appearance ), @@ -72,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> { "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. 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?} 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, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, reduce_basic ), @@ -90,7 +90,7 @@ pub fn specs() -> Vec<CommandSpec> { "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. 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 | 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, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, remove_item ), @@ -108,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> { "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. 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 | 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, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, duplicate_item ), @@ -117,7 +117,7 @@ pub fn specs() -> Vec<CommandSpec> { "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. 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}", + "{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, weight, opacity and blend mode 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<CommandSpec> { "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 and the object's opacity and blend mode (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, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {ids}", has_doc, copy_from ), @@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> { "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. 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}", + "{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, weight, opacity and blend mode 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<CommandSpec> { "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 and the object's opacity and blend mode (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, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {source, target}", has_doc, transfer ), diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index b600abed4..3128992c6 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> { "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. 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?} 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, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, default_paint ), diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index e975c067c..8de02b506 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> { "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) 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}", + "{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, weight, opacity and blend mode 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<CommandSpec> { "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 and the object's opacity and blend mode (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, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {name}", has_doc, style_redefine ), From f916d981d3bcee5855267e783e7a9f4fc96573b6 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 19:42:38 -0800 Subject: [PATCH 110/160] Plug-ins: a live effect's commands may change the document A live effect that declares commands may list `doc.write` and `state`, and gets them when its manifest leaves `permissions` out, as a filter does. Only its commands use them: drawing the effect changes no object and keeps no data. Before, every output of such a command that changed an object was refused, since a live effect could never hold `doc.write`. --- crates/engine/src/tests/plugins.rs | 25 +++++++++++-- crates/plugins/src/manifest.rs | 21 +++++++++-- crates/plugins/src/permissions.rs | 59 +++++++++++++++++++++--------- docs/plugins.md | 20 +++++----- docs/target-app-parity.md | 2 +- 5 files changed, 92 insertions(+), 35 deletions(-) diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index fc46cfcd7..cba53c6b6 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -1026,8 +1026,9 @@ fn a_malformed_ids_is_refused_not_read_as_the_selection() { run(&mut s, "plugin.remove", json!({"id": id})); } -/// A live effect's commands are listed under Effect › Plug-ins, run under its permissions (it may -/// not change the document), and labels show bidirectional characters escaped. A malformed +/// A live effect's commands are listed under Effect › Plug-ins and run under its permissions (with +/// commands it gets `doc.write` unless its list leaves it out), and labels show bidirectional +/// characters escaped. A malformed /// `commands` entry is refused at install, and the error names it. #[test] fn effect_commands_labels_and_malformed_entries() { @@ -1038,10 +1039,28 @@ fn effect_commands_labels_and_malformed_entries() { let c = listed(&s, &crate::cmd::plugin::command_id(id, "flip")).expect("listed"); assert_eq!((c.label, c.menu.as_slice()), ("Flip\\u{202E}txt", &["Effect", "Plug-ins", "Cmd Test"][..])); run(&mut s, "select.none", json!({})); - let before = s.doc().unwrap().doc.clone(); + // Without `permissions`, a live effect with commands may change the document: its command adds + // the triangle, as one undo step. + let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let out = run(&mut s, &crate::cmd::plugin::command_id(id, "flip"), json!({})); + assert_eq!(out["ids"].as_array().map(Vec::len), Some(1), "{out}"); + assert_ne!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + run(&mut s, "plugin.remove", json!({"id": id})); + // One that lists its permissions without `doc.write` is refused the change, and the document + // stays as it was. + let listed = format!( + r#"{{"id":"{id}","name":"Cmd Test","version":"1","kind":"effect","permissions":["doc.read","effect.render"],"commands":[{{"id":"flip","label":"Flip"}}]}}"# + ); + let wasm = wat::parse_str(tpl::returning(&listed, triangle)).unwrap(); + let hash = vectorcraft_plugins::permissions::sha256_hex(&wasm); + let grant = json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read", "effect.render"], "hash": hash}); + run(&mut s, "plugin.install", grant); + let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); let e = s.execute(&crate::cmd::plugin::command_id(id, "flip"), &json!({})).unwrap_err().to_string(); assert!(e.contains("wasn't granted `doc.write`"), "{e}"); assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); run(&mut s, "plugin.remove", json!({"id": id})); let bad = id.replace("effect", "bad"); diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index d2f780bf6..0abdd6016 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -351,7 +351,7 @@ impl Manifest { return Err(Error::Manifest(format!("`api` is {}; this host implements plug-in API levels 1 to {API_LEVEL}", self.api))); } if let Some(names) = &self.permissions { - permissions::validate(self.kind, names)?; + permissions::validate(self.kind, !self.commands.is_empty(), names)?; } self.validate_extensions()?; match (self.budget, self.kind) { @@ -475,11 +475,14 @@ impl Manifest { self.params.iter().map(|(k, p)| (k.clone(), p.spec.default_value())).collect() } - /// What the plug-in may do: the permissions it lists, else the ones its kind implies. + /// What the plug-in may do: the permissions it lists, else every one its kind may list + /// ([`Permission::allowed`]: a live effect with commands also changes the document and keeps + /// data with it). pub fn permissions(&self) -> Vec<Permission> { + let commands = !self.commands.is_empty(); match &self.permissions { - Some(names) => permissions::validate(self.kind, names).unwrap_or_default(), - None => Permission::implied(self.kind).to_vec(), + Some(names) => permissions::validate(self.kind, commands, names).unwrap_or_default(), + None => Permission::allowed(self.kind, commands).to_vec(), } } @@ -849,6 +852,16 @@ mod tests { assert!(serde_json::to_value(&plain).unwrap().get("permissions").is_none()); let effect = Manifest::parse(br#"{"id":"a","name":"x","kind":"effect"}"#).unwrap(); assert_eq!(effect.permissions(), [Permission::DocRead, Permission::EffectRender]); + // A live effect's commands may change the document: one with commands gets `doc.write` + // and `state` too, and may list them. + let commands = br#"{"id":"a","name":"x","kind":"effect","commands":[{"id":"c","label":"C"}]}"#; + let with_commands = Manifest::parse(commands).unwrap(); + assert_eq!(with_commands.permissions(), [Permission::DocRead, Permission::DocWrite, Permission::State, Permission::EffectRender]); + let listing = + br#"{"id":"a","name":"x","kind":"effect","permissions":["doc.read","effect.render","doc.write"],"commands":[{"id":"c","label":"C"}]}"#; + assert_eq!(Manifest::parse(listing).unwrap().permissions(), [Permission::DocRead, Permission::DocWrite, Permission::EffectRender]); + let without = br#"{"id":"a","name":"x","kind":"effect","permissions":["doc.read","effect.render","doc.write"]}"#; + assert!(Manifest::parse(without).unwrap_err().to_string().contains("`doc.write` doesn't apply")); let exporter = Manifest::parse(br#"{"id":"a","name":"x","kind":"exporter","extensions":["plt"]}"#).unwrap(); assert_eq!(exporter.permissions(), [Permission::DocRead, Permission::ExportWrite]); assert!(!Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","permissions":[]}"#).unwrap().asks_permissions()); diff --git a/crates/plugins/src/permissions.rs b/crates/plugins/src/permissions.rs index 1d6b0664e..ac75e8e13 100644 --- a/crates/plugins/src/permissions.rs +++ b/crates/plugins/src/permissions.rs @@ -90,6 +90,17 @@ impl Permission { } } + /// The permissions a plug-in of `kind` that declares `commands` or not may list, which are also + /// the ones its manifest gets without `permissions`: those `kind` implies, and for a live effect + /// with commands also `doc.write` and `state`, which only its commands use (drawing the effect + /// changes no object and keeps no data). + pub fn allowed(kind: Kind, commands: bool) -> &'static [Permission] { + match kind { + Kind::Effect if commands => &[Permission::DocRead, Permission::DocWrite, Permission::State, Permission::EffectRender], + _ => Permission::implied(kind), + } + } + /// The permissions a plug-in of `kind` can't work without, which its list must hold: a live /// effect and an exporter are always handed objects, and only draw or write. A filter may /// leave any of its permissions out. @@ -101,10 +112,11 @@ impl Permission { } } -/// Checks the `permissions` a manifest of `kind` lists → them, in the order [`ALL`] has. A name -/// that is not a permission, a reserved one, one that doesn't apply to the kind, one listed twice -/// and a list without what the kind needs are errors that name it. -pub(crate) fn validate(kind: Kind, names: &[String]) -> Result<Vec<Permission>> { +/// Checks the `permissions` a manifest of `kind` lists → them, in the order [`ALL`] has; `commands` +/// is whether it declares commands ([`Permission::allowed`]). A name that is not a permission, a +/// reserved one, one that doesn't apply to the kind, one listed twice and a list without what the +/// kind needs are errors that name it. +pub(crate) fn validate(kind: Kind, commands: bool, names: &[String]) -> Result<Vec<Permission>> { if names.len() > MAX_PERMISSIONS { return Err(Error::Manifest(format!("at most {MAX_PERMISSIONS} `permissions`"))); } @@ -117,7 +129,7 @@ pub(crate) fn validate(kind: Kind, names: &[String]) -> Result<Vec<Permission>> } return Err(Error::Manifest(format!("unknown permission `{shown}`"))); }; - if !Permission::implied(kind).contains(&p) { + if !Permission::allowed(kind, commands).contains(&p) { return Err(Error::Manifest(format!("permission `{shown}` doesn't apply to a plug-in of kind `{}`", kind.name()))); } if out.contains(&p) { @@ -214,23 +226,34 @@ mod tests { #[test] fn lists_are_checked_against_the_kind() { let list = |names: &[&str]| names.iter().map(|s| s.to_string()).collect::<Vec<_>>(); - assert_eq!(validate(Kind::Filter, &list(&["doc.write", "doc.read"])).unwrap(), [Permission::DocRead, Permission::DocWrite]); - assert_eq!(validate(Kind::Filter, &[]).unwrap(), []); - assert!(validate(Kind::Effect, &list(&["doc.read", "effect.render"])).is_ok()); - assert!(validate(Kind::Exporter, &list(&["export.write", "doc.read"])).is_ok()); - for (kind, names, says) in [ - (Kind::Filter, &["doc.read", "clipboard"][..], "unknown permission `clipboard`"), - (Kind::Filter, &["progress"], "`progress` is reserved"), - (Kind::Filter, &["export.write"], "doesn't apply to a plug-in of kind `filter`"), - (Kind::Exporter, &["doc.read", "export.write", "doc.read"], "`doc.read` is listed twice"), - (Kind::Effect, &["doc.read"], "needs the permission `effect.render`"), - (Kind::Exporter, &["export.write"], "needs the permission `doc.read`"), + assert_eq!(validate(Kind::Filter, false, &list(&["doc.write", "doc.read"])).unwrap(), [Permission::DocRead, Permission::DocWrite]); + assert_eq!(validate(Kind::Filter, false, &[]).unwrap(), []); + assert!(validate(Kind::Effect, false, &list(&["doc.read", "effect.render"])).is_ok()); + assert!(validate(Kind::Exporter, false, &list(&["export.write", "doc.read"])).is_ok()); + // A live effect with commands may also change the document and keep data with it. + assert_eq!( + validate(Kind::Effect, true, &list(&["state", "effect.render", "doc.write", "doc.read"])).unwrap(), + [Permission::DocRead, Permission::DocWrite, Permission::State, Permission::EffectRender] + ); + assert_eq!(Permission::allowed(Kind::Effect, false), Permission::implied(Kind::Effect)); + assert_eq!(Permission::allowed(Kind::Filter, true), Permission::implied(Kind::Filter)); + for (kind, commands, names, says) in [ + (Kind::Filter, false, &["doc.read", "clipboard"][..], "unknown permission `clipboard`"), + (Kind::Filter, false, &["progress"], "`progress` is reserved"), + (Kind::Filter, false, &["export.write"], "doesn't apply to a plug-in of kind `filter`"), + (Kind::Exporter, false, &["doc.read", "export.write", "doc.read"], "`doc.read` is listed twice"), + (Kind::Effect, false, &["doc.read"], "needs the permission `effect.render`"), + (Kind::Effect, false, &["doc.read", "effect.render", "doc.write"], "`doc.write` doesn't apply to a plug-in of kind `effect`"), + (Kind::Effect, true, &["doc.read", "doc.write"], "needs the permission `effect.render`"), + (Kind::Effect, true, &["doc.read", "effect.render", "export.write"], "`export.write` doesn't apply"), + (Kind::Exporter, true, &["doc.read", "export.write", "doc.write"], "`doc.write` doesn't apply"), + (Kind::Exporter, false, &["export.write"], "needs the permission `doc.read`"), ] { - let e = validate(kind, &list(names)).unwrap_err().to_string(); + let e = validate(kind, commands, &list(names)).unwrap_err().to_string(); assert!(e.contains(says), "{names:?}: {e}"); } let many = list(&["doc.read"; MAX_PERMISSIONS + 1]); - assert!(validate(Kind::Filter, &many).unwrap_err().to_string().contains("at most")); + assert!(validate(Kind::Filter, false, &many).unwrap_err().to_string().contains("at most")); } /// Control, bidirectional override and separator characters show as escapes, so a name can't diff --git a/docs/plugins.md b/docs/plugins.md index 777dc69e8..656cde4a1 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -69,8 +69,8 @@ replaced by a module without it. - **Runs.** The parameters are checked against the command's declarations before the plug-in runs. The plug-in runs through `vc_run` on the selection (or `ids`) as a filter run does, with `_context.mode` `"command"` and `_context.command` set to the command's id ([Parameters](#parameters)), under the plug-in's - [permissions](#permissions): a live effect's command has no `doc.write`, so an output that changes the document is - refused. `ids` left out or null means the selection; an `ids` that is not a list of object ids is an error. One run + [permissions](#permissions): a live effect with commands may also hold `doc.write` and `state`, which only its + commands use. `ids` left out or null means the selection; an `ids` that is not a list of object ids is an error. One run is one undo step named after the command. A run that traps, runs out of its instruction budget or fails leaves the document and the undo history as they were, and the error starts with the plug-in's name and the command's label (`“Cmd Test” › Grow: …`). @@ -136,16 +136,18 @@ capability model proposed for PhotoCraft plug-ins | Permission | Kinds | What it allows | |---|---|---| | `doc.read` | all | Read the objects it is given. | -| `doc.write` | filter | Change, add and delete objects in the document. | -| `state` | filter | Keep data of its own with the document (`documentData`). | +| `doc.write` | filter, effect with `commands` | Change, add and delete objects in the document. | +| `state` | filter, effect with `commands` | Keep data of its own with the document (`documentData`). | | `effect.render` | effect | Change how the objects its effect is applied to are drawn. | | `export.write` | exporter | Write the document as a file in its own format. | - A manifest without `permissions` gets the ones its kind implies, as plug-ins did before the key existed: a filter - `doc.read`, `doc.write` and `state`; an effect `doc.read` and `effect.render`; an exporter `doc.read` and - `export.write`. Such a plug-in installs without a prompt. This rule is VectorCraft's own. A filter whose list is - empty may do none of these, and also installs without a prompt. -- An effect or an exporter that lists permissions lists both of its implied ones. A filter may list any of its three. + `doc.read`, `doc.write` and `state`; an effect `doc.read` and `effect.render`, and also `doc.write` and `state` + when it declares [commands](#commands); an exporter `doc.read` and `export.write`. Such a plug-in installs without + a prompt. This rule is VectorCraft's own. A filter whose list is empty may do none of these, and also installs + without a prompt. +- An effect or an exporter that lists permissions lists `doc.read` and its own (`effect.render`, `export.write`). An + effect with commands may add `doc.write` and `state`, which only its commands use. A filter may list any of its three. An unknown name, a name listed twice, a name that doesn't apply to the kind, more than 16 names, or one of the names kept for a later plug-in API (`progress`, `ui.panel`, `ui.tool`) is refused when the module is installed, and the error names it. @@ -162,7 +164,7 @@ capability model proposed for PhotoCraft plug-ins in it count for `plugin.install`, `plugin.inspect`, `plugin.reload` and the Additional Plug-ins Folder. At startup, a stored plug-in whose permissions are not granted is skipped. The Additional Plug-ins Folder and `plugin.reload` install only plug-ins whose permissions were granted, and report the others. -- The host enforces the grant on a filter run: without `doc.read` the filter is given no objects; without `state` its +- The host enforces the grant on a filter run and a command run: without `doc.read` the filter is given no objects; without `state` its `documentData` is `null` and an output that sets it is refused; without `doc.write` an output that changes the document is refused. A refused output leaves the document as it was. - `api` is the plug-in API level the manifest is written for: `1` (the default) is the only level this host diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 52d8aa2b9..b767e347b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1429,7 +1429,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Plug-in data kept with the document (G17):** a filter writes JSON of at most 1 MiB with `documentData` (one undo step with its objects; `null` removes it) and reads it back in `_context.documentData`, which no other filter is given. It is the document's foreign data (`unknown.pluginData`, by plug-in id), as the perspective grid is, so the native file keeps it with no section of its own; an exporter is given the whole document, so it can read every plug-in's. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_export.rs` (the exporter), `crates/doc/src/plugin_data.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged. - **Heavy filter plug-ins (G17):** a filter's manifest may ask for `"budget": "heavy"`: 1 G instructions (20 times a light run's) and 300 s a run, for routing, packing or simulation. Install Plug-in… and File › Open ask the user first, `plugin.install` needs `allowHeavy: true` (`plugin.inspect` tells beforehand), the Additional Plug-ins Folder skips such filters, and the plug-in store keeps the answer. Its dialog has no Preview; the app runs it on a thread of its own with a progress window (Cancel, Continue in Background) and applies the result as `plugin.run` with the same parameters, one undo step, unless the document or the plug-in changed meanwhile. Regressions: `crates/plugins/tests/abi.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. -- **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin.<id>.<command>` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions, is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. +- **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin.<id>.<command>` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions (a live effect with commands may also hold `doc.write` and `state`, and gets them without a `permissions` list), is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one. From 3d1ce948bc228c1f8db84e71ef8aaef22c122d1e Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 19:45:57 -0800 Subject: [PATCH 111/160] Docs: the JavaScript text built-ins line matches the radix output and matcher memory --- 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 d31203fb8..0782b7bbf 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -655,7 +655,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files. - `vectorcraft-js` parses JavaScript as ECMAScript 5.1 defines it, with the legacy octal forms, block functions and lenient regular expressions of Annex B, into a syntax tree with source spans; it reads the `#target`, `#include` and other preprocessor lines as directives without running them, and a script that breaks the grammar or an early-error rule gives a `SyntaxError` with its line and column. Nesting past the parser's depth limit (`Options::max_depth`, 200 by default) gives a `SyntaxError` instead of exhausting the stack. `cargo xtask test262` fetches the conformance suite at a pinned commit and parses its ES5 tests: 5,411 of 5,418 runs pass (the 7 others use `let` and `const`). This entry covers parsing; the next entry covers running scripts. Tests: `crates/js/src/tests.rs`, `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs`. - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap. Built-ins are limited to `Object`, `Function` with `call` and `apply`, `eval` and the error types. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only these built-ins: 3,074 of 3,078 runs pass (the 4 others expect the completion values of later editions). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. - - `vectorcraft-js` has `String`, `Number`, `RegExp` and `Date` as ECMAScript 5.1 defines them (sections 15.5, 15.7, 15.9 and 15.10, https://262.ecma-international.org/5.1/), with Annex B's `substr`, `getYear`, `setYear` and `toGMTString`. `toFixed`, `toExponential`, `toPrecision` and `toString(radix)` format a number from its exact decimal value. Regular expressions (flags `g`, `i` and `m`) run on a backtracking matcher that keeps its choice points on the heap and takes fuel for every step, so a catastrophic pattern stops at the fuel limit and 50,000 nested groups give a `SyntaxError` at the default depth limit. A string method that would build a string past the heap limit stops before it allocates. `Date` reads the clock and time zone the host sets (`Realm::set_clock`, `Realm::set_time_zone`; local time is UTC by default). `cargo xtask test262` runs the suite's `built-ins` tests for the four (https://github.com/tc39/test262): of the runs of the ES5 tests that need only the built-ins present, 1,113 of 1,169 pass for `String`, 262 of 262 for `Number`, 878 of 908 for `RegExp` and 6 of 6 for `Date`. The others need `Array` and `Boolean` methods, or expect later editions' rules (built-in functions whose `length` can be deleted, flags given with a RegExp object, a negative `lastIndex`, `RegExp.prototype` as a plain object). The ES5 language tests that can now run give 3,742 of 3,746 passing runs. Tests: `crates/js/src/text_tests.rs`, `crates/js/tests/test262.rs`. + - `vectorcraft-js` has `String`, `Number`, `RegExp` and `Date` as ECMAScript 5.1 defines them (sections 15.5, 15.7, 15.9 and 15.10, https://262.ecma-international.org/5.1/), with Annex B's `substr`, `getYear`, `setYear` and `toGMTString`. `toFixed`, `toExponential` and `toPrecision` format a number from its exact decimal value, and `toString(radix)` writes any base from 2 to 36. Regular expressions (flags `g`, `i` and `m`) run on a backtracking matcher whose choice points and capture state count against the heap limit and which takes fuel for every step, so a catastrophic pattern stops at the fuel limit and 50,000 nested groups give a `SyntaxError` at the default depth limit. A string method that would build a string past the heap limit stops before it allocates. `Date` reads the clock and time zone the host sets (`Realm::set_clock`, `Realm::set_time_zone`; local time is UTC by default). `cargo xtask test262` runs the suite's `built-ins` tests for the four (https://github.com/tc39/test262): of the runs of the ES5 tests that need only the built-ins present, 1,113 of 1,169 pass for `String`, 262 of 262 for `Number`, 878 of 908 for `RegExp` and 6 of 6 for `Date`. The others need `Array` and `Boolean` methods, or expect later editions' rules (built-in functions whose `length` can be deleted, flags given with a RegExp object, a negative `lastIndex`, `RegExp.prototype` as a plain object). The ES5 language tests that can now run give 3,742 of 3,746 passing runs. Tests: `crates/js/src/text_tests.rs`, `crates/js/tests/test262.rs`. - **UI:** Illustrator 2026 layout restyled to measured values: - Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs. - Hint bar, contextual task bar, 19 dock panels with ≡ menus. From 4969ea6cab3423ef7398b345ac8a38e5d6d19e40 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 20:08:02 -0800 Subject: [PATCH 112/160] i18n: French rows keep the no-break space before punctuation --- crates/ui-egui/src/i18n/fr.tsv | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index c53f5c4d5..33399781d 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5784,35 +5784,35 @@ axis Y Axis Axe Y @msg an exporter always gets an export's budget: leave `budget` out un module externe d’exportation reçoit toujours le budget d’une exportation : omettez `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees « {_1} » demande un budget de calcul important (chaque exécution jusqu’à {minutes} minutes et {times} fois les instructions habituelles) : installez-le avec `allowHeavy: true` une fois que l’utilisateur l’accepte # Plug-in permissions (Install Plug-in…, plugin.install grant) - Install “{name}”, which asks for these permissions? Installer « {name} », qui demande ces autorisations ? - ID: {id} ID : {id} - Version: {version} Version : {version} - File SHA-256: {hash} SHA-256 du fichier : {hash} + Install “{name}”, which asks for these permissions? Installer « {name} », qui demande ces autorisations ? + ID: {id} ID : {id} + Version: {version} Version : {version} + File SHA-256: {hash} SHA-256 du fichier : {hash} Read the objects it is given Lire les objets qui lui sont transmis Change, add and delete objects in the document Modifier, ajouter et supprimer des objets du document Keep data of its own with the document Conserver ses propres données avec le document Change how the objects its effect is applied to are drawn Modifier le rendu des objets auxquels son effet est appliqué Write the document as a file in its own format Écrire le document dans un fichier à son propre format -@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` vaut {_1} ; cet hôte prend en charge les niveaux d’API de module externe 1 à {API_LEVEL} +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` vaut {_1} ; cet hôte prend en charge les niveaux d’API de module externe 1 à {API_LEVEL} @msg `grant` must be a list of permission names `grant` doit être une liste de noms d’autorisations @msg `grant` names an unknown permission `{shown}` `grant` nomme une autorisation inconnue `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` un module externe de type `{_1}` a besoin de l’autorisation `{_2}` @msg at most {MAX_PERMISSIONS} `permissions` au plus {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` l’autorisation `{shown}` ne s’applique pas à un module externe de type `{_1}` @msg permission `{shown}` is listed twice l’autorisation `{shown}` figure deux fois -@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it l’autorisation `{shown}` est réservée à une future API de module externe ; cet hôte ne l’accorde pas +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it l’autorisation `{shown}` est réservée à une future API de module externe ; cet hôte ne l’accorde pas @msg unknown permission `{shown}` autorisation inconnue `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees « {name} » demande les autorisations {asked} : installez-le avec `grant` qui les liste une fois que l’utilisateur l’accepte -@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed « {name} » n’a pas reçu `{p}`, dont sa sortie a besoin : rien n’a été modifié +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees « {name} » demande les autorisations {asked} : installez-le avec `grant` qui les liste une fois que l’utilisateur l’accepte +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed « {name} » n’a pas reçu `{p}`, dont sa sortie a besoin : rien n’a été modifié @msg a heavy filter runs in the background, so it can't declare `commands` un filtre lourd s’exécute en arrière-plan, il ne peut donc pas déclarer `commands` @msg at most {MAX_COMMANDS} `commands` au plus {MAX_COMMANDS} `commands` @msg command {name} is declared twice la commande {name} est déclarée deux fois -@msg command {name}: `label` must be 1-64 printable characters commande {name} : `label` doit compter 1-64 caractères imprimables -@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter commande {name} : utilisez un id de 1-{MAX_COMMAND_ID} caractères parmi a-z 0-9 _ commençant par une lettre -@msg command {name}: {e} commande {name} : {e} +@msg command {name}: `label` must be 1-64 printable characters commande {name} : `label` doit compter 1-64 caractères imprimables +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter commande {name} : utilisez un id de 1-{MAX_COMMAND_ID} caractères parmi a-z 0-9 _ commençant par une lettre +@msg command {name}: {e} commande {name} : {e} @msg only a filter or a live effect declares `commands` seul un filtre ou un effet dynamique déclare `commands` -@msg “{name}” › {label}: {e} « {name} » › {label} : {e} -@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed le module n’a pas le SHA-256 indiqué par `hash` : rien n’a été installé +@msg “{name}” › {label}: {e} « {name} » › {label} : {e} +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed le module n’a pas le SHA-256 indiqué par `hash` : rien n’a été installé # Actions panel: action sets (#3712). New Set… Nouveau jeu… New Set Nouveau jeu From 52e94d4f8a585d95312ccd4f7792f6ee88b1b9ce Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 20:12:42 -0800 Subject: [PATCH 113/160] JavaScript: compiling a pattern charges the room its lists reserve - The compiler charges each working list's added capacity before it grows (capacity doubles), the code that joins alternatives, and the flattened code with its stack, instead of an estimate from instruction and fragment counts. - The scan for capturing groups and the syntax check before compiling each take one unit of fuel per unit of the pattern. - Bytes a dropped HeldBytes released leave the heap's total at the next limit check, not at the next collection. - The progress line lists the deletable RegExp.prototype flag properties among the test262 failures. --- crates/js/src/interp.rs | 20 +++- crates/js/src/regexp_builtins.rs | 3 +- crates/js/src/regexp_vm.rs | 177 +++++++++++++++++++------------ crates/js/src/text_tests.rs | 55 +++++++++- docs/target-app-parity.md | 2 +- 5 files changed, 178 insertions(+), 79 deletions(-) diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index fb4e607b5..895630fd7 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -170,15 +170,18 @@ pub(crate) struct Interp { pub(crate) held: Rc<HeldCount>, } -/// The bytes all live [`HeldBytes`] hold. +/// The bytes all live [`HeldBytes`] hold, and the bytes dropped holders released that the heap's +/// total still counts. #[derive(Default)] pub(crate) struct HeldCount { now: std::cell::Cell<usize>, + released: std::cell::Cell<usize>, } /// Bytes that native code holds in Rust memory while it works (a matcher's state and stack, a -/// replacement being built, a pattern being compiled), counted against the heap limit. A -/// collection adds them to the live total until the holder is dropped. +/// replacement being built, a pattern being compiled), counted against the heap limit. They are +/// in the heap's total, and in the live total of each collection, until the holder is dropped; +/// the heap's total gives them back before the next limit check. pub(crate) struct HeldBytes { count: Rc<HeldCount>, bytes: usize, @@ -209,6 +212,7 @@ impl HeldBytes { impl Drop for HeldBytes { fn drop(&mut self) { self.count.now.set(self.count.now.get().saturating_sub(self.bytes)); + self.count.released.set(self.count.released.get().saturating_add(self.bytes)); } } @@ -439,6 +443,7 @@ impl Interp { fn tick(&mut self) -> JsResult<()> { self.use_fuel()?; + self.settle_held(); if self.heap.bytes > self.gc_threshold { self.collect_garbage(); if self.heap.bytes > self.limits.heap_bytes { @@ -452,6 +457,7 @@ impl Interp { /// Ends the run with the heap limit when `bytes` more would not fit in it after a /// collection. Everything the caller still needs must be reachable from the roots. pub(crate) fn reserve_heap(&mut self, bytes: usize) -> JsResult<()> { + self.settle_held(); if self.heap.bytes.saturating_add(bytes) <= self.limits.heap_bytes { return Ok(()); } @@ -463,6 +469,12 @@ impl Interp { Ok(()) } + /// Takes the bytes that dropped [`HeldBytes`] released off the heap's total. + fn settle_held(&mut self) { + let released = self.held.released.replace(0); + self.heap.bytes = self.heap.bytes.saturating_sub(released); + } + /// Collects garbage now and sets when the next collection runs. #[cfg(test)] pub(crate) fn collect_for_room(&mut self) { @@ -497,6 +509,8 @@ impl Interp { roots.objects.extend(self.intrinsics.all()); roots.envs.push(self.global_env); self.heap.collect(&roots); + // The collection counts the live total again, so bytes already released are not in it. + self.held.released.set(0); self.heap.bytes = self.heap.bytes.saturating_add(self.held.now.get()); } diff --git a/crates/js/src/regexp_builtins.rs b/crates/js/src/regexp_builtins.rs index 2ecadafd8..8733ded43 100644 --- a/crates/js/src/regexp_builtins.rs +++ b/crates/js/src/regexp_builtins.rs @@ -35,7 +35,7 @@ pub(crate) fn regexp_of(interp: &Interp, v: &Value) -> Option<(ObjId, Rc<Regex>) } /// Checks and compiles a pattern, throwing a `SyntaxError` for a pattern or flags that are not -/// valid. What compiling holds is held against the heap limit as it grows. The compiled form's +/// valid. The check takes one unit of fuel for each unit of the pattern. What compiling holds is held against the heap limit as it grows. The compiled form's /// bytes are then reserved together with `extra` more and added to the heap, so a caller that /// stores the result in an object it allocates next reserves that object here. fn compile_regex(interp: &mut Interp, pattern: &[u16], flags: &[u16], extra: usize) -> JsResult<Rc<Regex>> { @@ -44,6 +44,7 @@ fn compile_regex(interp: &mut Interp, pattern: &[u16], flags: &[u16], extra: usi Err(m) => return Err(interp.throw(ErrorType::Syntax, &m)), }; let depth = interp.options.max_depth; + use_fuel_n(interp, pattern.len() as u64)?; if let Err(m) = crate::regexp::check(pattern, depth) { return Err(interp.throw(ErrorType::Syntax, &format!("invalid regular expression: {m}"))); } diff --git a/crates/js/src/regexp_vm.rs b/crates/js/src/regexp_vm.rs index 26b0b37ef..c0c557ea7 100644 --- a/crates/js/src/regexp_vm.rs +++ b/crates/js/src/regexp_vm.rs @@ -328,10 +328,12 @@ impl Frags { } /// The instructions of `top` with every fragment in place, written with a loop, with room for - /// one more. + /// one more. Its stack holds at most one entry for each fragment and two more, so it never + /// grows past [`flatten_bytes`]. fn flatten(&self, top: &[Item]) -> Vec<Inst> { let mut out = Vec::with_capacity(self.len_of(top).saturating_add(1)); - let mut stack: Vec<(&[Item], usize)> = vec![(top, 0)]; + let mut stack: Vec<(&[Item], usize)> = Vec::with_capacity(self.items.len().saturating_add(2)); + stack.push((top, 0)); while let Some((items, i)) = stack.pop() { let Some(item) = items.get(i) else { continue }; stack.push((items, i + 1)); @@ -365,12 +367,55 @@ impl Ctx { } } -/// Adds `inst` to `ctx`, counting it in `emitted`. -fn emit(emitted: &mut usize, ctx: &mut Ctx, inst: Inst) { - *emitted += 1; +/// Adds `inst` to `ctx`. +fn emit(ctx: &mut Ctx, inst: Inst) { ctx.cur.push(Item::Inst(inst)); } +/// The bytes of one item of code being compiled. +pub(crate) const ITEM_BYTES: usize = size_of::<Item>(); + +/// The bytes of one instruction of compiled code. +pub(crate) const INST_BYTES: usize = size_of::<Inst>(); + +/// Charges `bytes` that compiling holds. +fn hold(bytes: usize, charge: &mut dyn FnMut(Cost) -> JsResult<()>) -> Result<(), CompileFail> { + if bytes == 0 { + return Ok(()); + } + charge(Cost::Heap(bytes)).map_err(CompileFail::Abrupt) +} + +/// Makes room in `list` for `extra` more entries. When it must grow, its capacity at least +/// doubles, and the bytes of the added capacity are charged before it is reserved. +fn room<T>(list: &mut Vec<T>, extra: usize, charge: &mut dyn FnMut(Cost) -> JsResult<()>) -> Result<(), CompileFail> { + let need = list.len().saturating_add(extra); + let cap = list.capacity(); + if need <= cap { + return Ok(()); + } + let new_cap = need.max(cap.saturating_mul(2)); + hold(size_of::<T>().saturating_mul(new_cap - cap), charge)?; + list.reserve_exact(new_cap - list.len()); + Ok(()) +} + +/// The bytes `join` holds for `alts` alternatives besides the fragments it adds: the code that +/// joins them and their lengths. +fn join_bytes(alts: usize) -> usize { + if alts <= 1 { + return 0; + } + alts.saturating_mul(2 * ITEM_BYTES + size_of::<usize>()) +} + +/// The bytes flattening holds: `instructions` and one more, and its stack for `fragments`. +fn flatten_bytes(instructions: usize, fragments: usize) -> usize { + INST_BYTES + .saturating_mul(instructions.saturating_add(1)) + .saturating_add(size_of::<(&[Item], usize)>().saturating_mul(fragments.saturating_add(2))) +} + fn offset(n: usize) -> Result<i32, CompileFail> { i32::try_from(n).map_err(|_| CompileFail::Syntax("regular expression too large".into())) } @@ -593,81 +638,62 @@ fn class(pattern: &[u16], mut i: usize, work: &mut u64, charge: &mut dyn FnMut(C } } -/// At least the bytes a class escape's set (`\d`, `\S`, ...) holds. -const ESCAPE_SET_BYTES: usize = size_of::<CharSet>() + 16 * size_of::<(u16, u16)>(); +/// At least the bytes of a class escape's ranges (`\d`, `\S`, ...). +const ESCAPE_RANGES_BYTES: usize = 16 * size_of::<(u16, u16)>(); /// The bytes a character class's ranges hold. fn set_bytes(set: &CharSet) -> usize { size_of::<(u16, u16)>().saturating_mul(set.ranges.capacity()) } -/// The bytes compiling holds: each instruction as an item and in the flattened code, each -/// fragment with its item, and the classes. -pub(crate) fn compile_bytes(instructions: usize, fragments: usize, sets_size: usize) -> usize { - instructions - .saturating_mul(size_of::<Item>() + size_of::<Inst>()) - .saturating_add(fragments.saturating_mul(size_of::<Item>() + 32)) - .saturating_add(sets_size) -} - -/// Compiles `pattern`. Groups may nest `max_depth` deep. `charge` takes fuel for the work done, and -/// heap bytes for what compiling holds before it grows: the instructions, fragments and classes -/// made so far (with the two instructions each `|` adds), the groups still open and the ranges of -/// the class being read. An error from `charge` stops compiling. +/// Compiles `pattern`. Groups may nest `max_depth` deep. `charge` takes fuel for the work done, +/// counting each unit of the scan for capturing groups and of the compile itself, and heap bytes +/// for what compiling holds before it is made: the capacity each list reserves as it grows, the +/// ranges of each class, and the code that joins alternatives and the flattened code. An error +/// from `charge` stops compiling. pub(crate) fn compile(pattern: &[u16], flags: Flags, max_depth: usize, charge: &mut dyn FnMut(Cost) -> JsResult<()>) -> Result<Regex, CompileFail> { + let mut work: u64 = 0; + add_work(&mut work, pattern.len() as u64, charge)?; let total_groups = count_groups(pattern); + hold(size_of::<Ctx>(), charge)?; let mut sets: Vec<CharSet> = Vec::new(); - let mut sets_size = 0usize; let mut stack: Vec<Ctx> = vec![Ctx::new(Kind::Top, (0, 0))]; let mut frags = Frags::default(); let mut groups: u32 = 0; let mut loops: u32 = 0; let mut i = 0; - let mut work: u64 = 0; - // The instructions made so far, counting the `Split` and `Jump` each `|` adds when it is joined. - let mut emitted = 0usize; - // The fragments the joins of the groups still open will add, one for each `|` read in them. - let mut joined = 0usize; - // The heap bytes charged so far. - let mut held = 0usize; let canon = |u: u16| if flags.ignore_case { canonicalize(u) } else { u }; while let Some(&u) = pattern.get(i) { add_work(&mut work, 1, charge)?; - // One step adds at most three instructions, two fragments, one open group and one class - // escape's set (a class charges its own ranges), so their bytes are charged before the - // step makes them. - let now = compile_bytes(emitted + 3, frags.items.len() + joined + 2, sets_size.saturating_add(ESCAPE_SET_BYTES)) - .saturating_add(size_of::<Ctx>().saturating_mul(stack.len() + 1)); - if now > held { - charge(Cost::Heap(now - held)).map_err(CompileFail::Abrupt)?; - held = now; - } + // One step adds at most one open group, one class, one alternative and four items to the + // current alternative, so those lists have room for them before the step. + room(&mut stack, 1, charge)?; + room(&mut sets, 1, charge)?; let ctx = stack.last_mut().ok_or("unmatched ')'")?; + room(&mut ctx.cur, 4, charge)?; + room(&mut ctx.alts, 1, charge)?; let start = ctx.cur.len(); let before = groups; match u { 0x5E | 0x24 => { - emit(&mut emitted, ctx, if u == 0x5E { Inst::LineStart } else { Inst::LineEnd }); + emit(ctx, if u == 0x5E { Inst::LineStart } else { Inst::LineEnd }); ctx.atom = None; i += 1; } 0x7C => { - emitted += 2; - joined += 1; let cur = std::mem::take(&mut ctx.cur); ctx.alts.push(cur); ctx.atom = None; i += 1; } 0x2E => { - emit(&mut emitted, ctx, Inst::Any); + emit(ctx, Inst::Any); ctx.atom = Some((start, before)); i += 1; } 0x5B => { let (set, next) = class(pattern, i, &mut work, charge)?; - emit(&mut emitted, ctx, Inst::Set(u32::try_from(sets.len()).map_err(|_| "too many classes")?)); - sets_size = sets_size.saturating_add(size_of::<CharSet>() + set_bytes(&set)); + emit(ctx, Inst::Set(u32::try_from(sets.len()).map_err(|_| "too many classes")?)); sets.push(set); ctx.atom = Some((start, before)); i = next; @@ -695,27 +721,32 @@ pub(crate) fn compile(pattern: &[u16], flags: Flags, max_depth: usize, charge: & } let Some(mut done) = stack.pop() else { return Err("unmatched ')'".into()) }; let cur = std::mem::take(&mut done.cur); - // The fragments counted for this group's `|`s become real ones in the join. - joined = joined.saturating_sub(done.alts.len()); done.alts.push(cur); + // Joining adds a fragment for each alternative when there are several, and the + // group's body is one more. + let alts = done.alts.len(); + hold(join_bytes(alts), charge)?; + room(&mut frags.items, alts + 1, charge)?; + room(&mut frags.lens, alts + 1, charge)?; let body = join(std::mem::take(&mut done.alts), &mut frags)?; work += body.len() as u64; let body_len = frags.len_of(&body); let body = frags.add(body); let (start, before) = done.opened_at; let parent = stack.last_mut().ok_or("unmatched ')'")?; + room(&mut parent.cur, 3, charge)?; match done.kind { Kind::Capture(n) => { - emit(&mut emitted, parent, Inst::GroupStart(n)); + emit(parent, Inst::GroupStart(n)); parent.cur.push(body); - emit(&mut emitted, parent, Inst::GroupEnd(n)); + emit(parent, Inst::GroupEnd(n)); } Kind::Group | Kind::Top => parent.cur.push(body), Kind::Look(negative) => { let end = offset(body_len + 2)?; - emit(&mut emitted, parent, Inst::Look { negative, end }); + emit(parent, Inst::Look { negative, end }); parent.cur.push(body); - emit(&mut emitted, parent, Inst::LookEnd); + emit(parent, Inst::LookEnd); } } parent.atom = Some((start, before)); @@ -730,7 +761,7 @@ pub(crate) fn compile(pattern: &[u16], flags: Flags, max_depth: usize, charge: & Some(b) => b, None => { // Annex B: a `{` that starts no quantifier is itself. - emit(&mut emitted, ctx, Inst::Char(canon(u))); + emit(ctx, Inst::Char(canon(u))); ctx.atom = Some((start, before)); i += 1; continue; @@ -746,23 +777,26 @@ pub(crate) fn compile(pattern: &[u16], flags: Flags, max_depth: usize, charge: & if min == 1 && max == 1 { continue; } + hold(ITEM_BYTES.saturating_mul(ctx.cur.len().saturating_sub(atom_start)), charge)?; + room(&mut frags.items, 1, charge)?; + room(&mut frags.lens, 1, charge)?; let body: Vec<Item> = ctx.cur.drain(atom_start..).collect(); work += body.len() as u64; let reg = loops; loops = loops.checked_add(1).ok_or("too many quantifiers")?; let n = frags.len_of(&body); let body = frags.add(body); - emit(&mut emitted, ctx, Inst::RepInit(reg)); - emit(&mut emitted, ctx, Inst::Rep { reg, min, max, greedy, caps: (atom_groups + 1, groups + 1), exit: offset(n + 2)? }); + emit(ctx, Inst::RepInit(reg)); + emit(ctx, Inst::Rep { reg, min, max, greedy, caps: (atom_groups + 1, groups + 1), exit: offset(n + 2)? }); ctx.cur.push(body); - emit(&mut emitted, ctx, Inst::RepEnd { reg, head: -offset(n + 1)? }); + emit(ctx, Inst::RepEnd { reg, head: -offset(n + 1)? }); } 0x5C => { let next = *pattern.get(i + 1).ok_or("\\ at end of pattern")?; let mut atom = true; match next { 0x62 | 0x42 => { - emit(&mut emitted, ctx, Inst::WordBoundary(next == 0x62)); + emit(ctx, Inst::WordBoundary(next == 0x62)); atom = false; i += 2; } @@ -774,40 +808,41 @@ pub(crate) fn compile(pattern: &[u16], flags: Flags, max_depth: usize, charge: & j += 1; } if n <= total_groups as u64 { - emit(&mut emitted, ctx, Inst::BackRef(u32::try_from(n).map_err(|_| "too many groups")?)); + emit(ctx, Inst::BackRef(u32::try_from(n).map_err(|_| "too many groups")?)); i = j; } else if next <= 0x37 { let (v, end) = octal(pattern, i + 1); - emit(&mut emitted, ctx, Inst::Char(canon(v))); + emit(ctx, Inst::Char(canon(v))); i = end; } else { - emit(&mut emitted, ctx, Inst::Char(canon(next))); + emit(ctx, Inst::Char(canon(next))); i += 2; } } 0x30 => { if pattern.get(i + 2).is_some_and(|&d| (0x30..=0x37).contains(&d)) { let (v, end) = octal(pattern, i + 1); - emit(&mut emitted, ctx, Inst::Char(canon(v))); + emit(ctx, Inst::Char(canon(v))); i = end; } else { - emit(&mut emitted, ctx, Inst::Char(0)); + emit(ctx, Inst::Char(0)); i += 2; } } _ => { + if matches!(next, 0x64 | 0x44 | 0x73 | 0x53 | 0x77 | 0x57) { + hold(ESCAPE_RANGES_BYTES, charge)?; + } if let Some(r) = escape_ranges(next) { - emit(&mut emitted, ctx, Inst::Set(u32::try_from(sets.len()).map_err(|_| "too many classes")?)); - let set = CharSet { ranges: r, negated: false }; - sets_size = sets_size.saturating_add(size_of::<CharSet>() + set_bytes(&set)); - sets.push(set); + emit(ctx, Inst::Set(u32::try_from(sets.len()).map_err(|_| "too many classes")?)); + sets.push(CharSet { ranges: r, negated: false }); i += 2; } else { let (atom_unit, end) = match class_atom(pattern, i)? { (ClassAtom::Unit(v), end) => (v, end), (ClassAtom::Ranges(_), end) => (next, end), }; - emit(&mut emitted, ctx, Inst::Char(canon(atom_unit))); + emit(ctx, Inst::Char(canon(atom_unit))); i = end; } } @@ -815,7 +850,7 @@ pub(crate) fn compile(pattern: &[u16], flags: Flags, max_depth: usize, charge: & ctx.atom = if atom { Some((start, before)) } else { None }; } _ => { - emit(&mut emitted, ctx, Inst::Char(canon(u))); + emit(ctx, Inst::Char(canon(u))); ctx.atom = Some((start, before)); i += 1; } @@ -827,12 +862,14 @@ pub(crate) fn compile(pattern: &[u16], flags: Flags, max_depth: usize, charge: & let Some(mut top) = stack.pop() else { return Err("unterminated group".into()) }; let cur = std::mem::take(&mut top.cur); top.alts.push(cur); - // Joining adds a fragment for each alternative, and flattening writes every instruction. - let now = compile_bytes(emitted + 1, frags.items.len() + joined + 1, sets_size); - if now > held { - charge(Cost::Heap(now - held)).map_err(CompileFail::Abrupt)?; - } + // Joining adds a fragment for each alternative when there are several, and flattening writes + // every instruction. + let alts = top.alts.len(); + hold(join_bytes(alts), charge)?; + room(&mut frags.items, alts, charge)?; + room(&mut frags.lens, alts, charge)?; let top = join(top.alts, &mut frags)?; + hold(flatten_bytes(frags.len_of(&top), frags.items.len()), charge)?; let mut insts = frags.flatten(&top); insts.push(Inst::Match); sets.shrink_to_fit(); diff --git a/crates/js/src/text_tests.rs b/crates/js/src/text_tests.rs index 649924725..809adb5f8 100644 --- a/crates/js/src/text_tests.rs +++ b/crates/js/src/text_tests.rs @@ -2,6 +2,7 @@ use proptest::prelude::*; +use crate::regexp_vm::{INST_BYTES, ITEM_BYTES}; use crate::{JsValue, LimitKind, Limits, Options, Realm, RunError, RunErrorKind}; fn run_in(realm: &mut Realm, src: &str) -> JsValue { @@ -302,18 +303,64 @@ fn compiling_a_pattern_counts_against_the_heap() { assert_eq!(run(src), JsValue::Number(131_072.0)); // The compiler stops while it reads the pattern, before the code it builds passes the room. let pattern = "[\\s\\S]a*".repeat(1000); - assert!(stopped_by(&compile_within(&pattern, crate::regexp_vm::compile_bytes(400, 0, 0), u64::MAX).0, LimitKind::Heap)); + assert!(stopped_by(&compile_within(&pattern, 400 * (ITEM_BYTES + INST_BYTES), u64::MAX).0, LimitKind::Heap)); assert!(compile_within(&pattern, usize::MAX, u64::MAX).0.is_ok()); // A literal pattern takes what its instructions hold, not the most a unit can take. - assert!(compile_within(&"a".repeat(10_000), crate::regexp_vm::compile_bytes(20_000, 0, 0), u64::MAX).0.is_ok()); + assert!(compile_within(&"a".repeat(10_000), 20_000 * (ITEM_BYTES + INST_BYTES), u64::MAX).0.is_ok()); // A class's ranges count while they are read, even when they collapse into one range. assert!(stopped_by(&compile_within(&format!("[{}]", "a".repeat(10_000)), 1000, u64::MAX).0, LimitKind::Heap)); // So do the ranges of class escapes, and each atom and range read takes fuel. let escapes = format!("[{}]", "\\w".repeat(10_000)); assert!(stopped_by(&compile_within(&escapes, 64 << 10, u64::MAX).0, LimitKind::Heap)); assert!(stopped_by(&compile_within(&escapes, usize::MAX, 20_000).0, LimitKind::Fuel)); - // The joining and flattening after the last unit count too. - assert!(stopped_by(&compile_within("ab", crate::regexp_vm::compile_bytes(1, 0, 0), u64::MAX).0, LimitKind::Heap)); + // The lists count with the room they reserve as they double: 4097 literal units hold room for + // 8192 items, and the flattened code after the last unit adds 4098 instructions. + let literal = "a".repeat(4097); + let (compiled, bytes, _) = compile_within(&literal, usize::MAX, u64::MAX); + assert!(compiled.is_ok()); + assert!(bytes >= 8192 * ITEM_BYTES + 4098 * INST_BYTES, "{bytes}"); + assert!(stopped_by(&compile_within(&literal, 8192 * ITEM_BYTES + 4000 * INST_BYTES, u64::MAX).0, LimitKind::Heap)); +} + +#[test] +fn compiling_a_pattern_takes_fuel_for_each_scan_of_it() { + // The scan for capturing groups takes a unit of fuel for each unit of the pattern, and so does + // the compile itself. + let literal = "a".repeat(100_000); + let (compiled, _, fuel) = compile_within(&literal, usize::MAX, u64::MAX); + assert!(compiled.is_ok()); + assert!(fuel >= 200_000, "{fuel}"); + assert!(stopped_by(&compile_within(&literal, usize::MAX, 150_000).0, LimitKind::Fuel)); + // The syntax check before compiling takes a unit for each unit too. + let mut realm = Realm::new(); + let interp = realm.interp(); + let (source, flags) = (crate::value::Str::from(literal.as_str()), crate::value::Str::from("")); + interp.fuel = 1 << 40; + assert!(crate::regexp_builtins::new_regexp(interp, &source, &flags).is_ok()); + let used = (1u64 << 40) - interp.fuel; + assert!(used >= 300_000, "{used}"); + interp.fuel = 250_000; + assert!(matches!(crate::regexp_builtins::new_regexp(interp, &source, &flags), Err(crate::interp::Abrupt::Limit(LimitKind::Fuel)))); +} + +#[test] +fn held_bytes_leave_the_heap_total_when_their_holder_is_dropped() { + let mut realm = Realm::new(); + let interp = realm.interp(); + interp.collect_for_room(); + let live = interp.heap.bytes; + interp.limits.heap_bytes = live + 10_000; + let mut held = crate::interp::HeldBytes::new(interp); + assert!(held.add(interp, 6000).is_ok()); + assert_eq!(interp.heap.bytes, live + 6000); + assert!(matches!(crate::interp::HeldBytes::new(interp).add(interp, 6000), Err(crate::interp::Abrupt::Limit(LimitKind::Heap)))); + drop(held); + // The next check gives the bytes back without a collection. + assert!(interp.reserve_heap(0).is_ok()); + assert_eq!(interp.heap.bytes, live); + let mut again = crate::interp::HeldBytes::new(interp); + assert!(again.add(interp, 6000).is_ok()); + assert_eq!(interp.heap.bytes, live + 6000); } /// Compiles `pattern` with no flags through a charge that allows `heap` bytes and `fuel` units, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0782b7bbf..80afed899 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -655,7 +655,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files. - `vectorcraft-js` parses JavaScript as ECMAScript 5.1 defines it, with the legacy octal forms, block functions and lenient regular expressions of Annex B, into a syntax tree with source spans; it reads the `#target`, `#include` and other preprocessor lines as directives without running them, and a script that breaks the grammar or an early-error rule gives a `SyntaxError` with its line and column. Nesting past the parser's depth limit (`Options::max_depth`, 200 by default) gives a `SyntaxError` instead of exhausting the stack. `cargo xtask test262` fetches the conformance suite at a pinned commit and parses its ES5 tests: 5,411 of 5,418 runs pass (the 7 others use `let` and `const`). This entry covers parsing; the next entry covers running scripts. Tests: `crates/js/src/tests.rs`, `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs`. - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap. Built-ins are limited to `Object`, `Function` with `call` and `apply`, `eval` and the error types. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only these built-ins: 3,074 of 3,078 runs pass (the 4 others expect the completion values of later editions). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. - - `vectorcraft-js` has `String`, `Number`, `RegExp` and `Date` as ECMAScript 5.1 defines them (sections 15.5, 15.7, 15.9 and 15.10, https://262.ecma-international.org/5.1/), with Annex B's `substr`, `getYear`, `setYear` and `toGMTString`. `toFixed`, `toExponential` and `toPrecision` format a number from its exact decimal value, and `toString(radix)` writes any base from 2 to 36. Regular expressions (flags `g`, `i` and `m`) run on a backtracking matcher whose choice points and capture state count against the heap limit and which takes fuel for every step, so a catastrophic pattern stops at the fuel limit and 50,000 nested groups give a `SyntaxError` at the default depth limit. A string method that would build a string past the heap limit stops before it allocates. `Date` reads the clock and time zone the host sets (`Realm::set_clock`, `Realm::set_time_zone`; local time is UTC by default). `cargo xtask test262` runs the suite's `built-ins` tests for the four (https://github.com/tc39/test262): of the runs of the ES5 tests that need only the built-ins present, 1,113 of 1,169 pass for `String`, 262 of 262 for `Number`, 878 of 908 for `RegExp` and 6 of 6 for `Date`. The others need `Array` and `Boolean` methods, or expect later editions' rules (built-in functions whose `length` can be deleted, flags given with a RegExp object, a negative `lastIndex`, `RegExp.prototype` as a plain object). The ES5 language tests that can now run give 3,742 of 3,746 passing runs. Tests: `crates/js/src/text_tests.rs`, `crates/js/tests/test262.rs`. + - `vectorcraft-js` has `String`, `Number`, `RegExp` and `Date` as ECMAScript 5.1 defines them (sections 15.5, 15.7, 15.9 and 15.10, https://262.ecma-international.org/5.1/), with Annex B's `substr`, `getYear`, `setYear` and `toGMTString`. `toFixed`, `toExponential` and `toPrecision` format a number from its exact decimal value, and `toString(radix)` writes any base from 2 to 36. Regular expressions (flags `g`, `i` and `m`) run on a backtracking matcher whose choice points and capture state count against the heap limit and which takes fuel for every step, so a catastrophic pattern stops at the fuel limit and 50,000 nested groups give a `SyntaxError` at the default depth limit. A string method that would build a string past the heap limit stops before it allocates. `Date` reads the clock and time zone the host sets (`Realm::set_clock`, `Realm::set_time_zone`; local time is UTC by default). `cargo xtask test262` runs the suite's `built-ins` tests for the four (https://github.com/tc39/test262): of the runs of the ES5 tests that need only the built-ins present, 1,113 of 1,169 pass for `String`, 262 of 262 for `Number`, 878 of 908 for `RegExp` and 6 of 6 for `Date`. The others need `Array` and `Boolean` methods, or expect later editions' rules (built-in functions whose `length` can be deleted, flags given with a RegExp object, a negative `lastIndex`, `RegExp.prototype` as a plain object, and `RegExp.prototype`'s `global`, `ignoreCase` and `multiline` properties that can be deleted). The ES5 language tests that can now run give 3,742 of 3,746 passing runs. Tests: `crates/js/src/text_tests.rs`, `crates/js/tests/test262.rs`. - **UI:** Illustrator 2026 layout restyled to measured values: - Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs. - Hint bar, contextual task bar, 19 dock panels with ≡ menus. From 971a0d662616836d99be05f8684935afa627afe8 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 20:26:40 -0800 Subject: [PATCH 114/160] JavaScript: a backreference takes fuel for each 16 units before it compares them --- crates/js/src/regexp_vm.rs | 16 ++++++++++++---- crates/js/src/text_tests.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/crates/js/src/regexp_vm.rs b/crates/js/src/regexp_vm.rs index c0c557ea7..139469422 100644 --- a/crates/js/src/regexp_vm.rs +++ b/crates/js/src/regexp_vm.rs @@ -5,7 +5,8 @@ //! pattern with a loop and a stack of open groups, and the matcher keeps its choice points and the //! values it must restore on its own stack, so neither recurses however deeply the groups nest. //! Quantifiers count their iterations in registers instead of repeating their atom's code. Every -//! instruction run, every capture slot cleared and every choice point taken back costs fuel, and +//! instruction run, every capture slot cleared, every choice point taken back and every 16 units a +//! backreference compares cost fuel, and //! the compiler's work, the matcher's state and its stack are held against the heap limit, so a //! pattern that backtracks without end stops at a limit. @@ -1294,12 +1295,19 @@ impl<'r> Matcher<'r> { true } else { let len = e - s; - let hit = pos + len <= input.len() - && (0..len).all(|k| match (input.get(s + k), input.get(pos + k)) { + let mut hit = pos + len <= input.len(); + // The units are compared 16 at a time, and each 16, or fewer at the end, + // count as a step taken before they are compared. + let mut k = 0; + while hit && k < len { + let end = len.min(k + 16); + self.step(interp)?; + hit = (k..end).all(|i| match (input.get(s + i), input.get(pos + i)) { (Some(&a), Some(&b)) => re.canon(a) == re.canon(b), _ => false, }); - self.steps += len as u64 / 16; + k = end; + } if hit { pos += len; } diff --git a/crates/js/src/text_tests.rs b/crates/js/src/text_tests.rs index 809adb5f8..70e3cd6eb 100644 --- a/crates/js/src/text_tests.rs +++ b/crates/js/src/text_tests.rs @@ -447,6 +447,33 @@ fn the_matcher_state_counts_against_the_heap() { assert_eq!(interp.heap.bytes, live); } +#[test] +fn a_backreference_takes_fuel_for_the_units_it_compares() { + let mut realm = Realm::with_options(Options::default(), Limits::default()); + let interp = realm.interp(); + let mut fuel_for = |pattern: &str, input: &str, want: bool| { + let input: Vec<u16> = input.encode_utf16().collect(); + let Ok(re) = compile_within(pattern, usize::MAX, u64::MAX).0 else { panic!("the pattern compiles") }; + let Ok(mut m) = crate::regexp_vm::Matcher::new(&re, interp) else { panic!("the state fits") }; + interp.fuel = 1 << 40; + assert!(matches!(m.match_at(interp, &input, 0), Ok(hit) if hit == want), "{pattern}"); + (1u64 << 40) - interp.fuel + }; + // The same 20,000 iterations, each running a one-unit backreference instead of a character: + // each comparison takes a step besides the instruction's own. + let a = "a".repeat(20_001); + let (by_char, by_backref) = (fuel_for("(a)(?:a)*$", &a, true), fuel_for("(a)(?:\\1)*$", &a, true)); + assert!(by_backref >= by_char + 20_000, "{by_backref} < {by_char} + 20000"); + // A long comparison takes a step for each 16 units it compares. + let long = fuel_for("^(a{10000})\\1", &a, true); + let plain = fuel_for("^(a{10000})", &a, true); + assert!(long >= plain + 625, "{long} < {plain} + 625"); + // One that fails on its first unit takes a step for those 16 units only. + let ab = format!("{}{}", "a".repeat(10_000), "b".repeat(10_000)); + let (by_backref, by_char) = (fuel_for("^(a{10000})\\1", &ab, false), fuel_for("^(a{10000})c", &ab, false)); + assert!(by_backref <= by_char + 2, "{by_backref} > {by_char} + 2"); +} + #[test] fn clearing_matcher_state_takes_fuel() { let limits = Limits { fuel: 500_000, ..Limits::default() }; From 647f81933dcece2ac6e22c06a6f0421e6f652618 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 20:29:18 -0800 Subject: [PATCH 115/160] .ai save: the large-canvas warning says geometry effects are applied to the art On a large canvas no art style is written, so the editing data holds an object's geometry effects only as the artwork they made: the effects can no longer be edited when the file is opened again. The warning now says that the geometry effects are applied to the artwork and can no longer be edited, and that the object's other effects are left out. --- crates/eps/src/ai_editing/writer/styles.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index 8307ea302..ace7e6b0f 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -18,8 +18,7 @@ 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, no art styles are written: objects with live effects keep only their geometry effects in the editing data for other apps"; +pub(in crate::ai_editing) const LARGE_CANVAS_STYLES: &str = "on a large canvas, no art styles or graphic styles are written: objects with live effects are written with their geometry effects applied to the artwork, where they can no longer be edited, and their other effects are left out of 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 From 328aa0e340f44560a0f045c7639b83952581ef4c Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 20:33:40 -0800 Subject: [PATCH 116/160] JavaScript: a RegExp source escapes a line terminator after a backslash, and Date.parse reads its words in place A constructor pattern with a backslash before a line terminator wrote the raw terminator into source and toString, so the text did not read back as a literal; it now writes the terminator's escape, which matches the same unit (ECMA-262 5.1 section 15.10.2.10). Date.parse no longer collects every word of a text date into a list before it checks them, and reads month, weekday and zone names without copying the word. --- crates/js/src/date_builtins.rs | 26 ++++++++++++-------------- crates/js/src/regexp_builtins.rs | 6 ++++++ crates/js/src/text_tests.rs | 8 ++++++++ 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/crates/js/src/date_builtins.rs b/crates/js/src/date_builtins.rs index e81f73ee9..55f9e1e81 100644 --- a/crates/js/src/date_builtins.rs +++ b/crates/js/src/date_builtins.rs @@ -346,28 +346,22 @@ fn parse_iso(s: &[u8]) -> Option<f64> { /// year, an optional `HH:mm[:ss]` time and an optional `GMT[±hhmm]` offset, in either order of /// day and month. Without an offset the time is local. fn parse_text(interp: &Interp, s: &str) -> Option<f64> { - let mut words: Vec<&str> = Vec::new(); - for w in s.split(|c: char| c.is_whitespace() || c == ',') { - if w.starts_with('(') { - break; - } - if !w.is_empty() { - words.push(w); - } - } + let starts = |w: &str, prefix: &str| w.get(..prefix.len()).is_some_and(|head| head.eq_ignore_ascii_case(prefix)); let (mut month, mut date, mut year) = (None, None, None); let (mut h, mut m, mut sec) = (0.0, 0.0, 0.0); let mut offset: Option<f64> = None; + // The words are read one at a time, and none is copied. + let words = s.split(|c: char| c.is_whitespace() || c == ',').take_while(|w| !w.starts_with('(')).filter(|w| !w.is_empty()); for w in words { - let lower = w.to_ascii_lowercase(); - if DAYS.iter().any(|d| lower.starts_with(&d.to_ascii_lowercase())) && month.is_none() && date.is_none() && w.len() >= 3 && !w.contains(':') { + if DAYS.iter().any(|d| starts(w, d)) && month.is_none() && date.is_none() && w.len() >= 3 && !w.contains(':') { continue; } - if let Some(mi) = MONTHS.iter().position(|mo| lower.starts_with(&mo.to_ascii_lowercase())) { + if let Some(mi) = MONTHS.iter().position(|mo| starts(w, mo)) { month = Some(mi as f64); continue; } - if let Some(rest) = lower.strip_prefix("gmt").or_else(|| lower.strip_prefix("utc")).or_else(|| (lower == "z").then_some("")) { + let zone = if starts(w, "gmt") || starts(w, "utc") { w.get(3..) } else { w.eq_ignore_ascii_case("z").then_some("") }; + if let Some(rest) = zone { if rest.is_empty() { offset = Some(0.0); continue; @@ -377,7 +371,8 @@ fn parse_text(interp: &Interp, s: &str) -> Option<f64> { Some(b'-') => (-1.0, rest.get(1..)?), _ => return None, }; - let digits = digits.replace(':', ""); + // Five units are enough to tell whether four digits remain without the colons. + let digits: String = digits.chars().filter(|&c| c != ':').take(5).collect(); if digits.len() != 4 || !digits.bytes().all(|b| b.is_ascii_digit()) { return None; } @@ -386,6 +381,9 @@ fn parse_text(interp: &Interp, s: &str) -> Option<f64> { continue; } if w.contains(':') { + if w.split(':').count() > 3 { + return None; + } let parts: Vec<&str> = w.split(':').collect(); let field = |i: usize| -> Option<f64> { parts.get(i).map(|p| p.parse::<f64>().ok()).unwrap_or(Some(0.0)) }; if parts.len() < 2 || parts.len() > 3 || !parts.iter().all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())) { diff --git a/crates/js/src/regexp_builtins.rs b/crates/js/src/regexp_builtins.rs index 8733ded43..fcafd82c5 100644 --- a/crates/js/src/regexp_builtins.rs +++ b/crates/js/src/regexp_builtins.rs @@ -117,6 +117,12 @@ fn source_units(pattern: &[u16], mut emit: impl FnMut(&[u16])) { while let Some(&u) = pattern.get(i) { match u { 0x5C => { + // A backslash before a line terminator escapes the terminator as itself (section + // 15.10.2.10); the terminator's escape written below matches the same unit. + if pattern.get(i + 1).is_some_and(|&n| crate::chars::is_line_terminator(u32::from(n))) { + i += 1; + continue; + } emit(pattern.get(i..i + 2).unwrap_or(&[BACKSLASH])); i += 2; continue; diff --git a/crates/js/src/text_tests.rs b/crates/js/src/text_tests.rs index 70e3cd6eb..b150ba3f5 100644 --- a/crates/js/src/text_tests.rs +++ b/crates/js/src/text_tests.rs @@ -411,6 +411,12 @@ fn a_constructed_source_counts_against_the_heap_before_it_is_built() { let Ok(source) = crate::regexp_builtins::source_text(interp, &pattern) else { panic!("the source fits") }; assert_eq!(source.flat().len(), 60_000); assert_eq!(string("String(new RegExp('a/\\\\/[/]\\n\\r\\u2028\\u2029'))"), "/a\\/\\/[/]\\n\\r\\u2028\\u2029/"); + // A backslash before a line terminator reads back as the terminator's escape. + let escaped = "var r = new RegExp(String.fromCharCode(92, 10, 124, 92, 0x2028)); var back = eval(String(r));"; + assert_eq!( + string(&format!("{escaped} String(r) + ' ' + r.test('\\n') + back.test('\\n') + back.test('\\u2028') + back.test('x')")), + "/\\n|\\u2028/ truetruetruefalse" + ); } #[test] @@ -600,6 +606,8 @@ fn dates_round_trip_through_their_strings() { "Object.prototype.toString.call(Date.prototype) === '[object Date]' && isNaN_(Date.prototype.getTime())", "var d = new Date(0); d + d === d.toString() + d.toString() && d - d === 0 && d == d.toString()", "isNaN_(Date.parse('1970-00-01')) && isNaN_(Date.parse('1970-13-01')) && isNaN_(Date.parse('1970-02-30'))", + "Date.parse('thu, 01 JAN 1970 00:00:00 gmt') === 0 && Date.parse('jan 2 1970 utc+01:00') === 82800000", + "isNaN_(Date.parse('Jan 1 1970 1:2:3:4 GMT')) && isNaN_(Date.parse('Jan 1 1970 GMT+01000')) && isNaN_(Date.parse('Jan 1 1970 x'))", ] { let full = format!("function Math_floor(t) {{ return t - t % 1000; }} function isNaN_(n) {{ return n !== n; }} {src}"); assert_eq!(run_in(&mut realm, &full), JsValue::Bool(true), "{src}"); From 142366d9eeaabd06f4eefce3cf3a343b46752e83 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 20:43:10 -0800 Subject: [PATCH 117/160] Effects: placed gradients move with Transform and Free Distort effects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A gradient placed with the Gradient tool stayed where it was when the object's Transform or Free Distort effect moved, turned or scaled the outline, so the shape slid across its own gradient. The Transform effect now maps placed gradients by its own transform, and each of its copies takes them as many times as it takes the transform. Under Free Distort, each path's placed gradients move and scale from the path's bounds before the effect to its bounds after it. This change does not alter gradients fitted to the object. The canvas, Expand Appearance and export, Pathfinder and compound shapes, groups, a fill's or stroke's own effects, and Object › Expand of a gradient fill apply the same rule. In a group, the own fills and strokes of the group, of the groups inside it and of a symbol's art follow it too, and images and symbol instances that a group's Transform effect moves take the placed gradients of their own fills and strokes along. A group whose path keeps its shape because the effect would pass the geometry budget keeps its own gradients in place. This changes how existing documents draw: a gradient placed on a path, or on an image's or symbol instance's own fill, inside a group with a Transform effect now moves with it. Before, only the gradients of type moved (#2214). How gradients follow each effect is VectorCraft's own rule. The Distort & Transform help page describes what the effects do to the object: https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html --- crates/effects/src/bake.rs | 46 +++-- crates/effects/src/bake/tests.rs | 130 ++++++++++++++ crates/effects/src/catalog.rs | 4 +- crates/effects/src/distort.rs | 26 +-- crates/effects/src/geometry.rs | 115 +++++++++++++ crates/effects/src/group.rs | 25 ++- crates/effects/src/group/tests.rs | 178 +++++++++++++++++++- crates/effects/src/lib.rs | 4 +- crates/effects/src/reshape.rs | 243 ++++++++++++++++++++++++--- crates/effects/src/tests.rs | 48 ++++++ crates/engine/src/cmd/expand.rs | 35 ++-- crates/engine/src/tests/objexpand.rs | 31 ++++ crates/render/src/fx/bounds.rs | 13 ++ crates/render/src/fx/entries.rs | 7 +- crates/render/src/fx/pixels.rs | 25 ++- crates/render/src/fx/renderer.rs | 42 +++-- crates/render/src/tests/objectfx.rs | 46 +++++ docs/target-app-parity.md | 1 + 18 files changed, 928 insertions(+), 91 deletions(-) diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index cdde7d135..3eaf685f0 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -28,7 +28,7 @@ use crate::distort::{transform_move, transform_offsets}; use crate::group::{has_own_paint, paints}; use crate::reshape::{as_art, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves}; use crate::{ - GeomContext, adjust_in_document, apply_geometry_with, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry, + GeomContext, adjust_in_document, apply_geometry_carrying, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry, has_item_geometry, has_pathfinder, is_geometry, is_pathfinder, merged_params, needs_outline, outline_text, reshape, }; @@ -71,12 +71,27 @@ fn geometry(n: &Node) -> Option<(PathData, FillRule)> { } } -fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext) -> PathData { +/// `path` with the geometry effects of `effects` applied, and how gradients follow them +/// ([`crate::Carry`]). +fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext) -> (PathData, crate::Carry) { if !has_geometry(effects) || path.is_empty() { - return path.clone(); + return (path.clone(), crate::Carry::default()); } let b = vectorcraft_geom::Shape::bounding_box(&path.to_bezpath()); - apply_geometry_with(effects, path, b, ctx) + apply_geometry_carrying(effects, path, b, ctx) +} + +/// The geometry `g` with the own effects of `item` (one of the fills and strokes `ctx` is the +/// context of) applied, and `carried` (`item` as the object's effects left it) with its gradient +/// moved along with them. +fn apply_item(item: &AppearanceItem, carried: &AppearanceItem, g: &PathData, ctx: &GeomContext) -> (PathData, AppearanceItem) { + let (ig, carry) = apply(item_effects(item), g, &ctx.item(item)); + let mut ap = Appearance { items: vec![carried.clone()], ..Default::default() }; + if !carry.moves(&ap) { + return (ig, carried.clone()); + } + carry.apply(&mut ap); + (ig, ap.items.pop().unwrap_or_else(|| carried.clone())) } /// A path node of `kind`'s flavour (compound when the result has several subpaths). @@ -414,12 +429,15 @@ fn piece(d: &mut Document, n: &Node, path: PathData, rule: FillRule, item: Appea } /// `n`'s geometry with its object-level geometry effects applied, its fill rule, the context of -/// its effects, and `n` without those effects. +/// its effects, and `n` without those effects, its gradients moved along with them. fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> { let (base, rule) = geometry(n)?; let ctx = GeomContext::of(n); - let g = apply(&n.appearance.effects, &base, &ctx); let mut m = n.clone(); + let (g, carry) = apply(&n.appearance.effects, &base, &ctx); + if carry.moves(&m.appearance) { + carry.apply(&mut m.appearance); + } m.appearance.effects.retain(|e| !is_geometry(&e.id)); Some((g, rule, ctx, m)) } @@ -436,9 +454,9 @@ fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> { .appearance .items .iter() - .map(|item| { - let ig = apply(item_effects(item), &g, &ctx.item(item)); - let mut it = item.clone(); + .zip(&m.appearance.items) + .map(|(item, carried)| { + let (ig, mut it) = apply_item(item, carried, &g, &ctx); clear_item_effects(&mut it); Arc::new(piece(d, n, ig, rule, it)) }) @@ -470,9 +488,8 @@ pub fn expand_leaf(d: &mut Document, n: &Node, stroke_art: StrokeArt) -> Option< let (g, rule, ctx, mut m) = leaf_base(n)?; m.appearance.effects.retain(|e| e.visible); let mut pieces = vec![]; - for item in n.appearance.items.iter().filter(|i| paints(i)) { - let ig = apply(item.effects(), &g, &ctx.item(item)); - let mut it = item.clone(); + for (item, carried) in n.appearance.items.iter().zip(&m.appearance.items).filter(|(i, _)| paints(i)) { + let (ig, mut it) = apply_item(item, carried, &g, &ctx); clear_item_effects(&mut it); it.effects_mut().retain(|e| e.visible); let p = match &it { @@ -562,9 +579,8 @@ pub fn bake_appearance(n: &Node) -> Option<Node> { Arc::new(p) }; let mut pieces = vec![]; - for item in n.appearance.items.iter().filter(|i| paints(i)) { - let ig = apply(item_effects(item), &g, &ctx.item(item)); - let mut it = item.clone(); + for (item, carried) in n.appearance.items.iter().zip(&m.appearance.items).filter(|(i, _)| paints(i)) { + let (ig, mut it) = apply_item(item, carried, &g, &ctx); clear_item_effects(&mut it); pieces.push(match &it { AppearanceItem::Stroke(st) if st.brush.is_none() => { diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index e16742270..0484f9cbe 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -45,6 +45,136 @@ fn object_effects_become_geometry_and_raster_effects_stay() { assert_eq!(m.appearance.effects[0].id, "stylize.dropShadow"); } +#[test] +fn a_baked_transform_moves_a_placed_gradient_with_the_path() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + let geom = GradientGeom { start: Point::new(0.0, 50.0), end: Point::new(100.0, 50.0), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })); + let start = |n: &Node| match n.appearance.fill_paint() { + Paint::Gradient(g) => g.geom.unwrap().start.x, + p => panic!("a gradient: {p:?}"), + }; + // Alone, with a stroke whose own effect makes one path for each fill and stroke, and as the + // fill's own effect. + for (split, own) in [(false, false), (true, false), (true, true)] { + let mut ap = Appearance::basic(placed.clone(), Paint::solid(Color::BLACK), if split { 1.0 } else { 0.0 }); + if split && let AppearanceItem::Stroke(s) = &mut ap.items[1] { + s.effects.push(fx("path.offsetPath", json!({"offset": 5.0}))); + } + let transform = fx("distort.transform", json!({"moveH": 30.0})); + if own { + ap.items[0].effects_mut().push(transform.clone()); + } + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap); + if !own { + n.appearance.effects.push(transform); + } + let d = bake_document(&doc_with(n)).unwrap(); + let m = d.node(NodeId(50)).unwrap(); + let fill: &Node = if split { &m.children().unwrap()[0] } else { m }; + assert!((fill.geometric_bounds().unwrap().x0 - 30.0).abs() < 1e-6); + assert!((start(fill) - 30.0).abs() < 1e-6, "split {split}, own {own}: the gradient starts at {}", start(fill)); + } +} + +/// A stroke's own Transform effect or Free Distort moves the gradient placed on that stroke. +#[test] +fn a_strokes_own_transform_and_free_distort_move_its_placed_gradient() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + let geom = GradientGeom { start: Point::new(0.0, 50.0), end: Point::new(100.0, 50.0), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })); + // Moved 30 pt right by Transform, and one width (100 pt) right by Free Distort. + let moves = [ + (fx("distort.transform", json!({"moveH": 30.0})), 30.0), + (fx("distort.freeDistort", json!({"corners": [[1.0, 0.0], [2.0, 0.0], [2.0, 1.0], [1.0, 1.0]]})), 100.0), + ]; + for (e, moved) in moves { + let mut ap = Appearance::basic(Paint::None, placed.clone(), 4.0); + let Some(stroke) = ap.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) else { panic!("a stroke") }; + stroke.effects_mut().push(e.clone()); + let n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap); + let d = bake_document(&doc_with(n)).unwrap(); + let mut starts = vec![]; + d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| { + for item in &c.appearance.items { + if let AppearanceItem::Stroke(s) = item + && let Paint::Gradient(g) = &s.paint + { + starts.push(g.geom.unwrap().start.x); + } + } + }); + assert!(!starts.is_empty() && starts.iter().all(|s| (s - moved).abs() < 1e-6), "{}: {starts:?}", e.id); + } +} + +#[test] +fn a_fills_gradient_follows_the_effects_around_its_transform_with_options() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + let geom = GradientGeom { start: Point::new(0.0, 50.0), end: Point::new(100.0, 50.0), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })); + let moved = fx("distort.transform", json!({"moveH": 30.0})); + let scales_strokes = fx("distort.transform", json!({"strokes": true})); + // A move before and a move after the fill's Transform effect with Scale Strokes & Effects on. + for fill_fx in [vec![moved.clone(), scales_strokes.clone()], vec![scales_strokes, moved]] { + let mut ap = Appearance::basic(placed.clone(), Paint::None, 0.0); + *ap.items[0].effects_mut() = fill_fx.clone(); + let n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap); + let d = bake_document(&doc_with(n)).unwrap(); + let mut starts = vec![]; + d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| { + if let Paint::Gradient(g) = c.appearance.fill_paint() { + starts.push((c.geometric_bounds().unwrap().x0, g.geom.unwrap().start.x)); + } + }); + assert!(!starts.is_empty() && starts.iter().all(|(x0, s)| (x0 - 30.0).abs() < 1e-6 && (s - 30.0).abs() < 1e-6), "{fill_fx:?}: {starts:?}"); + } +} + +#[test] +fn baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = |kind| GradientPaint::new(Gradient::new(kind, stops.clone())); + let linear = GradientGeom { start: Point::new(20.0, 50.0), end: Point::new(80.0, 50.0), aspect: 1.0, focal: None }; + let radial = GradientGeom { start: Point::new(40.0, 30.0), end: Point::new(60.0, 30.0), aspect: 0.5, focal: None }; + let placed_linear = Paint::Gradient(Box::new(GradientPaint { geom: Some(linear), ..ramp(GradientKind::Linear) })); + let placed_radial = Paint::Gradient(Box::new(GradientPaint { geom: Some(radial), ..ramp(GradientKind::Radial) })); + let fitted = Paint::Gradient(Box::new(ramp(GradientKind::Linear))); + let baked = |fill: &Paint, e: Effect| { + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(fill.clone(), Paint::None, 0.0)); + n.appearance.effects.push(e); + let d = bake_document(&doc_with(n)).unwrap(); + let m = d.node(NodeId(50)).unwrap(); + match m.appearance.fill_paint() { + Paint::Gradient(g) => (m.geometric_bounds().unwrap(), (*g).clone()), + p => panic!("a gradient: {p:?}"), + } + }; + let near = |a: Point, b: Point| a.distance(b) < 1e-6; + // The bottom right corner moves 50 pt right, so the bounds grow from 0..100 to 0..150 across. + let corners = json!({"corners": [[0.0, 0.0], [1.0, 0.0], [1.5, 1.0], [0.0, 1.0]]}); + let distort = || fx("distort.freeDistort", corners.clone()); + let (b, g) = baked(&placed_linear, distort()); + assert!((b.x1 - 150.0).abs() < 1e-6, "{b:?}"); + let geom = g.geom.unwrap(); + assert!(near(geom.start, Point::new(30.0, 50.0)) && near(geom.end, Point::new(120.0, 50.0)), "{geom:?}"); + // A radial gradient's center and ellipse take the same stretch. + let (_, g) = baked(&placed_radial, distort()); + let Paint::Gradient(mut expected) = placed_radial.clone() else { panic!("a gradient") }; + expected.transform(vectorcraft_geom::Affine::new([1.5, 0.0, 0.0, 1.0, 0.0, 0.0])); + assert_eq!(g.geom, expected.geom); + assert!(near(g.geom.unwrap().start, Point::new(60.0, 30.0)), "{:?}", g.geom); + // A fitted gradient stays fitted, and fits the distorted outline. + assert_eq!(baked(&fitted, distort()).1.geom, None); +} + #[test] fn item_effects_split_into_one_path_per_item() { let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 844063932..a014807a7 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -223,7 +223,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> { "distort.freeDistort", "Free Distort…", DT, - "{corners: [[x,y]×4] (new positions of the bounding-box corners TL, TR, BR, BL in unit box coordinates; identity [[0,0],[1,0],[1,1],[0,1]])}", + "{corners: [[x,y]×4] (new positions of the bounding-box corners TL, TR, BR, BL in unit box coordinates; identity [[0,0],[1,0],[1,1],[0,1]]; placed gradients move and scale from the object's bounds to its distorted bounds)}", json!({"corners": [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]}), ), g("distort.puckerBloat", "Pucker & Bloat…", DT, "{amount: % (-200 pucker … 200 bloat, default 0)}", json!({"amount": 0.0})), @@ -238,7 +238,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> { "distort.transform", "Transform…", DT, - "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise), copies: 0..1000 (0; each copy transforms the last again), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}", + "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise), copies: 0..1000 (0; each copy transforms the last again; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}", json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false, "objects": true, "patterns": false, "strokes": false}), ), g( diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index 1b496a9c5..c07045702 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -13,6 +13,21 @@ fn diag(b: Rect) -> f64 { /// Free Distort: bilinear map of the bounding box onto four new corners (unit box coordinates), /// making at most `max` anchors ([`map_nonlinear_within`]). pub fn free_distort(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData { + let c = free_distort_corners(b, p); + let (w, h) = (b.width().max(1e-9), b.height().max(1e-9)); + let f = |q: Point| { + let u = (q.x - b.x0) / w; + let v = (q.y - b.y0) / h; + let top = c[0].lerp(c[1], u); + let bot = c[3].lerp(c[2], u); + top.lerp(bot, v) + }; + map_nonlinear_within(path, diag(b) / 16.0, max, f) +} + +/// The four corners (top left, top right, bottom right, bottom left) Free Distort with params `p` +/// moves bounding box `b`'s corners to, in the document. +pub(crate) fn free_distort_corners(b: Rect, p: &Value) -> [Point; 4] { let mut c = [Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(1.0, 1.0), Point::new(0.0, 1.0)]; if let Some(arr) = p.get("corners").and_then(Value::as_array) { for (i, v) in arr.iter().take(4).enumerate() { @@ -26,16 +41,7 @@ pub fn free_distort(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData } } let (w, h) = (b.width().max(1e-9), b.height().max(1e-9)); - let to_doc = |q: Point| Point::new(b.x0 + q.x * w, b.y0 + q.y * h); - let c = c.map(to_doc); - let f = |q: Point| { - let u = (q.x - b.x0) / w; - let v = (q.y - b.y0) / h; - let top = c[0].lerp(c[1], u); - let bot = c[3].lerp(c[2], u); - top.lerp(bot, v) - }; - map_nonlinear_within(path, diag(b) / 16.0, max, f) + c.map(|q| Point::new(b.x0 + q.x * w, b.y0 + q.y * h)) } /// Pucker & Bloat: anchors move toward (bloat) or away from (pucker) the centre, handles the diff --git a/crates/effects/src/geometry.rs b/crates/effects/src/geometry.rs index f6f007b5c..715143e1a 100644 --- a/crates/effects/src/geometry.rs +++ b/crates/effects/src/geometry.rs @@ -157,6 +157,118 @@ pub(crate) fn optimize_geometry_effects<'a>(effects: &[&'a Effect], style_safe: /// [`apply_geometry_with`] whose effects keep to `budget`, raised to what `path` costs where that /// is more. pub(crate) fn apply_geometry_within(effects: &[Effect], path: &PathData, bounds: Rect, ctx: &GeomContext, budget: Budget) -> PathData { + apply_geometry_carrying_within(effects, path, bounds, ctx, budget, None) +} + +/// [`apply_geometry_with`], and the [`Carry`] that moves the object's gradients along with the +/// effects that move the object as a whole. +pub fn apply_geometry_carrying(effects: &[Effect], path: &PathData, bounds: Rect, ctx: &GeomContext) -> (PathData, Carry) { + let mut carry = Carry::default(); + let out = apply_geometry_carrying_within(effects, path, bounds, ctx, Budget::EFFECTS, Some(&mut carry)); + (out, carry) +} + +/// [`apply_geometry_carrying`] on kurbo paths. +pub fn apply_geometry_bez_carrying(effects: &[Effect], path: &BezPath, bounds: Rect, ctx: &GeomContext) -> (BezPath, Carry) { + let (out, carry) = apply_geometry_carrying(effects, &PathData::from_bezpath(path), bounds, ctx); + (out.to_bezpath(), carry) +} + +/// How placed gradients follow geometry effects. The Transform effect without copies maps them as +/// it maps the object (with copies, `crate::reshape` gives each copy its own). Free Distort moves +/// and scales them from the object's bounds before the effect to its bounds after it. For the +/// other geometry effects it is empty. It does not change gradients fitted to the object. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Carry(Vec<CarryStep>); + +#[derive(Clone, Copy, Debug, PartialEq)] +enum CarryStep { + Affine(vectorcraft_geom::Affine), + /// From box `from` to box `to` (strokes: their stroke-inflated boxes). + Boxes { + from: Rect, + to: Rect, + }, +} + +impl Carry { + /// Does effect `id` with merged params `p` on geometry with reference box `b` (`seed` for + /// Random) move placed gradients? Free Distort does unless it leaves every corner in place; + /// the Transform effect does without copies (with copies the original stays where it is, and + /// `crate::reshape` gives each copy its own gradients). + pub(crate) fn follows(id: &str, p: &Value, b: Rect, seed: u64) -> bool { + match id { + "distort.transform" => { + let (m, copies) = distort::transform_map(b, p, seed); + copies == 0 && m != vectorcraft_geom::Affine::IDENTITY && m.is_finite() + } + "distort.freeDistort" => { + let corners = distort::free_distort_corners(b, p); + let tol = 1e-9 * b.width().hypot(b.height()).max(1.0); + let still = + [(b.x0, b.y0), (b.x1, b.y0), (b.x1, b.y1), (b.x0, b.y1)].iter().zip(&corners).all(|(a, c)| c.distance((*a).into()) <= tol); + !still && corners.iter().all(|c| c.x.is_finite() && c.y.is_finite()) + } + _ => false, + } + } + + /// How placed gradients follow effect `id` (as [`Self::follows`]) on an object whose geometric + /// bounds are `from` before the effect and `to` after it; empty when they stay in place. + pub(crate) fn of(id: &str, p: &Value, b: Rect, from: Option<Rect>, to: Option<Rect>, seed: u64) -> Carry { + if !Self::follows(id, p, b, seed) { + return Carry::default(); + } + let step = match id { + "distort.transform" => Some(CarryStep::Affine(distort::transform_map(b, p, seed).0)), + _ => match (from, to) { + (Some(from), Some(to)) if from != to && [to.x0, to.y0, to.x1, to.y1].iter().all(|v| v.is_finite()) => { + Some(CarryStep::Boxes { from, to }) + } + _ => None, + }, + }; + Carry(step.into_iter().collect()) + } + + /// Does it leave gradients where they are? + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Does it move any of the gradients of `ap`? Callers check first, so an appearance shared + /// with other objects is not copied for nothing. + pub fn moves(&self, ap: &Appearance) -> bool { + !self.0.is_empty() && ap.has_placed_gradient() + } + + /// Move the placed gradients of `ap` along, through each step in turn. + pub fn apply(&self, ap: &mut Appearance) { + if !self.moves(ap) { + return; + } + for s in &self.0 { + match *s { + CarryStep::Affine(m) => ap.transform_gradients(m), + CarryStep::Boxes { from, to } => crate::reshape::follow_boxes(ap, from, to), + } + } + } + + pub(crate) fn push(&mut self, more: Carry) { + self.0.extend(more.0); + } +} + +/// [`apply_geometry_within`], adding to `carry` how gradients follow each effect. +pub(crate) fn apply_geometry_carrying_within( + effects: &[Effect], + path: &PathData, + bounds: Rect, + ctx: &GeomContext, + budget: Budget, + mut carry: Option<&mut Carry>, +) -> PathData { // `budget` raised to what `path` costs, measured once, and only for a path that has geometry // effects. let has_stroke = match (ctx.item, ctx.appearance) { @@ -185,6 +297,9 @@ pub(crate) fn apply_geometry_within(effects: &[Effect], path: &PathData, bounds: let p = merged_params(&e.id, &e.params); let limit = *limit.get_or_insert_with(|| budget.at_least(Budget::of(path))); if let Some(next) = apply_one(&e.id, &p, &cur, b, ctx, limit) { + if let Some(c) = carry.as_deref_mut() { + c.push(Carry::of(&e.id, &p, b, cur.bounds(), next.bounds(), ctx.seed)); + } cur = next; } } diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index ce8c38077..a3f6859b8 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -198,7 +198,11 @@ fn reshaped_art(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Opt Some(area) => Some(reshaped(&area, n)), None => { let mut m = crate::reshape::outlined_members_with(None, n, outline, nodes); - crate::reshape::reshape_leaves(&mut m, &fx); + let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx); + // The group's own fills and strokes follow the effects as its members do. + if carry.moves(&m.appearance) { + carry.apply(&mut m.appearance); + } without_geometry_effects(&mut m); Some(m) } @@ -210,7 +214,11 @@ fn reshaped_art(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Opt let art = h(n).filter(|art| nodes.charge(art))?; let mut art = crate::reshape::symbol_art_with(&art, h, nodes, symbol); crate::reshape::outline_nested_text(&mut art, false); - crate::reshape::reshape_leaves(&mut art, &fx); + let carry = crate::reshape::reshape_leaves_carrying(&mut art, &fx); + // The art's own fills and strokes follow the effects as its members do. + if carry.moves(&art.appearance) { + carry.apply(&mut art.appearance); + } (art.opacity, art.blend, art.isolate) = (n.opacity, n.blend, n.isolate); art.appearance.effects = n.appearance.effects.clone(); without_geometry_effects(&mut art); @@ -230,8 +238,12 @@ fn reshaped(src: &Node, n: &Node) -> Node { let mut m = src.clone(); if let Some((path, rule)) = geometry(src) { let bounds = vectorcraft_geom::Shape::bounding_box(&path.to_bezpath()); - let g = crate::apply_geometry_with(&n.appearance.effects, &path, bounds, &crate::GeomContext::of(n)); + let (g, carry) = crate::apply_geometry_carrying(&n.appearance.effects, &path, bounds, &crate::GeomContext::of(n)); m.kind = NodeKind::Path { path: g, rule, live: None, clipping: false, guide: false }; + // Its gradients move with the outline, as the canvas paints them. + if carry.moves(&m.appearance) { + carry.apply(&mut m.appearance); + } } without_geometry_effects(&mut m); m @@ -711,7 +723,12 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option<Node> { } if !fx.is_empty() { m = crate::reshape::outlined_members(doc, &m); - crate::reshape::reshape_leaves(&mut m, &fx); + let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx); + // The group's own fills and strokes paint the members as a whole: their gradients follow + // the effects as the members' do. + if paint && carry.moves(&m.appearance) { + carry.apply(&mut m.appearance); + } } m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !crate::is_geometry(&e.id)); if paint { diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index b67279bbb..a0cddc94d 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -248,7 +248,7 @@ fn hidden_effect_or_plain_group_is_untouched() { } #[test] -fn a_transform_on_a_group_moves_the_gradients_of_its_type_and_not_those_placed_on_its_paths() { +fn a_transform_on_a_group_moves_the_gradients_of_its_type_and_those_placed_on_its_paths() { if skip_without_craft_fonts() { return; } @@ -273,7 +273,181 @@ fn a_transform_on_a_group_moves_the_gradients_of_its_type_and_not_those_placed_o let members = art.children().unwrap(); let after = start(&members[1].children().unwrap()[0]); assert!((after - before - 30.0).abs() < 1e-6, "the type's gradient starts at {before}, then {after}"); - assert_eq!(start(&members[0]), 0.0, "the square's gradient stays where it was placed"); + assert!((start(&members[0]) - 30.0).abs() < 1e-6, "the square's gradient moves with it"); +} + +#[test] +fn a_groups_transform_moves_the_gradient_of_its_own_fill() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + let geom = GradientGeom { start: (0.0, 5.0).into(), end: (15.0, 5.0).into(), aspect: 1.0, focal: None }; + let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::BLACK), sq(2, 5.0, Color::BLACK)]); + g.appearance + .items + .push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }))))); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let mut starts = vec![]; + art.walk(&mut |c: &Node| { + if let Paint::Gradient(p) = c.appearance.fill_paint() { + starts.push(p.geom.unwrap().start.x); + } + }); + assert!(!starts.is_empty() && starts.iter().all(|s| (s - 30.0).abs() < 1e-6), "{starts:?}"); +} + +/// The art Pathfinder and compound shapes take (`shape_art`) paints as the canvas does: a path's +/// gradient and a group's own fill move with a Transform effect. +#[test] +fn shape_art_moves_gradients_with_a_transform_effect() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })); + let moved = || Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 40}), visible: true }; + let starts = |art: &Node| { + let mut out = vec![]; + art.walk(&mut |c: &Node| { + for item in &c.appearance.items { + if let Paint::Gradient(p) = item.paint() { + out.push(p.geom.unwrap().start.x); + } + } + }); + out + }; + let mut path = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(placed.clone(), Paint::None, 0.0)); + path.appearance.effects.push(moved()); + let got = starts(&shape_art(&path, None).unwrap()); + assert!(got.len() == 1 && (got[0] - 40.0).abs() < 1e-6, "the path's gradient: {got:?}"); + let mut g = Node::group(NodeId(10), vec![sq(2, 0.0, Color::BLACK)]); + g.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(placed))); + g.appearance.effects.push(moved()); + let got = starts(&shape_art(&g, None).unwrap()); + assert!(!got.is_empty() && got.iter().all(|s| (s - 40.0).abs() < 1e-6), "the group's own fill: {got:?}"); +} + +/// A symbol instance's Transform effect moves the gradient placed on its art's own fill in the +/// art Pathfinder takes, as it moves its members. +#[test] +fn shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })); + let mut art = Node::group(NodeId(20), vec![sq(21, 0.0, Color::BLACK)]); + art.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(placed))); + let hook = move |_: &Node| Some(art.clone()); + let mut instance = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY }); + instance.appearance.effects.push(Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true }); + let out = shape_art(&instance, Some(&hook)).unwrap(); + let Paint::Gradient(g) = out.appearance.fill_paint() else { panic!("a gradient: {:?}", out.appearance) }; + assert!((g.geom.unwrap().start.x - 30.0).abs() < 1e-6, "{:?}", g.geom); + assert!((out.geometric_bounds().unwrap().x0 - 30.0).abs() < 1e-6, "the art moves"); +} + +#[test] +fn a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })); + let ends = |e: Effect| { + let square = + Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(placed.clone(), Paint::None, 0.0))); + let mut g = Node::group(NodeId(10), vec![square, sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(e); + let art = evaluate_container(None, &g).unwrap(); + match art.children().unwrap()[0].appearance.fill_paint() { + Paint::Gradient(g) => g.geom.map(|g| (g.start, g.end)).unwrap(), + p => panic!("a gradient: {p:?}"), + } + }; + let near = |a: Point, b: Point| a.distance(b) < 1e-6; + // A quarter turn about the group's center (15, 5) takes (0, 5) to (15, 20) and (10, 5) to (15, 10). + let (s, e) = ends(Effect { id: "distort.transform".into(), params: serde_json::json!({"rotate": 90}), visible: true }); + assert!(near(s, Point::new(15.0, 20.0)) && near(e, Point::new(15.0, 10.0)), "{s:?} {e:?}"); + // Free Distort moving the group's top corners 10 pt right slants the square to 0..20 across: + // its gradient follows its own bounds (0..10 to 0..20), not the group's (0..30 to 0..40). + let corners = serde_json::json!({"corners": [[1.0 / 3.0, 0.0], [4.0 / 3.0, 0.0], [1.0, 1.0], [0.0, 1.0]]}); + let (s, e) = ends(Effect { id: "distort.freeDistort".into(), params: corners, visible: true }); + assert!(near(s, Point::new(0.0, 5.0)) && near(e, Point::new(20.0, 5.0)), "{s:?} {e:?}"); +} + +/// A group nested in a group with a Transform effect or Free Distort keeps its own placed gradient +/// on its members: the Transform's map moves it, and Free Distort moves and scales it from the +/// nested group's bounds before the effect to its bounds after it. +#[test] +fn a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })); + let ends = |e: Effect| { + let mut inner = Node::group(NodeId(5), vec![sq(1, 0.0, Color::BLACK)]); + inner.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(placed.clone()))); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(e); + let art = evaluate_container(None, &g).unwrap(); + match art.children().unwrap()[0].appearance.fill_paint() { + Paint::Gradient(g) => g.geom.map(|g| (g.start, g.end)).unwrap(), + p => panic!("a gradient: {p:?}"), + } + }; + let near = |a: Point, b: Point| a.distance(b) < 1e-6; + let (s, e) = ends(Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true }); + assert!(near(s, Point::new(30.0, 5.0)) && near(e, Point::new(40.0, 5.0)), "{s:?} {e:?}"); + let corners = serde_json::json!({"corners": [[1.0 / 3.0, 0.0], [4.0 / 3.0, 0.0], [1.0, 1.0], [0.0, 1.0]]}); + let (s, e) = ends(Effect { id: "distort.freeDistort".into(), params: corners, visible: true }); + assert!(near(s, Point::new(0.0, 5.0)) && near(e, Point::new(20.0, 5.0)), "{s:?} {e:?}"); +} + +/// An image or a symbol instance that a group's Transform effect moves takes the placed gradients +/// of its own fills along, as a path does, and each copy takes them as many times as it takes the +/// transform. +#[test] +fn a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + use vectorcraft_doc::ImageObject; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })); + let image = || { + NodeKind::Image(Box::new(ImageObject { + key: "k".into(), + width: 10, + height: 10, + xf: vectorcraft_geom::Affine::IDENTITY, + link: None, + placement: Default::default(), + pixels: None, + })) + }; + let instance = || NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY }; + let starts = |kind: NodeKind, params: serde_json::Value| { + let mut object = Node::new(NodeId(1), kind); + object.appearance = Appearance::basic(placed.clone(), Paint::None, 0.0).into(); + let mut g = Node::group(NodeId(10), vec![Arc::new(object), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let mut out = vec![]; + art.walk(&mut |c: &Node| { + if let (NodeKind::Image(_) | NodeKind::SymbolInstance { .. }, Paint::Gradient(p)) = (&c.kind, c.appearance.fill_paint()) { + out.push(p.geom.unwrap().start.x); + } + }); + out + }; + for kind in [image as fn() -> NodeKind, instance] { + assert_eq!(starts(kind(), serde_json::json!({"moveH": 30})), [30.0], "{:?}", kind()); + assert_eq!(starts(kind(), serde_json::json!({"moveH": 30, "copies": 2})), [0.0, 30.0, 60.0], "{:?}", kind()); + } } #[test] diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 8835d769f..70c23ade8 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -97,7 +97,9 @@ mod params; pub use catalog::{EffectInfo, Lengths, WARP_STYLES, effect_catalog, effect_info, is_length, length_range, plugin_effects, raster_px_range}; pub(crate) use geometry::{Budget, apply_one}; -pub use geometry::{GeomContext, apply_geometry, apply_geometry_bez, apply_geometry_with}; +pub use geometry::{ + Carry, GeomContext, apply_geometry, apply_geometry_bez, apply_geometry_bez_carrying, apply_geometry_carrying, apply_geometry_with, +}; #[cfg(test)] pub(crate) use geometry::{MAX_EFFECT_LINES, apply_geometry_within}; pub use params::{ diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index b3acf07a5..4ea3e4610 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -11,6 +11,7 @@ //! strokes paint only the shape that a Convert to Shape or Outline Object effect on them gives //! them ([`image_rows`]). +use std::collections::HashSet; use std::sync::Arc; use vectorcraft_doc::live::Hooks; @@ -594,9 +595,15 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) { reshape_leaves_within(art, fx, Budget::EFFECTS); } -/// [`reshape_leaves`] whose paths keep to `budget` together, or to what they cost. -pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budget) { - reshape_leaves_apart(art, fx, budget, false); +/// [`reshape_leaves`], and how gradients that paint the art as a whole (a group's own fills and +/// strokes) follow the effects ([`crate::Carry`]). +pub(crate) fn reshape_leaves_carrying(art: &mut Node, fx: &[&Effect]) -> crate::Carry { + reshape_leaves_within(art, fx, Budget::EFFECTS) +} + +/// [`reshape_leaves_carrying`] whose paths keep to `budget` together, or to what they cost. +pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budget) -> crate::Carry { + reshape_leaves_apart(art, fx, budget, false) } /// `art`, the group of the one piece a path's copies are made from ([`outline_art_with`]), holding @@ -616,10 +623,12 @@ fn hoist_copies(art: &mut Node) { } /// [`reshape_leaves_within`]; with `apart`, a Transform effect's copies of each path are objects -/// of their own ([`copy_leaf`]) whatever paints them, as Expand Appearance makes them. -fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: bool) { +/// of their own ([`copy_leaf`]) whatever paints them, as Expand Appearance makes them. Returns how +/// gradients that paint the art as a whole follow the effects. +fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: bool) -> crate::Carry { + let mut whole = crate::Carry::default(); if fx.is_empty() { - return; + return whole; } let mut leaves_safe = true; for_each_leaf(art, &mut |leaf| { @@ -638,15 +647,32 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b let opt_fx = crate::geometry::optimize_geometry_effects(fx, style_safe); let fx = opt_fx.as_slice(); let seed = art.id.0; - // The placed gradients of type's outlines in `art` (`outline_text`) move and scale with the - // pieces, from the outlines' bounds before the effects to their bounds after them. - let mut outlines = vec![]; - type_outline_bounds(art, &mut outlines); let mut used = art_cost(art); let max = budget.at_least(used); for e in fx { let Some(bounds) = art.geometric_bounds() else { break }; let params = merged_params(&e.id, &e.params); + // Placed gradients follow a Transform effect and Free Distort ([`crate::Carry`]): under + // Free Distort (`boxes`) each path's move and scale from its bounds before the effect to + // its bounds after it. Under that and the other effects, the placed gradients of type's + // outlines in `art` (`outline_text`) move and scale with the outlines' bounds. + let follows = crate::Carry::follows(&e.id, ¶ms, bounds, seed); + let boxes = follows && e.id == "distort.freeDistort"; + let mut outlines = vec![]; + if !follows || boxes { + type_outline_bounds(art, &mut outlines); + } + let mut leaves = vec![]; + if boxes { + leaf_bounds(art, &mut leaves); + } + let mut groups = vec![]; + if follows { + group_marks(art, &mut vec![], &HashSet::new(), &mut groups); + } + // Whether a path kept its shape (past the budget), and the ids of those that did. + let mut kept = false; + let mut kept_ids = HashSet::new(); let transform = (e.id == "distort.transform").then(|| { let (m, copies) = crate::distort::transform_map(bounds, ¶ms, seed); let options = crate::distort::TransformOptions::of(¶ms); @@ -668,8 +694,12 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b || leaf.appearance.has_placed_gradient() || paints_copies_apart(&leaf.appearance, rule)); if options.reach_appearance() { - let made = transform_leaf(leaf, &path, map, copies, options, room); + let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room); used = used.minus(before).plus(made); + if !moved { + kept = true; + kept_ids.insert(leaf.id); + } return; } if separate { @@ -678,19 +708,41 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b return; } } - let Some(out) = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) }, room) else { return }; + let Some(out) = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) }, room) else { + kept = true; + kept_ids.insert(leaf.id); + return; + }; used = used.minus(before).plus(Budget::of(&out)); + if follows && !boxes && leaf.appearance.has_placed_gradient() { + crate::Carry::of(&e.id, ¶ms, bounds, None, None, seed).apply(&mut leaf.appearance); + } set_geometry(leaf, out, rule); }); + if boxes { + rebase_leaves(art, &mut leaves.into_iter()); + } if let Some((m, copies, options, _)) = transform && options.objects { transform_placed(art, m, copies); } + if outlines.iter().any(Option::is_some) { + rebase_type_outlines(art, &mut outlines.into_iter()); + } + // What paints the art as a whole, and the own fills and strokes of the groups inside it, + // follow their bounds under Free Distort (they stay when no path changed them). Each stays + // when a path under it kept its shape (past the budget). + if !groups.is_empty() { + let mut after = vec![]; + group_marks(art, &mut vec![], &kept_ids, &mut after); + carry_groups(art, groups, after, &|from, to| crate::Carry::of(&e.id, ¶ms, bounds, from, to, seed)); + } + if follows && !kept { + whole.push(crate::Carry::of(&e.id, ¶ms, bounds, Some(bounds), art.geometric_bounds(), seed)); + } } - if outlines.iter().any(Option::is_some) { - rebase_type_outlines(art, &mut outlines.into_iter()); - } + whole } /// Largest factor a Transform effect's Scale Strokes & Effects scales one object's strokes by @@ -704,8 +756,16 @@ const MAX_STROKE_SCALE: f64 = 1.0e6; /// and the distances of the effects on its fills and strokes scale by `m`'s mean scale, and its /// pattern paints transform by `m`. With copies, the original keeps them and copy `k` takes them /// `k` times, each copy an object of its own ([`copy_leaf`], within `budget`). With Transform -/// Objects off the geometry stays and the options apply once. Returns what the leaf costs after. -fn transform_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, options: crate::distort::TransformOptions, budget: Budget) -> Budget { +/// Objects off the geometry stays and the options apply once. Returns what the leaf costs after, +/// and `false` when the path kept its shape because the moved path would pass `budget`. +fn transform_leaf( + leaf: &mut Node, + path: &PathData, + m: Affine, + copies: usize, + options: crate::distort::TransformOptions, + budget: Budget, +) -> (Budget, bool) { let scaling = vectorcraft_doc::Scaling { strokes: options.strokes, effects: options.strokes.then_some(crate::scale_effect as fn(&mut Effect, f64)), @@ -722,18 +782,18 @@ fn transform_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, op if options.patterns { vectorcraft_doc::pattern::transform_pattern_paints(leaf, m); } - return Budget::of(path); + return (Budget::of(path), true); } if copies == 0 { // A path scaled up is drawn with more lines; past the budget it keeps its shape. let moved = path.transformed(m); if !budget.fits(Budget::of(&moved)) { - return Budget::of(path); + return (Budget::of(path), false); } leaf.transform(m, scaling); - return Budget::of(&moved); + return (Budget::of(&moved), true); } - copy_leaf(leaf, path, m, copies, scaling, budget) + (copy_leaf(leaf, path, m, copies, scaling, budget), true) } /// Replace `leaf` (a path or compound path with geometry `path`) by a group of it and `copies` @@ -843,8 +903,10 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) { let reserved = (0..later(i)).fold(Budget { anchors: 0, lines: 0 }, |r, _| r.plus(cost)); let limit = max.minus(reserved); let room = limit.minus(*used); + // The fill's or stroke's gradient follows the geometry effects before the Transform effect. + let mut carry = crate::Carry::default(); let g = if crate::has_geometry(before) && !path.is_empty() { - crate::geometry::apply_geometry_within(before, &path, bounds, &ctx.item(item), room) + crate::geometry::apply_geometry_carrying_within(before, &path, bounds, &ctx.item(item), room, Some(&mut carry)) } else { path.clone() }; @@ -857,8 +919,11 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) { let rest: Vec<Effect> = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect(); *it.effects_mut() = rest; let mut p = piece(vec![it]); + if carry.moves(&p.appearance) { + carry.apply(&mut p.appearance); + } set_geometry(&mut p, g.clone(), rule); - let made = transform_leaf(&mut p, &g, map, copies, options, room); + let (made, _) = transform_leaf(&mut p, &g, map, copies, options, room); *used = used.plus(made); transform_piece(&mut p, ctx.seed, limit, used); pieces.push(Arc::new(p)); @@ -902,7 +967,7 @@ fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget) { let (_, copies) = crate::distort::transform_map(bounds, ¶ms, seed); let options = crate::distort::TransformOptions::of(¶ms); let map = crate::distort::transform_affine(bounds, ¶ms, seed); - let made = transform_leaf(leaf, &path, map, copies, options, room); + let (made, _) = transform_leaf(leaf, &path, map, copies, options, room); *used = used.minus(before).plus(made); return; } @@ -912,8 +977,13 @@ fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget) { AppearanceItem::Fill(_) => None, }; let ctx = GeomContext { stroke, rule, seed, appearance: Some(&appearance), item: Some(0) }; - let out = crate::geometry::apply_geometry_within(std::slice::from_ref(&e), &path, bounds, &ctx, room); + let mut carry = crate::Carry::default(); + let out = crate::geometry::apply_geometry_carrying_within(std::slice::from_ref(&e), &path, bounds, &ctx, room, Some(&mut carry)); *used = used.minus(before).plus(Budget::of(&out)); + // The fill's or stroke's gradient follows the effect as the object's does. + if carry.moves(&leaf.appearance) { + carry.apply(&mut leaf.appearance); + } set_geometry(leaf, out, rule); }); } @@ -957,11 +1027,128 @@ fn rebase_type_outlines(n: &mut Node, before: &mut std::vec::IntoIter<Option<Rec } } +/// The geometric bounds of each path under `n` that [`for_each_leaf`] visits, outside groups of +/// type's outlines (those follow the outlines' bounds, [`rebase_type_outlines`]). +fn leaf_bounds(n: &Node, out: &mut Vec<Option<Rect>>) { + match &n.kind { + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => out.push(n.geometric_bounds()), + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } if !n.extras().type_outlines => { + for c in children { + leaf_bounds(c, out); + } + } + _ => {} + } +} + +/// A group inside the art whose own fills and strokes [`carry_groups`] moves (groups of type's +/// outlines follow the outlines' bounds, [`rebase_type_outlines`]). +fn is_member_group(n: &Node) -> bool { + matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && !n.extras().type_outlines +} + +/// A group below the art that [`is_member_group`] takes and whose own fills or strokes hold a +/// placed gradient: the child indices that lead to it from the art, its geometric bounds, and +/// whether a path below it kept its shape. +type GroupMark = (Vec<usize>, Option<Rect>, bool); + +/// The geometric bounds of `n` (as [`Node::geometric_bounds`] gives them) and whether a path or +/// compound path below it is in `kept`, measured in one walk. Each group below `n` that +/// [`GroupMark`] describes goes into `out`, reached from `n` through `at`. +fn group_marks(n: &Node, at: &mut Vec<usize>, kept: &HashSet<NodeId>, out: &mut Vec<GroupMark>) -> (Option<Rect>, bool) { + match &n.kind { + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => (n.geometric_bounds(), kept.contains(&n.id)), + NodeKind::Group { children, clip } | NodeKind::Layer { children, clip, .. } if !n.extras().type_outlines => { + let layer = matches!(n.kind, NodeKind::Layer { .. }); + let skip = children.len().saturating_sub(n.editing_children().len()); + let (mut bounds, mut held) = (None, false); + for (i, c) in children.iter().enumerate() { + at.push(i); + let (b, h) = group_marks(c, at, kept, out); + if is_member_group(c) && c.appearance.has_placed_gradient() { + out.push((at.clone(), b, h)); + } + at.pop(); + held |= h; + let counts = if *clip { i == 0 } else { i >= skip && (c.visible || !layer) }; + if counts { + bounds = vectorcraft_geom::union_opt(bounds, b); + } + } + (bounds, held) + } + // Type's outlines: measured as a whole, with each outline that kept its shape counted. + NodeKind::Group { .. } | NodeKind::Layer { .. } => (n.geometric_bounds(), !kept.is_empty() && holds_kept(n, kept)), + _ => (n.geometric_bounds(), false), + } +} + +/// Is a path or compound path below `n` (`n` included) in `kept`? +fn holds_kept(n: &Node, kept: &HashSet<NodeId>) -> bool { + match &n.kind { + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => kept.contains(&n.id), + _ => n.children().into_iter().flatten().any(|c| holds_kept(c, kept)), + } +} + +/// Move the placed gradients of the own fills and strokes of each group in `after` (the groups of +/// `before`, measured again once the paths changed) by what `carry` gives for its bounds before +/// and after. A group with a path below it that kept its shape keeps them in place. +fn carry_groups(art: &mut Node, before: Vec<GroupMark>, after: Vec<GroupMark>, carry: &dyn Fn(Option<Rect>, Option<Rect>) -> crate::Carry) { + for ((at, from, _), (at_after, to, held)) in before.into_iter().zip(after) { + if held || at != at_after { + continue; + } + let k = carry(from, to); + if k.is_empty() { + continue; + } + if let Some(n) = node_at(art, &at) + && is_member_group(n) + && k.moves(&n.appearance) + { + k.apply(&mut n.appearance); + } + } +} + +/// The node below `n` that the child indices `at` lead to. +fn node_at<'a>(n: &'a mut Node, at: &[usize]) -> Option<&'a mut Node> { + let Some((first, rest)) = at.split_first() else { return Some(n) }; + let c = n.children_mut()?.get_mut(*first)?; + node_at(Arc::make_mut(c), rest) +} + +/// Move and scale the placed gradients of each path under `n` that [`leaf_bounds`] lists from its +/// bounds in `before` (before its geometry changed) to its bounds now. +fn rebase_leaves(n: &mut Node, before: &mut std::vec::IntoIter<Option<Rect>>) { + if matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. }) { + if let Some(Some(from)) = before.next() + && let Some(to) = n.geometric_bounds() + && from != to + && n.appearance.has_placed_gradient() + { + follow_boxes(&mut n.appearance, from, to); + } + return; + } + if n.extras().type_outlines { + return; + } + if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &mut n.kind { + for c in children.iter_mut() { + if matches!(c.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } | NodeKind::Group { .. } | NodeKind::Layer { .. }) { + rebase_leaves(Arc::make_mut(c), before); + } + } + } +} + /// Map the placed gradients of `ap` from box `from` to box `to` through the map that takes one box /// onto the other (strokes: their stroke-inflated boxes). The start and end go where /// [`Appearance::rebase_gradients`] puts them, and a radial's ellipse and a linear's isolines are /// mapped with them. -fn follow_boxes(ap: &mut Appearance, from: Rect, to: Rect) { +pub(crate) fn follow_boxes(ap: &mut Appearance, from: Rect, to: Rect) { for i in &mut ap.items { match i { AppearanceItem::Fill(f) => { @@ -1036,6 +1223,10 @@ fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) { NodeKind::SymbolInstance { xf: at, .. } => *at = xf * *at, _ => {} } + // The placed gradients of its own fills and strokes move with it, as a path's do. + if xf != Affine::IDENTITY && one.appearance.has_placed_gradient() { + one.appearance.transform_gradients(xf); + } one }; if copies == 0 { diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index ea3843f0e..0b3fc0b1c 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -407,6 +407,54 @@ fn stacked_effects_on_art_stay_within_the_anchor_budget() { assert_eq!(reshaped, 10); } +/// A path that keeps its shape because its Transform effect would pass the budget keeps the +/// gradients that paint the art as a whole in place too, also with Scale Strokes & Effects on. +#[test] +fn art_past_the_budget_keeps_its_own_gradients_in_place() { + use std::sync::Arc; + + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + use vectorcraft_doc::{AppearanceItem, FillLayer, NodeId, color::Paint}; + let id = NodeId(9); + let stops = vec![GradientStop::new(0.0, vectorcraft_color::Color::BLACK), GradientStop::new(1.0, vectorcraft_color::Color::WHITE)]; + let geom = GradientGeom { start: (0.0, 50.0).into(), end: (100.0, 50.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) })); + let circle = || Arc::new(Node::path(id, shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default())); + // The group's own fill, a gradient placed across the circle, after `carry` moves it. + let own_end = |carry: &crate::Carry| { + let mut ap = Appearance::default(); + ap.items.push(AppearanceItem::Fill(FillLayer::new(placed.clone()))); + carry.apply(&mut ap); + match ap.fill_paint() { + Paint::Gradient(g) => g.geom.map(|g| g.end.x), + _ => None, + } + }; + for params in [json!({"scaleH": 1000, "scaleV": 1000}), json!({"scaleH": 1000, "scaleV": 1000, "strokes": true})] { + let grow = fx("distort.transform", params.clone()); + let mut art = Node::group(id, vec![circle()]); + let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget::EFFECTS); + let end = own_end(&carry).unwrap(); + assert!((end - 550.0).abs() < 1e-6, "{params}: within the budget the group's gradient grows with the circle: {end}"); + // No room beyond what the circle costs: scaled up, it would be drawn with more lines. + let mut art = Node::group(id, vec![circle()]); + let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget { anchors: usize::MAX, lines: 0 }); + let b = art.geometric_bounds().unwrap(); + assert!(b.width() < 101.0 && b.height() < 101.0, "{params}: the circle keeps its shape: {b:?}"); + assert_eq!(own_end(&carry), Some(100.0), "{params}: the group's gradient stays where it was placed"); + // A group inside the art whose square took the effect moves its own gradient all the same. + let square = Arc::new(Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default())); + let mut inner = Node::group(NodeId(2), vec![square]); + inner.appearance.items.push(AppearanceItem::Fill(FillLayer::new(placed.clone()))); + let mut art = Node::group(id, vec![circle(), Arc::new(inner)]); + let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget { anchors: usize::MAX, lines: 0 }); + assert_eq!(own_end(&carry), Some(100.0), "{params}: the art's own gradient stays"); + let inner = &art.children().unwrap()[1]; + let Paint::Gradient(g) = inner.appearance.fill_paint() else { panic!("a gradient") }; + assert!((g.geom.unwrap().end.x - 550.0).abs() < 1e-6, "{params}: the inner group's gradient: {:?}", g.geom); + } +} + /// #3578: a large Roughen draws each of its points as many lines; they stay within the budget, /// the Roughen built again with fewer points where they wouldn't. #[test] diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index d67b89a97..de056e4ec 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -407,8 +407,9 @@ fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> { let mut expanded = false; for item in &l.appearance.items { // Items that paint nothing go; a gradient that can't be expanded stays. - let art = gradient_fill(item).and_then(|(f, g)| { - let (fl, path, bounds) = fill_shape(l, item, &path, bounds)?; + let art = gradient_fill(item).and_then(|_| { + let (fl, path, bounds, moved) = fill_shape(l, item, &path, bounds)?; + let (f, g) = gradient_fill(&moved)?; gradient_art(d, &fl, &path, bounds, f, g, to) }); let Some(art) = art else { @@ -470,29 +471,39 @@ fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &F /// 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<Node> { - use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, is_geometry}; + use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, 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 (g, carry) = apply_geometry_carrying(&l.appearance.effects, &path, path.bounds()?, &GeomContext::of(l)); let mut m = with_path(l, g); + // Its gradients move with the outline, as the canvas paints them. + if carry.moves(&m.appearance) { + carry.apply(&mut m.appearance); + } m.appearance.effects.retain(|e| !is_geometry(&e.id)); Some(m) } /// 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)> { - use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry}; +/// outline, box and fill: when the fill has visible geometry effects, `l` with the outline they +/// make of `path` as a plain path, that outline's box, and the fill with its gradient moved along +/// with the outline. +fn fill_shape(l: &Node, item: &AppearanceItem, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect, AppearanceItem)> { + use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry}; if !has_geometry(item.effects()) { - return Some((l.clone(), path.clone(), bounds)); + return Some((l.clone(), path.clone(), bounds, item.clone())); } // In the fill's context, as drawing runs them: a plug-in effect is told which fill it is on. - let outline = apply_geometry_with(item.effects(), path, bounds, &GeomContext::of(l).item(item)); + let (outline, carry) = apply_geometry_carrying(item.effects(), path, bounds, &GeomContext::of(l).item(item)); let b = outline.bounds()?; - Some((with_path(l, outline.clone()), outline, b)) + let mut one = Appearance { items: vec![item.clone()], ..Default::default() }; + if carry.moves(&one) { + carry.apply(&mut one); + } + let moved = one.items.pop().unwrap_or_else(|| item.clone()); + Some((with_path(l, outline.clone()), outline, b, moved)) } /// Path or compound path `l` with `path` as its path, a plain path. @@ -809,7 +820,7 @@ fn expand_patterns_where(d: &mut Document, l: &Node, which: impl Fn(&Document, & later -= usize::from(fill.is_some()); let room = if later > 0 { left.saturating_sub(l.count() + 1) } else { left }; let tiles = fill.filter(|_| !full).and_then(|(f, def, xf)| { - let (fl, _, bounds) = fill_shape(l, item, &path, bounds)?; + let (fl, _, bounds, _) = fill_shape(l, item, &path, bounds)?; let Some(t) = pattern_tiles(d, &def, xf, bounds, room) else { full = true; return None; diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 59b8db890..4210a1d96 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -133,6 +133,37 @@ fn white_black() -> Value { json!({"kind": "linear", "stops": [{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]}) } +#[test] +fn a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it() { + // The object's own effect, and the fill's (the first appearance item). + let slant = json!({"corners": [[0.25, 0.0], [1.25, 0.0], [1.0, 1.0], [0.0, 1.0]]}); + for (effect, params, item) in [ + ("distort.transform", json!({"rotate": 90}), json!(null)), + ("distort.transform", json!({"rotate": 30, "scaleH": 60}), json!(null)), + ("distort.transform", json!({"rotate": 90}), json!(0)), + ("distort.freeDistort", slant.clone(), json!(null)), + ("distort.freeDistort", slant, json!(0)), + ] { + let mut s = session(); + let id = gradient_rect(&mut s, Rect::new(40.0, 70.0, 160.0, 130.0), white_black(), false); + // Placed from a quarter of the way across to the right edge. + run(&mut s, "paint.setGradientGeom", json!({"start": [70, 100], "end": [160, 100], "stroke": false})); + let placed = s.doc().unwrap().doc.node(id).and_then(|n| match n.appearance.fill_paint() { + vectorcraft_color::Paint::Gradient(g) => g.geom, + _ => None, + }); + assert!(placed.is_some(), "the fill's gradient is placed"); + run(&mut s, "effect.apply", json!({"effect": effect, "params": params, "ids": [id.0], "item": item})); + let before = render(&s); + // Fill alone: the gradient becomes strips inside the outline the effect makes. + run(&mut s, "object.expand", json!({"object": false, "stroke": false, "steps": 64})); + let after = render(&s); + // The gradient moves with the rectangle on the canvas, and the strips follow it. + let diff = mean_diff(&before, &after); + assert!(diff < 1.0, "{effect} {params} on item {item}: mean difference {diff}"); + } +} + #[test] fn eight_strips_match_the_gradient_samples() { let mut s = session(); diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index 8bccf2c36..662aa9824 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -32,6 +32,19 @@ pub(crate) fn effected_path(n: &Node, bp: &BezPath) -> BezPath { apply(&n.appearance.effects, bp, &GeomContext::of(n)) } +/// [`apply`], and how gradients follow the effects ([`effects::Carry`]). +pub(super) fn apply_carrying(effects: &[Effect], bp: &BezPath, ctx: &GeomContext) -> (BezPath, effects::Carry) { + if !effects::has_geometry(effects) || bp.elements().is_empty() { + return (bp.clone(), effects::Carry::default()); + } + effects::apply_geometry_bez_carrying(effects, bp, bp.bounding_box(), ctx) +} + +/// [`effected_path`], and how `n`'s gradients follow its effects. +pub(crate) fn effected_path_carrying(n: &Node, bp: &BezPath) -> (BezPath, effects::Carry) { + apply_carrying(&n.appearance.effects, bp, &GeomContext::of(n)) +} + pub(super) fn item_effects(item: &AppearanceItem) -> &[Effect] { match item { AppearanceItem::Fill(f) => &f.effects, diff --git a/crates/render/src/fx/entries.rs b/crates/render/src/fx/entries.rs index ec223173b..29f5724c3 100644 --- a/crates/render/src/fx/entries.rs +++ b/crates/render/src/fx/entries.rs @@ -360,6 +360,11 @@ pub(super) fn raster_bytes(pm: &vello_cpu::Pixmap) -> usize { pub(crate) struct FxGeom { pub(crate) node: Arc<Node>, pub(super) g: Arc<BezPath>, - pub(super) items: Vec<Option<Arc<BezPath>>>, + /// The node with its gradients moved along with its effects, when they move. + pub(super) carried: Option<Arc<Node>>, + pub(super) items: Vec<Option<ItemGeom>>, pub(crate) stamp: u64, } + +/// One appearance item's own effected geometry, and how its gradient follows those effects. +pub(super) type ItemGeom = (Arc<BezPath>, Arc<effects::Carry>); diff --git a/crates/render/src/fx/pixels.rs b/crates/render/src/fx/pixels.rs index 3f13d1b3e..5ab6a090b 100644 --- a/crates/render/src/fx/pixels.rs +++ b/crates/render/src/fx/pixels.rs @@ -1,5 +1,5 @@ -use super::bounds::{apply, item_effects}; -use super::entries::{Content, DocInputs, Drawn, FX_RASTER_BUDGET, Inputs, KeptImage, Made, Reads, feather_outline}; +use super::bounds::{apply_carrying, item_effects}; +use super::entries::{Content, DocInputs, Drawn, FX_RASTER_BUDGET, Inputs, ItemGeom, KeptImage, Made, Reads, feather_outline}; #[cfg(test)] use super::raster::RASTERIZED; use super::raster::{ @@ -624,7 +624,7 @@ impl Renderer { g: &BezPath, rule: FillRule, gctx: &GeomContext, - mut cache: Option<&mut Vec<Option<Arc<BezPath>>>>, + mut cache: Option<&mut Vec<Option<ItemGeom>>>, ) { let bounds = g.bounding_box(); for (j, item) in n.appearance.items.iter().enumerate() { @@ -633,12 +633,23 @@ impl Renderer { } // The item's own geometry effects on `g`, from the cache when there is one. let slot = cache.as_deref_mut().and_then(|c| c.get_mut(j)); - let ig: Arc<BezPath> = match slot { - Some(Some(ig)) => ig.clone(), - Some(s) => s.insert(Arc::new(apply(item_effects(item), g, &gctx.item(item)))).clone(), - None => Arc::new(apply(item_effects(item), g, &gctx.item(item))), + let made = || { + let (ig, carry) = apply_carrying(item_effects(item), g, &gctx.item(item)); + (Arc::new(ig), Arc::new(carry)) + }; + let (ig, carry): ItemGeom = match slot { + Some(Some(e)) => e.clone(), + Some(s) => s.insert(made()).clone(), + None => made(), }; let ig = &*ig; + // The item with its gradient moved along with its own effects. + let carried = (!carry.is_empty() && matches!(item.paint(), vectorcraft_color::Paint::Gradient(_))).then(|| { + let mut ap = vectorcraft_doc::Appearance { items: vec![item.clone()], ..Default::default() }; + carry.apply(&mut ap); + ap.items.pop() + }); + let item = carried.as_ref().and_then(Option::as_ref).unwrap_or(item); let ib = if effects::has_geometry(item_effects(item)) && !ig.elements().is_empty() { ig.bounding_box() } else { bounds }; let rfx = effects::raster_effects(item_effects(item)); if rfx.is_empty() { diff --git a/crates/render/src/fx/renderer.rs b/crates/render/src/fx/renderer.rs index fd8d68a98..7722a0a86 100644 --- a/crates/render/src/fx/renderer.rs +++ b/crates/render/src/fx/renderer.rs @@ -1,5 +1,5 @@ -use super::bounds::{cull_bounds, effected_path}; -use super::entries::{Content, Drawn, FX_RASTER_BUDGET, FxGeom, Inputs, Kept, Made, Reads, Shadow, ShadowEntry}; +use super::bounds::{cull_bounds, effected_path_carrying}; +use super::entries::{Content, Drawn, FX_RASTER_BUDGET, FxGeom, Inputs, ItemGeom, Kept, Made, Reads, Shadow, ShadowEntry}; use super::predicates::{evaluates_live, is_container, rasterize_bounds, reads_outside, shifted}; use super::raster::{ DRAG_PIXEL_BUDGET, MAX_RASTERIZE, PIXEL_BUDGET, ViewCrop, content_changing, doc_raster_ppi, image_size, linear_key, pan_margin, pcolor, @@ -314,29 +314,39 @@ impl Renderer { let stamp = self.stamp; let e = match self.fx_geom.remove(&key) { Some(e) if Arc::ptr_eq(&e.node, &o) => e, - _ => FxGeom { g: Arc::new(effected_path(n, bp)), items: vec![None; n.appearance.items.len()], node: o, stamp }, + _ => { + let (g, carry) = effected_path_carrying(n, bp); + FxGeom { g: Arc::new(g), carried: carried(n, &carry), items: vec![None; n.appearance.items.len()], node: o, stamp } + } }; (key, FxGeom { stamp, ..e }) }); - let g = match &cached { - Some((_, e)) => e.g.clone(), - None => Arc::new(effected_path(n, bp)), + let (g, carried) = match &cached { + Some((_, e)) => (e.g.clone(), e.carried.clone()), + None => { + let (g, carry) = effected_path_carrying(n, bp); + (Arc::new(g), carried(n, &carry)) + } }; - self.draw_shape_fx_geom(ctx, f, n, &g, rule, cached.as_mut().map(|(_, e)| &mut e.items)); + let paints = carried.as_deref().unwrap_or(n); + self.draw_shape_fx_geom(ctx, f, n, paints, &g, rule, cached.as_mut().map(|(_, e)| &mut e.items)); if let Some((key, e)) = cached { self.fx_geom.insert(key, e); } } - /// [`Self::draw_shape_fx`] on the effected geometry `g`, with the items' cached geometry. + /// [`Self::draw_shape_fx`] on the effected geometry `g`, with the items' cached geometry: + /// `paints` is `n` with the fills and strokes it paints. + #[allow(clippy::too_many_arguments)] fn draw_shape_fx_geom( &mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, + paints: &Node, g: &BezPath, rule: FillRule, - items: Option<&mut Vec<Option<Arc<BezPath>>>>, + items: Option<&mut Vec<Option<ItemGeom>>>, ) { if g.elements().is_empty() { return; @@ -345,7 +355,7 @@ impl Renderer { self.hairline(ctx, f, g, [0, 0, 0, 255]); return; } - let gctx = GeomContext::of(n); + let gctx = GeomContext::of(paints); let rfx = effects::raster_effects(&n.appearance.effects); // Document-space reach of the painted geometry (strokes, arrowheads…). let outset = n.appearance.outset(); @@ -355,7 +365,7 @@ impl Renderer { let reach = bounds.map_or(reach, |b| reach.union(b)); let content = Content { node: n, owner: None, cache: Some(0), reach, outline: Some((g, rule)), bounds, kept: None }; let mut items = items; - self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_items(c, fr, n, g, rule, &gctx, items.as_deref_mut())); + self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_items(c, fr, paints, g, rule, &gctx, items.as_deref_mut())); ctx.set_transform(Affine::IDENTITY); } @@ -772,6 +782,16 @@ impl Renderer { } } +/// `n` with the gradients of its fills and strokes moved along with its effects by `carry`, or +/// `None` when they stay where they are. +fn carried(n: &Node, carry: &effects::Carry) -> Option<Arc<Node>> { + carry.moves(&n.appearance).then(|| { + let mut m = n.clone(); + carry.apply(&mut m.appearance); + Arc::new(m) + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/render/src/tests/objectfx.rs b/crates/render/src/tests/objectfx.rs index b6baab074..173d91bb0 100644 --- a/crates/render/src/tests/objectfx.rs +++ b/crates/render/src/tests/objectfx.rs @@ -811,6 +811,52 @@ fn a_path_with_its_own_transform_paints_each_copy_with_its_own_gradient() { } } +#[test] +fn a_paths_gradient_moves_with_its_transform_and_free_distort() { + use vectorcraft_color::GradientGeom; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + // Placed across the square from its left edge (red) to its right edge (blue). + let geom = GradientGeom { start: Point::new(10.0, 50.0), end: Point::new(40.0, 50.0), aspect: 1.0, focal: None }; + let draw = |paint: &GradientPaint, e: Option<Effect>| { + let square = shapes::rectangle(vectorcraft_geom::Rect::new(10.0, 35.0, 40.0, 65.0)); + let mut n = Node::path(NodeId(0), square, Appearance::basic(Paint::Gradient(Box::new(paint.clone())), Paint::None, 0.0)); + n.appearance.effects = e.into_iter().collect(); + render(&doc_with(Document::new(100.0, 100.0), n)) + }; + let placed = GradientPaint { geom: Some(geom), ..ramp.clone() }; + let rb = |img: &Rendered, x: u32, y: u32| { + let p = img.pixel(x, y); + (p[0], p[2]) + }; + let red = |(r, b): (u8, u8)| r > 200 && b < 60; + let blue = |(r, b): (u8, u8)| b > 200 && r < 60; + // Moved 40 pt right by Transform, and 30 pt (one width) right by a Free Distort that moves + // every corner. + let corners = json!({"corners": [[1.0, 0.0], [2.0, 0.0], [2.0, 1.0], [1.0, 1.0]]}); + for e in [fx("distort.transform", json!({"moveH": 40})), fx("distort.freeDistort", corners)] { + let img = draw(&placed, Some(e.clone())); + let moved = if e.id == "distort.transform" { 40 } else { 30 }; + let (left, right) = (11 + moved, 38 + moved); + assert!(red(rb(&img, left, 50)) && blue(rb(&img, right, 50)), "{}: {:?} {:?}", e.id, rb(&img, left, 50), rb(&img, right, 50)); + } + // Turned a quarter: the gradient runs from top to bottom (or bottom to top). + let turn = fx("distort.transform", json!({"rotate": 90})); + let img = draw(&placed, Some(turn)); + let (top, bottom) = (rb(&img, 25, 36), rb(&img, 25, 63)); + assert!((red(top) && blue(bottom)) || (blue(top) && red(bottom)), "{top:?} {bottom:?}"); + assert_eq!(rb(&img, 12, 50), rb(&img, 38, 50), "the same color across"); + // Scaled to half its width about its center: the gradient spans the narrower square. + let img = draw(&placed, Some(fx("distort.transform", json!({"scaleH": 50})))); + assert!(red(rb(&img, 18, 50)) && blue(rb(&img, 31, 50)), "{:?} {:?}", rb(&img, 18, 50), rb(&img, 31, 50)); + // The fill's own Transform moves its gradient too. + let square = shapes::rectangle(vectorcraft_geom::Rect::new(10.0, 35.0, 40.0, 65.0)); + let mut n = Node::path(NodeId(0), square, Appearance::basic(Paint::Gradient(Box::new(placed.clone())), Paint::None, 0.0)); + n.appearance.items[0].effects_mut().push(fx("distort.transform", json!({"moveH": 40}))); + let img = render(&doc_with(Document::new(100.0, 100.0), n)); + assert!(red(rb(&img, 51, 50)) && blue(rb(&img, 78, 50)), "{:?} {:?}", rb(&img, 51, 50), rb(&img, 78, 50)); +} + #[test] fn darkness_subtracts_from_rgb_before_shadow_opacity() { for opacity in [50.0, 100.0] { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7bf38edc6..cdb4efaa1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -538,6 +538,7 @@ since 2026-10-01. - **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes, the distances of its later effects and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). Not yet: object effects after a Transform effect keep their distances. Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged. - **Warp Options (#1760):** Effect > Warp and existing Appearance effect links share the fifteen existing styles, original schematic previews, orientation choices and percent Bend/Distortion controls. Focused tests passed for real numeric entry and slider input, Preview/Cancel, immediate Enter/OK, mixed-style edits, target/slot/visibility preservation and Undo/Redo. Each applied style stays unchanged until an explicit Style choice. Original style-menu and live-preview captures were reviewed; their numeric values were supplied programmatically. Public references: [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) and [Using effects](https://helpx.adobe.com/illustrator/using/effects.html). Warp geometry, optional Properties integration and remembered defaults are unchanged; no broader parity claim. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. +- **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (<https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rotate-objects-by-specific-angles.html>). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests. - **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged. From 16b291aca1da1d37ef053fcf2bba4561859fb322 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 21:48:35 -0800 Subject: [PATCH 118/160] JavaScript: built-ins install under any limits, and class octal escapes match the compiler A realm made with a low fuel or heap limit lost RegExp and Date, because installing them compiled a pattern under that limit and the error was dropped. The built-ins now install before the realm's limits apply. The early pattern check read an octal class escape starting with 4 to 7 as three digits, while the compiler reads two, so `/[\477-z]/` gave a SyntaxError for a pattern the compiler accepts. The check now reads class octal escapes with the compiler's own reader. docs/target-app-parity.md: the interpreter's entry lists the new built-ins and the current language-test count. --- crates/js/src/builtins.rs | 3 ++- crates/js/src/interp.rs | 8 ++++++-- crates/js/src/regexp.rs | 11 ++--------- crates/js/src/regexp_vm.rs | 4 ++-- crates/js/src/text_tests.rs | 14 ++++++++++++++ docs/target-app-parity.md | 2 +- 6 files changed, 27 insertions(+), 15 deletions(-) diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index 2228fac5a..90dc0d060 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -41,7 +41,8 @@ pub(crate) fn constructor(interp: &mut Interp, name: &'static str, ctor: ObjId, data(interp, global, name, Value::Object(ctor), HIDDEN) } -/// Creates the built-ins and the global object's properties. +/// Creates the built-ins and the global object's properties. The caller installs them before it +/// sets the realm's limits, so no limit stops the install. pub(crate) fn install(interp: &mut Interp) { if let Err(e) = try_install(interp) { // Installing touches only objects it has just made, so this does not happen; a realm diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 895630fd7..9e72285f1 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -330,8 +330,10 @@ impl Interp { global, global_env, intrinsics: Intrinsics::placeholder(), - fuel: limits.fuel, - limits, + fuel: u64::MAX, + // The built-ins are installed without limits; the realm's limits apply from the + // first run. + limits: Limits { fuel: u64::MAX, heap_bytes: usize::MAX, frames: usize::MAX, reentry: usize::MAX }, options, interrupt: Arc::new(AtomicBool::new(false)), reentry: 0, @@ -343,6 +345,8 @@ impl Interp { held: Rc::default(), }; crate::builtins::install(&mut interp); + interp.fuel = limits.fuel; + interp.limits = limits; interp.gc_threshold = interp.next_threshold(); interp } diff --git a/crates/js/src/regexp.rs b/crates/js/src/regexp.rs index cda4f5307..ccb86b998 100644 --- a/crates/js/src/regexp.rs +++ b/crates/js/src/regexp.rs @@ -176,15 +176,8 @@ fn class_atom(pattern: &[u16], i: usize) -> Result<(Option<u32>, usize), String> _ => (Some(u32::from(b'\\')), i + 1), }, '0'..='7' => { - let mut value = 0; - let mut j = i + 1; - while j < i + 4 - && let Some(d) = pattern.get(j).and_then(|&u| char::from_u32(u32::from(u))).and_then(|ch| ch.to_digit(8)) - { - value = value * 8 + d; - j += 1; - } - (Some(value), j) + let (value, end) = crate::regexp_vm::octal(pattern, i + 1); + (Some(u32::from(value)), end) } _ => (Some(next), i + 2), }) diff --git a/crates/js/src/regexp_vm.rs b/crates/js/src/regexp_vm.rs index 139469422..476758bd6 100644 --- a/crates/js/src/regexp_vm.rs +++ b/crates/js/src/regexp_vm.rs @@ -473,8 +473,8 @@ fn hex_digits(pattern: &[u16], at: usize, n: usize) -> Option<u16> { } /// A legacy octal escape (Annex B) whose first digit is at `at`: its value and the position after -/// it. -fn octal(pattern: &[u16], at: usize) -> (u16, usize) { +/// it. [`crate::regexp::check`] reads class escapes with it too. +pub(crate) fn octal(pattern: &[u16], at: usize) -> (u16, usize) { let digit = |i: usize| pattern.get(i).filter(|&&u| (0x30..=0x37).contains(&u)).map(|&u| u - 0x30); let Some(first) = digit(at) else { return (0, at) }; let max = if first <= 3 { 3 } else { 2 }; diff --git a/crates/js/src/text_tests.rs b/crates/js/src/text_tests.rs index b150ba3f5..c873bdaf5 100644 --- a/crates/js/src/text_tests.rs +++ b/crates/js/src/text_tests.rs @@ -184,12 +184,26 @@ fn regular_expressions_match_as_section_15_10_2_says() { ("/]/.test(']') + ' ' + show(/[\\d-z]+/.exec('5-z')) + ' ' + /\\8/.test('8')", "true 5-z true"), ("/(?:a|b)*c/.exec('ababc').index + ' ' + /x*/.exec('yyy').index", "0 0"), ("/[]/.test('a') + ' ' + /[^]/.test('\\n')", "false true"), + // An octal escape starting with 4 to 7 takes two digits, so `\477-z` is `'` and the range `7-z`. + ("show(/[\\477-z]+/.exec(\"'8y\")) + ' ' + new RegExp('[\\\\477-z]').test('8') + ' ' + /[\\377]/.test('\\xff')", "'8y true true"), ]; for (src, want) in cases { shows(src, want); } } +#[test] +fn a_realm_made_under_low_limits_has_every_built_in() { + let low = Limits { fuel: 0, heap_bytes: 1, ..Limits::default() }; + let mut realm = Realm::with_options(Options::default(), low.clone()); + assert_eq!(realm.limits(), &low); + realm.set_limits(Limits::default()); + assert_eq!( + run_in(&mut realm, "typeof String + typeof Number + typeof RegExp + typeof Date + typeof /a/.exec + typeof new Date(0).getTime"), + JsValue::String("function".repeat(6)) + ); +} + #[test] fn regexp_objects_follow_sections_15_10_4_to_15_10_7() { truth("var r = /a/g; r.lastIndex === 0 && r.global && !r.ignoreCase && !r.multiline && r.source === 'a'"); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 56da493f5..78938c36a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -658,7 +658,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785); `run --commands FILE` (or `-` for standard input) reads the steps from a JSON array or JSON Lines, past the Windows command-line limit (#3147, `apps/vectorcraft-cli/tests/it/cli.rs` `run_reads_*`, `run_refuses_malformed_commands_naming_the_step`). - File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files. - `vectorcraft-js` parses JavaScript as ECMAScript 5.1 defines it, with the legacy octal forms, block functions and lenient regular expressions of Annex B, into a syntax tree with source spans; it reads the `#target`, `#include` and other preprocessor lines as directives without running them, and a script that breaks the grammar or an early-error rule gives a `SyntaxError` with its line and column. Nesting past the parser's depth limit (`Options::max_depth`, 200 by default) gives a `SyntaxError` instead of exhausting the stack. `cargo xtask test262` fetches the conformance suite at a pinned commit and parses its ES5 tests: 5,411 of 5,418 runs pass (the 7 others use `let` and `const`). This entry covers parsing; the next entry covers running scripts. Tests: `crates/js/src/tests.rs`, `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs`. - - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap. Built-ins are limited to `Object`, `Function` with `call` and `apply`, `eval` and the error types. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only these built-ins: 3,074 of 3,078 runs pass (the 4 others expect the completion values of later editions). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. + - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap. Built-ins are `Object`, `Function` with `call` and `apply`, `eval`, the error types, and the `String`, `Number`, `RegExp` and `Date` of the next entry. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only these built-ins: 3,742 of 3,746 runs pass (the 4 others expect the completion values of later editions). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. - `vectorcraft-js` has `String`, `Number`, `RegExp` and `Date` as ECMAScript 5.1 defines them (sections 15.5, 15.7, 15.9 and 15.10, https://262.ecma-international.org/5.1/), with Annex B's `substr`, `getYear`, `setYear` and `toGMTString`. `toFixed`, `toExponential` and `toPrecision` format a number from its exact decimal value, and `toString(radix)` writes any base from 2 to 36. Regular expressions (flags `g`, `i` and `m`) run on a backtracking matcher whose choice points and capture state count against the heap limit and which takes fuel for every step, so a catastrophic pattern stops at the fuel limit and 50,000 nested groups give a `SyntaxError` at the default depth limit. A string method that would build a string past the heap limit stops before it allocates. `Date` reads the clock and time zone the host sets (`Realm::set_clock`, `Realm::set_time_zone`; local time is UTC by default). `cargo xtask test262` runs the suite's `built-ins` tests for the four (https://github.com/tc39/test262): of the runs of the ES5 tests that need only the built-ins present, 1,113 of 1,169 pass for `String`, 262 of 262 for `Number`, 878 of 908 for `RegExp` and 6 of 6 for `Date`. The others need `Array` and `Boolean` methods, or expect later editions' rules (built-in functions whose `length` can be deleted, flags given with a RegExp object, a negative `lastIndex`, `RegExp.prototype` as a plain object, and `RegExp.prototype`'s `global`, `ignoreCase` and `multiline` properties that can be deleted). The ES5 language tests that can now run give 3,742 of 3,746 passing runs. Tests: `crates/js/src/text_tests.rs`, `crates/js/tests/test262.rs`. - **UI:** Illustrator 2026 layout restyled to measured values: - Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs. From f680b612e920a1dedc795f7715e70b161ec52aa4 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 21:53:42 -0800 Subject: [PATCH 119/160] Tests: edit.copy and edit.cut journal entries carry the locked-art preference --- crates/engine/src/tests/journal.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/tests/journal.rs b/crates/engine/src/tests/journal.rs index 29eae431d..7dd2983c2 100644 --- a/crates/engine/src/tests/journal.rs +++ b/crates/engine/src/tests/journal.rs @@ -384,7 +384,8 @@ fn edit_copy_after_a_paste_with_the_artboard_tool_replays_on_the_pasted_artboard } /// 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. +/// chosen then, and a batch step records and leaves out the same values. The locked-art preference +/// that the Artboard tool's copy notes stays in the action. #[test] fn edit_copy_and_cut_with_the_artboard_tool_record_nothing_for_an_action() { for id in ["edit.copy", "edit.cut"] { @@ -392,7 +393,7 @@ fn edit_copy_and_cut_with_the_artboard_tool_record_nothing_for_an_action() { 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}"); + assert_eq!(action, [(id.to_string(), json!({"lockedAndHidden": false}))], "{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(); @@ -405,9 +406,13 @@ fn edit_copy_and_cut_with_the_artboard_tool_record_nothing_for_an_action() { 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, "lockedAndHidden": false}), "{id}"); + assert_eq!( + s.journal.last().unwrap().1["commands"][0]["params"], + json!({"selectedArtboards": [1], "art": true, "lockedAndHidden": false}), + "{id}" + ); assert_eq!(documents(&replayed(&s)), documents(&s), "{id}"); - assert_eq!(s.journal_for_action(start)[0].1["commands"][0]["params"], json!({}), "{id}"); + assert_eq!(s.journal_for_action(start)[0].1["commands"][0]["params"], json!({"lockedAndHidden": false}), "{id}"); } } From c43e4a388edc04eb5e737878acff1bae70337166 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 23:24:09 -0800 Subject: [PATCH 120/160] Tests: placed gradient tests check the whole gradient after Transform and Free Distort The baked Transform and Free Distort tests now compare each placed gradient's start, end, aspect and focal point with the expected values within a tolerance, and the Transform test adds a turn with a scale. --- crates/effects/src/bake/tests.rs | 97 ++++++++++++++++++++++---------- 1 file changed, 66 insertions(+), 31 deletions(-) diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index 0484f9cbe..0ff1cdac0 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -17,6 +17,20 @@ fn fx(id: &str, params: serde_json::Value) -> Effect { Effect { id: id.into(), params, visible: true } } +/// Asserts that placed gradient `got` has `want`'s start, end, aspect and focal point, within 1e-6. +fn assert_geom_near(got: vectorcraft_color::GradientGeom, want: vectorcraft_color::GradientGeom, what: &str) { + let near = |a: Point, b: Point| a.distance(b) < 1e-6; + let focal = match (got.focal, want.focal) { + (None, None) => true, + (Some(a), Some(b)) => near(a, b), + _ => false, + }; + assert!( + near(got.start, want.start) && near(got.end, want.end) && (got.aspect - want.aspect).abs() < 1e-6 && focal, + "{what}: got {got:?}, want {want:?}" + ); +} + fn doc_with(n: Node) -> Document { let mut d = Document::new(400.0, 400.0); let l = d.layers[0].id; @@ -52,30 +66,43 @@ fn a_baked_transform_moves_a_placed_gradient_with_the_path() { let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); let geom = GradientGeom { start: Point::new(0.0, 50.0), end: Point::new(100.0, 50.0), aspect: 1.0, focal: None }; let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })); - let start = |n: &Node| match n.appearance.fill_paint() { - Paint::Gradient(g) => g.geom.unwrap().start.x, + let placed_geom = |n: &Node| match n.appearance.fill_paint() { + Paint::Gradient(g) => g.geom.unwrap(), p => panic!("a gradient: {p:?}"), }; + // A move 30 pt right, and a turn of 90° with the width halved about the center (50, 50): the + // square becomes 100 pt wide and 50 pt tall, and the gradient runs from the bottom up. + let moves = [ + (json!({"moveH": 30.0}), Point::new(30.0, 0.0), GradientGeom { start: Point::new(30.0, 50.0), end: Point::new(130.0, 50.0), ..geom }), + ( + json!({"rotate": 90.0, "scaleH": 50.0}), + Point::new(0.0, 25.0), + GradientGeom { start: Point::new(50.0, 75.0), end: Point::new(50.0, 25.0), ..geom }, + ), + ]; // Alone, with a stroke whose own effect makes one path for each fill and stroke, and as the // fill's own effect. - for (split, own) in [(false, false), (true, false), (true, true)] { - let mut ap = Appearance::basic(placed.clone(), Paint::solid(Color::BLACK), if split { 1.0 } else { 0.0 }); - if split && let AppearanceItem::Stroke(s) = &mut ap.items[1] { - s.effects.push(fx("path.offsetPath", json!({"offset": 5.0}))); + for (params, corner, want) in moves { + for (split, own) in [(false, false), (true, false), (true, true)] { + let mut ap = Appearance::basic(placed.clone(), Paint::solid(Color::BLACK), if split { 1.0 } else { 0.0 }); + if split && let AppearanceItem::Stroke(s) = &mut ap.items[1] { + s.effects.push(fx("path.offsetPath", json!({"offset": 5.0}))); + } + let transform = fx("distort.transform", params.clone()); + if own { + ap.items[0].effects_mut().push(transform.clone()); + } + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap); + if !own { + n.appearance.effects.push(transform); + } + let d = bake_document(&doc_with(n)).unwrap(); + let m = d.node(NodeId(50)).unwrap(); + let fill: &Node = if split { &m.children().unwrap()[0] } else { m }; + let b = fill.geometric_bounds().unwrap(); + assert!(b.origin().distance(corner) < 1e-6, "{params}, split {split}, own {own}: {b:?}"); + assert_geom_near(placed_geom(fill), want, &format!("{params}, split {split}, own {own}")); } - let transform = fx("distort.transform", json!({"moveH": 30.0})); - if own { - ap.items[0].effects_mut().push(transform.clone()); - } - let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap); - if !own { - n.appearance.effects.push(transform); - } - let d = bake_document(&doc_with(n)).unwrap(); - let m = d.node(NodeId(50)).unwrap(); - let fill: &Node = if split { &m.children().unwrap()[0] } else { m }; - assert!((fill.geometric_bounds().unwrap().x0 - 30.0).abs() < 1e-6); - assert!((start(fill) - 30.0).abs() < 1e-6, "split {split}, own {own}: the gradient starts at {}", start(fill)); } } @@ -98,17 +125,21 @@ fn a_strokes_own_transform_and_free_distort_move_its_placed_gradient() { stroke.effects_mut().push(e.clone()); let n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap); let d = bake_document(&doc_with(n)).unwrap(); - let mut starts = vec![]; + let mut geoms = vec![]; d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| { for item in &c.appearance.items { if let AppearanceItem::Stroke(s) = item && let Paint::Gradient(g) = &s.paint { - starts.push(g.geom.unwrap().start.x); + geoms.push(g.geom.unwrap()); } } }); - assert!(!starts.is_empty() && starts.iter().all(|s| (s - moved).abs() < 1e-6), "{}: {starts:?}", e.id); + assert!(!geoms.is_empty(), "{}: the stroke keeps its gradient", e.id); + let want = GradientGeom { start: Point::new(moved, 50.0), end: Point::new(100.0 + moved, 50.0), ..geom }; + for g in geoms { + assert_geom_near(g, want, &e.id); + } } } @@ -127,13 +158,18 @@ fn a_fills_gradient_follows_the_effects_around_its_transform_with_options() { *ap.items[0].effects_mut() = fill_fx.clone(); let n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap); let d = bake_document(&doc_with(n)).unwrap(); - let mut starts = vec![]; + let mut found = vec![]; d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| { if let Paint::Gradient(g) = c.appearance.fill_paint() { - starts.push((c.geometric_bounds().unwrap().x0, g.geom.unwrap().start.x)); + found.push((c.geometric_bounds().unwrap(), g.geom.unwrap())); } }); - assert!(!starts.is_empty() && starts.iter().all(|(x0, s)| (x0 - 30.0).abs() < 1e-6 && (s - 30.0).abs() < 1e-6), "{fill_fx:?}: {starts:?}"); + assert!(!found.is_empty(), "{fill_fx:?}: the fill keeps its gradient"); + let want = GradientGeom { start: Point::new(30.0, 50.0), end: Point::new(130.0, 50.0), ..geom }; + for (b, g) in found { + assert!(b.origin().distance(Point::new(30.0, 0.0)) < 1e-6, "{fill_fx:?}: {b:?}"); + assert_geom_near(g, want, &format!("{fill_fx:?}")); + } } } @@ -143,7 +179,7 @@ fn baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bo let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; let ramp = |kind| GradientPaint::new(Gradient::new(kind, stops.clone())); let linear = GradientGeom { start: Point::new(20.0, 50.0), end: Point::new(80.0, 50.0), aspect: 1.0, focal: None }; - let radial = GradientGeom { start: Point::new(40.0, 30.0), end: Point::new(60.0, 30.0), aspect: 0.5, focal: None }; + let radial = GradientGeom { start: Point::new(40.0, 30.0), end: Point::new(60.0, 30.0), aspect: 0.5, focal: Some(Point::new(45.0, 32.0)) }; let placed_linear = Paint::Gradient(Box::new(GradientPaint { geom: Some(linear), ..ramp(GradientKind::Linear) })); let placed_radial = Paint::Gradient(Box::new(GradientPaint { geom: Some(radial), ..ramp(GradientKind::Radial) })); let fitted = Paint::Gradient(Box::new(ramp(GradientKind::Linear))); @@ -165,12 +201,11 @@ fn baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bo assert!((b.x1 - 150.0).abs() < 1e-6, "{b:?}"); let geom = g.geom.unwrap(); assert!(near(geom.start, Point::new(30.0, 50.0)) && near(geom.end, Point::new(120.0, 50.0)), "{geom:?}"); - // A radial gradient's center and ellipse take the same stretch. + // A radial gradient's center, focal point and ellipse take the same stretch: the radius along + // the vector grows from 20 to 30 pt and the 10 pt radius across it stays. let (_, g) = baked(&placed_radial, distort()); - let Paint::Gradient(mut expected) = placed_radial.clone() else { panic!("a gradient") }; - expected.transform(vectorcraft_geom::Affine::new([1.5, 0.0, 0.0, 1.0, 0.0, 0.0])); - assert_eq!(g.geom, expected.geom); - assert!(near(g.geom.unwrap().start, Point::new(60.0, 30.0)), "{:?}", g.geom); + let want = GradientGeom { start: Point::new(60.0, 30.0), end: Point::new(90.0, 30.0), aspect: 1.0 / 3.0, focal: Some(Point::new(67.5, 32.0)) }; + assert_geom_near(g.geom.unwrap(), want, "radial"); // A fitted gradient stays fitted, and fits the distorted outline. assert_eq!(baked(&fitted, distort()).1.geom, None); } From cdd2cc234fb6557211dc053bb328b1a2bdeea14e Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 7 Nov 2026 23:29:09 -0800 Subject: [PATCH 121/160] fmt: wrap the .ai reader's document imports as rustfmt does --- crates/eps/src/import/ai/read.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index bece405e7..b3475dd1d 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -135,8 +135,8 @@ mod types; pub(crate) use art::join_and; use art::preview_png; pub use document::{ - BindingRef, Brushes, PaletteSwatches, Symbols, VariableRead, active_artboard, artboards, bindings_note, brushes, graphic_styles, palette, symbols, - variables, + BindingRef, Brushes, PaletteSwatches, Symbols, VariableRead, active_artboard, artboards, bindings_note, brushes, graphic_styles, palette, + symbols, variables, }; pub(crate) use document::{slot_frame, slot_of}; pub use types::IMAGE_TOO_LARGE; From 95678340b074f781d2375d7352fb019cafd351e5 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sun, 8 Nov 2026 04:34:49 -0800 Subject: [PATCH 122/160] .ai import: a symbol's linked color named like a color group is renamed A global or spot color that a symbol library's art links to and whose name a color group of the document has now comes in under a free variant of the name, which the art then names, as for a name another swatch has. Swatches and color groups share one set of names. --- crates/eps/src/import/native/layers.rs | 8 ++++---- crates/eps/src/import/tests_native.rs | 7 +++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs index d7e22c33e..37c274e57 100644 --- a/crates/eps/src/import/native/layers.rs +++ b/crates/eps/src/import/native/layers.rs @@ -336,7 +336,7 @@ fn use_styles(data: &[u8], doc: &mut Document, large_canvas: bool, unit: f64, bo /// 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 +/// of the name, which the art then names, as does one whose name a color group has), 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 @@ -352,21 +352,21 @@ fn use_symbols(data: &[u8], doc: &mut Document, own_palette: bool, warnings: &mu 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. + // default swatch, and else a free variant of the name, as does one whose name a color group has. 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), + None if !doc.swatch_name_taken(&s.name) => 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 }); diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index e846db387..a520d5fa6 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -1651,6 +1651,13 @@ fn an_ai_symbol_librarys_symbols_keep_their_spot_colors() { 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 color group of the PDF part keeps its name: the library's color of that name is renamed. + let mut part = pdf_part(10.0, 86.0); + part.swatch_groups.push(vectorcraft_color::SwatchGroup { name: "Kit".into(), swatches: vec![] }); + let (d, warnings, _) = crate::layered_ai(&private(&library("", "Kit")), part, vec![], false, true, 1.0); + assert_eq!(link(&d).as_deref(), Some("Kit 2"), "{warnings:?}"); + assert_eq!(ink(&d, "Kit 2"), Some((yellow, true))); + assert!(d.swatch("Kit").is_none()); } /// A symbol library's symbols bring a global process color their art is filled with as a global From 923faeb35aceeae2a4fa8f145b7c626b1692043c Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sun, 8 Nov 2026 05:52:46 -0800 Subject: [PATCH 123/160] JavaScript: the test262 interpreter runs fail when a harness file is missing or no test runs run_tests read an unreadable harness, include or test file as an empty string, so a broken suite checkout passed with every run counted as a failure. It now stops with the file's path. The language run checks that test/language exists, and the language and built-ins runs check that at least one ES5 test ran. --- crates/js/tests/test262.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/js/tests/test262.rs b/crates/js/tests/test262.rs index f090e7273..6303ab078 100644 --- a/crates/js/tests/test262.rs +++ b/crates/js/tests/test262.rs @@ -22,8 +22,8 @@ //! //! A third test runs the tests under `test/built-ins/String`, `Number`, `RegExp` and `Date` the same //! way, counting the tests that name an ES5 section apart from the other tests without a feature -//! tag; its failures go to `builtins-failures.txt`. The interpreter tests fail only when the -//! interpreter panics. +//! tag; its failures go to `builtins-failures.txt`. The interpreter tests fail when the +//! interpreter panics, when a harness or test file cannot be read, or when no ES5 test runs. use std::collections::HashSet; use std::path::{Path, PathBuf}; @@ -288,8 +288,10 @@ impl RunCounts { fn run_language_subset(dir: &Path) { let language = dir.join("test").join("language"); + assert!(language.is_dir(), "{} is not a test262 checkout", dir.display()); let missing = missing_builtins(&[TEXT_BUILTINS]); let (es5, _) = run_tests(dir, &language, &missing, false); + assert!(es5.tests > 0, "no ES5 tests ran under {}", language.display()); let report = dir.join("run-failures.txt"); if let Err(e) = std::fs::write(&report, &es5.failures) { println!("could not write {}: {e}", report.display()); @@ -328,6 +330,7 @@ fn run_text_builtins(dir: &Path) { let missing = if name == "String" { missing_builtins(&[TEXT_BUILTINS, STRING_METHODS_SHARED_WITH_ARRAY]) } else { missing_builtins(&[TEXT_BUILTINS]) }; let (es5, later) = run_tests(dir, &root, &missing, true); + assert!(es5.tests > 0, "no ES5 tests ran under {}", root.display()); println!("test262 built-ins/{name}, ES5 tests: {}. Other tests without a feature tag: {}.", es5.summary(), later.summary()); report.push_str(&format!("# built-ins/{name}, ES5 tests\n{}\n# built-ins/{name}, other tests\n{}\n", es5.failures, later.failures)); panicked += es5.panicked + later.panicked; @@ -345,8 +348,11 @@ fn run_text_builtins(dir: &Path) { /// that name an ES5 section and, when `later` is set, for the others (otherwise those are left out). fn run_tests(dir: &Path, root: &Path, missing: &HashSet<&str>, later: bool) -> (RunCounts, RunCounts) { let harness_dir = dir.join("harness"); - // A file that cannot be read gives an empty source. - let read = |p: &Path| String::from_utf8_lossy(&std::fs::read(p).unwrap_or_default()).into_owned(); + let read = |p: &Path| { + let bytes = std::fs::read(p).map_err(|e| format!("cannot read {}: {e}", p.display())); + assert!(bytes.is_ok(), "{}", bytes.as_ref().err().map_or("", String::as_str)); + String::from_utf8_lossy(&bytes.unwrap_or_default()).into_owned() + }; let prelude = format!("{}\n{}\n", read(&harness_dir.join("assert.js")), read(&harness_dir.join("sta.js"))); let verbose = std::env::var_os("VECTORCRAFT_TEST262_VERBOSE").is_some(); let (mut es5_counts, mut later_counts) = (RunCounts::default(), RunCounts::default()); From 76bb829e3af2f356d049c7ca36ce14ffdcfa2289 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sun, 8 Nov 2026 06:49:57 -0800 Subject: [PATCH 124/160] Graphs: a style that removes a mark's painted row resets the stored opacity Applying or redefining a graphic style on a series mark whose fill or stroke had paint, when the style leaves that row without paint, now stores the row's opacity and blend mode on the graph. Before, the opacity and blend mode another mark of the series left stayed, so a Filled Lines band's outline kept a stroke opacity after a style without a stroke went on a stroked marker. A row with no paint before and after the edit still keeps what the graph stores. --- crates/engine/src/cmd/appearance.rs | 8 ++++++-- crates/engine/src/cmd/graph/tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index e24b55105..4f9152bcf 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -687,11 +687,15 @@ pub(super) fn capture_series_writes( 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 kinds = |(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))) + rows.filter(|(id, kind)| !top_only || top_shows(d, *id, *kind)).collect() }; + let written = |d: &vectorcraft_doc::Document, rows: (bool, bool)| painted_rows(d, kinds(rows)); let mut opacity_rows = top_opacity_changed(before, d, ids); + // A written row that had paint before the edit counts too when the edit left it without paint: only a row without + // paint both before and after keeps what the graph stores. + opacity_rows.extend(painted_rows(before, kinds(opacities))); opacity_rows.extend(written(d, opacities)); let mut rows = top_paint_changed(before, d, ids); rows.extend(written(d, paints)); diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index b90d456bb..2c93799ba 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -1218,6 +1218,32 @@ fn a_filled_line_bands_outline_keeps_a_stroke_opacity_and_blend_mode() { assert_eq!((st.opacity, st.blend), (0.5, BlendMode::Multiply)); } +/// A graphic style without a stroke applied to a marker whose stroke had paint replaces the stroke opacity and blend mode +/// the series' Filled Lines band left: the band's outline is drawn opaque and Normal again. A marker that had no stroke +/// before the style leaves them as they are. +#[test] +fn a_style_that_removes_a_marks_stroke_replaces_the_kept_stroke_opacity() { + for stroked in [true, false] { + let (mut s, id, band) = band_graph(); + let marker = series_parts(&s, id, 0)[1].id; + if stroked { + let row = top_row(&s, marker, false); + s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": row, "color": "#ff0000"})).unwrap(); + } + let row = top_row(&s, band, false); + s.execute("appearance.setItem", &json!({"ids": [band.0], "index": row, "opacity": 40, "blend": "multiply"})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke_opacity, Some(0.4)); + blue_style(&mut s, "Blue", false); + s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marker.0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + let want = if stroked { (None, None) } else { (Some(0.4), Some(BlendMode::Multiply)) }; + assert_eq!((kept.stroke_opacity, kept.stroke_blend), want, "stroked {stroked}"); + redrawn_band(&mut s, id); + let st = series_parts(&s, id, 0)[0].appearance.stroke().unwrap().clone(); + assert_eq!((Some(st.opacity).filter(|o| *o < 1.0), Some(st.blend).filter(|b| *b != BlendMode::Normal)), want, "stroked {stroked}"); + } +} + /// 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] From 394bbb37c014e25f0ac838e219451d1208e9185f Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sun, 8 Nov 2026 07:23:49 -0800 Subject: [PATCH 125/160] Effects: placed gradients move and scale with the Warp effects A gradient placed with the Gradient tool stayed where it was when the object's Warp effect changed the outline, so the warped shape no longer matched its gradient. Under each Warp style the placed gradients of a path now move and scale from the path's bounds before the warp to its bounds after it, as they do under Free Distort. This applies to an object's own Warp and to a fill's or stroke's own (a stroke uses its stroke-inflated bounds), on the canvas and in Object > Expand of the fill. In a group with a Warp effect, each path's and compound path's gradient follows its own bounds, and the own fills of the group and of the groups inside it follow those groups' bounds. A warp with no bend and no distortion leaves gradients exactly where they are, and gradients fitted to the object are unchanged. This changes how existing documents draw: a placed gradient on art with a Warp effect now follows the warped outline instead of staying in place. How gradients follow the warp is VectorCraft's own rule. What the Warp effects do to the object is described in https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html --- crates/effects/src/bake/tests.rs | 139 +++++++++++++++++++++++++++ crates/effects/src/catalog.rs | 3 +- crates/effects/src/geometry.rs | 21 ++-- crates/effects/src/group/tests.rs | 98 +++++++++++++++++++ crates/effects/src/reshape.rs | 15 +-- crates/effects/src/warp.rs | 9 ++ crates/engine/src/tests/objexpand.rs | 38 ++++++++ crates/render/src/tests/objectfx.rs | 31 ++++++ docs/mcp.md | 3 +- docs/target-app-parity.md | 1 + 10 files changed, 342 insertions(+), 16 deletions(-) diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index 0ff1cdac0..1829f3834 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -210,6 +210,145 @@ fn baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bo assert_eq!(baked(&fitted, distort()).1.geom, None); } +#[test] +fn baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = |kind| GradientPaint::new(Gradient::new(kind, stops.clone())); + // A ramp across the square and one down it, so both axes of the box map show. + let across = GradientGeom { start: Point::new(20.0, 30.0), end: Point::new(80.0, 30.0), aspect: 1.0, focal: None }; + let down = GradientGeom { start: Point::new(60.0, 10.0), end: Point::new(60.0, 75.0), aspect: 1.0, focal: None }; + let placed = |geom| Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp(GradientKind::Linear) })); + let fitted = Paint::Gradient(Box::new(ramp(GradientKind::Linear))); + let square = Rect::new(0.0, 0.0, 100.0, 100.0); + let baked = |fill: &Paint, e: Effect| { + let mut n = Node::path(NodeId(50), shapes::rectangle(square), Appearance::basic(fill.clone(), Paint::None, 0.0)); + n.appearance.effects.push(e); + let d = bake_document(&doc_with(n)).unwrap(); + let m = d.node(NodeId(50)).unwrap(); + match m.appearance.fill_paint() { + Paint::Gradient(g) => (m.geometric_bounds().unwrap(), (*g).clone()), + p => panic!("a gradient: {p:?}"), + } + }; + // Where the box map from the square to `to` takes `p`. + let follow = |p: Point, to: Rect| Point::new(to.x0 + p.x / 100.0 * to.width(), to.y0 + p.y / 100.0 * to.height()); + let styles = [ + "arc", + "arcLower", + "arcUpper", + "arch", + "bulge", + "shellLower", + "shellUpper", + "flag", + "wave", + "fish", + "rise", + "fisheye", + "inflate", + "squeeze", + "twist", + ]; + let mut moved = 0; + for style in styles { + for params in + [json!({"bend": 30.0}), json!({"bend": -40.0, "orientation": "vertical"}), json!({"bend": 20.0, "horizontal": 30.0, "vertical": -20.0})] + { + for linear in [across, down] { + let what = format!("warp.{style} {params} {linear:?}"); + let (b, g) = baked(&placed(linear), fx(&format!("warp.{style}"), params.clone())); + let want = GradientGeom { start: follow(linear.start, b), end: follow(linear.end, b), ..linear }; + assert_geom_near(g.geom.unwrap(), want, &what); + if b != square { + moved += 1; + } + } + let what = format!("warp.{style} {params}"); + // A fitted gradient stays fitted, and fits the warped outline. + assert_eq!(baked(&fitted, fx(&format!("warp.{style}"), params)).1.geom, None, "{what}"); + } + } + assert!(moved >= 80, "most of the warps change the bounds: {moved}"); + // With no bend and no distortion the gradient stays where it is. + let (_, g) = baked(&placed(across), fx("warp.arc", json!({"bend": 0.0}))); + assert_geom_near(g.geom.unwrap(), across, "no bend"); +} + +/// A warp with no distortion and no bend (or a bend Arc takes as none) leaves a placed gradient +/// exactly where it was, even where warping the outline moves its bounds by a rounding error. +#[test] +fn a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let geom = GradientGeom { start: Point::new(13.7, 31.3), end: Point::new(91.1, 47.9), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) })); + let mut drift = 0; + let cases = ["arc", "bulge", "flag", "wave", "rise", "fisheye", "inflate", "squeeze", "twist"].map(|s| (s, 0.0)); + for (k, (style, bend)) in cases.into_iter().chain([("arc", 1e-5), ("arc", -1e-5)]).enumerate() { + let k = k as f64; + let oval = Rect::new(10.1 + k * 0.37, 20.3 - k * 1.13, 97.7 + k * 3.31, 71.9 + k * 0.71); + let mut n = Node::path(NodeId(50), shapes::ellipse(oval), Appearance::basic(placed.clone(), Paint::None, 0.0)); + n.appearance.effects.push(fx(&format!("warp.{style}"), json!({ "bend": bend }))); + let d = bake_document(&doc_with(n)).unwrap(); + let m = d.node(NodeId(50)).unwrap(); + drift += usize::from(m.geometric_bounds() != shapes::ellipse(oval).bounds()); + let Paint::Gradient(g) = m.appearance.fill_paint() else { panic!("warp.{style} {bend}: a gradient") }; + assert_eq!(g.geom, Some(geom), "warp.{style} {bend}"); + } + assert!(drift > 0, "the warped outlines' bounds move by a rounding error"); + // Another style with as small a bend still moves the outline, and the gradient follows it. + let mut n = Node::path(NodeId(50), shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(placed, Paint::None, 0.0)); + n.appearance.effects.push(fx("warp.rise", json!({"bend": 1e-5}))); + let d = bake_document(&doc_with(n)).unwrap(); + let Paint::Gradient(g) = d.node(NodeId(50)).unwrap().appearance.fill_paint() else { panic!("warp.rise: a gradient") }; + assert_ne!(g.geom, Some(geom), "warp.rise 1e-5"); +} + +/// A Warp effect on a fill or a stroke itself moves and scales its placed gradient from the +/// path's bounds before the warp to its bounds after it (a stroke: its stroke-inflated bounds). +#[test] +fn a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + let geom = GradientGeom { start: Point::new(20.0, 60.0), end: Point::new(80.0, 60.0), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })); + let square = Rect::new(0.0, 0.0, 100.0, 100.0); + let width = 4.0; + // Where the box map from `from` to `to` takes `p`. + let follow = |p: Point, from: Rect, to: Rect| { + Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height()) + }; + let pad = |r: Rect| r.inflate(width / 2.0, width / 2.0); + for style in ["arc", "rise", "inflate"] { + for stroke in [false, true] { + let what = format!("warp.{style} on the {}", if stroke { "stroke" } else { "fill" }); + let mut ap = + if stroke { Appearance::basic(Paint::None, placed.clone(), width) } else { Appearance::basic(placed.clone(), Paint::None, 0.0) }; + let Some(item) = ap.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_)) == stroke) else { panic!("{what}: the item") }; + item.effects_mut().push(fx(&format!("warp.{style}"), json!({"bend": 40.0}))); + let n = Node::path(NodeId(50), shapes::rectangle(square), ap); + let d = bake_document(&doc_with(n)).unwrap(); + let mut found = vec![]; + d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| { + for item in &c.appearance.items { + if let Paint::Gradient(g) = item.paint() { + found.push((c.geometric_bounds().unwrap(), g.geom.unwrap())); + } + } + }); + assert!(!found.is_empty(), "{what}: the gradient is kept"); + for (b, g) in found { + assert_ne!(b, square, "{what}: the warp changes the bounds"); + let (from, to) = if stroke { (pad(square), pad(b)) } else { (square, b) }; + let want = GradientGeom { start: follow(geom.start, from, to), end: follow(geom.end, from, to), ..geom }; + assert_geom_near(g, want, &what); + } + } + } +} + #[test] fn item_effects_split_into_one_path_per_item() { let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index a014807a7..cad3cd2ed 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -109,8 +109,7 @@ fn lengths_of(id: &str) -> Lengths { } } -const WARP_DOC: &str = - "{bend: % (-100..100, 50), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\"}"; +const WARP_DOC: &str = "{bend: % (-100..100, 50; placed gradients move and scale from each path's bounds to its warped bounds), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\"}"; const DT: &[&str] = &["Effect", "Distort & Transform"]; diff --git a/crates/effects/src/geometry.rs b/crates/effects/src/geometry.rs index 715143e1a..3b8d42d7f 100644 --- a/crates/effects/src/geometry.rs +++ b/crates/effects/src/geometry.rs @@ -175,9 +175,10 @@ pub fn apply_geometry_bez_carrying(effects: &[Effect], path: &BezPath, bounds: R } /// How placed gradients follow geometry effects. The Transform effect without copies maps them as -/// it maps the object (with copies, `crate::reshape` gives each copy its own). Free Distort moves -/// and scales them from the object's bounds before the effect to its bounds after it. For the -/// other geometry effects it is empty. It does not change gradients fitted to the object. +/// it maps the object (with copies, `crate::reshape` gives each copy its own). Free Distort and +/// the Warp effects move and scale them from the object's bounds before the effect to its bounds +/// after it. For the other geometry effects it is empty. It does not change gradients fitted to +/// the object. #[derive(Clone, Debug, Default, PartialEq)] pub struct Carry(Vec<CarryStep>); @@ -193,9 +194,10 @@ enum CarryStep { impl Carry { /// Does effect `id` with merged params `p` on geometry with reference box `b` (`seed` for - /// Random) move placed gradients? Free Distort does unless it leaves every corner in place; - /// the Transform effect does without copies (with copies the original stays where it is, and - /// `crate::reshape` gives each copy its own gradients). + /// Random) move placed gradients? Free Distort does unless it leaves every corner in place, a + /// Warp effect unless it has no bend and no distortion, and the Transform effect does without + /// copies (with copies the original stays where it is, and `crate::reshape` gives each copy + /// its own gradients). pub(crate) fn follows(id: &str, p: &Value, b: Rect, seed: u64) -> bool { match id { "distort.transform" => { @@ -209,10 +211,17 @@ impl Carry { [(b.x0, b.y0), (b.x1, b.y0), (b.x1, b.y1), (b.x0, b.y1)].iter().zip(&corners).all(|(a, c)| c.distance((*a).into()) <= tol); !still && corners.iter().all(|c| c.x.is_finite() && c.y.is_finite()) } + _ if id.starts_with("warp.") => crate::warp::bends(id, p), _ => false, } } + /// Does effect `id` move placed gradients from each path's bounds before it to its bounds + /// after it, rather than by one map for the whole object? + pub(crate) fn by_bounds(id: &str) -> bool { + id == "distort.freeDistort" || id.starts_with("warp.") + } + /// How placed gradients follow effect `id` (as [`Self::follows`]) on an object whose geometric /// bounds are `from` before the effect and `to` after it; empty when they stay in place. pub(crate) fn of(id: &str, p: &Value, b: Rect, from: Option<Rect>, to: Option<Rect>, seed: u64) -> Carry { diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index a0cddc94d..064c64438 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -378,6 +378,104 @@ fn a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths() assert!(near(s, Point::new(0.0, 5.0)) && near(e, Point::new(20.0, 5.0)), "{s:?} {e:?}"); } +/// A group's Warp effect moves and scales the gradient placed on each of its paths from that path's +/// bounds before the warp to its bounds after it, not by the group's bounds. +#[test] +fn a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + let geom = |x: f64| GradientGeom { start: (x + 2.0, 3.0).into(), end: (x + 8.0, 3.0).into(), aspect: 1.0, focal: None }; + let placed = |x: f64| Paint::Gradient(Box::new(GradientPaint { geom: Some(geom(x)), ..ramp.clone() })); + let square = |id: u64, x: f64| { + Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::basic(placed(x), Paint::None, 0.0))) + }; + for style in ["arc", "bulge", "rise", "twist"] { + let mut g = Node::group(NodeId(10), vec![square(1, 0.0), square(2, 20.0)]); + g.appearance.effects.push(Effect { id: format!("warp.{style}"), params: serde_json::json!({"bend": 40.0}), visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let mut moved = false; + for (i, x) in [0.0, 20.0].into_iter().enumerate() { + let m = &art.children().unwrap()[i]; + let b = m.geometric_bounds().unwrap(); + let follow = |p: Point| Point::new(b.x0 + (p.x - x) / 10.0 * b.width(), b.y0 + p.y / 10.0 * b.height()); + let Paint::Gradient(gp) = m.appearance.fill_paint() else { panic!("a gradient") }; + let got = gp.geom.unwrap(); + let want = geom(x); + assert!( + got.start.distance(follow(want.start)) < 1e-6 && got.end.distance(follow(want.end)) < 1e-6, + "warp.{style} path {i}: {got:?} in {b:?}" + ); + moved |= got.start.distance(want.start) > 0.1; + } + assert!(moved, "warp.{style} moves a gradient"); + } +} + +/// Under a group's Warp effect, the group's own placed gradient follows the group's bounds, a +/// nested group's own placed gradient follows the nested group's bounds, and a compound path's +/// placed gradient follows the compound path's bounds, each from before the warp to after it. +#[test] +fn a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + let geom = GradientGeom { start: (12.0, 4.0).into(), end: (26.0, 4.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })); + let own_fill = |n: &mut Node| n.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(placed.clone()))); + let follow = |p: Point, from: Rect, to: Rect| { + Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height()) + }; + for style in ["arc", "bulge", "rise", "flag"] { + let mut inner = Node::group(NodeId(5), vec![sq(1, 0.0, Color::BLACK)]); + own_fill(&mut inner); + let ring = vec![ + Arc::new(Node::path(NodeId(6), shapes::rectangle(Rect::new(20.0, 0.0, 30.0, 14.0)), Appearance::default())), + Arc::new(Node::path(NodeId(7), shapes::rectangle(Rect::new(23.0, 3.0, 27.0, 7.0)), Appearance::default())), + ]; + let mut compound = Node::new(NodeId(3), NodeKind::Compound { children: ring, rule: FillRule::EvenOdd }); + compound.appearance = Appearance::basic(placed.clone(), Paint::None, 0.0).into(); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner), Arc::new(compound), sq(2, 40.0, Color::BLACK)]); + own_fill(&mut g); + let mut before = std::collections::HashMap::new(); + g.walk(&mut |c: &Node| { + before.insert(c.id, c.geometric_bounds().unwrap()); + }); + g.appearance.effects.push(Effect { id: format!("warp.{style}"), params: serde_json::json!({"bend": 40.0}), visible: true }); + let art = evaluate_container(None, &g).unwrap(); + // The bounds after the warp of the group, the nested group and the compound path (the paths + // a group's own fill paints carry the group's id). + let mut after = std::collections::HashMap::new(); + art.walk(&mut |c: &Node| { + if matches!(c.kind, NodeKind::Group { .. } | NodeKind::Compound { .. }) { + after.entry(c.id).or_insert_with(|| c.geometric_bounds().unwrap()); + } + }); + let mut checked = vec![]; + art.walk(&mut |c: &Node| { + if ![NodeId(3), NodeId(5), NodeId(10)].contains(&c.id) { + return; + } + for item in &c.appearance.items { + let Paint::Gradient(gp) = item.paint() else { continue }; + let (from, to) = (before[&c.id], after[&c.id]); + assert_ne!(from, to, "warp.{style} {:?}: the warp changes the bounds", c.id); + let got = gp.geom.unwrap(); + let (s, e) = (follow(geom.start, from, to), follow(geom.end, from, to)); + assert!( + got.start.distance(s) < 1e-6 && got.end.distance(e) < 1e-6, + "warp.{style} {:?}: {got:?}, want {s:?} {e:?} ({from:?} to {to:?})", + c.id + ); + checked.push(c.id); + } + }); + checked.sort(); + checked.dedup(); + assert_eq!(checked, vec![NodeId(3), NodeId(5), NodeId(10)], "warp.{style}"); + } +} + /// A group nested in a group with a Transform effect or Free Distort keeps its own placed gradient /// on its members: the Transform's map moves it, and Free Distort moves and scales it from the /// nested group's bounds before the effect to its bounds after it. diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 4ea3e4610..02464988c 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -652,12 +652,13 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b for e in fx { let Some(bounds) = art.geometric_bounds() else { break }; let params = merged_params(&e.id, &e.params); - // Placed gradients follow a Transform effect and Free Distort ([`crate::Carry`]): under - // Free Distort (`boxes`) each path's move and scale from its bounds before the effect to - // its bounds after it. Under that and the other effects, the placed gradients of type's - // outlines in `art` (`outline_text`) move and scale with the outlines' bounds. + // Placed gradients follow a Transform effect, Free Distort and the Warp effects + // ([`crate::Carry`]): under Free Distort and Warp (`boxes`) each path's move and scale from + // its bounds before the effect to its bounds after it. Under those and the other effects, + // the placed gradients of type's outlines in `art` (`outline_text`) move and scale with + // the outlines' bounds. let follows = crate::Carry::follows(&e.id, ¶ms, bounds, seed); - let boxes = follows && e.id == "distort.freeDistort"; + let boxes = follows && crate::Carry::by_bounds(&e.id); let mut outlines = vec![]; if !follows || boxes { type_outline_bounds(art, &mut outlines); @@ -731,8 +732,8 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b rebase_type_outlines(art, &mut outlines.into_iter()); } // What paints the art as a whole, and the own fills and strokes of the groups inside it, - // follow their bounds under Free Distort (they stay when no path changed them). Each stays - // when a path under it kept its shape (past the budget). + // follow their bounds under Free Distort and Warp (they stay when no path changed them). + // Each stays when a path under it kept its shape (past the budget). if !groups.is_empty() { let mut after = vec![]; group_marks(art, &mut vec![], &kept_ids, &mut after); diff --git a/crates/effects/src/warp.rs b/crates/effects/src/warp.rs index fb0279ede..8c887c0ff 100644 --- a/crates/effects/src/warp.rs +++ b/crates/effects/src/warp.rs @@ -32,3 +32,12 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) - let piece = b.width().hypot(b.height()) / 24.0; map_nonlinear_within(path, piece, max, f) } + +/// Does warp effect `id` with params `p` move any point? Every style leaves the box as it is with +/// no bend and no distortion, and Arc (`warp.arc`) takes a bend under 0.0001 % as none. +pub(crate) fn bends(id: &str, p: &Value) -> bool { + let share = |key: &str, default: f64| num(p, key, default).clamp(-100.0, 100.0) / 100.0; + let bend = share("bend", 50.0); + let bent = if id == "warp.arc" { bend.abs() >= 1e-6 } else { bend != 0.0 }; + bent || share("horizontal", 0.0) != 0.0 || share("vertical", 0.0) != 0.0 +} diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 4210a1d96..657e0dd00 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -164,6 +164,44 @@ fn a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_ } } +#[test] +fn a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it() { + // The object's own effect, and the fill's (the first appearance item). + for (effect, params, item) in [ + ("warp.arc", json!({"bend": 40}), json!(null)), + ("warp.bulge", json!({"bend": 40, "orientation": "vertical"}), json!(null)), + ("warp.inflate", json!({"bend": 40}), json!(0)), + ] { + let mut s = session(); + let id = gradient_rect(&mut s, Rect::new(40.0, 70.0, 160.0, 130.0), white_black(), false); + // Placed from a quarter of the way across to the right edge. + run(&mut s, "paint.setGradientGeom", json!({"start": [70, 100], "end": [160, 100], "stroke": false})); + let placed = s.doc().unwrap().doc.node(id).and_then(|n| match n.appearance.fill_paint() { + vectorcraft_color::Paint::Gradient(g) => g.geom, + _ => None, + }); + assert!(placed.is_some(), "the fill's gradient is placed"); + run(&mut s, "effect.apply", json!({"effect": effect, "params": params, "ids": [id.0], "item": item})); + let before = render(&s); + // Fill alone: the gradient becomes strips inside the outline the warp makes. + run(&mut s, "object.expand", json!({"object": false, "stroke": false, "steps": 64})); + let after = render(&s); + let diff = mean_diff(&before, &after); + assert!(diff < 1.0, "{effect} {params} on item {item}: mean difference {diff}"); + // The ramp's midpoint, 115 pt before the warp, moves and scales with the warped bounds. + let doc = &s.doc().unwrap().doc; + let b = doc.node(doc.layers[0].id).and_then(|l| l.geometric_bounds()).unwrap(); + let want = b.x0 + (115.0 - 40.0) / 120.0 * b.width(); + assert!((want - 115.0).abs() > 2.0, "{effect} {params}: the warp moves the midpoint ({b:?})"); + let red = |x: usize| f64::from(after.pixels[(100 * 200 + x) * 4]); + let half = (b.x0.ceil() as usize + 1..b.x1.floor() as usize - 1) + .find(|&x| red(x) >= 127.5 && red(x + 1) < 127.5) + .map(|x| x as f64 + 0.5 + (red(x) - 127.5) / (red(x) - red(x + 1))) + .unwrap(); + assert!((half - want).abs() < 1.0, "{effect} {params} on item {item}: the midpoint is at {half}, want {want}"); + } +} + #[test] fn eight_strips_match_the_gradient_samples() { let mut s = session(); diff --git a/crates/render/src/tests/objectfx.rs b/crates/render/src/tests/objectfx.rs index 173d91bb0..304ae2160 100644 --- a/crates/render/src/tests/objectfx.rs +++ b/crates/render/src/tests/objectfx.rs @@ -857,6 +857,37 @@ fn a_paths_gradient_moves_with_its_transform_and_free_distort() { assert!(red(rb(&img, 51, 50)) && blue(rb(&img, 78, 50)), "{:?} {:?}", rb(&img, 51, 50), rb(&img, 78, 50)); } +#[test] +fn a_paths_gradient_moves_and_scales_with_its_warped_bounds() { + use vectorcraft_color::GradientGeom; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + let draw = |paint: &GradientPaint, e: Effect| { + let square = shapes::rectangle(vectorcraft_geom::Rect::new(30.0, 35.0, 60.0, 65.0)); + let mut n = Node::path(NodeId(0), square, Appearance::basic(Paint::Gradient(Box::new(paint.clone())), Paint::None, 0.0)); + n.appearance.effects = vec![e]; + render(&doc_with(Document::new(100.0, 100.0), n)) + }; + // Placed from the square's left edge to its right edge, the gradient is drawn as one fitted to + // the square is: after a warp both run from the warped outline's left edge to its right edge. + let placed = GradientPaint { + geom: Some(GradientGeom { start: Point::new(30.0, 50.0), end: Point::new(60.0, 50.0), aspect: 1.0, focal: None }), + ..ramp.clone() + }; + for (style, params) in [("arc", json!({"bend": 50})), ("fisheye", json!({"bend": 60})), ("rise", json!({"bend": 40, "horizontal": 50}))] { + let e = fx(&format!("warp.{style}"), params); + let (got, want) = (draw(&placed, e.clone()), draw(&ramp, e)); + let mut worst = 0; + for y in 0..100 { + for x in 0..100 { + let (a, b) = (got.pixel(x, y), want.pixel(x, y)); + worst = worst.max((0..3).map(|i| a[i].abs_diff(b[i])).max().unwrap_or(0)); + } + } + assert!(worst <= 3, "warp.{style}: worst channel difference {worst}"); + } +} + #[test] fn darkness_subtracts_from_rgb_before_shadow_opacity() { for opacity in [50.0, 100.0] { diff --git a/docs/mcp.md b/docs/mcp.md index d3fe607e7..4d31a69c4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2283,7 +2283,8 @@ counts as a value left out except for `recolor.apply`'s `model` and a `map` row' Groups and layers carry fills, strokes and effects of their own, as in the reference app: their fills and strokes paint every member's geometry, and their effects apply to the members as one piece (one combined drop shadow; a -Transform or Warp moves or bends the whole group). Their Contents row (type: Characters) is a slot in the stack: +Transform or Warp moves or bends the whole group; under a Warp, the gradient placed on each path inside moves and scales +from that path's bounds to its warped bounds). Their Contents row (type: Characters) is a slot in the stack: fills and strokes above it paint over the members (characters), those below under them; new items go above it (on an image, below it). `appearance.moveItem {"from": "contents", "to": n}` puts the row above the bottom `n` items, and an item move takes diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cdb4efaa1..4ab6ae9c6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -539,6 +539,7 @@ since 2026-10-01. - **Warp Options (#1760):** Effect > Warp and existing Appearance effect links share the fifteen existing styles, original schematic previews, orientation choices and percent Bend/Distortion controls. Focused tests passed for real numeric entry and slider input, Preview/Cancel, immediate Enter/OK, mixed-style edits, target/slot/visibility preservation and Undo/Redo. Each applied style stays unchanged until an explicit Style choice. Original style-menu and live-preview captures were reviewed; their numeric values were supplied programmatically. Public references: [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) and [Using effects](https://helpx.adobe.com/illustrator/using/effects.html). Warp geometry, optional Properties integration and remembered defaults are unchanged; no broader parity claim. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. - **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged. +- **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles, a gradient placed with the Gradient tool moves and scales from the path's bounds before the warp to its bounds after it, as under Free Distort. This holds for an object's own Warp and for a fill's or stroke's own (a stroke uses its stroke-inflated bounds), on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradient follows its own bounds, a nested group's own fills follow the nested group's bounds, and the group's own fills follow the group's bounds. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (<https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rotate-objects-by-specific-angles.html>). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests. - **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged. From 00ac0ac5aeffb82a046110e076ddf34e1f0db4aa Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sun, 8 Nov 2026 09:27:55 -0800 Subject: [PATCH 126/160] Tests: placed gradients under Warp on strokes, symbol art and compound shapes Covers the other places a Warp now moves placed gradients: a group's and a nested group's own strokes and a compound path's stroke (with the stroke-inflated bounds), the art of a symbol instance and the area of a compound shape with a Warp, and a stroke's own Warp on the canvas. A new test checks that Arc's no-bend threshold for gradients matches the one the warp uses, and the no-bend test now covers all fifteen styles. The render test's Fisheye case, which left the square's bounds as they were, is replaced by a vertical Bulge, and each style there is checked to move the outline's left or right edge. The Object > Expand test checks that it measures the ramp and not an edge of the outline. The params text and docs/mcp.md now state which bounds each gradient follows, and that a warp moves them with a bend or a distortion. --- crates/effects/src/bake/tests.rs | 2 +- crates/effects/src/catalog.rs | 2 +- crates/effects/src/group/tests.rs | 68 ++++++++++++++++++++++++---- crates/effects/src/tests.rs | 18 ++++++++ crates/effects/src/warp.rs | 4 +- crates/engine/src/tests/objexpand.rs | 12 +++-- crates/render/src/tests/objectfx.rs | 61 ++++++++++++++++++++++++- docs/mcp.md | 6 ++- docs/target-app-parity.md | 2 +- 9 files changed, 154 insertions(+), 21 deletions(-) diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index 1829f3834..e95fc29b7 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -284,7 +284,7 @@ fn a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place() { let geom = GradientGeom { start: Point::new(13.7, 31.3), end: Point::new(91.1, 47.9), aspect: 1.0, focal: None }; let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) })); let mut drift = 0; - let cases = ["arc", "bulge", "flag", "wave", "rise", "fisheye", "inflate", "squeeze", "twist"].map(|s| (s, 0.0)); + let cases = crate::WARP_STYLES.map(|(s, _)| (s, 0.0)); for (k, (style, bend)) in cases.into_iter().chain([("arc", 1e-5), ("arc", -1e-5)]).enumerate() { let k = k as f64; let oval = Rect::new(10.1 + k * 0.37, 20.3 - k * 1.13, 97.7 + k * 3.31, 71.9 + k * 0.71); diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index cad3cd2ed..c5a82cb79 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -109,7 +109,7 @@ fn lengths_of(id: &str) -> Lengths { } } -const WARP_DOC: &str = "{bend: % (-100..100, 50; placed gradients move and scale from each path's bounds to its warped bounds), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\"}"; +const WARP_DOC: &str = "{bend: % (-100..100, 50; with a bend or a distortion, placed gradients move and scale from the object's bounds to its warped bounds), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\"}"; const DT: &[&str] = &["Effect", "Distort & Transform"]; diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 064c64438..9c7e2a31a 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -349,6 +349,42 @@ fn shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform() { assert!((out.geometric_bounds().unwrap().x0 - 30.0).abs() < 1e-6, "the art moves"); } +/// Under a Warp effect, the gradient placed on a symbol instance's art's own fill follows the art's +/// bounds, and the gradient placed on a compound shape follows its area's bounds, each from before +/// the warp to after it, in the art Pathfinder takes. +#[test] +fn shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + let geom = GradientGeom { start: (2.0, 3.0).into(), end: (9.0, 3.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })); + let follow = |p: Point, from: Rect, to: Rect| { + Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height()) + }; + let check = |out: &Node, from: Rect, what: &str| { + let to = out.geometric_bounds().unwrap(); + assert_ne!(from, to, "{what}: the warp changes the bounds"); + let Paint::Gradient(g) = out.appearance.fill_paint() else { panic!("{what}: a gradient: {:?}", out.appearance) }; + let got = g.geom.unwrap(); + let (s, e) = (follow(geom.start, from, to), follow(geom.end, from, to)); + assert!(got.start.distance(s) < 1e-6 && got.end.distance(e) < 1e-6, "{what}: {got:?}, want {s:?} {e:?}"); + }; + for style in ["arc", "rise", "bulge"] { + let warp = || Effect { id: format!("warp.{style}"), params: serde_json::json!({"bend": 40.0}), visible: true }; + let mut art = Node::group(NodeId(20), vec![sq(21, 0.0, Color::BLACK)]); + art.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(placed.clone()))); + let hook = move |_: &Node| Some(art.clone()); + let mut instance = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY }); + instance.appearance.effects.push(warp()); + check(&shape_art(&instance, Some(&hook)).unwrap(), Rect::new(0.0, 0.0, 10.0, 10.0), &format!("warp.{style} on a symbol instance")); + let mut shape = compound_shape(ShapeMode::Add); + shape.appearance = Appearance::basic(placed.clone(), Paint::None, 0.0).into(); + shape.appearance.effects.push(warp()); + check(&shape_art(&shape, None).unwrap(), Rect::new(0.0, 0.0, 15.0, 10.0), &format!("warp.{style} on a compound shape")); + } +} + #[test] fn a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths() { use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; @@ -412,9 +448,10 @@ fn a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds() } } -/// Under a group's Warp effect, the group's own placed gradient follows the group's bounds, a -/// nested group's own placed gradient follows the nested group's bounds, and a compound path's -/// placed gradient follows the compound path's bounds, each from before the warp to after it. +/// Under a group's Warp effect, the group's own placed gradients follow the group's bounds, a +/// nested group's own placed gradients follow the nested group's bounds, and a compound path's +/// placed gradients follow the compound path's bounds, each from before the warp to after it (a +/// stroke's: those bounds inflated by the stroke). #[test] fn a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds() { use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; @@ -422,7 +459,10 @@ fn a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); let geom = GradientGeom { start: (12.0, 4.0).into(), end: (26.0, 4.0).into(), aspect: 1.0, focal: None }; let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })); - let own_fill = |n: &mut Node| n.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(placed.clone()))); + let own_fill = |n: &mut Node| { + n.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(placed.clone()))); + n.appearance.items.push(AppearanceItem::Stroke(vectorcraft_doc::StrokeLayer::new(placed.clone(), 3.0))); + }; let follow = |p: Point, from: Rect, to: Rect| { Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height()) }; @@ -434,7 +474,7 @@ fn a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by Arc::new(Node::path(NodeId(7), shapes::rectangle(Rect::new(23.0, 3.0, 27.0, 7.0)), Appearance::default())), ]; let mut compound = Node::new(NodeId(3), NodeKind::Compound { children: ring, rule: FillRule::EvenOdd }); - compound.appearance = Appearance::basic(placed.clone(), Paint::None, 0.0).into(); + compound.appearance = Appearance::basic(placed.clone(), placed.clone(), 2.0).into(); let mut g = Node::group(NodeId(10), vec![Arc::new(inner), Arc::new(compound), sq(2, 40.0, Color::BLACK)]); own_fill(&mut g); let mut before = std::collections::HashMap::new(); @@ -460,19 +500,29 @@ fn a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by let Paint::Gradient(gp) = item.paint() else { continue }; let (from, to) = (before[&c.id], after[&c.id]); assert_ne!(from, to, "warp.{style} {:?}: the warp changes the bounds", c.id); + let stroke = match item { + AppearanceItem::Stroke(s) => Some(s), + AppearanceItem::Fill(_) => None, + }; + let (from, to) = match stroke { + Some(s) => (s.paint_bounds(from), s.paint_bounds(to)), + None => (from, to), + }; let got = gp.geom.unwrap(); let (s, e) = (follow(geom.start, from, to), follow(geom.end, from, to)); assert!( got.start.distance(s) < 1e-6 && got.end.distance(e) < 1e-6, - "warp.{style} {:?}: {got:?}, want {s:?} {e:?} ({from:?} to {to:?})", - c.id + "warp.{style} {:?} (stroke: {}): {got:?}, want {s:?} {e:?} ({from:?} to {to:?})", + c.id, + stroke.is_some() ); - checked.push(c.id); + checked.push((c.id, stroke.is_some())); } }); checked.sort(); checked.dedup(); - assert_eq!(checked, vec![NodeId(3), NodeId(5), NodeId(10)], "warp.{style}"); + let want: Vec<_> = [NodeId(3), NodeId(5), NodeId(10)].into_iter().flat_map(|id| [(id, false), (id, true)]).collect(); + assert_eq!(checked, want, "warp.{style}"); } } diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 0b3fc0b1c..623fff27a 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -180,6 +180,24 @@ fn warp_zero_bend_is_identity_ish() { } } +/// `warp::bends` says a warp moves placed gradients exactly when the warp moves the outline: +/// Arc leaves the outline as it is under a bend of 0.0001 % and moves it above that, and the other +/// styles move it at any bend. +#[test] +fn warp_bends_when_the_warp_moves_the_outline() { + // A star: Fisheye moves nothing on the edge of the box, where an ellipse or a rectangle lies. + let oval = shapes::star(Point::new(51.7, 34.3), 40.9, 17.3, 5, 7.0); + for (s, _) in WARP_STYLES { + let id = format!("warp.{s}"); + let flat = run(&id, json!({"bend": 0}), &oval); + for bend in [0.0, 1e-5, -1e-5, 0.9e-4, -0.9e-4, 1.1e-4, -1.1e-4] { + let p = json!({ "bend": bend }); + let moved = run(&id, p.clone(), &oval) != flat; + assert_eq!(crate::warp::bends(&id, &p), moved, "{id} at {bend} %"); + } + } +} + #[test] fn warp_arc_bends_the_top_edge() { let out = run("warp.arc", json!({"bend": 50}), &square()); diff --git a/crates/effects/src/warp.rs b/crates/effects/src/warp.rs index 8c887c0ff..84f196a8b 100644 --- a/crates/effects/src/warp.rs +++ b/crates/effects/src/warp.rs @@ -34,7 +34,9 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) - } /// Does warp effect `id` with params `p` move any point? Every style leaves the box as it is with -/// no bend and no distortion, and Arc (`warp.arc`) takes a bend under 0.0001 % as none. +/// no bend and no distortion, and Arc (`warp.arc`) takes a bend under 0.0001 % as none: the +/// threshold must match the one in [`warp_point`]'s Arc case (`warp_bends_when_the_warp_moves_the_outline` +/// tests both sides of it). pub(crate) fn bends(id: &str, p: &Value) -> bool { let share = |key: &str, default: f64| num(p, key, default).clamp(-100.0, 100.0) / 100.0; let bend = share("bend", 50.0); diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 657e0dd00..57b5aeeeb 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -194,10 +194,14 @@ fn a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it() { let want = b.x0 + (115.0 - 40.0) / 120.0 * b.width(); assert!((want - 115.0).abs() > 2.0, "{effect} {params}: the warp moves the midpoint ({b:?})"); let red = |x: usize| f64::from(after.pixels[(100 * 200 + x) * 4]); - let half = (b.x0.ceil() as usize + 1..b.x1.floor() as usize - 1) - .find(|&x| red(x) >= 127.5 && red(x + 1) < 127.5) - .map(|x| x as f64 + 0.5 + (red(x) - 127.5) / (red(x) - red(x + 1))) - .unwrap(); + // White turns dark once on row 100, and gradually, so the crossing is the ramp's midpoint + // and not an edge of the outline. + let crossings: Vec<usize> = + (b.x0.ceil() as usize + 3..b.x1.floor() as usize - 4).filter(|&x| red(x) >= 127.5 && red(x + 1) < 127.5).collect(); + assert_eq!(crossings.len(), 1, "{effect} {params}: white turns dark once on row 100: {crossings:?}"); + let x = crossings[0]; + assert!(red(x - 2) - red(x + 3) < 100.0, "{effect} {params}: the ramp, not an edge: {} to {}", red(x - 2), red(x + 3)); + let half = x as f64 + 0.5 + (red(x) - 127.5) / (red(x) - red(x + 1)); assert!((half - want).abs() < 1.0, "{effect} {params} on item {item}: the midpoint is at {half}, want {want}"); } } diff --git a/crates/render/src/tests/objectfx.rs b/crates/render/src/tests/objectfx.rs index 304ae2160..e6054b722 100644 --- a/crates/render/src/tests/objectfx.rs +++ b/crates/render/src/tests/objectfx.rs @@ -874,9 +874,20 @@ fn a_paths_gradient_moves_and_scales_with_its_warped_bounds() { geom: Some(GradientGeom { start: Point::new(30.0, 50.0), end: Point::new(60.0, 50.0), aspect: 1.0, focal: None }), ..ramp.clone() }; - for (style, params) in [("arc", json!({"bend": 50})), ("fisheye", json!({"bend": 60})), ("rise", json!({"bend": 40, "horizontal": 50}))] { - let e = fx(&format!("warp.{style}"), params); + let styles = [ + ("arc", json!({"bend": 50})), + ("bulge", json!({"bend": 60, "orientation": "vertical"})), + ("rise", json!({"bend": 40, "vertical": 50, "orientation": "vertical"})), + ]; + for (style, params) in styles.clone() { + let e = fx(&format!("warp.{style}"), params.clone()); let (got, want) = (draw(&placed, e.clone()), draw(&ramp, e)); + // The warp moves the outline's left or right edge, so a gradient left in place would not + // run from edge to edge. + let square = vectorcraft_geom::Rect::new(30.0, 35.0, 60.0, 65.0); + let path = shapes::rectangle(square); + let b = vectorcraft_effects::apply_geometry(&[fx(&format!("warp.{style}"), params.clone())], &path, square).bounds().unwrap(); + assert!((b.x0 - 30.0).abs() > 0.5 || (b.x1 - 60.0).abs() > 0.5, "warp.{style}: the outline spans {b:?}"); let mut worst = 0; for y in 0..100 { for x in 0..100 { @@ -886,6 +897,52 @@ fn a_paths_gradient_moves_and_scales_with_its_warped_bounds() { } assert!(worst <= 3, "warp.{style}: worst channel difference {worst}"); } + // A stroke's own Warp: the canvas draws its gradient moved and scaled from the stroke-inflated + // bounds before the warp to those after it, as on the warped path with that gradient. + let geom = GradientGeom { start: Point::new(35.0, 50.0), end: Point::new(55.0, 50.0), aspect: 1.0, focal: None }; + let stroked = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp.clone() })); + let square = vectorcraft_geom::Rect::new(30.0, 35.0, 60.0, 65.0); + let follow = |p: Point, from: vectorcraft_geom::Rect, to: vectorcraft_geom::Rect| { + Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height()) + }; + for (style, params) in styles { + let mut n = Node::path(NodeId(0), shapes::rectangle(square), Appearance::basic(Paint::None, stroked.clone(), 6.0)); + let Some(AppearanceItem::Stroke(layer)) = n.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))).cloned() else { + panic!("a stroke") + }; + let Some(item) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) else { panic!("a stroke") }; + item.effects_mut().push(fx(&format!("warp.{style}"), params)); + let d = doc_with(Document::new(100.0, 100.0), n); + let live = render(&d); + let baked = vectorcraft_effects::bake_document(&d).unwrap(); + let mut warped = None; + for l in &baked.layers { + l.walk(&mut |c: &Node| { + if let (Some(path), true) = (c.path_data(), c.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_)))) { + warped.get_or_insert_with(|| path.clone()); + } + }); + } + let path = warped.unwrap(); + let b = vectorcraft_geom::Shape::bounding_box(&path.to_bezpath()); + assert_ne!(b, square, "warp.{style}: the warp changes the bounds"); + let (from, to) = (layer.paint_bounds(square), layer.paint_bounds(b)); + let moved = GradientGeom { start: follow(geom.start, from, to), end: follow(geom.end, from, to), ..geom }; + let want_node = Node::path( + NodeId(0), + path, + Appearance::basic(Paint::None, Paint::Gradient(Box::new(GradientPaint { geom: Some(moved), ..ramp.clone() })), 6.0), + ); + let want = render(&doc_with(Document::new(100.0, 100.0), want_node)); + let mut worst = 0; + for y in 0..100 { + for x in 0..100 { + let (a, b) = (live.pixel(x, y), want.pixel(x, y)); + worst = worst.max((0..3).map(|i| a[i].abs_diff(b[i])).max().unwrap_or(0)); + } + } + assert!(worst <= 3, "warp.{style} on the stroke: worst channel difference {worst}"); + } } #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 4d31a69c4..163f272ed 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2283,8 +2283,10 @@ counts as a value left out except for `recolor.apply`'s `model` and a `map` row' Groups and layers carry fills, strokes and effects of their own, as in the reference app: their fills and strokes paint every member's geometry, and their effects apply to the members as one piece (one combined drop shadow; a -Transform or Warp moves or bends the whole group; under a Warp, the gradient placed on each path inside moves and scales -from that path's bounds to its warped bounds). Their Contents row (type: Characters) is a slot in the stack: +Transform or Warp moves or bends the whole group). Under a group's Warp, each placed gradient moves and scales from +bounds before the warp to bounds after it: a path's or compound path's gradients use its own bounds, a nested group's +own fills and strokes use the nested group's bounds, and the group's own fills and strokes use the group's bounds; a +stroke uses those bounds inflated by the stroke. Their Contents row (type: Characters) is a slot in the stack: fills and strokes above it paint over the members (characters), those below under them; new items go above it (on an image, below it). `appearance.moveItem {"from": "contents", "to": n}` puts the row above the bottom `n` items, and an item move takes diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4ab6ae9c6..6f2af0228 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -537,9 +537,9 @@ since 2026-10-01. - **Selected Mirror Repeat handles:** a headless canvas regression creates the default mirror repeat and drags its Top bounding-box handle where the axis crosses it; the repeat grows vertically while its width stays fixed. Existing mirror-axis checks cover center movement and end-handle hit priority. The focused UI mirror tests pass. Scores and hours unchanged. - **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes, the distances of its later effects and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). Not yet: object effects after a Transform effect keep their distances. Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged. - **Warp Options (#1760):** Effect > Warp and existing Appearance effect links share the fifteen existing styles, original schematic previews, orientation choices and percent Bend/Distortion controls. Focused tests passed for real numeric entry and slider input, Preview/Cancel, immediate Enter/OK, mixed-style edits, target/slot/visibility preservation and Undo/Redo. Each applied style stays unchanged until an explicit Style choice. Original style-menu and live-preview captures were reviewed; their numeric values were supplied programmatically. Public references: [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) and [Using effects](https://helpx.adobe.com/illustrator/using/effects.html). Warp geometry, optional Properties integration and remembered defaults are unchanged; no broader parity claim. +- **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles with a bend or a distortion, a gradient placed with the Gradient tool moves and scales from the object's bounds before the warp to its bounds after it, as under Free Distort; a stroke's gradient uses those bounds inflated by the stroke. This holds for an object's own Warp and for a fill's or stroke's own, on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradients follow its own bounds, a nested group's own fills and strokes follow the nested group's bounds, and the group's own fills and strokes follow the group's bounds. In the art Pathfinder and compound shapes take, a symbol instance's Warp moves the gradients of its symbol's art by the art's bounds, and a compound shape's Warp moves its gradients by the bounds of its area. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `tests::warp_bends_when_the_warp_moves_the_outline`, `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`, `group::tests::shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. - **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged. -- **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles, a gradient placed with the Gradient tool moves and scales from the path's bounds before the warp to its bounds after it, as under Free Distort. This holds for an object's own Warp and for a fill's or stroke's own (a stroke uses its stroke-inflated bounds), on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradient follows its own bounds, a nested group's own fills follow the nested group's bounds, and the group's own fills follow the group's bounds. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (<https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rotate-objects-by-specific-angles.html>). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests. - **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged. From 8fc6fe00ea9088f65d05fb8f974b098f523018ae Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sun, 8 Nov 2026 09:43:30 -0800 Subject: [PATCH 127/160] UI: bottom-bar tests read the viewport menu floor and hide the Help Bar with the status bar --- crates/ui-egui/src/chrome/tests.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 04427c61c..322ebd075 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -95,7 +95,7 @@ fn help_bar_and_status_bar_render_independently() { } assert_eq!(shown.iter().any(|t| t == "?"), help && screen_mode < 3, "{help}/{status}/{screen_mode}: {shown:?}"); assert_eq!(shown.iter().any(|t| t.contains("Saved recovery data")), status && screen_mode < 3, "{help}/{status}/{screen_mode}: {shown:?}"); - let floor = ctx.data(|data| data.get_temp::<f32>(egui::Id::unique("menu_floor"))).unwrap(); + let floor = ctx.data(|data| data.get_temp::<f32>(egui::Id::unique("menu_floor").with(ctx.viewport_id()))).unwrap(); let expected = if screen_mode < 3 && (help || status) { let id = parent.unwrap().with(if help { "hint_bar" } else { "status_bar" }); egui::containers::panel::PanelState::load(&ctx, id).unwrap().outer_rect.top() @@ -1973,7 +1973,8 @@ fn menus_stop_above_a_control_bar_docked_at_the_bottom() { let ctx = egui::Context::default(); let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); let floor = |app: &mut crate::VectorcraftApp, status: bool, bottom: bool, screen_mode: u8| { - (app.ui.status_bar, app.ui.control_bar, app.ui.control_bar_bottom, app.ui.screen_mode) = (status, true, bottom, screen_mode); + (app.ui.status_bar, app.ui.help_bar, app.ui.control_bar, app.ui.control_bar_bottom, app.ui.screen_mode) = + (status, status, true, bottom, screen_mode); for _ in 0..3 { let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| { app.logic(ui.ctx()); From 65d1afdf263999695eeb345bed176ef572297558 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sun, 8 Nov 2026 09:56:29 -0800 Subject: [PATCH 128/160] i18n: Help Bar rows for Hindi and Hungarian --- crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + 2 files changed, 2 insertions(+) diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 0f212f4fd..df119c557 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2041,6 +2041,7 @@ Help सहायता Help › Search Commands… मदद › खोज कमांड… HEX HEX + Help Bar मदद बार Hex by Column कॉलम द्वारा हेक्स Hex by Row पंक्ति द्वारा हेक्स Hexagon षट्भुज diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a400c495f..3cb9b8ff5 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6400,6 +6400,7 @@ breakText Glyphs Glifák Create Swatch Legend Színmintajelmagyarázat létrehozása Create Swatch Legend… Színmintajelmagyarázat létrehozása… HEX HEX + Help Bar Súgósáv # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Szövegeszközök Text Tweaker… Szöveghangoló… From 5a4772d24a38ca8429b006edb3a51e3af75dabc2 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:10:17 +0100 Subject: [PATCH 129/160] Reject non-digit object ids in MCP object resource URIs Found while testing crates/mcp/src/resources.rs:191 (read_object) Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- crates/mcp/src/resources.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/mcp/src/resources.rs b/crates/mcp/src/resources.rs index 704c3392e..50792de6d 100644 --- a/crates/mcp/src/resources.rs +++ b/crates/mcp/src/resources.rs @@ -188,6 +188,10 @@ fn decode(s: &str) -> String { /// caller turns it into `-32602`); other failures (no document, an unreachable app) stay /// backend errors. fn read_object(b: &mut dyn Backend, id: &str) -> Result<Option<Value>, String> { + // `parse` alone would accept a leading `+`. + if id.is_empty() || !id.bytes().all(|c| c.is_ascii_digit()) { + return Ok(None); + } let Ok(id) = id.parse::<u64>() else { return Ok(None) }; match b.call("engine.execute", json!({"command": "document.node", "params": {"id": id, "summary": true}})) { Ok(v) => Ok(Some(v)), @@ -268,6 +272,17 @@ mod tests { assert!(matches!(read(empty.as_mut(), "vectorcraft://object/1"), Err(ReadError::Backend(_)))); } + #[test] + fn object_id_must_be_plain_digits() { + let mut b = Box::new(crate::Headless::with_document()); + let made = b.call("engine.execute", json!({"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 50, "height": 40}})).unwrap(); + let id = made["id"].as_i64().expect("shape.rectangle returns an integer id"); + for uri in [format!("vectorcraft://object/+{id}"), format!("vectorcraft://object/-{id}")] { + assert!(matches!(read(b.as_mut(), &uri), Err(ReadError::Invalid(_))), "{uri}"); + } + assert!(read(b.as_mut(), &format!("vectorcraft://object/{id}")).is_ok()); + } + #[test] fn an_object_is_readable_once_it_exists() { let mut b = Box::new(crate::Headless::with_document()); From b23bac473398d22917b2a18cdf229ec6047d6fa0 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sun, 8 Nov 2026 12:22:17 -0800 Subject: [PATCH 130/160] i18n: Hindi rows for the Keep Colors trace messages --- crates/ui-egui/src/i18n/hi.tsv | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index a9523b13c..72d21b030 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -7403,6 +7403,11 @@ @msg the image is fully transparent इमेज पूरी तरह से पारदर्शी है @msg the image is not on an artboard; pass rect इमेज एक आर्टबोर्ड पर नहीं है; सही होकर गुजरना @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. Flat लोगो मोड 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. कुछ भी ट्रेस नहीं हुआ: Ignore रंग 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 the image's pixels are missing इमेज के पिक्सेल लापता हैं @msg the image's pixels aren't in the document इमेज के पिक्सेल डॉक्यूमेंट में नहीं हैं @msg the image's pixels can't be read इमेज के पिक्सेल को पढ़ा नहीं जा सकता है From 3146c7143df5d84da8ea49af9ac6a0c7ee186ef8 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sun, 8 Nov 2026 12:27:51 -0800 Subject: [PATCH 131/160] i18n: Hindi names the Anti-aliasing Method and its choices Hindi became a complete catalog on main, and the merge gave it only the antiAlias message row. It now has the Anti-aliasing Method label, the Sharp method's antiAlias row, Crisp and Strong, so the dropdown no longer shows English or the Contour tool's corner word for Sharp. A test checks that every complete catalog has these rows. --- crates/ui-egui/src/i18n/hi.tsv | 4 ++++ crates/ui-egui/src/i18n/tests.rs | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index be3a451cf..3826544b0 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -343,6 +343,10 @@ Anti-Alias एंटी-एलियास Anti-alias Rasters रास्टर एंटी-एलियास करें Anti-aliased Artwork एंटी-एलियास किया आर्टवर्क + Anti-aliasing Method एंटी-एलियासिंग विधि +antiAlias Sharp शार्प + Crisp क्रिस्प + Strong स्ट्रॉन्ग Anti-aliasing: एंटी-एलियासिंग: Any except extracting pages पेज निकालने के अलावा सभी Any Kind किसी भी प्रकार का diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 40dab2ea0..053456320 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -194,6 +194,19 @@ fn marquee_selection_labels_are_in_complete_catalogs() { } } +/// The Character panel's Anti-aliasing Method label and its choices, with the Sharp method's +/// own `antiAlias` row apart from the plain "Sharp" corner row. +#[test] +fn anti_aliasing_method_labels_are_in_complete_catalogs() { + for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) { + let cat = lang.catalog(); + for label in ["Anti-aliasing Method", "Crisp", "Strong"] { + assert!(cat.plain(label).is_some(), "{} is missing {label}", lang.code); + } + assert!(cat.contextual("antiAlias", "Sharp").is_some(), "{} is missing the antiAlias Sharp row", lang.code); + } +} + /// Each language counts with its own plural rule: the form a count picks (`one`, then `few`, then /// `other`/`many`). #[test] From 0de0cb7cb9f334f8eeeaea4346a98e58d87a8f46 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 9 Nov 2026 03:38:46 +0700 Subject: [PATCH 132/160] Image Trace: Ignore Color can leave out only the background Squashed from #3952 onto main. --- crates/engine/src/cmd/buildcmds.rs | 2 +- crates/engine/src/tests/build.rs | 36 +++++++++ crates/trace/src/layers.rs | 15 ++-- crates/trace/src/lib.rs | 2 + crates/trace/src/params.rs | 5 ++ crates/trace/src/pipeline.rs | 101 ++++++++++++++++++++++--- crates/trace/src/tests.rs | 116 +++++++++++++++++++++++++++++ docs/image-trace-adjustments.md | 7 ++ docs/target-app-parity.md | 1 + 9 files changed, 269 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 1b37ca96f..2aa759411 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> { "Make", ["Object", "Image Trace"], None, - "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), 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), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), 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, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; 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 while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, 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; Flat Logo names only the size it traced a larger image at)}", + "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), 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), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), 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), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), 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, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; 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 while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, 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; Flat Logo names only the size it traced a larger image at)}", has_selection_or_ids, |s, p| trace_make(s, p, false) ), diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index a94696d16..f686d80dc 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -3012,6 +3012,42 @@ fn a_trace_run_hands_its_trace_to_the_command() { assert!((bb.center().x - 160.0).abs() < 1.5, "traced where the image is now: {bb:?}"); } +#[test] +fn image_trace_ignore_background_keeps_the_color_inside_the_art() { + // A black field, a light panel on it and a black ring on the panel. + let art = vectorcraft_trace::Raster::from_fn(200, 150, |x, y| { + let r = (f64::from(x) + 0.5 - 100.0).hypot(f64::from(y) + 0.5 - 75.0); + if !((20..180).contains(&x) && (20..130).contains(&y)) || (37.0..=43.0).contains(&r) { [0, 0, 0, 255] } else { [240, 240, 220, 255] } + }); + let traced = |background: bool| { + let mut s = session(); + add_image(&mut s, &art, Affine::IDENTITY); + let params = json!({"params": {"mode": "color", "palette": "limited", "colors": 2, "ignoreWhite": true, "ignoreColor": "#000000", "ignoreBackground": background}}); + let r = s.execute("imageTrace.makeAndExpand", ¶ms).unwrap(); + let g = node(&s, NodeId(r["id"].as_u64().unwrap())); + let black: Vec<_> = g + .children() + .unwrap() + .iter() + .filter(|n| n.appearance.fill_paint() == Paint::solid(Color::rgb8(0, 0, 0))) + .map(|n| outline(n).0.bounds().unwrap()) + .collect(); + (r["colors"].clone(), black) + }; + // The whole color: the ring goes with the field. + let (colors, black) = traced(false); + assert_eq!(colors, 1); + assert!(black.is_empty(), "{black:?}"); + // The background only: the ring stays, and nothing black reaches the image's edge. + let (colors, black) = traced(true); + assert_eq!(colors, 2); + assert_eq!(black.len(), 1, "{black:?}"); + let bb = black[0]; + assert!((bb.x0 - 57.0).abs() < 1.5 && (bb.x1 - 143.0).abs() < 1.5 && (bb.y0 - 32.0).abs() < 1.5 && (bb.y1 - 118.0).abs() < 1.5, "{bb:?}"); + // The params doc tells agents about the option. + assert!(find_command("imageTrace.make").unwrap().params.contains("ignoreBackground: false")); +} + /// A Document Library trace made away from its command no longer applies once the library's /// colours change: the settings' JSON leaves them out (#4605 review). #[test] diff --git a/crates/trace/src/layers.rs b/crates/trace/src/layers.rs index d9b34cc54..f9c77fa67 100644 --- a/crates/trace/src/layers.rs +++ b/crates/trace/src/layers.rs @@ -1,7 +1,7 @@ use super::params::{Method, Mode, TraceParams}; #[cfg(test)] use super::pipeline::bw_tones; -use super::pipeline::{TracedPath, is_ignored}; +use super::pipeline::{TracedPath, drops_color, is_ignored}; use super::raster::Raster; use super::{ AtomicUsize, Crack, Loop, Ordering, PathData, Quantized, TRANSPARENT, TracedGradient, TracedShape, centerline, contour, fit, logo, shapes, @@ -13,6 +13,9 @@ use super::{denoise, quantize}; /// What tracing one colour layer needs. pub(crate) struct Layers<'a> { pub(crate) q: &'a Quantized, + /// The label each layer traces a pixel as: `q.labels`, or with Ignore Color for the background + /// only, those with the background [`TRANSPARENT`]. Edges are placed by `q.labels`. + pub(crate) traced: &'a [u16], /// Each palette entry's layer, bottom (0) up. pub(crate) rank: &'a [usize], pub(crate) params: &'a TraceParams, @@ -36,9 +39,10 @@ pub(crate) 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). + /// them, and must not reach under white that is left out). With `ignore_background` the + /// background is [`TRANSPARENT`] in `traced`, and the color's other areas are traced. 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)) + drops_color(self.params) && self.q.palette.get(label as usize).is_some_and(|c| is_ignored(*c, self.ignore)) } /// 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. @@ -270,7 +274,7 @@ impl Layers<'_> { let (thin, lines) = if params.strokes { // The ink the lines are read against: the layer's colour; Black and White's own black. let ink = if params.mode == Mode::BlackAndWhite { [self.bw_tones[usize::from(ci == 1)]; 3] } else { color.map(f64::from) }; - centerline::lines(&self.q.labels, u16::try_from(ci).unwrap_or(TRANSPARENT), w, h, params.stroke_width, self.img, ink, &self.style) + centerline::lines(self.traced, u16::try_from(ci).unwrap_or(TRANSPARENT), w, h, params.stroke_width, self.img, ink, &self.style) } else { (vec![], vec![]) }; @@ -288,7 +292,7 @@ impl Layers<'_> { let (bw, bh) = (x1 - x0, y1 - y0); let mut mask = Vec::with_capacity(bw * bh); for y in y0..y1 { - let row = self.q.labels.get(y * w + x0..y * w + x1)?; + let row = self.traced.get(y * w + x0..y * w + x1)?; mask.extend(row.iter().enumerate().map(|(k, &l)| l != TRANSPARENT && !thin.get(y * w + x0 + k).copied().unwrap_or(false) && part(l))); } // Overlapping: a part of the mask with no pixel of this color holds only colors above it, @@ -433,6 +437,7 @@ pub(crate) fn shift_probe(img: &Raster, params: &TraceParams) -> Vec<[f64; 4]> { let gradients = vec![None; n]; let job = Layers { q: &q, + traced: &q.labels, rank: &rank, params, img, diff --git a/crates/trace/src/lib.rs b/crates/trace/src/lib.rs index e68118d63..46c29f4e0 100644 --- a/crates/trace/src/lib.rs +++ b/crates/trace/src/lib.rs @@ -81,6 +81,8 @@ pub(crate) use layers::shift_probe; pub use params::{Method, Mode, PRESET_NAMES, Palette, TraceParams, enhanced_preset, preset, presets}; pub use pipeline::{TraceResult, TracedPath, trace, trace_watched, trace_within}; #[cfg(test)] +use pipeline::{clear_background, label_extents}; +#[cfg(test)] use raster::fit_size; pub use raster::{MAX_PIXELS, MAX_SOURCE_PIXELS, Raster, TraceError}; diff --git a/crates/trace/src/params.rs b/crates/trace/src/params.rs index 65e5bb5c0..ce805e1c7 100644 --- a/crates/trace/src/params.rs +++ b/crates/trace/src/params.rs @@ -91,6 +91,10 @@ 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<String>, + /// With Ignore Color: leave out only the areas of that color that reach the image's edge (a + /// background), through neighbors above, below and to either side, and trace the areas of it + /// that other colors enclose. (Flat Logo mode leaves out every white area.) + pub ignore_background: bool, /// Replace nearly-straight curves with straight lines. In Logo mode this also snaps tight arcs /// to true circles. pub snap_curves_to_lines: bool, @@ -140,6 +144,7 @@ impl Default for TraceParams { method: Method::Abutting, ignore_white: false, ignore_color: None, + ignore_background: false, snap_curves_to_lines: false, shapes: false, auto_grouping: false, diff --git a/crates/trace/src/pipeline.rs b/crates/trace/src/pipeline.rs index 486eb9c0a..79a1ef8ec 100644 --- a/crates/trace/src/pipeline.rs +++ b/crates/trace/src/pipeline.rs @@ -59,6 +59,79 @@ pub(crate) fn is_ignored(c: [u8; 3], target: Option<[u8; 3]>) -> bool { } } +/// Whether Ignore Color leaves out every area of its color (not only the background). +pub(crate) fn drops_color(params: &TraceParams) -> bool { + params.ignore_white && !params.ignore_background +} + +/// Ignore Color, background only: clears to [`TRANSPARENT`] every area of a label `ignored` leaves +/// out that reaches the image's edge, through neighbors above, below and to either side. It fills +/// one run of a row at a time, and fills from one edge pixel at a time. +pub(crate) fn clear_background(labels: &mut [u16], w: usize, h: usize, ignored: &[bool]) { + let (last_row, last_col) = (h.saturating_sub(1) * w, w.saturating_sub(1)); + let mut seeds = Vec::new(); + for edge in (0..w).flat_map(|x| [x, last_row + x]).chain((0..h).flat_map(|y| [y * w, y * w + last_col])) { + seeds.push(edge); + fill_left_out(labels, w, h, ignored, &mut seeds); + } +} + +/// Clears to [`TRANSPARENT`] the left-out areas that `seeds` reach, and empties `seeds`. +fn fill_left_out(labels: &mut [u16], w: usize, h: usize, ignored: &[bool], seeds: &mut Vec<usize>) { + let left_out = |labels: &[u16], i: usize| labels.get(i).is_some_and(|&l| ignored.get(l as usize).copied().unwrap_or(false)); + while let Some(i) = seeds.pop() { + if !left_out(labels, i) { + continue; + } + // The run of left-out pixels along the row through `i`. + let row = i - i % w; + let (mut a, mut b) = (i, i + 1); + while a > row && left_out(labels, a - 1) { + a -= 1; + } + while b < row + w && left_out(labels, b) { + b += 1; + } + if let Some(run) = labels.get_mut(a..b) { + run.fill(TRANSPARENT); + } + // A seed at the start of each run of left-out pixels next to it in the rows above and below. + for next in [row.checked_sub(w), Some(row + w).filter(|&r| r < w * h)].into_iter().flatten() { + let mut inside = false; + for x in a - row..b - row { + let here = left_out(labels, next + x); + if here && !inside { + seeds.push(next + x); + } + inside = here; + } + } + } +} + +/// A box of pixels: `(x0, y0, x1, y1)`, the far sides excluded. +type PixelBox = (usize, usize, usize, usize); + +/// Each of `n` palette entries' pixel count in `labels`, and the box its pixels lie in within +/// `traced` (`labels` with Ignore Color's background [`TRANSPARENT`]). A layer is traced within its +/// box, so a color whose background is left out is traced within the box of its remaining areas. +pub(crate) fn label_extents(labels: &[u16], traced: &[u16], w: usize, n: usize) -> (Vec<usize>, Vec<Option<PixelBox>>) { + let mut counts = vec![0usize; n]; + for &l in labels { + if let Some(c) = counts.get_mut(l as usize) { + *c += 1; + } + } + let mut boxes: Vec<Option<PixelBox>> = vec![None; n]; + for (i, &l) in traced.iter().enumerate() { + if let Some(b) = boxes.get_mut(l as usize) { + let (x, y) = (i % w, i / w); + *b = Some(b.map_or((x, y, x + 1, y + 1), |b| (b.0.min(x), b.1.min(y), b.2.max(x + 1), b.3.max(y + 1)))); + } + } + (counts, boxes) +} + /// 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 { @@ -160,16 +233,23 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize, steps: &( let keep: Vec<bool> = q.palette.iter().map(|c| rare.contains(c)).collect(); quantize::denoise_keeping(&mut q.labels, w, h, params.noise as usize, &keep); go_on()?; - // Pixel count per palette entry, and the box its pixels lie in. - let mut counts = vec![0usize; q.palette.len()]; - let mut boxes: Vec<Option<(usize, usize, usize, usize)>> = vec![None; q.palette.len()]; - for (i, &l) in q.labels.iter().enumerate() { - if let (Some(c), Some(b)) = (counts.get_mut(l as usize), boxes.get_mut(l as usize)) { - *c += 1; - let (x, y) = (i % w, i / w); - *b = Some(b.map_or((x, y, x + 1, y + 1), |b| (b.0.min(x), b.1.min(y), b.2.max(x + 1), b.3.max(y + 1)))); + // Ignore Color, background only: the labels traced, with the ignored color's areas that reach + // the edge left out. Its other areas are traced with the rest. Edges are still placed by + // `q.labels`, as when the whole color is left out. + let cleared = (params.ignore_white && params.ignore_background).then(|| { + let ignored: Vec<bool> = q.palette.iter().map(|&c| is_ignored(c, ignore)).collect(); + let mut labels = q.labels.clone(); + clear_background(&mut labels, w, h, &ignored); + labels + }); + // Whether any of a palette entry's pixels are left after the background. + let mut remains = vec![false; q.palette.len()]; + for &l in cleared.iter().flatten() { + if let Some(r) = remains.get_mut(l as usize) { + *r = true; } } + let (counts, boxes) = label_extents(&q.labels, cleared.as_deref().unwrap_or(&q.labels), w, q.palette.len()); // Layers bottom → top: largest area first. let mut order: Vec<usize> = (0..q.palette.len()).filter(|&i| counts[i] > 0).collect(); order.sort_by(|a, b| counts[*b].cmp(&counts[*a]).then(a.cmp(b))); @@ -191,10 +271,11 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize, steps: &( 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(); + let dropped = |ci: usize| params.ignore_white && is_ignored(q.palette[ci], ignore) && !remains.get(ci).copied().unwrap_or(false); + let layers: Vec<(usize, usize)> = order.iter().copied().enumerate().filter(|&(_, ci)| !dropped(ci)).collect(); let job = Layers { q: &q, + traced: cleared.as_deref().unwrap_or(&q.labels), rank: &rank, params, img, diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 93467b352..22090dd2d 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -1444,6 +1444,122 @@ fn ignore_color_leaves_out_the_colour_picked() { assert_eq!(serde_json::to_value(¶ms).unwrap()["ignoreColor"], serde_json::Value::Null); } +/// A 600 × 400 field of `edge` with a light panel (40..560 × 40..360) and, on the panel, a ring of +/// `line` 8 px wide and 100 px in radius around (300, 200), filled with `inside`. +fn panel_with_ring(edge: [u8; 4], line: [u8; 4], inside: [u8; 4]) -> Raster { + Raster::from_fn(600, 400, |x, y| { + let (fx, fy) = (f64::from(x) + 0.5, f64::from(y) + 0.5); + let r = Point::new(fx, fy).distance(Point::new(300.0, 200.0)); + if !((40.0..560.0).contains(&fx) && (40.0..360.0).contains(&fy)) { + edge + } else if (96.0..=104.0).contains(&r) { + line + } else if r < 96.0 { + inside + } else { + [240, 240, 220, 255] + } + }) +} + +#[test] +fn ignore_background_leaves_out_only_the_areas_of_the_color_that_reach_the_edge() { + use kurbo::Shape; + let covers = |res: &TraceResult, color: [u8; 3], x: f64, y: f64| { + res.paths.iter().any(|t| t.color == color && t.path.to_bezpath().winding(kurbo::Point::new(x, y)) != 0) + }; + let covered = |res: &TraceResult, x: f64, y: f64| res.paths.iter().any(|t| t.path.to_bezpath().winding(kurbo::Point::new(x, y)) != 0); + // A black field, a light panel and a black ring on it. + let light = [240, 240, 220, 255]; + let img = panel_with_ring(BLACK, BLACK, light); + for method in [Method::Abutting, Method::Overlapping] { + let params = TraceParams { + mode: Mode::Color, + palette: Palette::Limited, + colors: 2, + method, + ignore_white: true, + ignore_color: Some("#000000".into()), + ..TraceParams::default() + }; + // The whole color: the ring goes with the field. + let all = trace(&img, ¶ms); + assert!(all.paths.iter().all(|p| p.color != [0, 0, 0]), "{method:?}"); + assert!(!covered(&all, 300.0, 100.0) && !covered(&all, 10.0, 10.0), "{method:?}"); + // The background only: the field goes, the ring stays. + let res = trace(&img, &TraceParams { ignore_background: true, ..params.clone() }); + assert!(covers(&res, [0, 0, 0], 300.0, 100.0) && covers(&res, [0, 0, 0], 300.0, 300.0), "{method:?}"); + assert!(covers(&res, [240, 240, 220], 100.0, 200.0) && covers(&res, [240, 240, 220], 300.0, 200.0), "{method:?}"); + assert!(!covered(&res, 10.0, 10.0) && !covered(&res, 590.0, 390.0) && !covered(&res, 300.0, 20.0), "{method:?}"); + // Without Ignore Color, the setting has no effect. + let kept = |p: &TraceParams| trace(&img, &TraceParams { ignore_white: false, ..p.clone() }); + assert_eq!(kept(&TraceParams { ignore_background: true, ..params.clone() }), kept(¶ms), "{method:?}"); + } + // White, the default: a white field goes, the white inside a ring stays. + let img = panel_with_ring(WHITE, BLACK, WHITE); + let params = TraceParams { mode: Mode::Color, colors: 3, ignore_white: true, ignore_background: true, ..TraceParams::default() }; + let res = trace(&img, ¶ms); + assert!(covers(&res, [255, 255, 255], 300.0, 200.0) && !covered(&res, 10.0, 10.0)); + assert!(!covered(&trace(&img, &TraceParams { ignore_background: false, ..params }), 300.0, 200.0)); + // With no area of the color enclosed, the trace is the one that leaves out the whole color, + // its anti-aliased edges placed the same. + let img = three_colour_art(0.0); + for method in [Method::Abutting, Method::Overlapping] { + let params = TraceParams { colors: 3, method, ignore_white: true, ignore_color: Some("#14285a".into()), ..sixteen_colors() }; + assert_eq!(trace(&img, &TraceParams { ignore_background: true, ..params.clone() }), trace(&img, ¶ms), "{method:?}"); + } + // Old documents have no such setting: the whole color. + let old: TraceParams = serde_json::from_str(r#"{"mode":"color","ignoreWhite":true}"#).unwrap(); + assert!(!old.ignore_background); + let on: TraceParams = serde_json::from_str(r#"{"ignoreWhite":true,"ignoreBackground":true}"#).unwrap(); + assert!(on.ignore_background); +} + +#[test] +fn ignore_background_traces_a_color_within_the_box_of_its_remaining_areas() { + // A 40 × 30 field of color 0 with a ring of color 1 around a 2 × 2 area of color 0. + let (w, h) = (40, 30); + let mut labels = vec![0u16; w * h]; + for y in 10..16 { + for x in 20..26 { + labels[y * w + x] = 1; + } + } + for y in 12..14 { + for x in 22..24 { + labels[y * w + x] = 0; + } + } + let mut cleared = labels.clone(); + clear_background(&mut cleared, w, h, &[true, false]); + let (counts, boxes) = label_extents(&labels, &cleared, w, 2); + // The counts stay those of the whole image (they order the layers); the boxes are those of + // what is traced. + assert_eq!(counts, vec![w * h - 32, 32]); + assert_eq!(boxes, vec![Some((22, 12, 24, 14)), Some((20, 10, 26, 16))]); + // Without Ignore Color, the boxes are those of every pixel. + assert_eq!(label_extents(&labels, &labels, w, 2).1, vec![Some((0, 0, w, h)), Some((20, 10, 26, 16))]); +} + +#[test] +fn ignore_background_clears_a_tall_narrow_image_from_its_edges() { + // A 1 × 200,000 column of color 0 with color 1 at rows 1,000 to 1,009: every pixel is on the + // edge, so all of color 0 is background. + let (w, h) = (1, 200_000); + let mut labels = vec![0u16; w * h]; + for l in &mut labels[1_000..1_010] { + *l = 1; + } + clear_background(&mut labels, w, h, &[true, false]); + assert_eq!(labels.iter().filter(|&&l| l == TRANSPARENT).count(), h - 10); + assert!(labels[1_000..1_010].iter().all(|&l| l == 1)); + // In a 2 × 100,000 column of color 0, every pixel is on the edge too. + let (w, h) = (2, 100_000); + let mut labels = vec![0u16; w * h]; + clear_background(&mut labels, w, h, &[true, false]); + assert!(labels.iter().all(|&l| l == TRANSPARENT)); +} + #[test] fn snap_curves_to_lines_levels_a_rectangle_a_degree_askew() { // 120 × 60, turned 1.5° about its centre. diff --git a/docs/image-trace-adjustments.md b/docs/image-trace-adjustments.md index 92e5f42ac..7c9f0567c 100644 --- a/docs/image-trace-adjustments.md +++ b/docs/image-trace-adjustments.md @@ -51,6 +51,13 @@ traced fills and strokes take the library color without a link to its swatch. Th spot and tint swatches. Tints trace at the color shown by the tint swatch. Editing a swatch later does not recolor the trace. Colors from other swatch libraries are also unlinked. +**Ignore Color, background only.** Ignore Color leaves out every area of the chosen color. With +`"ignoreBackground": true` beside `"ignoreWhite": true` in the params of `imageTrace.make` or +`imageTrace.makeAndExpand`, it leaves out only the areas of that color that reach the image's edge, +joined through pixels above, below or to either side, and traces the areas of it that other colors +enclose: black line work inside a panel stays when the black around the panel is left out. Flat Logo +leaves out every white area whatever `ignoreBackground` is. + **Document Library** traces with the library's colors as they are. Each pixel takes the library color nearest to it in lightness and hue, with a difference in hue or chroma counted three times as much as one in lightness, so a gray or a muted brown takes a black, white or other neutral diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b7e9e7612..17b1704cc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1104,6 +1104,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, Document Library keeps neutral tones neutral:** each pixel takes the library color nearest in Oklab with differences in hue and chroma counted three times, so a gray or a muted brown traces as a neutral library color instead of a saturated one of similar lightness; a code-drawn test of gray and brown patches with a black, light and blue library in `crates/trace`. - **Image Trace, source image on hold:** while the eye button next to the Image Trace panel's View is held down, the canvas shows the selected Image Trace objects' source image whatever their View, and shows their View again on release, with no change to the document or the undo history. - **Image Trace, large images:** images over 1500 px on a side trace with a warning in the result and the status bar; the anchor limit is 8,000,000 (2,000,000 on the web), measured at about 90 bytes an anchor (see [quality notes](image-trace-quality.md#large-images)). +- **Image Trace, Ignore Color for the background only:** `"ignoreBackground": true` with Ignore Color leaves out only the areas of the ignored color that reach the image's edge and traces the areas of it that other colors enclose, in Black and White, Grayscale and Color; code-drawn tests in `crates/trace` and `crates/engine`. - **Image Trace, Flat Logo working size:** Flat Logo traces at most 1024 px on a side; a larger decoded raster (the 64-megapixel copy for a bigger image) is averaged down in whole-number blocks first, and the trace's `warnings` name the size it was traced at and the image's own size, in place of the slower-trace and 64-megapixel notes; code-drawn 1024 and 1025 px tests in `crates/trace` and `crates/engine`. - **Image Trace, Flat Logo working size:** Flat Logo traces at most 1024 px on a side; a larger image is averaged down by a whole factor first, and the trace's `warnings` name the size it was traced at and the image's own size, in place of the slower-trace and 64-megapixel notes; code-drawn 1024 and 1025 px tests in `crates/trace` and `crates/engine`. - **Image Trace, Preview:** the Image Trace panel's Preview checkbox, before Trace, is on by default, and a setting change traces the selected Image Trace object again at once. Off, changes wait in the panel until Trace applies them as one undo step, or until Preview is turned on again. Changes stay with the object they were made for while another object is selected. When Preview is turned on with that object not selected, the object is traced with its changes when it is selected again. Preview is kept with the UI preferences, and Reset to Default leaves it. The `imageTrace.preview` command turns it on or off. From 7b8d5ba8f5fc4f3b748cc841eb3835d6d83d8b80 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 9 Nov 2026 03:39:05 +0700 Subject: [PATCH 133/160] Type: Tab and Shift+Tab move a list item down and up a level Squashed from #5037 onto main. --- crates/cad/src/scene.rs | 6 +- crates/cad/src/tests.rs | 25 +- crates/doc/src/text.rs | 10 +- crates/doc/src/text/object.rs | 12 +- crates/doc/src/text/para_style.rs | 6 + crates/doc/src/text/tabs_lists.rs | 157 +++- crates/doc/src/text/tests.rs | 82 +- crates/engine/src/cmd/panelcmds.rs | 81 +- crates/engine/src/cmd/textedit.rs | 2 + crates/engine/src/cmd/threads.rs | 20 +- crates/engine/src/cmd/threads/tests.rs | 103 +++ crates/engine/src/cmd/typecmd.rs | 2 +- crates/engine/src/cmd/typecmd/area_tests.rs | 242 +++++- crates/engine/src/cmd/typemenu.rs | 6 +- crates/engine/src/tests/paragraphs.rs | 36 + crates/engine/src/tests/textedit.rs | 51 ++ crates/pdf/src/tests/realtext.rs | 15 + crates/svg/tests/it/text_layout.rs | 11 + crates/text/src/layout.rs | 31 +- crates/text/src/layout/breaking.rs | 17 +- crates/text/src/layout/flow.rs | 19 +- crates/text/src/lib.rs | 3 +- crates/text/src/tests.rs | 151 ++++ crates/text/src/thread.rs | 54 +- crates/text/src/thread/tests.rs | 34 + crates/tools/src/text.rs | 37 + crates/tools/src/text/tests.rs | 127 +++ crates/tools/src/text/tool.rs | 3 +- crates/ui-egui/src/dialogs.rs | 2 + .../ui-egui/src/dialogs/bullets_numbering.rs | 802 ++++++++++++++++++ crates/ui-egui/src/i18n/bg.tsv | 7 + crates/ui-egui/src/i18n/bs.tsv | 6 + crates/ui-egui/src/i18n/ca.tsv | 6 + crates/ui-egui/src/i18n/cs.tsv | 7 + crates/ui-egui/src/i18n/da.tsv | 7 + crates/ui-egui/src/i18n/de.tsv | 7 + crates/ui-egui/src/i18n/el.tsv | 7 + crates/ui-egui/src/i18n/es.tsv | 7 + crates/ui-egui/src/i18n/fi.tsv | 7 + crates/ui-egui/src/i18n/fr.tsv | 7 + crates/ui-egui/src/i18n/hu.tsv | 7 + crates/ui-egui/src/i18n/id.tsv | 7 + crates/ui-egui/src/i18n/it.tsv | 7 + crates/ui-egui/src/i18n/ja.tsv | 7 + crates/ui-egui/src/i18n/ka.tsv | 7 + crates/ui-egui/src/i18n/ko.tsv | 7 + crates/ui-egui/src/i18n/nl.tsv | 7 + crates/ui-egui/src/i18n/pl.tsv | 7 + crates/ui-egui/src/i18n/pt-br.tsv | 7 + crates/ui-egui/src/i18n/ru.tsv | 7 + crates/ui-egui/src/i18n/sv.tsv | 7 + crates/ui-egui/src/i18n/tr.tsv | 7 + crates/ui-egui/src/i18n/uk.tsv | 7 + crates/ui-egui/src/i18n/vi.tsv | 7 + crates/ui-egui/src/i18n/zh-hans.tsv | 7 + crates/ui-egui/src/i18n/zh-hant.tsv | 7 + crates/ui-egui/src/menus/run.rs | 1 + crates/ui-egui/src/menus/ui_commands.rs | 6 + crates/ui-egui/src/panels/paragraph.rs | 108 ++- crates/ui-egui/src/panels/paragraph/tests.rs | 30 + crates/ui-egui/src/panels/tabs.rs | 67 +- crates/ui-egui/src/panels/tabs/tests.rs | 33 +- docs/mcp.md | 52 +- docs/target-app-parity.md | 6 +- 64 files changed, 2465 insertions(+), 165 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/bullets_numbering.rs diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 72fa4f7b3..45ed99299 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -1227,10 +1227,10 @@ impl<'a> Scene<'a> { let Some(&first) = run.first() else { continue }; let lo = run.iter().map(|g| g.byte).min().unwrap_or(first.byte); let hi = run.iter().map(|g| g.byte + g.len).max().unwrap_or(lo); - // A right-to-left paragraph's marker is drawn with its tab first and its number's - // period at the number's left; the TEXT entity holds the characters in that order. + // A right-to-left paragraph's marker is drawn with its number's period at the + // number's left; the TEXT entity holds the characters in that order. let marker = chunk.first().and_then(|(_, _, m)| *m).map(|m| match m.text.strip_suffix('.') { - Some(number) if m.tab == m.glyphs.start => format!(".{number}"), + Some(number) if layout.glyphs.get(m.tab).is_some_and(|g| g.rtl) => format!(".{number}"), _ => m.text.clone(), }); let Some(s) = marker.as_deref().or_else(|| plain.get(lo..hi).map(str::trim_end)).filter(|s| !s.trim().is_empty()) else { continue }; diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 5c57fa634..90e4cbc2c 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -1076,15 +1076,15 @@ fn a_bullet_is_text_of_its_own_before_its_paragraph() { /// A numbered list paragraph's number and period are a TEXT entity of their own where they are /// drawn, in the order they are drawn: in a right-to-left paragraph the period comes first, at the -/// number's left. +/// number's left, with the marker aligned left or right. #[test] fn a_number_is_text_of_its_own_before_its_paragraph() { - use vectorcraft_doc::text::{NumberStyle, ParaList}; - let numbered = |text: &str, count: u32| { + use vectorcraft_doc::text::{ListAlign, NumberStyle, ParaList}; + let numbered_aligned = |text: &str, count: u32, align: ListAlign| { let mut d = text_doc(text, 20.0); let id = d.layers[0].children().unwrap()[0].id; if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { - t.para.list = Some(ParaList::numbered(NumberStyle::Arabic)); + t.para.list = Some(ParaList::numbered(NumberStyle::Arabic).aligned(align)); t.para.left_indent = 30.0; t.para.first_line_indent = -30.0; t.list_count = count; @@ -1092,10 +1092,27 @@ fn a_number_is_text_of_its_own_before_its_paragraph() { let out = export(&d, &DxfOptions { version: DxfVersion::R2018, preserve: Preserve::Editability, ..opts(&d) }).unwrap(); entities(&out).iter().map(|e| (get(e, 1).map(str::to_string), all(e, 10)[0])).collect::<Vec<_>>() }; + let numbered = |text: &str, count: u32| numbered_aligned(text, count, ListAlign::Left); assert_eq!(numbered("Item", 11), [(Some("12.".to_string()), 10.0), (Some("Item".to_string()), 40.0)]); let rtl = numbered("שלום", 11); assert_eq!(rtl.iter().map(|(s, _)| s.clone().unwrap_or_default()).collect::<Vec<_>>(), ["שלום", ".12"]); assert!(rtl[1].1 > rtl[0].1, "the number right of the text: {rtl:?}"); + // Aligned right, the number keeps its order and moves right, against the text. + let right = numbered_aligned("Item", 11, ListAlign::Right); + assert_eq!((right[0].0.as_deref(), right[1].clone()), (Some("12."), (Some("Item".to_string()), 40.0))); + assert!(right[0].1 > 10.0 && right[0].1 < 40.0, "{right:?}"); + let rtl = numbered_aligned("שלום", 11, ListAlign::Right); + assert_eq!(rtl.iter().map(|(s, _)| s.clone().unwrap_or_default()).collect::<Vec<_>>(), ["שלום", ".12"]); + // A mixed paragraph: the period follows the paragraph's direction, not the script of the + // words it holds, with either alignment. + let marker = |text: &str, align: ListAlign| { + let out = numbered_aligned(text, 11, align); + out.into_iter().filter_map(|(s, _)| s).find(|s| s.contains("12")).unwrap_or_default() + }; + for align in [ListAlign::Left, ListAlign::Right] { + assert_eq!(marker("Item שלום", align), "12.", "{align:?}"); + assert_eq!(marker("שלום Item", align), ".12", "{align:?}"); + } } /// Area type that Shrink Text to Fit scales down is written at the height it draws at. diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 11be20ee2..9f900629d 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -27,7 +27,10 @@ pub use para_style::{ ParaStyle, SingleWordJustify, }; pub use runs::{INLINE_CHAR, INLINE_MAX_SCALE, InlineArt, RubyAlign, RubyPosition, RubySpan, TextKind, TextRun, TextStyleDef}; -pub use tabs_lists::{Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, NumberStyle, ParaList, TabAlign, TabStop}; +pub use tabs_lists::{ + Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, LIST_LEVEL_INDENT, LIST_LEVELS, ListAlign, ListCounts, NumberStyle, ParaList, TabAlign, + TabStop, +}; pub use wrap::{resolve_wrap, wrap_work}; /// A text object. `runs` split into paragraphs at `\n`. @@ -101,6 +104,11 @@ pub struct TextObject { /// file saves the count only when it is not 0. #[serde(default, rename = "listCount", skip_serializing_if = "crate::skip::is_default")] pub list_count: u32, + /// Threaded text: as [`Self::list_count`], the counts the earlier frames carry into this frame + /// at list levels 2 on, level 2 first ([`ListCounts::from_parts`]). Empty when they are all 0, + /// and the file saves them only when not empty. A file's longer list opens with the first 8. + #[serde(default, rename = "listLevelCounts", skip_serializing_if = "Vec::is_empty", deserialize_with = "tabs_lists::read_level_counts")] + pub list_level_counts: Vec<u32>, /// Cached layout bounds in text space, filled in by the layout engine (not serialized). #[serde(skip)] pub cached_bounds: Option<Rect>, diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs index 70e953922..7d226aa3e 100644 --- a/crates/doc/src/text/object.rs +++ b/crates/doc/src/text/object.rs @@ -2,7 +2,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::tabs_lists::ParaList; +use super::tabs_lists::{ListCounts, ParaList}; use super::{Affine, PathData, Point, Rect, TextObject, TextStyleScale, Vec2}; impl TextObject { @@ -93,10 +93,20 @@ impl TextObject { system_layout: false, fractional_widths: true, list_count: 0, + list_level_counts: Vec::new(), cached_bounds: None, cached_baselines: Vec::new(), } } + /// The list counts the story's earlier frames carry into this frame at each level + /// ([`Self::list_count`] and [`Self::list_level_counts`]). + pub fn list_counts(&self) -> ListCounts { + ListCounts::from_parts(self.list_count, &self.list_level_counts) + } + /// Set the list counts the story's earlier frames carry into this frame ([`Self::list_counts`]). + pub fn set_list_counts(&mut self, counts: ListCounts) { + (self.list_count, self.list_level_counts) = counts.parts(); + } pub fn plain_text(&self) -> String { self.runs.iter().map(|r| r.text.as_str()).collect() } diff --git a/crates/doc/src/text/para_style.rs b/crates/doc/src/text/para_style.rs index f189801bc..0300e604d 100644 --- a/crates/doc/src/text/para_style.rs +++ b/crates/doc/src/text/para_style.rs @@ -258,6 +258,12 @@ impl ParaStyle { } } + /// The left indent the layout sets the paragraph at: its left indent, and for a paragraph in a + /// list at a level past the first, [`ParaList::level_indent`] more. + pub fn laid_out_left_indent(&self) -> f64 { + self.left_indent + self.list.map_or(0.0, |l| l.level_indent()) + } + /// The leading of characters styled `st` in this paragraph, as the layout sets it: their own /// leading, or for Auto leading the paragraph's Justification › Auto Leading share of their /// size (exactly [`CharStyle::effective_leading`] at the default 120%, or when the share is diff --git a/crates/doc/src/text/tabs_lists.rs b/crates/doc/src/text/tabs_lists.rs index 7f6382b2f..c4ec92b70 100644 --- a/crates/doc/src/text/tabs_lists.rs +++ b/crates/doc/src/text/tabs_lists.rs @@ -155,14 +155,29 @@ fn roman(mut n: u32) -> String { s } +/// Where a list's marker stands before the first line's text ([`ParaList::align`]): the Bullets +/// and Numbering dialog's Alignment. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ListAlign { + /// The marker starts where the first line starts: at the left indent plus the first-line + /// indent. + #[default] + Left, + /// The marker ends where the first line's text starts, against the text. + Right, +} + /// The list a paragraph is in (Paragraph panel › Bullets and Numbered List). The layout draws the /// list's marker before the paragraph's first line, in the font, size and color of the paragraph's /// first character without its baseline shift, rotation, superscript or subscript, tracking, manual /// kerning, tate-chu-yoko, underline and strikethrough ([`Self::marker_style`]), and the text after -/// it starts at the next tab stop, the left indent counting as a stop. The marker is not a -/// character of the text, and an empty paragraph has none. A saved list reads the fields it leaves -/// out as their defaults, and a build ignores the fields it doesn't know. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +/// it starts at the next tab stop, the left indent counting as a stop. The marker stands at the +/// first line's start, or against the text with [`ListAlign::Right`]. A paragraph at a level past +/// the first ([`Self::level`]) is set [`LIST_LEVEL_INDENT`] further in for each level. The marker +/// is not a character of the text, and an empty paragraph has none. A saved list reads the fields +/// it leaves out as their defaults, and a build ignores the fields it doesn't know. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(default)] pub struct ParaList { /// The bullet each paragraph of a bulleted list starts with. A numbered list keeps it for @@ -172,17 +187,127 @@ pub struct ParaList { /// numbered list. #[serde(skip_serializing_if = "Option::is_none")] pub number: Option<NumberStyle>, + /// Where the marker stands before the first line's text. The file saves it only when it is + /// [`ListAlign::Right`]. + #[serde(skip_serializing_if = "crate::skip::is_default")] + pub align: ListAlign, + /// The paragraph's level in the list, 1 to [`LIST_LEVELS`] (Tab and Shift+Tab at the start of + /// the paragraph, Bullets and Numbering…'s Level): each level counts its own numbers + /// ([`Self::next_marker`]). A value outside that range is read as the nearest level + /// ([`Self::depth`]). The file saves it only when it is not 1. + #[serde(skip_serializing_if = "is_first_level", deserialize_with = "read_level")] + pub level: u8, +} + +impl Default for ParaList { + /// A bulleted list with a dot, its marker aligned left, at level 1. + fn default() -> Self { + Self { bullet: Bullet::default(), number: None, align: ListAlign::default(), level: 1 } + } +} + +/// Is `level` the first list level, which the file does not save? +fn is_first_level(level: &u8) -> bool { + *level == 1 +} + +/// Reads a saved list level, a whole number, as the nearest of 1 to [`LIST_LEVELS`], so a level +/// such as -1 or 300 opens the file at level 1 or 9. Anything else is an error. +fn read_level<'de, D: serde::Deserializer<'de>>(d: D) -> Result<u8, D::Error> { + let level = match serde_json::Value::deserialize(d)? { + serde_json::Value::Number(n) if n.is_i64() || n.is_u64() => n.as_i64().map_or(LIST_LEVELS as i64, |n| n.clamp(1, LIST_LEVELS as i64)), + _ => return Err(serde::de::Error::custom("a list level must be a whole number")), + }; + Ok(level as u8) +} + +/// Reads a type object's saved counts at list levels 2 on ([`ListCounts::from_parts`]), keeping +/// the first `LIST_LEVELS - 1` and skipping the rest of a longer list without storing it. +pub(super) fn read_level_counts<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<u32>, D::Error> { + struct Counts; + impl<'de> serde::de::Visitor<'de> for Counts { + type Value = Vec<u32>; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a list of list counts") + } + fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<u32>, A::Error> { + let mut out = Vec::new(); + while out.len() < LIST_LEVELS - 1 { + match seq.next_element::<u32>()? { + Some(c) => out.push(c), + None => return Ok(out), + } + } + while seq.next_element::<serde::de::IgnoredAny>()?.is_some() {} + Ok(out) + } + } + d.deserialize_seq(Counts) +} + +/// How many levels a list has (Bullets and Numbering…'s Level: 1 to 9). +pub const LIST_LEVELS: usize = 9; + +/// How much further in (pt) the layout sets a list paragraph for each level past the first: its +/// left indent counts that much more, and the marker and the text move with it. +pub const LIST_LEVEL_INDENT: f64 = 18.0; + +/// The numbered paragraphs a story's list has counted at each level, level 1 first +/// ([`ParaList::next_marker`]). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ListCounts(pub [u32; LIST_LEVELS]); + +impl ListCounts { + /// The counts with `first` at level 1 and `deeper` at levels 2 on, a missing level at 0, as a + /// type object saves them ([`TextObject::list_count`], [`TextObject::list_level_counts`]). + pub fn from_parts(first: u32, deeper: &[u32]) -> Self { + let mut counts = [0; LIST_LEVELS]; + counts[0] = first; + for (c, &d) in counts[1..].iter_mut().zip(deeper) { + *c = d; + } + Self(counts) + } + + /// The count at level 1, and the counts at levels 2 on without the zeros that end them. + pub fn parts(&self) -> (u32, Vec<u32>) { + let deeper = &self.0[1..]; + let used = deeper.iter().rposition(|&c| c != 0).map_or(0, |i| i + 1); + (self.0[0], deeper[..used].to_vec()) + } } impl ParaList { /// A bulleted list with `bullet`. pub fn bulleted(bullet: Bullet) -> Self { - Self { bullet, number: None } + Self { bullet, ..Self::default() } } /// A numbered list with `style`. pub fn numbered(style: NumberStyle) -> Self { - Self { bullet: Bullet::default(), number: Some(style) } + Self { number: Some(style), ..Self::default() } + } + + /// This list with its marker at `align`. + pub fn aligned(self, align: ListAlign) -> Self { + Self { align, ..self } + } + + /// This list at level `level` (1 to [`LIST_LEVELS`]). + pub fn at_level(self, level: u8) -> Self { + Self { level, ..self } + } + + /// The list's level, 1 to [`LIST_LEVELS`]: [`Self::level`], a value outside the range taken + /// as the nearest level. + pub fn depth(&self) -> usize { + usize::from(self.level).clamp(1, LIST_LEVELS) + } + + /// How much further in (pt) than its left indent the layout sets a paragraph of this list: + /// [`LIST_LEVEL_INDENT`] for each level past the first. + pub fn level_indent(&self) -> f64 { + (self.depth() - 1) as f64 * LIST_LEVEL_INDENT } /// The characters of the marker the layout draws before the paragraph: the bullet, or for a @@ -195,18 +320,26 @@ impl ParaList { } /// The marker of the next paragraph of a story, whose list is `list` (None: in no list) and - /// which holds no text when `empty`, with `count` the numbered paragraphs counted before it. - /// A paragraph in no list sets the count back to 0 and has no marker; an empty paragraph has - /// none and leaves the count as it is; a bulleted paragraph has its bullet and leaves the - /// count as it is; a numbered paragraph adds one to the count and is numbered with it. - pub fn next_marker(list: Option<ParaList>, empty: bool, count: &mut u32) -> Option<String> { + /// which holds no text when `empty`, with `counts` the numbered paragraphs counted before it at + /// each level. A paragraph in no list sets every count back to 0 and has no marker; an empty + /// paragraph has none and leaves the counts as they are. A paragraph with text sets the counts + /// of the levels deeper than its own back to 0, so a deeper paragraph after it counts from 1 + /// again; a bulleted one has its bullet and leaves the count of its level as it is, and a + /// numbered one adds one to the count of its level and is numbered with it. A paragraph back + /// at a shallower level then goes on with that level's count. + pub fn next_marker(list: Option<ParaList>, empty: bool, counts: &mut ListCounts) -> Option<String> { let Some(list) = list else { - *count = 0; + *counts = ListCounts::default(); return None; }; if empty { return None; } + let level = list.depth() - 1; + for deeper in &mut counts.0[level + 1..] { + *deeper = 0; + } + let count = &mut counts.0[level]; if list.number.is_some() { *count = count.saturating_add(1); } diff --git a/crates/doc/src/text/tests.rs b/crates/doc/src/text/tests.rs index a3f4ab4d2..6a997c9e5 100644 --- a/crates/doc/src/text/tests.rs +++ b/crates/doc/src/text/tests.rs @@ -194,13 +194,13 @@ fn number_styles_write_their_numbers_and_roll_over() { assert_eq!(ParaList::numbered(NumberStyle::UpperRoman).marker(4), "IV."); assert_eq!(ParaList::bulleted(Bullet::Square).marker(4), "\u{25AA}"); let (num, dot) = (Some(ParaList::numbered(NumberStyle::Arabic)), Some(ParaList::bulleted(Bullet::Dot))); - let mut count = 0; + let mut count = ListCounts::default(); let markers: Vec<Option<String>> = [(num, false), (num, true), (dot, false), (num, false), (None, false), (num, false)] .iter() .map(|&(l, empty)| ParaList::next_marker(l, empty, &mut count)) .collect(); assert_eq!(markers, [Some("1.".into()), None, Some("\u{2022}".into()), Some("2.".into()), None, Some("1.".into())]); - assert_eq!(count, 1); + assert_eq!(count.parts(), (1, vec![])); let read = |v: serde_json::Value| serde_json::from_value::<ParaList>(v).unwrap(); assert_eq!(read(serde_json::json!({"bullet": "dash"})), ParaList::bulleted(Bullet::Dash)); assert_eq!(read(serde_json::json!({"number": "threeDigits"})), ParaList::numbered(NumberStyle::ThreeDigits)); @@ -211,6 +211,20 @@ fn number_styles_write_their_numbers_and_roll_over() { ); } +/// A list's marker alignment: Left by default and not saved, Right saved as `"align": "right"`; +/// a list saved without it reads as Left. +#[test] +fn a_list_saves_its_marker_alignment_only_when_it_is_right() { + let right = ParaList::numbered(NumberStyle::Arabic).aligned(ListAlign::Right); + assert_eq!(ParaList::bulleted(Bullet::Dot).align, ListAlign::Left); + assert_eq!(serde_json::to_value(right).unwrap(), serde_json::json!({"bullet": "dot", "number": "arabic", "align": "right"})); + assert_eq!(serde_json::to_value(ParaList::bulleted(Bullet::Dash)).unwrap(), serde_json::json!({"bullet": "dash"})); + let read = |v: serde_json::Value| serde_json::from_value::<ParaList>(v).unwrap(); + assert_eq!(read(serde_json::json!({"bullet": "dot", "number": "arabic", "align": "right"})), right); + assert_eq!(read(serde_json::json!({"bullet": "square"})).align, ListAlign::Left); + assert!(serde_json::from_value::<ParaList>(serde_json::json!({"align": "center"})).is_err()); +} + /// Typed list detection: "1.", "1)", "A." and "A)" and a space at the start of a paragraph /// start a numbered list, and "*", "+", "#" and ">" and a space start a bulleted list with the /// filled dot, the hollow circle, the filled square and the dash. Other text, the same characters @@ -240,6 +254,70 @@ fn typed_list_starts_are_found_at_a_paragraphs_start() { assert_eq!(t.typed_list_at(19), Some((16, letters))); } +/// List levels: each level counts its own numbers, a deeper paragraph starts its count at 1, +/// a paragraph back at a shallower level goes on with that level's count, and a paragraph in +/// no list starts every level again. A level past the first sets the paragraph 18 pt further +/// in; the file saves the level only when it is not 1, and a level outside 1 to 9 reads as the +/// nearest one. A type object's counts save level 1 and the deeper levels apart. +#[test] +fn list_levels_count_their_own_numbers_and_save_only_past_the_first() { + let num = |level| Some(ParaList::numbered(NumberStyle::Arabic).at_level(level)); + let dot = |level| Some(ParaList::bulleted(Bullet::Dot).at_level(level)); + let mut counts = ListCounts::default(); + let markers: Vec<Option<String>> = [ + (num(1), false), + (num(2), false), + (num(2), false), + (num(3), false), + (num(2), true), + (num(2), false), + (num(1), false), + (num(2), false), + (dot(1), false), + (num(2), false), + (num(1), false), + (None, false), + (num(2), false), + ] + .iter() + .map(|&(l, empty)| ParaList::next_marker(l, empty, &mut counts)) + .collect(); + let want = ["1.", "1.", "2.", "1.", "", "3.", "2.", "1.", "\u{2022}", "1.", "3.", "", "1."]; + assert_eq!(markers.iter().map(|m| m.as_deref().unwrap_or("")).collect::<Vec<_>>(), want); + assert_eq!(counts, ListCounts([0, 1, 0, 0, 0, 0, 0, 0, 0])); + assert_eq!((ParaList::default().level, ParaList::bulleted(Bullet::Dot).depth()), (1, 1)); + assert_eq!([0, 1, 4, 9, 12].map(|l| ParaList::bulleted(Bullet::Dot).at_level(l).depth()), [1, 1, 4, 9, 9]); + assert_eq!([1, 2, 9].map(|l| ParaList::bulleted(Bullet::Dot).at_level(l).level_indent()), [0.0, 18.0, 144.0]); + let para = ParaStyle { left_indent: 18.0, list: dot(3), ..Default::default() }; + assert_eq!((para.laid_out_left_indent(), ParaStyle { list: None, ..para.clone() }.laid_out_left_indent()), (54.0, 18.0)); + assert_eq!(serde_json::to_value(dot(1)).unwrap(), serde_json::json!({"bullet": "dot"})); + assert_eq!(serde_json::to_value(num(4)).unwrap(), serde_json::json!({"bullet": "dot", "number": "arabic", "level": 4})); + let read = |v: serde_json::Value| serde_json::from_value::<ParaList>(v).unwrap(); + assert_eq!((read(serde_json::json!({"bullet": "dash"})).level, read(serde_json::json!({"level": 4}))), (1, dot(4).unwrap())); + assert!(serde_json::from_value::<ParaList>(serde_json::json!({"level": "two"})).is_err()); + assert!(serde_json::from_value::<ParaList>(serde_json::json!({"level": 2.5})).is_err()); + let levels = [serde_json::json!(0), serde_json::json!(-1), serde_json::json!(12), serde_json::json!(300), serde_json::json!(u64::MAX)]; + assert_eq!( + levels.map(|l| read(serde_json::json!({ "level": l })).level), + [1, 1, 9, 9, 9], + "a saved level outside 1 to 9 reads as the nearest one" + ); + let deep = ListCounts([5, 0, 2, 0, 0, 0, 0, 0, 0]); + assert_eq!(deep.parts(), (5, vec![0, 2])); + assert_eq!((ListCounts::from_parts(5, &[0, 2]), ListCounts::default().parts()), (deep, (0, vec![]))); + let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default()); + t.set_list_counts(deep); + assert_eq!((t.list_count, t.list_level_counts.as_slice(), t.list_counts()), (5, &[0, 2][..], deep)); + let saved = serde_json::to_value(&t).unwrap(); + assert_eq!((&saved["listCount"], &saved["listLevelCounts"]), (&serde_json::json!(5), &serde_json::json!([0, 2]))); + t.set_list_counts(ListCounts::default()); + assert!(serde_json::to_value(&t).unwrap().get("listLevelCounts").is_none(), "no counts past level 1 are saved without them"); + let mut saved = serde_json::to_value(&t).unwrap(); + saved["listLevelCounts"] = serde_json::json!((1..=20).collect::<Vec<u32>>()); + let opened = serde_json::from_value::<TextObject>(saved).unwrap(); + assert_eq!(opened.list_level_counts, (1..=8).collect::<Vec<u32>>(), "a file keeps only the counts of levels 2 to 9"); +} + /// In a threaded frame whose first paragraph continues from the frame before, the text at the /// frame's start is not the start of a paragraph, so typing a list's start there starts nothing. /// The paragraphs after it still start lists. diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 052a5c69c..2d31997c4 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec<CommandSpec> { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", 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), 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), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), 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\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), 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\"|\"optical\", 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), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (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; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), 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), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), 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\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), 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 ), @@ -453,6 +453,19 @@ fn spacing_param(p: &Value, key: &str, c: &str) -> Result<Option<[Option<f64>; 3 } } +/// The list level `listLevel` gives (None without one): a whole number from 1 to +/// [`vectorcraft_doc::text::LIST_LEVELS`], written `2` or `2.0`. +fn list_level_param(p: &Value, cmd: &str) -> Result<Option<u8>> { + let key = "listLevel"; + let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) }; + let (min, max) = (1, vectorcraft_doc::text::LIST_LEVELS); + match v.as_f64() { + Some(x) if x.fract() == 0.0 && (min as f64..=max as f64).contains(&x) => Ok(Some(x as u8)), + Some(x) => Err(bad(cmd, format!("`{key}` must be a whole number between {min} and {max} (got {x})"))), + None => Err(bad(cmd, format!("`{key}` must be a whole number"))), + } +} + fn set_format(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "text.setFormat"; let ids = super::typecmd::text_targets(s, p, C)?; @@ -505,6 +518,8 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { "list", "bullet", "number", + "listAlign", + "listLevel", "hyphenate", "wordsLongerThan", "afterFirst", @@ -625,6 +640,11 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { &["none", "arabic", "upperLetters", "lowerLetters", "upperRoman", "lowerRoman", "twoDigits", "threeDigits"], )? .map(|n| NUMBERS.iter().find(|(k, _)| *k == n).map(|&(_, style)| style)); + // …and `listAlign` where the marker stands before the text, in every list paragraph. + let list_align = super::object::choice(p, C, "listAlign", &["left", "right"])? + .map(|a| if a == "right" { vectorcraft_doc::text::ListAlign::Right } else { vectorcraft_doc::text::ListAlign::Left }); + // …and `listLevel` the level of every list paragraph, 1 to 9. + let list_level = list_level_param(p, C)?; let indents_given = num("leftIndent").is_some() || num("firstLineIndent").is_some(); let spacing = |k: &str| spacing_param(p, k, C); let (word_spacing, letter_spacing, glyph_scaling) = (spacing("wordSpacing")?, spacing("letterSpacing")?, spacing("glyphScaling")?); @@ -721,6 +741,31 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { st.font_height_reference = v; } }; + // The list settings of one paragraph: `list`, `bullet`, `number`, `listAlign` and `listLevel`. + let lists = |para: &mut vectorcraft_doc::ParaStyle| { + if list.is_some() || bullet.is_some() || number.is_some() { + let on = list != Some("none") && bullet != Some(None) && number != Some(None); + let had = para.list; + // A numbered list for list "number", or without `list` for a number style. + let numbered = list == Some("number") || (list.is_none() && number.is_some()); + // The list's other settings (the marker's alignment) stay as they were. + para.list = on.then(|| vectorcraft_doc::text::ParaList { + bullet: bullet.flatten().or(had.map(|l| l.bullet)).unwrap_or_default(), + number: numbered.then(|| number.flatten().or(had.and_then(|l| l.number)).unwrap_or_default()), + ..had.unwrap_or_default() + }); + // A paragraph that joins a list with no left or first-line indent gets a + // hanging indent, unless the call sets an indent. + if on && had.is_none() && !indents_given && para.left_indent == 0.0 && para.first_line_indent == 0.0 { + para.left_indent = vectorcraft_doc::text::LIST_HANGING_INDENT; + para.first_line_indent = -vectorcraft_doc::text::LIST_HANGING_INDENT; + } + } + if let Some(l) = para.list.as_mut() { + l.align = list_align.unwrap_or(l.align); + l.level = list_level.unwrap_or(l.level); + } + }; s.edit("Character", |d, _| { for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; @@ -746,22 +791,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { if let Some(v) = num("spaceAfter") { para.space_after = v.clamp(-MAX_PARA, MAX_PARA); } - if list.is_some() || bullet.is_some() || number.is_some() { - let on = list != Some("none") && bullet != Some(None) && number != Some(None); - let had = para.list; - // A numbered list for list "number", or without `list` for a number style. - let numbered = list == Some("number") || (list.is_none() && number.is_some()); - para.list = on.then(|| vectorcraft_doc::text::ParaList { - bullet: bullet.flatten().or(had.map(|l| l.bullet)).unwrap_or_default(), - number: numbered.then(|| number.flatten().or(had.and_then(|l| l.number)).unwrap_or_default()), - }); - // A paragraph that joins a list with no left or first-line indent gets a - // hanging indent, unless the call sets an indent. - if on && had.is_none() && !indents_given && para.left_indent == 0.0 && para.first_line_indent == 0.0 { - para.left_indent = vectorcraft_doc::text::LIST_HANGING_INDENT; - para.first_line_indent = -vectorcraft_doc::text::LIST_HANGING_INDENT; - } - } + lists(para); if let Some(v) = flag("hyphenate") { para.hyphenate = v; } @@ -853,14 +883,14 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { /// The part of a story paragraph in one frame, as [`convert_list_to_text`] reads it: the frame, /// the frame's paragraph, its bytes there, its list, the marker the frame's layout draws for it -/// and the numbered paragraphs the frame counts before it. +/// and the numbered paragraphs the frame counts before it at each list level. struct ParagraphPart { frame: usize, index: usize, bytes: std::ops::Range<usize>, list: Option<vectorcraft_doc::text::ParaList>, marker: Option<String>, - count: u32, + count: vectorcraft_doc::text::ListCounts, } /// A paragraph of a story: its parts in thread order (a later frame's first paragraph goes on @@ -902,7 +932,7 @@ fn marker_extent( lay: &vectorcraft_text::TextLayout, index: usize, bytes: std::ops::Range<usize>, - count: u32, + count: vectorcraft_doc::text::ListCounts, ) -> (f64, f64) { let width = |lay: &vectorcraft_text::TextLayout, start: usize| { lay.markers.iter().find(|m| lay.glyphs.get(m.glyphs.start).is_some_and(|g| g.byte == start)).map(|m| { @@ -916,7 +946,7 @@ fn marker_extent( probe.runs = vectorcraft_text::edit::slice_runs(&t.runs, bytes.start, bytes.end); probe.ruby.clear(); probe.set_all_paras(t.para_at(index).clone()); - probe.list_count = count; + probe.set_list_counts(count); probe.first_para_continues = false; width(&vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &probe), 0).unwrap_or((0.0, 0.0)) }) @@ -1068,7 +1098,7 @@ fn convert_list_to_text(s: &mut Session, p: &Value) -> Result<Value> { let plains: Vec<String> = frames.iter().map(|t| t.plain_text()).collect(); for (f, ((id, t), plain)) in story.iter().zip(&frames).zip(&plains).enumerate() { let named = if ids.contains(id) { range.map_or(0..t.paragraph_count(), |r| r.paras(t)) } else { 0..0 }; - let mut count = t.list_count; + let mut count = t.list_counts(); let mut start = 0; for (k, piece) in plain.split('\n').enumerate() { let bytes = start..start + piece.len(); @@ -1130,8 +1160,9 @@ fn convert_list_to_text(s: &mut Session, p: &Value) -> Result<Value> { // the left indent, nor one past it in a paragraph with no tab of its own. let style = t.para_at(para.index); let (width, gap) = marker_extent(t, lay, para.index, para.bytes.clone(), para.count); - let pen = style.left_indent + style.first_line_indent + width; - let indent = style.left_indent; + // A list level past the first sets the paragraph further in than its left indent. + let indent = style.laid_out_left_indent(); + let pen = indent + style.first_line_indent + width; let before = style.tabs.iter().any(|tab| tab.position > pen + 1e-6 && tab.position < indent - 1e-6); let end = pen + gap; let needed = |x: f64| !plain_stop_at(&style.tabs, x) || style.tabs.iter().any(|tab| aligned_between(tab, pen, x)); @@ -1163,6 +1194,8 @@ fn convert_list_to_text(s: &mut Session, p: &Value) -> Result<Value> { // A paragraph in several frames is changed in its main part and in its empty parts: // re-flowing the thread gives the other parts with text the main part's style. let Some(mut style) = frames.get(para.frame).map(|t| t.para_at(para.index).clone()) else { continue }; + // The paragraph keeps the indent its list level set it at. + style.left_indent = style.laid_out_left_indent(); style.list = None; if direction.is_some() { style.direction = direction; diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 4274ce6d5..a88562b13 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -1607,6 +1607,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> { system_layout: false, fractional_widths: true, list_count: 0, + list_level_counts: Vec::new(), cached_bounds: None, cached_baselines: Vec::new(), }; @@ -1895,6 +1896,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> { system_layout: t.system_layout, fractional_widths: t.fractional_widths, list_count: 0, + list_level_counts: Vec::new(), cached_bounds: None, cached_baselines: Vec::new(), }; diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index 61fc7f33a..ae15ab8b9 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -195,6 +195,7 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { || optical != t.optical_margin || vertical != t.vertical || part.list_count != t.list_count + || part.list_level_counts != t.list_level_counts || continues != t.first_para_continues { t.runs = next.runs; @@ -203,6 +204,7 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { t.optical_margin = optical; t.vertical = vertical; t.list_count = part.list_count; + t.list_level_counts = part.list_level_counts; t.first_para_continues = continues; refresh_bounds(t); } @@ -216,14 +218,15 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { } /// 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 -/// its thread) gets 0 and is laid out again. +/// from 1. Each such type object with another [`TextObject::list_count`] or +/// [`TextObject::list_level_counts`] (a copy of a frame, a frame taken out of its thread, a frame +/// converted to point type, which [`reflow`] takes out of its thread) gets 0 at every level and is +/// laid out again. pub(crate) fn restart_unthreaded_counts(doc: &mut std::sync::Arc<Document>) { let mut stale = vec![]; doc.walk(|n| { if let NodeKind::Text(t) = &n.kind - && t.list_count != 0 + && (t.list_count != 0 || !t.list_level_counts.is_empty()) { stale.push(n.id); } @@ -240,6 +243,7 @@ pub(crate) fn restart_unthreaded_counts(doc: &mut std::sync::Arc<Document>) { for id in stale { if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { t.list_count = 0; + t.list_level_counts.clear(); refresh_bounds(t); } } @@ -444,13 +448,13 @@ fn cut_to_shown(n: &mut Node, ids: &[NodeId]) { { // Split as `take_overflow` splits it: the first part is what the frame shows. let from: &TextObject = t; - let parts = vectorcraft_text::thread::distribute_continuing( + let parts = vectorcraft_text::thread::distribute_continuing_levels( vectorcraft_text::FontDb::global(), &[from, from], &from.runs, &from.paragraph_styles(), from.first_para_continues, - from.list_count, + from.list_counts(), ); if let (Some(head), Some(_)) = (parts.first(), parts.get(1).filter(|p| p.runs.iter().any(|r| !r.text.is_empty()))) { t.runs = head.runs.clone(); @@ -485,13 +489,13 @@ fn take_overflow(d: &mut Document, to: NodeId, from: &TextObject) { // `from`'s text split as if it flowed on into a second frame: the second part is what `from` // has no room for. Its share is measured as it is laid out, continuing a paragraph or not and // with its numbered list paragraphs counted on from the frames before it. - let parts = vectorcraft_text::thread::distribute_continuing( + let parts = vectorcraft_text::thread::distribute_continuing_levels( db, &[from, from], &from.runs, &from.paragraph_styles(), from.first_para_continues, - from.list_count, + from.list_counts(), ); let Some(rest) = parts.get(1).filter(|p| p.runs.iter().any(|r| !r.text.is_empty())) else { return }; let mut tail = from.clone(); diff --git a/crates/engine/src/cmd/threads/tests.rs b/crates/engine/src/cmd/threads/tests.rs index 1ead2e823..a1358557c 100644 --- a/crates/engine/src/cmd/threads/tests.rs +++ b/crates/engine/src/cmd/threads/tests.rs @@ -981,6 +981,74 @@ fn outlining_a_numbered_last_frame_keeps_only_the_text_it_has_no_room_for() { assert_eq!(text(&s, a), format!("{}{hidden}", shown[0])); } +/// As above for a list at level 2: the last frame's numbered paragraphs are counted on from the +/// count the frames before it reached at that level. +#[test] +fn outlining_a_last_frame_numbered_at_a_deeper_level_keeps_only_the_text_it_has_no_room_for() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let items: String = (1..=7).map(|i| format!("Item {i}\n")).collect(); + let story = format!("{items}{}", TEXT.repeat(4)); + let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 106}})).unwrap()); + let c = new_id(s.execute("text.create", &json!({"x": 200, "y": 10, "text": "", "area": {"width": 150, "height": 20}})).unwrap()); + s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, c], "number": "upperRoman", "listLevel": 2, "leftIndent": 14, "firstLineIndent": -14})).unwrap(); + let shown = [a, c].map(|id| text(&s, id)); + assert!(shown[0].ends_with("Item 7\n") && shown[1].starts_with("The quick"), "{shown:?}"); + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)).cloned() else { panic!("not area type") }; + assert_eq!((t.list_count, t.list_level_counts.as_slice()), (0, &[7][..])); + let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + assert_eq!(laid.markers.first().map(|m| m.text.as_str()), Some("VIII.")); + assert!(laid.overflow, "the last frame has more text than it shows"); + let end = laid.lines.last().map_or(0, |l| l.end); + let from_one = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &TextObject { list_level_counts: vec![], ..t }); + assert_ne!(from_one.lines.last().map_or(0, |l| l.end), end, "counted from 1, the frame would show other text"); + let hidden = shown[1][end..].trim_start_matches(' '); + s.execute("select.set", &json!({"ids": [c]})).unwrap(); + s.execute("type.createOutlines", &json!({})).unwrap(); + assert_eq!(text(&s, a), format!("{}{hidden}", shown[0])); +} + +/// New Symbol and Redefine Symbol on a thread's last frame numbered at level 2: the symbol's +/// art holds the text the frame shows, measured with the count the frames before it reached at +/// that level, and the frame before it takes the rest. +#[test] +fn a_symbol_of_a_last_frame_numbered_at_a_deeper_level_holds_the_text_it_shows() { + for command in ["symbol.new", "symbol.update"] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let items: String = (1..=7).map(|i| format!("Item {i}\n")).collect(); + let story = format!("{items}{}", TEXT.repeat(4)); + let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 106}})).unwrap()); + let c = new_id(s.execute("text.create", &json!({"x": 200, "y": 10, "text": "", "area": {"width": 150, "height": 20}})).unwrap()); + s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, c], "number": "upperRoman", "listLevel": 2, "leftIndent": 14, "firstLineIndent": -14})) + .unwrap(); + let shown = [a, c].map(|id| text(&s, id)); + assert!(shown[0].ends_with("Item 7\n") && shown[1].starts_with("The quick"), "{command}: {shown:?}"); + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)).cloned() else { panic!("not area type") }; + let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + assert!(laid.overflow, "{command}: the last frame has more text than it shows"); + let end = laid.lines.last().map_or(0, |l| l.end); + let from_one = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &TextObject { list_level_counts: vec![], ..t }); + assert_ne!(from_one.lines.last().map_or(0, |l| l.end), end, "{command}: counted from 1, the frame would show other text"); + let hidden = shown[1][end..].trim_start_matches(' '); + let name = if command == "symbol.update" { + let mark = new_id(s.execute("shape.rectangle", &json!({"x": 400, "y": 200, "width": 20, "height": 20})).unwrap()); + s.execute("select.set", &json!({"ids": [mark]})).unwrap(); + s.execute("symbol.new", &json!({"name": "Mark"})).unwrap(); + "Mark" + } else { + "Story" + }; + s.execute("select.set", &json!({"ids": [c]})).unwrap(); + s.execute(command, &json!({"name": name})).unwrap(); + let held = symbol_texts(&s, name); + assert_eq!(held.iter().map(|t| t.trim_end()).collect::<Vec<_>>(), [shown[1][..end].trim_end()], "{command}: the symbol's art"); + assert_eq!(text(&s, a), format!("{}{hidden}", shown[0]), "{command}: the frame before it takes the rest"); + } +} + /// A story of one-line paragraphs that each start with a quotation mark, threaded from area /// type `a` into the rectangle `b`, which shows the paragraphs that `a` has no room for. fn quoted_thread() -> (Session, u64, u64) { @@ -1325,6 +1393,41 @@ fn numbered_list_paragraphs_count_on_through_a_thread() { assert_eq!(numbers(&s, b), all(1), "the frame left holds the story"); } +/// List levels in a thread: a deeper level's count runs on into the next frame, which saves +/// it (`listLevelCounts`), and a pasted copy of that frame counts every level from 1. +#[test] +fn list_levels_count_on_through_a_thread() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let items = (1..=10).map(|i| format!("Item {i}")).collect::<Vec<_>>().join("\n"); + let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": items, "area": {"width": 150, "height": 60}})).unwrap()); + let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number", "listLevel": 2})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a], "listLevel": 1, "start": 0, "end": 0})).unwrap(); + let frame = |s: &Session, id: u64| text_of(&s.doc().unwrap().doc, NodeId(id)).cloned().unwrap(); + let numbers = + |t: &TextObject| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).markers.iter().map(|m| m.text.clone()).collect::<Vec<_>>(); + let first = numbers(&frame(&s, a)); + assert!((2..10).contains(&first.len()), "both frames hold items: {first:?}"); + let expected: Vec<String> = std::iter::once("1.".to_string()).chain((1..=9).map(|n| format!("{n}."))).collect(); + assert_eq!([first.clone(), numbers(&frame(&s, b))].concat(), expected); + let b_counts = frame(&s, b).list_counts(); + assert_eq!(b_counts.parts(), (1, vec![first.len() as u32 - 1])); + let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Some(NodeKind::Text(back)) = reopened.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!((back.list_counts(), numbers(back)), (b_counts, numbers(&frame(&s, b))), "reopened"); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + let copy = s.doc().unwrap().selection.objects.first().copied().unwrap(); + assert_ne!(copy, NodeId(b)); + let pasted = frame(&s, copy.0); + assert_eq!((pasted.list_count, pasted.list_level_counts.as_slice()), (0, &[][..])); + assert_eq!(numbers(&pasted).first().map(String::as_str), Some("1."), "a pasted copy counts from 1"); +} + /// New Symbol from a numbered thread keeps each frame's count in the symbol's art, so the art /// looks as it did; Break Link puts the frames in no thread, and each counts from 1. #[test] diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index c5663b58a..c0e5b37fe 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -1279,7 +1279,7 @@ fn headline_fit(t: &TextObject, tracking: f64) -> Option<(f64, f64, usize)> { let cell = lay.frames.first()?; let line = lay.lines.first()?; let para = t.para_at(0); - let avail = cell.width() - 2.0 * t.area.inset - para.left_indent - para.right_indent; + let avail = cell.width() - 2.0 * t.area.inset - para.laid_out_left_indent() - para.right_indent; Some((line.x1 - line.x0, avail, line.end)) } diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index 4748dcc8d..0e2ba3d1f 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -437,7 +437,7 @@ fn bulleted_lists_are_set_per_paragraph_saved_and_continued() { assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "square"})); // A saved list reads the fields it leaves out as their defaults and ignores those it doesn't know. let read = |v: serde_json::Value| serde_json::from_value::<vectorcraft_doc::ParaStyle>(json!({ "list": v })).unwrap().list; - assert_eq!((read(json!({})), read(json!({"bullet": "dash", "level": 2}))), (dot, Some(ParaList::bulleted(Bullet::Dash)))); + assert_eq!((read(json!({})), read(json!({"bullet": "dash", "restart": 2}))), (dot, Some(ParaList::bulleted(Bullet::Dash)))); let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; assert_eq!((back.para_at(0).list, back.para_at(1).list, back.para_at(2).list), (square, dot, None)); @@ -500,7 +500,7 @@ fn numbered_lists_are_set_per_paragraph_saved_and_styled() { s.execute("text.setFormat", &json!({"bullet": "square", "start": 0, "end": 0})).unwrap(); assert_eq!(list(&s, 0), Some(ParaList::bulleted(Bullet::Square))); s.execute("text.setFormat", &json!({"number": "lowerLetters", "start": 0, "end": 0})).unwrap(); - let letters = Some(ParaList { bullet: Bullet::Square, number: Some(NumberStyle::LowerLetters) }); + let letters = Some(ParaList { bullet: Bullet::Square, number: Some(NumberStyle::LowerLetters), ..ParaList::default() }); assert_eq!(list(&s, 0), letters); assert_eq!(numbers(&s), ["a.", "2.", "3."]); assert_eq!((text(&s).para_at(0).left_indent, text(&s).para_at(0).first_line_indent), (18.0, -18.0)); @@ -536,6 +536,70 @@ fn numbered_lists_are_set_per_paragraph_saved_and_styled() { assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (None, None, None)); } +/// Bullets and Numbering… › Alignment: `text.setFormat {listAlign}` sets where the marker +/// stands in the list paragraphs it touches, in one undo step, and refuses unknown values; +/// a paragraph in no list gets no list from it. A list keeps its alignment when it changes +/// between bullets and numbers, and a call that puts a paragraph in a list gives it the +/// alignment too. The alignment is saved only when it is right, opens with the file, and +/// paragraph styles carry it both ways. +#[test] +fn list_markers_are_aligned_per_paragraph_saved_and_styled() { + use vectorcraft_doc::text::{Bullet, ListAlign, NumberStyle, ParaList}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo\nThree", "area": {"width": 300, "height": 200}})).unwrap()["id"] + .as_u64() + .unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + let list = |s: &Session, i: usize| text(s).para_at(i).list; + let align = |s: &Session| (0..3).map(|i| list(s, i).map(|l| l.align)).collect::<Vec<_>>(); + let (left, right) = (Some(ListAlign::Left), Some(ListAlign::Right)); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"list": "number", "start": 0, "end": 4})).unwrap(); + assert_eq!(align(&s), [left, left, None]); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("text.setFormat", &json!({"listAlign": "right", "start": 0, "end": 0})).unwrap(); + assert_eq!(align(&s), [right, left, None]); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(align(&s), [left, left, None]); + s.execute("text.setFormat", &json!({"listAlign": "right"})).unwrap(); + assert_eq!(align(&s), [right, right, None], "the paragraph in no list stays out of one"); + for bad in [json!({"listAlign": "center"}), json!({"listAlign": "Right"}), json!({"listAlign": 1}), json!({"listAlign": true})] { + assert!(s.execute("text.setFormat", &bad).is_err(), "{bad}"); + } + assert_eq!(align(&s), [right, right, None], "an unknown value changes nothing"); + // Bullets and numbers keep it; a call that makes a list sets it there too. + s.execute("text.setFormat", &json!({"bullet": "square", "start": 0, "end": 0})).unwrap(); + assert_eq!(list(&s, 0), Some(ParaList::bulleted(Bullet::Square).aligned(ListAlign::Right))); + s.execute("text.setFormat", &json!({"list": "bullet", "listAlign": "right", "start": 8, "end": 8})).unwrap(); + assert_eq!(list(&s, 2), Some(ParaList::bulleted(Bullet::Dot).aligned(ListAlign::Right))); + s.execute("text.setFormat", &json!({"listAlign": "left", "start": 8, "end": 8})).unwrap(); + assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "square", "align": "right"})); + assert_eq!(serde_json::to_value(text(&s).para_at(2)).unwrap()["list"], json!({"bullet": "dot"})); + let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!((back.para_at(0).list, back.para_at(1).list), (list(&s, 0), Some(ParaList::numbered(NumberStyle::Arabic).aligned(ListAlign::Right)))); + // A list turned off and on again starts aligned left. + s.execute("text.setFormat", &json!({"list": "none", "start": 4, "end": 4})).unwrap(); + s.execute("text.setFormat", &json!({"list": "number", "start": 4, "end": 4})).unwrap(); + assert_eq!(align(&s)[1], left); + // A style captured from a list aligned right gives other lists that alignment, and one + // captured from a list aligned left takes it back. + let other = s.execute("text.create", &json!({"x": 10, "y": 300, "text": "Other"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.setFormat", &json!({"ids": [other], "list": "number"})).unwrap(); + s.execute("paraStyle.new", &json!({"name": "Aligned left", "id": other})).unwrap(); + s.execute("paraStyle.new", &json!({"name": "Aligned right", "id": id})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Aligned right", "id": other})).unwrap(); + let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(other)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!(t.para_at(0).list.map(|l| l.align), right); + s.execute("paraStyle.apply", &json!({"name": "Aligned left", "id": id})).unwrap(); + assert_eq!(align(&s), [left, left, left]); +} + /// The text object `id`. fn text_at(s: &Session, id: u64) -> vectorcraft_doc::text::TextObject { match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { @@ -724,6 +788,56 @@ fn point_and_vertical_type_convert(s: &mut Session) { assert_eq!(s.doc().unwrap().history.undo.len(), steps); } +/// Convert to Text of a list whose markers are aligned right writes the same characters as for +/// a list aligned left, the marker and then a tab, and gives the paragraphs the same tab stops: +/// the text after the tab stays where it was, and the marker moves to the first line's start, +/// where a list aligned left draws it. Numbers narrower than the hanging indent (their tab went +/// to the left indent) and wider (their tab went to the next default stop), and bullets. +#[test] +fn convert_to_text_of_markers_aligned_right_keeps_the_text_in_place() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let text_glyphs = |s: &Session, id: u64| { + let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let milli = |v: f64| (v * 1000.0).round() as i64; + lay.glyphs + .iter() + .filter(|g| g.len > 0 && !g.outline.elements().is_empty()) + .map(|g| (g.gid, milli(g.origin.x), milli(g.origin.y))) + .collect::<Vec<_>>() + }; + let plain = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.plain_text(), + _ => panic!("text"), + }; + let items = (1..=10).map(|i| format!("Item {i}")).collect::<Vec<_>>().join("\n"); + for (list, converted) in [ + ("number", (1..=10).map(|i| format!("{i}.\tItem {i}")).collect::<Vec<_>>().join("\n")), + ("bullet", (1..=10).map(|i| format!("\u{2022}\tItem {i}")).collect::<Vec<_>>().join("\n")), + ] { + let make = |s: &mut Session, align: &str| { + let id = s.execute("text.create", &json!({"x": 300, "y": 10, "size": 20, "text": items, "area": {"width": 300, "height": 600}})).unwrap() + ["id"] + .as_u64() + .unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "list": list, "listAlign": align})).unwrap(); + id + }; + let (left, right) = (make(&mut s, "left"), make(&mut s, "right")); + let drawn_left = drawn_glyphs(&s, left); + let text_before = text_glyphs(&s, right); + assert_eq!(text_before, text_glyphs(&s, left), "{list}: the text starts at the same place with either alignment"); + assert_ne!(drawn_glyphs(&s, right), drawn_left, "{list}: the markers aligned right are drawn elsewhere"); + s.execute("text.convertListToText", &json!({"ids": [left, right]})).unwrap(); + assert_eq!((plain(&s, left), plain(&s, right)), (converted.clone(), converted), "{list}: the marker, then a tab"); + let after = text_glyphs(&s, right); + assert!(text_before.iter().all(|g| after.contains(g)), "{list}: the text stays where it was"); + assert_eq!(drawn_glyphs(&s, right), drawn_left, "{list}: the markers start where a list aligned left draws them"); + assert_eq!(drawn_glyphs(&s, left), drawn_left, "{list}: aligned left, every glyph stays"); + } +} + /// A right-to-left list paragraph converted to text starts with the number, its period and a /// tab, which are read first: the line draws its glyphs in the order it drew them with the /// marker, the number at the right with its period at its left. Letters and roman numerals @@ -907,6 +1021,130 @@ fn convert_to_text_moves_the_type_tools_selection() { assert_eq!(s.active().unwrap().selection.objects, vec![NodeId(a)], "the first frame is selected"); } +/// Convert to Text of list paragraphs at deeper levels writes each level's own numbers, those +/// a thread's earlier frames counted at each level included, and every glyph is drawn where it +/// was: each converted paragraph's left indent takes its level's indent, with the plain left +/// stop there. +#[test] +fn convert_to_text_keeps_deeper_list_levels_in_place() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let text = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + let numbers = |s: &Session, id: u64| { + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id)).markers.iter().map(|m| m.text.clone()).collect::<Vec<_>>() + }; + let text_in = "One\nTwo\nThree\nFour\nFive"; + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "size": 20, "text": text_in, "area": {"width": 400, "height": 400}})).unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "listLevel": 2, "start": 4, "end": 9})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "listLevel": 3, "start": 14, "end": 14})).unwrap(); + let before = drawn_glyphs(&s, id); + s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); + assert_eq!(drawn_glyphs(&s, id), before, "every glyph is drawn where it was"); + let t = text(&s, id); + assert_eq!(t.plain_text(), "1.\tOne\n1.\tTwo\n2.\tThree\n1.\tFour\n2.\tFive"); + let paras = (0..5) + .map(|k| { + let p = t.para_at(k); + (p.list.is_none(), p.left_indent, p.tabs.iter().map(|tab| tab.position).collect::<Vec<_>>()) + }) + .collect::<Vec<_>>(); + assert_eq!(paras, [18.0, 36.0, 36.0, 54.0, 18.0].map(|x| (true, x, vec![x])), "out of the list, at the level's indent"); + // A thread whose second frame goes on with the numbers of level 2. + let items = std::iter::once("Head".to_string()).chain((1..=9).map(|i| format!("Item {i}"))).collect::<Vec<_>>().join("\n"); + let a = + s.execute("text.create", &json!({"x": 10, "y": 500, "text": items, "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 500, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number", "listLevel": 2})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a], "listLevel": 1, "start": 0, "end": 0})).unwrap(); + let first = numbers(&s, a).len(); + assert!((3..10).contains(&first) && text(&s, a).plain_text().ends_with('\n'), "both frames hold items: {first}"); + assert_eq!(numbers(&s, b).first(), Some(&format!("{first}.")), "the second frame counts level 2 on"); + let before = (drawn_glyphs(&s, a), drawn_glyphs(&s, b)); + s.execute("text.convertListToText", &json!({"ids": [a, b]})).unwrap(); + assert_eq!((drawn_glyphs(&s, a), drawn_glyphs(&s, b)), before, "both frames"); + let story = std::iter::once("1.\tHead".to_string()).chain((1..=9).map(|i| format!("{i}.\tItem {i}"))).collect::<Vec<_>>().join("\n"); + assert_eq!(text(&s, a).plain_text() + &text(&s, b).plain_text(), story); +} + +/// List levels: `text.setFormat {listLevel}` sets the level of the list paragraphs it touches +/// as one undo step, from 1 to 9, and refuses other values; bullets and numbers keep it, a +/// list turned off and on starts at level 1 again, each level counts its own numbers, and the +/// level is saved past level 1, reopened and carried by paragraph styles. +#[test] +fn list_levels_are_set_per_paragraph_saved_and_styled() { + use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = + s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo\nThree\nFour", "area": {"width": 300, "height": 200}})).unwrap()["id"] + .as_u64() + .unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + let levels = |s: &Session| (0..4).map(|i| text(s).para_at(i).list.map(|l| l.level)).collect::<Vec<_>>(); + let numbers = |s: &Session| { + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s)).markers.iter().map(|m| m.text.clone()).collect::<Vec<_>>() + }; + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"list": "number", "start": 0, "end": 9})).unwrap(); + assert_eq!(levels(&s), [Some(1), Some(1), Some(1), None]); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("text.setFormat", &json!({"listLevel": 2, "start": 4, "end": 9})).unwrap(); + assert_eq!(levels(&s), [Some(1), Some(2), Some(2), None]); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(numbers(&s), ["1.", "1.", "2."]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(levels(&s), [Some(1), Some(1), Some(1), None]); + s.execute("text.setFormat", &json!({"listLevel": 2.0, "start": 4, "end": 4})).unwrap(); + assert_eq!(levels(&s)[1], Some(2)); + s.execute("text.setFormat", &json!({"listLevel": 9})).unwrap(); + assert_eq!(levels(&s), [Some(9), Some(9), Some(9), None], "the paragraph in no list stays out of one"); + let bad = [json!({"listLevel": 0}), json!({"listLevel": 10}), json!({"listLevel": 1.5}), json!({"listLevel": "2"}), json!({"listLevel": true})]; + for p in bad { + assert!(s.execute("text.setFormat", &p).is_err(), "{p}"); + } + let err = s.execute("text.setFormat", &json!({"listLevel": 10})).unwrap_err().to_string(); + assert!(err.contains("`listLevel` must be a whole number between 1 and 9 (got 10)"), "{err}"); + assert_eq!(levels(&s)[0], Some(9), "an unknown value changes nothing"); + // A deeper paragraph counts from 1, and one back at a shallower level goes on. + s.execute("text.setFormat", &json!({"listLevel": 1, "start": 0, "end": 0})).unwrap(); + s.execute("text.setFormat", &json!({"listLevel": 3, "start": 4, "end": 4})).unwrap(); + s.execute("text.setFormat", &json!({"listLevel": 1, "start": 8, "end": 8})).unwrap(); + assert_eq!(numbers(&s), ["1.", "1.", "2."]); + // Bullets and numbers keep the level; a call that makes a list sets it there too. + s.execute("text.setFormat", &json!({"bullet": "square", "start": 4, "end": 4})).unwrap(); + assert_eq!(text(&s).para_at(1).list, Some(ParaList::bulleted(Bullet::Square).at_level(3))); + s.execute("text.setFormat", &json!({"list": "bullet", "listLevel": 4, "start": 14, "end": 14})).unwrap(); + assert_eq!(text(&s).para_at(3).list, Some(ParaList::bulleted(Bullet::Dot).at_level(4))); + assert_eq!(serde_json::to_value(text(&s).para_at(1)).unwrap()["list"], json!({"bullet": "square", "level": 3})); + assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "dot", "number": "arabic"})); + let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!((0..4).map(|i| back.para_at(i).list).collect::<Vec<_>>(), (0..4).map(|i| text(&s).para_at(i).list).collect::<Vec<_>>()); + // A list turned off and on again starts at level 1. + s.execute("text.setFormat", &json!({"list": "none", "start": 4, "end": 4})).unwrap(); + s.execute("text.setFormat", &json!({"list": "number", "start": 4, "end": 4})).unwrap(); + assert_eq!(text(&s).para_at(1).list, Some(ParaList::numbered(NumberStyle::Arabic))); + // A style captured from a paragraph at level 4 sets that level, and one captured at level 1 + // takes it back. + s.execute("paraStyle.new", &json!({"name": "Level 4", "id": id, "start": 14, "end": 14})).unwrap(); + s.execute("paraStyle.new", &json!({"name": "Level 1", "id": id, "start": 0, "end": 0})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Level 4", "id": id, "start": 0, "end": 0})).unwrap(); + assert_eq!(levels(&s)[0], Some(4)); + s.execute("paraStyle.apply", &json!({"name": "Level 1", "id": id})).unwrap(); + assert_eq!(levels(&s), [Some(1); 4]); +} + /// Type › Create Outlines makes a path of a list item's bullet as well as of its characters. #[test] fn create_outlines_makes_a_path_of_the_bullet() { diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index 58b4e2e6b..0b0ea1042 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -373,8 +373,10 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> { } // The point origin sits where the alignment anchors the first line. let p0 = t.para_at(0); - let (x0, x1) = - lay.lines.first().map_or((fb.x0, fb.x1), |l| (l.avail.0 - p0.left_indent - p0.first_line_indent, l.avail.1 + p0.right_indent)); + let (x0, x1) = lay + .lines + .first() + .map_or((fb.x0, fb.x1), |l| (l.avail.0 - p0.laid_out_left_indent() - p0.first_line_indent, l.avail.1 + p0.right_indent)); let ox = match p0.justify { Justify::Center | Justify::JustifyCenter => (x0 + x1) * 0.5, Justify::Right | Justify::JustifyRight => x1, diff --git a/crates/engine/src/tests/paragraphs.rs b/crates/engine/src/tests/paragraphs.rs index 93cd13e57..5355cee5b 100644 --- a/crates/engine/src/tests/paragraphs.rs +++ b/crates/engine/src/tests/paragraphs.rs @@ -151,6 +151,42 @@ fn return_in_the_type_tool_continues_the_paragraph_it_splits() { assert_eq!(justs(&s, id), [Justify::Right, Justify::Center]); } +#[test] +fn tab_at_the_start_of_a_list_item_moves_it_a_level_down_in_one_undo_step() { + let mut s = session(); + let v = ViewInfo::default(); + let id = area(&mut s, "one\ntwo"); + s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap(); + s.select_tool("type", v).unwrap(); + // Click into the second line, then Home: the caret before "two". + let t = text(&s, id); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + let l1 = &lay.lines[1]; + let at = t.xf * vectorcraft_geom::Point::new((l1.x0 + l1.x1) / 2.0, l1.baseline - 3.0); + for k in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(k, at.x, at.y), v).unwrap(); + } + s.tool_key(ToolKey::Home, Mods::default(), v).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + s.tool_key(ToolKey::Tab, Mods::default(), v).unwrap(); + let levels = |s: &Session| text(s, id).paragraph_styles().iter().map(|p| p.list.map(|l| l.level)).collect::<Vec<_>>(); + assert_eq!(levels(&s), [Some(1), Some(2)]); + assert_eq!((text(&s, id).plain_text(), s.doc().unwrap().history.undo.len()), ("one\ntwo".to_string(), undo + 1)); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, id)); + let starts: Vec<f64> = lay.markers.iter().map(|m| lay.glyphs[m.glyphs.start].origin.x).collect(); + assert!((starts[1] - starts[0] - 18.0).abs() < 1e-6, "the item moved 18 pt in: {starts:?}"); + assert_eq!(lay.markers.iter().map(|m| m.text.as_str()).collect::<Vec<_>>(), ["1.", "1."]); + // Shift+Tab moves it back up, and typing then goes on at the caret. + s.tool_key(ToolKey::Tab, Mods { shift: true, ..Mods::default() }, v).unwrap(); + assert_eq!(levels(&s), [Some(1), Some(1)]); + s.tool_text("and ", v).unwrap(); + s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap(); + assert_eq!(text(&s, id).plain_text(), "one\nand two"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((text(&s, id).plain_text(), levels(&s)), ("one\ntwo".to_string(), vec![Some(1), Some(2)])); +} + #[test] fn paragraph_style_applies_to_one_paragraph() { let mut s = session(); diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index a895655b9..885ce09e1 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -1276,6 +1276,45 @@ fn start_list_command_removes_the_typed_characters_and_starts_the_list() { assert!(s.execute("text.startList", &json!({"at": 3})).is_err(), "id required"); } +/// A list typed with "1. " starts at level 1. Tab at the start of the item Return makes moves it +/// to level 2, where it is numbered from 1, Return continues the list at that level, and +/// Shift+Tab moves an item back up, where it goes on with level 1's count. "1. " typed at the +/// start of an item at level 2 stays text. +#[test] +fn a_typed_list_takes_its_levels_from_tab_and_shift_tab() { + use vectorcraft_doc::text::{NumberStyle, ParaList}; + let v = ViewInfo::default(); + let mut s = session(); + s.select_tool("type", v).unwrap(); + click(&mut s, 200.0, 200.0); + let id = s.doc().unwrap().selection.objects[0]; + let typed = |s: &mut Session, text: &str| { + for c in text.chars() { + s.tool_text(&c.to_string(), v).unwrap(); + } + }; + let shift = Mods { shift: true, ..Mods::default() }; + typed(&mut s, "1. Apples"); + key(&mut s, ToolKey::Enter, Mods::default()); + key(&mut s, ToolKey::Tab, Mods::default()); + typed(&mut s, "Pears"); + key(&mut s, ToolKey::Enter, Mods::default()); + typed(&mut s, "Plums"); + key(&mut s, ToolKey::Enter, Mods::default()); + key(&mut s, ToolKey::Tab, shift); + typed(&mut s, "Figs"); + let arabic = ParaList::numbered(NumberStyle::Arabic); + let t = obj(&s, id); + assert_eq!(t.plain_text(), "Apples\nPears\nPlums\nFigs"); + assert_eq!((0..4).map(|i| t.para_at(i).list).collect::<Vec<_>>(), [arabic, arabic.at_level(2), arabic.at_level(2), arabic].map(Some)); + assert_eq!(list_markers(&s, id), ["1.", "1.", "2.", "2."]); + key(&mut s, ToolKey::Enter, Mods::default()); + key(&mut s, ToolKey::Tab, Mods::default()); + typed(&mut s, "1. "); + let t = obj(&s, id); + assert_eq!((t.plain_text().as_str(), t.para_at(4).list), ("Apples\nPears\nPlums\nFigs\n1. ", Some(arabic.at_level(2)))); +} + /// A byte offset given as a negative or fractional number is an error, not the end of the text /// (`at`) or its start or end (`start`, `end`), and the text is left as it was. Null keeps the default. #[test] @@ -2318,6 +2357,18 @@ fn convert_area_point_keeps_text_in_place_for_every_alignment() { } } +#[test] +fn convert_area_to_point_keeps_a_deeper_list_item_in_place() { + let mut s = session(); + let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Item", "size": 18, "area": {"width": 150, "height": 200}})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("text.setFormat", &json!({"ids": [id.0], "list": "bullet", "listLevel": 3})).unwrap(); + let before = first_glyph_doc_pos(&obj(&s, id)); + s.execute("type.convertToPointType", &json!({"ids": [id.0]})).unwrap(); + let after = first_glyph_doc_pos(&obj(&s, id)); + assert!((after - before).hypot() < 0.01, "{before:?} → {after:?}"); +} + #[test] fn commands_are_listed_with_params() { let s = session(); diff --git a/crates/pdf/src/tests/realtext.rs b/crates/pdf/src/tests/realtext.rs index e866ba5cb..375b319b3 100644 --- a/crates/pdf/src/tests/realtext.rs +++ b/crates/pdf/src/tests/realtext.rs @@ -470,6 +470,21 @@ fn numbers_in_threaded_frames_are_written_as_text_in_each_frame() { assert_markers_in_place(&d, &back, &drawn.iter().map(String::as_str).collect::<Vec<_>>(), 0.1); } +/// The numbers of list paragraphs at a deeper level are written as text where the canvas draws +/// them, 18 pt further in, each level with its own count. +#[test] +fn numbers_at_a_deeper_list_level_are_written_as_text_where_the_canvas_draws_them() { + let number = |level| Some(ParaList::numbered(NumberStyle::Arabic).at_level(level)); + let d = doc(vec![listed("first\nsecond\nthird\nfourth", &[number(1), number(2), number(2), number(1)])]); + let origins = marker_origins(&d); + let x = |m: usize| origins.get(m).and_then(|o| o.first()).map_or(f64::NAN, |p| p.x); + assert!((x(1) - x(0) - 18.0).abs() < 1e-6 && (x(3) - x(0)).abs() < 1e-6, "{origins:?}"); + let back = import_as(&pdf(&d, false).bytes, TextAs::Text); + let all = texts(&back).concat(); + assert!(before(&all, "1.", "first") && before(&all, "2.", "third") && all.matches("2.").count() == 2, "{all}"); + assert_markers_in_place(&d, &back, &["1.", "1.", "2.", "2."], 0.1); +} + /// A number set in oldstyle figures (the first character's OpenType features) is written as its /// digits, though the character map gives the digits other glyphs. The reopened type has no /// OpenType features and sets lining figures, so only where each number starts is compared. diff --git a/crates/svg/tests/it/text_layout.rs b/crates/svg/tests/it/text_layout.rs index eee6e2fbf..adcc101f2 100644 --- a/crates/svg/tests/it/text_layout.rs +++ b/crates/svg/tests/it/text_layout.rs @@ -435,6 +435,17 @@ fn numbers_are_written_as_their_characters_where_they_are_drawn() { assert!(at(">I.\t<") < at("alpha") && at("alpha") < at(">II.\t<") && at(">II.\t<") < at("zeta") && at("zeta") < at(">III.\t<"), "{svg}"); assert!(!svg.contains(">-<"), "{svg}"); assert_drawn_in_place(&d); + // Aligned right: the gap comes before each number, which a viewer draws where the canvas does, + // against the text. + let mut t = TextObject::point(Point::new(20.0, 20.0), "alpha beta gamma delta epsilon\nzeta\neta", style(12.0)); + t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 120.0, 160.0)) }; + t.para.list = Some(ParaList::numbered(NumberStyle::UpperRoman).aligned(vectorcraft_doc::text::ListAlign::Right)); + t.para.left_indent = 18.0; + t.para.first_line_indent = -18.0; + let d = doc(t); + let svg = export(&d, &ExportOptions::default()); + assert!(svg.contains(">III.<") && !svg.contains(">III.\t<") && !svg.contains(">-<"), "{svg}"); + assert_drawn_in_place(&d); let mut line = BezPath::new(); line.move_to((0.0, 50.0)); line.line_to((250.0, 50.0)); diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 053cd2981..7a468f51e 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use kurbo::{Affine, BezPath, ParamCurve, PathEl, Point, Rect, Shape, Vec2}; use unicode_bidi::{BidiInfo, Level}; -use vectorcraft_doc::text::Kashida; +use vectorcraft_doc::text::{Kashida, ListAlign}; use vectorcraft_doc::{ Burasagari, CharStyle, HyphenationOptions, InlineArt, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, RubyPosition, SingleWordJustify, TextKind, TextObject, @@ -243,12 +243,16 @@ impl Ctx<'_> { /// before its glyphs `sg` and return how many glyphs went in (none for a paragraph without a /// marker). The marker's characters are set in /// [`vectorcraft_doc::text::ParaList::marker_style`], and a tab after them takes the text to - /// the next tab stop past them on the first line, the left indent counting as a stop when no - /// explicit stop comes before it. In a horizontal right-to-left paragraph, whose indents are - /// mirrored ([`line_indents`]), these are measured from the right, and a number reads left to - /// right with its period at its left. The tab keeps that width however the line is aligned or - /// justified. Each of these glyphs stands for no character of the text: length 0, at the - /// paragraph's first byte, in its first character's run, at the paragraph's embedding level. + /// the next tab stop past them on the first line, the left indent plus its list level's indent + /// ([`ParaStyle::laid_out_left_indent`]) counting as a stop when no explicit stop comes before + /// it. With the list's [`ListAlign::Right`] that tab goes before them instead, with the same + /// width: the marker ends where the text starts, and the text starts where it does with + /// [`ListAlign::Left`]. In a horizontal right-to-left paragraph, + /// whose indents are mirrored ([`line_indents`]), these are measured from the right, and a + /// number reads left to right with its period at its left. The tab keeps that width however + /// the line is aligned or justified. Each of these glyphs stands for no character of the + /// text: length 0, at the paragraph's first byte, in its first character's run, at the + /// paragraph's embedding level. fn prepend_marker(&self, pr: &Range<usize>, para: &ParaStyle, text: Option<&str>, rtl: bool, sg: &mut Vec<SGlyph>) -> usize { let Some(text) = text.filter(|_| para.list.is_some() && !pr.is_empty()) else { return 0 }; let st = vectorcraft_doc::text::ParaList::marker_style(self.style_at(pr.start)); @@ -281,11 +285,16 @@ impl Ctx<'_> { tab.kern_shift = 0.0; tab.tcy = None; tab.vpal = None; - let pen = para.left_indent + para.first_line_indent + marker.iter().map(|g| g.adv).sum::<f64>(); - let to_indent = para.left_indent - pen; - let stop_before_indent = para.tabs.iter().any(|t| t.position > pen + EPS && t.position < para.left_indent - EPS); + let left = para.laid_out_left_indent(); + let pen = left + para.first_line_indent + marker.iter().map(|g| g.adv).sum::<f64>(); + let to_indent = left - pen; + let stop_before_indent = para.tabs.iter().any(|t| t.position > pen + EPS && t.position < left - EPS); tab.adv = if to_indent > EPS && !stop_before_indent { to_indent } else { tab_advance(¶.tabs, 0.0, pen, sg).0 }; - marker.push(tab); + if para.list.is_some_and(|l| l.align == ListAlign::Right) { + marker.insert(0, tab); + } else { + marker.push(tab); + } let n = marker.len(); sg.splice(0..0, marker); n diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index fd663c9a9..a2534c420 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -192,7 +192,7 @@ pub(super) fn tab_advance<'t>(tabs: &'t [vectorcraft_doc::TabStop], origin: f64, /// The width of glyphs `from..to` of `sg` set on a line whose content starts `x_start` from the /// tab origin: tabs of the text take what [`tab_advance`] gives them, not the width of their -/// glyph. The tab after a list marker (length 0) keeps its own width. +/// glyph. The tab of a list marker (length 0) keeps its own width. pub(super) fn set_width(sg: &[SGlyph], from: usize, to: usize, tabs: &[vectorcraft_doc::TabStop], x_start: f64) -> f64 { let mut x = x_start; for (j, g) in sg.iter().enumerate().take(to).skip(from) { @@ -313,7 +313,8 @@ pub(super) fn break_line( } } } - let cluster_width: f64 = if gl.ch == '\t' { + // A list marker's glyphs, its tab included, keep their own widths. + let cluster_width: f64 = if gl.ch == '\t' && gl.len > 0 { tab_advance(tabs, 0.0, x_start + x, g.get(cluster_end..).unwrap_or_default()).0 } else { g[j..cluster_end].iter().map(|glyph| glyph.adv).sum() @@ -348,14 +349,14 @@ pub(super) fn break_line( if j >= g.len() { return (g.len(), false); } - // Hyphenate the word that overflows. - let word_start = last_break.map_or(i, |b| b.0); + // Hyphenate the word that overflows. A list marker starting the line is not part of a word. + let word_start = last_break.map_or(first, |b| b.0); let in_zone = |zone: f64| { let mut te = word_start; - while te > i && g[te - 1].is_space() { + while te > first && g[te - 1].is_space() { te -= 1; } - te > i && zone > 0.0 && width - g[i..te].iter().map(|g| g.adv).sum::<f64>() <= zone + EPS + te > first && zone > 0.0 && width - g[i..te].iter().map(|g| g.adv).sum::<f64>() <= zone + EPS }; if let Some((opts, zone)) = hyphenate && g[j].is_letter() @@ -555,7 +556,9 @@ pub(super) fn candidates(text: &str, g: &[SGlyph], hyphenate: Option<&Hyphenatio let mut v = vec![]; let mut ws = 0; for (j, gl) in g.iter().enumerate() { - let end_of_word = gl.is_space() || gl.break_after(); + // A list marker (the glyphs of length 0 here) ends before the first word. + let marker_end = gl.len == 0 && g.get(j + 1).is_some_and(|n| n.len > 0); + let end_of_word = gl.is_space() || gl.break_after() || marker_end; if end_of_word { if let Some(opts) = hyphenate && j > ws diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 79cc79ba6..698596215 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -34,7 +34,7 @@ fn compose_para( let may_hang = para.burasagari != Burasagari::None && sg.iter().any(hangs); // A tab's width depends on where it falls in its line, which the composer's fixed glyph widths // can't follow: such a paragraph is broken line by line ([`break_line`] measures its tabs). - // The tab after a list marker always starts the first line and has a fixed width. + // The tab of a list marker is on the first line, with the marker, and has a fixed width. let has_tab = sg.iter().any(|g| g.ch == '\t' && g.len > 0); let has_soft_break = sg.iter().any(|g| g.ch == '\u{2028}'); if composer != Composer::EveryLine @@ -66,7 +66,7 @@ fn compose_para( // indents of a right-to-left list ([`line_indents`]) keep. while widths.len() < sg.len() { let first = widths.is_empty(); - let ind_l = para.left_indent + if first && !continued { para.first_line_indent } else { 0.0 }; + let ind_l = para.laid_out_left_indent() + if first && !continued { para.first_line_indent } else { 0.0 }; // Only the first line's first word is known here. Later lines are placed as the composed // lines need: each fits its width, so its first word does too. let word = if first { word_width(sg, 0) } else { 0.0 }; @@ -104,11 +104,11 @@ fn compose_para( } /// The list marker of each of the paragraphs `paras` of `t` (None for none): bullets, and numbers -/// counted through consecutive list paragraphs from [`TextObject::list_count`], as +/// counted per level through consecutive list paragraphs from [`TextObject::list_counts`], as /// [`vectorcraft_doc::text::ParaList::next_marker`] counts them. pub(super) fn list_markers(t: &TextObject, paras: &[Range<usize>]) -> Vec<Option<String>> { - let mut count = t.list_count; - paras.iter().enumerate().map(|(pi, pr)| vectorcraft_doc::text::ParaList::next_marker(t.para_at(pi).list, pr.is_empty(), &mut count)).collect() + let mut counts = t.list_counts(); + paras.iter().enumerate().map(|(pi, pr)| vectorcraft_doc::text::ParaList::next_marker(t.para_at(pi).list, pr.is_empty(), &mut counts)).collect() } /// Whether paragraph `para` (right to left when `rtl`) has its indents mirrored: a horizontal @@ -120,9 +120,10 @@ fn mirrors_indents(para: &ParaStyle, rtl: bool, vertical: bool) -> bool { /// The indents at the left and at the right of a line of paragraph `para` (its first line when /// `first`). With `mirror` ([`mirrors_indents`]) the left and first-line indents are measured from /// the right and the right indent from the left: the bullet of a right-to-left list hangs at the -/// right, and the lines it wraps to line up with the text after the bullet. +/// right, and the lines it wraps to line up with the text after the bullet. A list level past the +/// first adds to the left indent ([`ParaStyle::laid_out_left_indent`]), mirrored with it. fn line_indents(para: &ParaStyle, first: bool, mirror: bool) -> (f64, f64) { - let start = para.left_indent + if first { para.first_line_indent } else { 0.0 }; + let start = para.laid_out_left_indent() + if first { para.first_line_indent } else { 0.0 }; if mirror { (para.right_indent, start) } else { (start, para.right_indent) } } @@ -723,7 +724,7 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData let mut scale = 1.0; let mut leader = ""; if glyph.ch == '\t' { - // The tab after a list marker keeps the width `prepend_marker` gave it. + // The tab of a list marker keeps the width `prepend_marker` gave it. if glyph.len > 0 { (advance, leader) = tab_advance(¶.tabs, tab_origin, x - tab_shift, &sg[glyph_index + 1..trimmed.max(glyph_index + 1)]); } @@ -764,7 +765,7 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData if glyph_index < paragraph.marker { let at = cx.out.glyphs.len(); marker_glyphs = Some(marker_glyphs.map_or(at..at + 1, |range: Range<usize>| range.start.min(at)..range.end.max(at + 1))); - if glyph_index + 1 == paragraph.marker { + if glyph.ch == '\t' { marker_tab = at; } } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 2dc80a3e9..dad7f0a6f 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -331,7 +331,8 @@ pub struct ListMarker { /// takes the text to the next tab stop. pub glyphs: std::ops::Range<usize>, /// The tab's glyph, which draws nothing: the last of [`Self::glyphs`], or the first in a - /// right-to-left paragraph. + /// right-to-left paragraph. A marker aligned right + /// ([`vectorcraft_doc::text::ListAlign::Right`]) has it the other way round. pub tab: usize, /// The marker's characters ("•"). pub text: String, diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index ef171316e..1338e08db 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1355,6 +1355,157 @@ fn a_right_to_left_numbered_paragraph_has_its_period_left_of_its_number() { assert!((last.origin.x + last.advance - 200.0).abs() < 1e-6, "the number ends the line at the right: {last:?}"); } +/// Bullets and Numbering › Alignment: Left puts a list paragraph's marker at the first line's +/// start, and Right puts it against the text, which starts at the same place with either. Numbers +/// of different widths aligned right end at the same place, the caret before the first character +/// stands at the text, and the lines the paragraph wraps to start at the left indent. A number +/// wider than the room before the left indent takes the text to the next tab stop and ends there. +/// In a right-to-left paragraph, a marker aligned right starts where the text ends. +#[test] +fn a_list_marker_aligned_right_ends_where_the_text_starts() { + use vectorcraft_doc::text::{Bullet, ListAlign, NumberStyle, ParaList}; + // Each marker's characters' left and right edges, and where its line's text starts. + let spans = |t: &TextObject| -> Vec<(f64, f64, f64)> { + let lay = layout(db(), t); + let mut out = vec![]; + for m in &lay.markers { + let chars: Vec<usize> = m.glyphs.clone().filter(|&i| i != m.tab).collect(); + let left = chars.iter().map(|&i| lay.glyphs[i].origin.x).fold(f64::MAX, f64::min); + let right = chars.iter().map(|&i| lay.glyphs[i].origin.x + lay.glyphs[i].advance).fold(f64::MIN, f64::max); + let line = &lay.lines[lay.glyphs[m.tab].line]; + let text = lay.glyphs[line.glyph_start..line.glyph_end].iter().find(|g| g.len > 0).unwrap(); + out.push((left, right, text.origin.x)); + } + out + }; + let numbered = |align: ListAlign, count: u32| { + let mut t = listed("One\nTwo", &[Some(ParaList::numbered(NumberStyle::Arabic).aligned(align)); 2]); + t.list_count = count; + t + }; + // "9." and "10.": aligned left, both start at the first-line indent (0); aligned right, both end + // at the left indent (18), where the text starts either way. + let left = spans(&numbered(ListAlign::Left, 8)); + let right = spans(&numbered(ListAlign::Right, 8)); + assert_eq!((left.len(), right.len()), (2, 2)); + for (k, ((l0, r0, text0), (l, r, text))) in left.iter().zip(&right).enumerate() { + assert!(l0.abs() < 1e-6 && (text0 - 18.0).abs() < 1e-6, "item {k} aligned left: {l0} {text0}"); + assert!((r - 18.0).abs() < 1e-6 && (text - text0).abs() < 1e-6, "item {k} aligned right ends at the text: {r} {text}"); + assert!(((r - l) - (r0 - l0)).abs() < 1e-6, "item {k} keeps its width"); + } + assert!(right[1].0 < right[0].0, "the wider number starts further left: {right:?}"); + let t = numbered(ListAlign::Right, 0); + let lay = layout(db(), &t); + let m = &lay.markers[0]; + assert_eq!((m.tab, lay.marker_text(m.tab), lay.marker_text(m.glyphs.start + 1)), (m.glyphs.start, Some("\t"), Some("1."))); + let (top, _) = caret_position(&lay, 0); + assert!((top.x - 18.0).abs() < 1e-6, "the caret before the first character: {top:?}"); + // A wrapped paragraph's other lines start at the left indent. + let long = listed(LOREM, &[Some(ParaList::bulleted(Bullet::Dot).aligned(ListAlign::Right))]); + let lay = layout(db(), &long); + assert!(lay.lines.len() > 2, "the paragraph wraps"); + for (i, line) in lay.lines.iter().enumerate() { + let first = lay.glyphs[line.glyph_start..line.glyph_end].iter().find(|g| g.len > 0).unwrap(); + assert!((first.origin.x - 18.0).abs() < 1e-6, "line {i} starts at {}", first.origin.x); + } + let bullet = &spans(&long)[0]; + assert!((bullet.1 - 18.0).abs() < 1e-6 && bullet.0 > 0.0, "{bullet:?}"); + // "1000." is wider than the 18 pt before the left indent: the text and the number's end go to + // the next default stop. + let mut wide = listed("One", &[Some(ParaList::numbered(NumberStyle::ThreeDigits).aligned(ListAlign::Right))]); + wide.list_count = 999; + let (_, end, text) = spans(&wide)[0]; + assert!((text - 36.0).abs() < 1e-6 && (end - 36.0).abs() < 1e-6, "{end} {text}"); + // Right to left: aligned left the bullet ends the line at the right; aligned right it starts + // where the text ends, and the text ends where it does aligned left. + let rtl = |align: ListAlign| { + let mut t = listed("שלום עולם", &[Some(ParaList::bulleted(Bullet::Dot).aligned(align))]); + t.edit_paras(None, |p| p.justify = Justify::Auto); + let lay = layout(db(), &t); + let m = &lay.markers[0]; + let bullet = m.glyphs.clone().find(|&i| i != m.tab).map(|i| (lay.glyphs[i].origin.x, lay.glyphs[i].origin.x + lay.glyphs[i].advance)); + let text_right = lay.glyphs.iter().filter(|g| g.len > 0).map(|g| g.origin.x + g.advance).fold(0.0, f64::max); + (bullet.unwrap(), text_right) + }; + let ((_, end), text_left_aligned) = rtl(ListAlign::Left); + assert!((end - 200.0).abs() < 1e-6, "{end}"); + let ((start, _), text_right_aligned) = rtl(ListAlign::Right); + assert!((start - text_right_aligned).abs() < 1e-6 && (text_right_aligned - text_left_aligned).abs() < 1e-6, "{start} {text_right_aligned}"); +} + +/// A list paragraph breaks its lines where the same paragraph without a marker, its text at the +/// same indent, breaks them: with either marker alignment, a bullet or a letter, and either +/// composer, the marker and its gap count at their own width and the first word hyphenates. +#[test] +fn a_list_paragraph_breaks_its_lines_as_its_text_alone_does() { + use vectorcraft_doc::text::{Bullet, ListAlign, NumberStyle, ParaList}; + let text = "Extraordinarily comprehensive typographical considerations notwithstanding everything."; + // Each line's end, and whether it ends with a hyphen. + let breaks = |list: Option<ParaList>, composer: vectorcraft_doc::Composer| { + let mut t = area(text, style(14.0), Rect::new(0.0, 0.0, 98.0, 600.0), Justify::Left); + t.para.left_indent = 18.0; + t.para.first_line_indent = if list.is_some() { -18.0 } else { 0.0 }; + t.para.hyphenate = true; + t.para.composer = composer; + t.para.list = list; + let lay = layout(db(), &t); + let hyphen = |k: usize| lay.glyphs[k].len == 0 && lay.marker_of(k).is_none(); + lay.lines.iter().map(|l| (l.end, (l.glyph_start..l.glyph_end).any(hyphen))).collect::<Vec<_>>() + }; + for composer in [vectorcraft_doc::Composer::SingleLine, vectorcraft_doc::Composer::EveryLine] { + let alone = breaks(None, composer); + assert!(alone.first().is_some_and(|&(_, hyphen)| hyphen), "the first word hyphenates: {alone:?}"); + for list in [ParaList::bulleted(Bullet::Dot), ParaList::numbered(NumberStyle::UpperLetters)] { + for align in [ListAlign::Left, ListAlign::Right] { + assert_eq!(breaks(Some(list.aligned(align)), composer), alone, "{composer:?} {list:?} {align:?}"); + } + } + } +} + +/// List levels: a paragraph at a level past the first is set 18 pt further in for each level, its +/// marker, its text and the lines it wraps to; each level counts its own numbers, a deeper level +/// starting at 1 and a shallower one going on with its count, from the counts the earlier frames of +/// a thread carry. A right-to-left list paragraph is set that much further in from the right. +#[test] +fn a_list_paragraph_at_a_deeper_level_is_set_further_in_and_counts_on_its_own() { + use vectorcraft_doc::text::{Bullet, ListCounts, NumberStyle, ParaList}; + let num = |level| Some(ParaList::numbered(NumberStyle::Arabic).at_level(level)); + let t = listed("One\nTwo\nThree\nFour\nFive\nSix\nSeven", &[num(1), num(2), num(2), num(3), num(2), num(1), num(2)]); + assert_eq!(marker_texts(&t), ["1.", "1.", "2.", "1.", "3.", "2.", "1."]); + let lay = layout(db(), &t); + for (k, (m, level)) in lay.markers.iter().zip([1, 2, 2, 3, 2, 1, 2]).enumerate() { + let number = &lay.glyphs[m.glyphs.start]; + let line = &lay.lines[number.line]; + let text = lay.glyphs[line.glyph_start..line.glyph_end].iter().find(|g| g.len > 0).unwrap(); + let step = 18.0 * f64::from(level - 1); + assert!((number.origin.x - step).abs() < 1e-6, "item {k}: the number starts at {}", number.origin.x); + assert!((text.origin.x - 18.0 - step).abs() < 1e-6, "item {k}: the text starts at {}", text.origin.x); + } + // The counts the earlier frames carry, per level. + let mut t = listed("Two\nThree\nFour", &[num(2), num(3), num(1)]); + t.set_list_counts(ListCounts([4, 6, 0, 0, 0, 0, 0, 0, 0])); + assert_eq!(marker_texts(&t), ["7.", "1.", "5."]); + // The lines a deeper paragraph wraps to start at its left indent plus the level's 18 pt. + let long = listed(LOREM, &[Some(ParaList::bulleted(Bullet::Dot).at_level(3))]); + let lay = layout(db(), &long); + assert!(lay.lines.len() > 2, "the paragraph wraps"); + assert!((lay.glyphs[lay.markers[0].glyphs.start].origin.x - 36.0).abs() < 1e-6); + for (i, line) in lay.lines.iter().enumerate() { + let first = lay.glyphs[line.glyph_start..line.glyph_end].iter().find(|g| g.len > 0).unwrap(); + assert!((first.origin.x - 54.0).abs() < 1e-6, "line {i} starts at {}", first.origin.x); + } + // Right to left: the bullet at level 2 ends 18 pt before the right edge. + let rtl = |level| { + let mut t = listed("שלום עולם", &[Some(ParaList::bulleted(Bullet::Dot).at_level(level))]); + t.edit_paras(None, |p| p.justify = Justify::Auto); + let lay = layout(db(), &t); + let m = &lay.markers[0]; + m.glyphs.clone().find(|&i| i != m.tab).map(|i| lay.glyphs[i].origin.x + lay.glyphs[i].advance).unwrap() + }; + assert!((rtl(1) - 200.0).abs() < 1e-6 && (rtl(2) - 182.0).abs() < 1e-6, "{} {}", rtl(1), rtl(2)); +} + /// Are the Japanese craft-fonts faces built in? Tests of Japanese glyphs skip (and say so) when /// they aren't: the bundled fonts have none, and [`db`] reads no system fonts. fn japanese_fonts() -> bool { diff --git a/crates/text/src/thread.rs b/crates/text/src/thread.rs index 191963107..cfdd41cc7 100644 --- a/crates/text/src/thread.rs +++ b/crates/text/src/thread.rs @@ -4,6 +4,7 @@ //! testing need nothing special; after an edit the engine joins the slices and re-distributes them //! with [`distribute`]. +use vectorcraft_doc::text::ListCounts; use vectorcraft_doc::{ParaStyle, TextObject, TextRun}; use crate::edit::{normalize, runs_len, slice_runs, style_at}; @@ -82,29 +83,38 @@ fn fit(db: &FontDb, frame: &TextObject, runs: &[TextRun]) -> usize { } /// A frame's share of a story: its runs, its paragraphs' styles (one per paragraph) and the -/// numbered list paragraphs counted before its first one ([`TextObject::list_count`]). +/// numbered list paragraphs counted before its first one, at level 1 and at the levels past it +/// ([`TextObject::list_count`], [`TextObject::list_level_counts`]). #[derive(Clone, Debug, PartialEq)] pub struct Slice { pub runs: Vec<TextRun>, pub paras: Vec<ParaStyle>, pub list_count: u32, + pub list_level_counts: Vec<u32>, /// The first paragraph continues from the frame before /// ([`TextObject::first_para_continues`]). pub continues: bool, } -/// The count of numbered list paragraphs after the paragraphs of `runs` (styles `paras`, one per -/// paragraph) but the last one, counted on from `count` as the layout counts them +impl Slice { + /// The list counts the slice's frame starts from ([`TextObject::list_counts`]). + pub fn list_counts(&self) -> ListCounts { + ListCounts::from_parts(self.list_count, &self.list_level_counts) + } +} + +/// The counts of numbered list paragraphs at each level after the paragraphs of `runs` (styles +/// `paras`, one per paragraph) but the last one, counted on from `counts` as the layout counts them /// ([`vectorcraft_doc::text::ParaList::next_marker`]). A slice's last paragraph also starts the /// next frame, which counts it. -fn count_list_paragraphs(runs: &[TextRun], paras: &[ParaStyle], mut count: u32) -> u32 { +fn count_list_paragraphs(runs: &[TextRun], paras: &[ParaStyle], mut counts: ListCounts) -> ListCounts { let text: String = runs.iter().map(|r| r.text.as_str()).collect(); let mut pieces: Vec<&str> = text.split('\n').collect(); pieces.pop(); for (piece, para) in pieces.iter().zip(paras) { - vectorcraft_doc::text::ParaList::next_marker(para.list, piece.is_empty(), &mut count); + vectorcraft_doc::text::ParaList::next_marker(para.list, piece.is_empty(), &mut counts); } - count + counts } /// Whether the frame after one whose share ends with byte `before` starts inside a paragraph: it @@ -134,21 +144,36 @@ pub fn continued(frames: &[&TextObject]) -> Vec<bool> { /// keep one empty run carrying the style at the split, so typing there has a style. A paragraph /// split between frames has its style in both, and each slice that starts inside a paragraph is /// marked as continuing it ([`Slice::continues`]). Each frame's numbered list paragraphs are counted -/// on from the frames before it. Each frame's share is measured with that mark and that count. +/// on from the frames before it, at each level. Each frame's share is measured with that mark and +/// those counts. pub fn distribute(db: &FontDb, frames: &[&TextObject], runs: &[TextRun], paras: &[ParaStyle]) -> Vec<Slice> { - distribute_continuing(db, frames, runs, paras, false, 0) + distribute_continuing_levels(db, frames, runs, paras, false, ListCounts::default()) } /// [`distribute`] for `runs` that follow text before them: the first frame's share is measured, /// and marked, as continuing a paragraph when `continued` is set, and its numbered list paragraphs -/// are counted on from `list_count`. +/// at level 1 are counted on from `list_count` ([`distribute_continuing_levels`] takes the counts +/// of every level). pub fn distribute_continuing( db: &FontDb, frames: &[&TextObject], runs: &[TextRun], paras: &[ParaStyle], continued: bool, - mut list_count: u32, + list_count: u32, +) -> Vec<Slice> { + distribute_continuing_levels(db, frames, runs, paras, continued, ListCounts::from_parts(list_count, &[])) +} + +/// [`distribute_continuing`] with the first frame's numbered list paragraphs counted on from +/// `counts` at each level. +pub fn distribute_continuing_levels( + db: &FontDb, + frames: &[&TextObject], + runs: &[TextRun], + paras: &[ParaStyle], + continued: bool, + mut counts: ListCounts, ) -> Vec<Slice> { let mut rest = runs.to_vec(); let mut out = Vec::with_capacity(frames.len()); @@ -165,7 +190,7 @@ pub fn distribute_continuing( let mut probe = (*f).clone(); probe.runs = rest.clone(); probe.set_paragraph_styles(slice_paras(paras, first_para, &rest)); - probe.list_count = list_count; + probe.set_list_counts(counts); probe.first_para_continues = continues; fit(db, &probe, &rest) }; @@ -177,11 +202,12 @@ pub fn distribute_continuing( rest = if tail.is_empty() { vec![TextRun { text: String::new(), style: style_at(&rest, len), inline: None }] } else { tail }; let head_paras = slice_paras(paras, first_para, &head); next_para += head_paras.len().saturating_sub(1); - let next_count = count_list_paragraphs(&head, &head_paras, list_count); + let next_counts = count_list_paragraphs(&head, &head_paras, counts); let starts_continued = continues; continues = continues_after(head.iter().rev().find_map(|r| r.text.bytes().last()), continues); - out.push(Slice { runs: head, paras: head_paras, list_count, continues: starts_continued }); - list_count = next_count; + let (list_count, list_level_counts) = counts.parts(); + out.push(Slice { runs: head, paras: head_paras, list_count, list_level_counts, continues: starts_continued }); + counts = next_counts; } out } diff --git a/crates/text/src/thread/tests.rs b/crates/text/src/thread/tests.rs index 222df20c2..c3f05cef4 100644 --- a/crates/text/src/thread/tests.rs +++ b/crates/text/src/thread/tests.rs @@ -378,6 +378,40 @@ fn a_list_paragraph_continued_onto_a_path_has_no_bullet_there() { assert_eq!(layout(db, &alone).markers.len(), 1); } +/// List levels: each level's count runs on through the frames, a deeper level in the next +/// frame going on with the count its paragraphs reached in the frame before. +#[test] +fn list_levels_count_on_through_the_frames() { + use vectorcraft_doc::text::{NumberStyle, ParaList}; + let db = FontDb::global(); + let (a, mut b) = (frame(0.0), frame(200.0)); + b.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(200.0, 0.0, 320.0, 400.0)) }; + let at = |level| ParaStyle { + list: Some(ParaList::numbered(NumberStyle::Arabic).at_level(level)), + left_indent: 18.0, + first_line_indent: -18.0, + ..Default::default() + }; + let numbers = |f: &TextObject, s: &Slice| { + let mut t = f.clone(); + t.runs = s.runs.clone(); + t.set_paragraph_styles(s.paras.clone()); + t.set_list_counts(s.list_counts()); + layout(db, &t).markers.iter().map(|m| m.text.clone()).collect::<Vec<_>>() + }; + // One item at level 1 and eleven at level 2: 1. then 1. to 11. through both frames. + let paras: Vec<ParaStyle> = (0..12).map(|i| at(if i == 0 { 1 } else { 2 })).collect(); + let text = (1..=12).map(|i| format!("Item {i}")).collect::<Vec<_>>().join("\n"); + let story = vec![TextRun { text, style: CharStyle::default(), inline: None }]; + let parts = distribute(db, &[&a, &b], &story, ¶s); + let first = numbers(&a, &parts[0]); + assert!((2..12).contains(&first.len()), "both frames hold items: {first:?}"); + let both: Vec<String> = [first.clone(), numbers(&b, &parts[1])].concat(); + let expected: Vec<String> = std::iter::once("1.".to_string()).chain((1..=11).map(|n| format!("{n}."))).collect(); + assert_eq!(both, expected); + assert_eq!((parts[1].list_count, parts[1].list_level_counts.clone()), (1, vec![first.len() as u32 - 1])); +} + #[test] fn short_story_leaves_later_frames_empty_with_a_style() { let db = FontDb::global(); diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 50afa31c2..78fc10a1b 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -476,6 +476,43 @@ impl TypeTool { out } + /// Tab (`by` 1) or Shift+Tab (`by` -1) in type `t` (plain text `text`) with the selection + /// `a..b`: with the caret at the start of a list paragraph, the `text.setFormat` that moves the + /// paragraph a level down or up (none at the first or the last level: the key then does + /// nothing). None when the caret is anywhere else, the selection holds characters, or the + /// paragraph is in no list. The start of a threaded frame whose first paragraph continues from + /// the frame before ([`TextObject::first_para_continues`]) is not the start of a paragraph. + fn list_level_step(&self, t: &TextObject, text: &str, a: usize, b: usize, by: i32) -> Option<Vec<Action>> { + let id = self.editing?; + if a != b || edit::paragraph_at(text, a).start != a || (a == 0 && t.first_para_continues) { + return None; + } + let para = text.get(..a)?.matches('\n').count(); + let list = t.para_at(para).list?; + let level = i32::try_from(list.depth()).ok()? + by; + let max = i32::try_from(vectorcraft_doc::text::LIST_LEVELS).ok()?; + if !(1..=max).contains(&level) { + return Some(vec![]); + } + Some(vec![Action::Exec("text.setFormat".into(), json!({"id": id.0, "start": a, "end": a, "listLevel": level}))]) + } + + /// Tab (Shift+Tab with `shift`) in type `t` (plain text `text`) with the selection `a..b`: with + /// the caret at the start of a list paragraph, it moves the paragraph a level down or up + /// ([`Self::list_level_step`]); anywhere else Tab inserts a tab. + fn tab(&mut self, cx: &ToolContext, t: &TextObject, text: &str, (a, b): (usize, usize), shift: bool) -> Vec<Action> { + match self.list_level_step(t, text, a, b, if shift { -1 } else { 1 }) { + // At the first or the last level the key does nothing, and the typing goes on. + Some(step) if step.is_empty() => step, + Some(step) => { + let mut out = self.commit(); + out.extend(step); + out + } + None => self.replace(cx, "\t", None), + } + } + /// The other frames whose whole text is selected along with the edited frame's after Select /// All in threaded text ([`Self::story`]), read from the document's current thread: none once /// the selection is not all of the edited frame's text. diff --git a/crates/tools/src/text/tests.rs b/crates/tools/src/text/tests.rs index 4ae1424f6..a7f302db6 100644 --- a/crates/tools/src/text/tests.rs +++ b/crates/tools/src/text/tests.rs @@ -535,6 +535,105 @@ fn backspace_deletes_selection_or_word() { assert_eq!(tool.caret, 6); } +/// Tab and Shift+Tab with the caret at the start of a list paragraph move it a level down or up +/// with one `text.setFormat` (one undo step), ending the typing first; at level 1 Shift+Tab and at +/// level 9 Tab do nothing. Anywhere else Tab inserts a tab: in the middle or at the end of a list +/// paragraph, with characters selected, and in a paragraph in no list. +#[test] +fn tab_at_the_start_of_a_list_paragraph_changes_its_level() { + use vectorcraft_doc::text::{Bullet, ParaList}; + let (mut d, id, mut tool) = editing("One\nTwo\nThree"); + let NodeKind::Text(t) = &mut d.node_mut(id).unwrap().kind else { panic!("text") }; + let dot = |level| vectorcraft_doc::ParaStyle { list: Some(ParaList::bulleted(Bullet::Dot).at_level(level)), ..Default::default() }; + t.set_paragraph_styles(vec![dot(1), dot(9), vectorcraft_doc::ParaStyle::default()]); + let press = |tool: &mut TypeTool, at: (usize, usize), mods: Mods| { + (tool.anchor, tool.caret) = at; + key(tool, &d, ToolKey::Tab, mods) + }; + let level = |acts: &[Action]| match acts { + [Action::Exec(cmd, p)] if cmd == "text.setFormat" => { + assert_eq!((p["id"].as_u64(), p["end"].as_u64()), (Some(id.0), p["start"].as_u64())); + (p["start"].as_u64(), p["listLevel"].as_i64()) + } + other => panic!("not a level change: {other:?}"), + }; + assert_eq!(level(&press(&mut tool, (0, 0), Mods::default())), (Some(0), Some(2))); + assert_eq!(level(&press(&mut tool, (4, 4), SHIFT)), (Some(4), Some(8))); + assert!(press(&mut tool, (0, 0), SHIFT).is_empty(), "Shift+Tab at level 1 does nothing"); + assert!(press(&mut tool, (4, 4), Mods::default()).is_empty(), "Tab at level 9 does nothing"); + let inserts_tab = |acts: &[Action], at: u64| { + acts.iter().any( + |a| matches!(a, Action::Preview(cmd, p) if cmd == "text.editRange" && p["start"].as_u64() == Some(at) && p["runs"][0]["text"] == "\t"), + ) + }; + for (at, why) in [((1, 1), "mid-item"), ((3, 3), "at the item's end"), ((0, 2), "with characters selected"), ((8, 8), "in no list")] { + let mut fresh = TypeTool::new("type"); + fresh.start_editing(id, 0); + let acts = press(&mut fresh, at, Mods::default()); + assert!(inserts_tab(&acts, at.0 as u64), "{why}: {acts:?}"); + } + // While typing, Tab at the start of a list paragraph ends the typing first. + let mut typing = TypeTool::new("type"); + typing.start_editing(id, 3); + let snapshot = d.clone(); + let mut after = d.clone(); + let acts = typing.text_input(&cx(&d, &Selection::default(), &paint()), "!"); + apply(&mut after, &snapshot, &acts); + // Tab at level 9 does nothing, and the typing goes on as one undo step. + (typing.anchor, typing.caret) = (5, 5); + let acts = typing.key(&cx(&after, &Selection::default(), &paint()), ToolKey::Tab, Mods::default()); + assert!(acts.is_empty() && typing.typing.is_some(), "Tab at level 9 while typing: {acts:?}"); + let acts = { + (typing.anchor, typing.caret) = (5, 5); + typing.key(&cx(&after, &Selection::default(), &paint()), ToolKey::Tab, SHIFT) + }; + assert_eq!(acts.first(), Some(&Action::Commit)); + assert!(matches!(&acts[1..], [Action::Exec(cmd, p)] if cmd == "text.setFormat" && p["listLevel"] == 8 && p["start"] == 5), "{acts:?}"); +} + +/// In a threaded frame, the start of the frame is the start of a list paragraph only when the +/// frames before it end with a paragraph break: Tab there in a paragraph that goes on from the +/// frame before inserts a tab, and Tab after the paragraph's end changes the next one's level. +#[test] +fn tab_at_the_start_of_a_threaded_frame_changes_a_level_only_at_a_paragraph_start() { + use vectorcraft_doc::text::{Bullet, ParaList}; + let dot = vectorcraft_doc::ParaStyle { list: Some(ParaList::bulleted(Bullet::Dot)), ..Default::default() }; + let threaded = |first: &str, second: &str| { + let (mut d, a) = doc_with_text(first); + let l = d.layers[0].id; + let b = d.alloc_id(); + let mut t = TextObject::point(Point::new(100.0, 300.0), second, CharStyle { size: 20.0, ..Default::default() }); + t.set_paragraph_styles(vec![dot.clone(); second.matches('\n').count() + 1]); + let NodeKind::Text(head) = &d.node(a).unwrap().kind else { panic!("text") }; + t.first_para_continues = vectorcraft_text::thread::continued(&[head, &t])[1]; + d.insert(Some(l), 0, Node::new(b, NodeKind::Text(Box::new(t)))).unwrap(); + let NodeKind::Text(t) = &mut d.node_mut(a).unwrap().kind else { panic!("text") }; + t.set_paragraph_styles(vec![dot.clone(); first.matches('\n').count() + 1]); + d.text_threads.push(vec![a, b]); + let mut tool = TypeTool::new("type"); + tool.start_editing(b, 0); + (d, b, tool) + }; + let level_change = |acts: &[Action]| matches!(acts, [Action::Exec(cmd, p)] if cmd == "text.setFormat" && p["listLevel"] == 2); + let inserts_tab = + |acts: &[Action]| acts.iter().any(|a| matches!(a, Action::Preview(cmd, p) if cmd == "text.editRange" && p["runs"][0]["text"] == "\t")); + // "Two" goes on from the first frame: Tab at the frame's start inserts a tab, and Tab at the + // start of "Three" changes its level. + let (d, _, mut tool) = threaded("One\nTw", "o\nThree"); + let acts = key(&mut tool, &d, ToolKey::Tab, Mods::default()); + assert!(inserts_tab(&acts), "mid-paragraph: {acts:?}"); + let (d, _, mut tool) = threaded("One\nTw", "o\nThree"); + (tool.anchor, tool.caret) = (2, 2); + let acts = key(&mut tool, &d, ToolKey::Tab, Mods::default()); + assert!(level_change(&acts), "at the next paragraph: {acts:?}"); + // A frame after a paragraph break, or after frames with no text, starts a paragraph. + for first in ["One\n", ""] { + let (d, _, mut tool) = threaded(first, "Two"); + let acts = key(&mut tool, &d, ToolKey::Tab, Mods::default()); + assert!(level_change(&acts), "after {first:?}: {acts:?}"); + } +} + #[test] fn double_and_triple_click_select_word_and_paragraph() { let (d, id, mut tool) = editing("Alpha beta\ngamma"); @@ -1740,3 +1839,31 @@ fn shift_enter_inserts_soft_return() { let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() }; assert_eq!(t.plain_text(), "Hello\u{2028}\n World"); } + +#[test] +fn soft_return_in_a_list_item_keeps_tab_within_the_paragraph() { + use vectorcraft_doc::text::{Bullet, ParaList}; + + let (mut d, id, mut tool) = editing("One"); + let NodeKind::Text(t) = &mut d.node_mut(id).unwrap().kind else { panic!("text") }; + t.set_all_paras(vectorcraft_doc::ParaStyle { list: Some(ParaList::bulleted(Bullet::Dot)), ..Default::default() }); + tool.caret = 3; + tool.anchor = 3; + let snapshot = d.clone(); + let acts = key(&mut tool, &d, ToolKey::Enter, SHIFT); + apply(&mut d, &snapshot, &acts); + let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!("text") }; + assert_eq!(t.plain_text(), "One\u{2028}"); + assert_eq!(t.paragraph_count(), 1); + assert_eq!(t.para_at(0).list, Some(ParaList::bulleted(Bullet::Dot))); + + let acts = key(&mut tool, &d, ToolKey::Tab, Mods::default()); + assert!(acts.iter().any( + |a| matches!(a, Action::Preview(cmd, p) if cmd == "text.editRange" && p["runs"][0]["text"].as_str().is_some_and(|s| s.ends_with('\t'))) + )); + + tool.caret = 0; + tool.anchor = 0; + let acts = key(&mut tool, &d, ToolKey::Tab, Mods::default()); + assert!(acts.iter().any(|a| matches!(a, Action::Exec(cmd, p) if cmd == "text.setFormat" && p["listLevel"] == 2))); +} diff --git a/crates/tools/src/text/tool.rs b/crates/tools/src/text/tool.rs index 5d79cceb2..be4815467 100644 --- a/crates/tools/src/text/tool.rs +++ b/crates/tools/src/text/tool.rs @@ -231,7 +231,8 @@ impl Tool for TypeTool { // tool shortcuts again (#1686). ToolKey::Escape => self.end_editing(cx), ToolKey::Enter => self.enter(cx, mods), - ToolKey::Tab => self.replace(cx, "\t", None), + ToolKey::Tab if story => self.replace(cx, "\t", None), + ToolKey::Tab => self.tab(cx, &t, &text, (a, b), mods.shift), ToolKey::Backspace | ToolKey::Delete if a != b || story => self.replace(cx, "", None), ToolKey::Backspace if self.caret > 0 => { self.anchor = if word { edit::prev_word(&text, self.caret) } else { edit::prev_char(&text, self.caret) }; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 48b9895a6..d5e2a30c6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -17,6 +17,7 @@ pub mod blend_options; mod brush_art_options; pub mod brush_delete; pub mod brush_options; +pub mod bullets_numbering; pub mod char_style_options; pub mod color_balance; pub mod color_guide_options; @@ -348,6 +349,7 @@ registry! { Corners: [corners::KIND] => corners::SPEC, Hyphenation: [hyphenation::KIND] => hyphenation::SPEC, Justification: [justification::KIND] => justification::SPEC, + BulletsAndNumbering: [bullets_numbering::KIND] => bullets_numbering::SPEC, TateChuYokoSettings: [tate_chu_yoko::KIND] => tate_chu_yoko::SPEC, CompositeFonts: [composite_fonts::KIND] => composite_fonts::SPEC, SavePdf: [save_pdf::KIND] => save_pdf::SPEC, diff --git a/crates/ui-egui/src/dialogs/bullets_numbering.rs b/crates/ui-egui/src/dialogs/bullets_numbering.rs new file mode 100644 index 000000000..43f15edbb --- /dev/null +++ b/crates/ui-egui/src/dialogs/bullets_numbering.rs @@ -0,0 +1,802 @@ +//! More Options at the end of the Paragraph panel's Bullets and Numbered List presets (or `ui.bulletsAndNumbering {tab?}`): a +//! Bullets tab and a Numbers tab, each with the Level field and the presets, and the Position group +//! (Alignment, Left Indent and First Line Indent) of the paragraphs the Paragraph panel acts on. +//! The dialog opens on the values the Paragraph panel shows, with no preset picked and a field +//! blank where the paragraphs differ. Each change shows on the type while the dialog is open. OK +//! runs `text.setFormat` with the values that changed, and with the indents it shows when a preset +//! puts the paragraphs in a list, as one undo step; Cancel puts the type back as it was. A preset +//! picked for paragraphs that are in no list and have no indents fills in the 18 pt and -18 pt +//! indents `text.setFormat` gives them, and None takes them out again. +//! +//! Fields: `tab` (`bullets` or `numbers`), `list` (`none`, `bullet` or `number`: the kind of the +//! preset picked), `bullet` (`dot`, `dash`, `circle` or `square`), `number` (`arabic`, +//! `upperLetters`, `lowerLetters`, `upperRoman`, `lowerRoman`, `twoDigits` or `threeDigits`), +//! `listAlign` (`left` or `right`), `listLevel` (1 to 9; 1 for paragraphs in no list), +//! `leftIndent` and `firstLineIndent` (pt), as `text.setFormat` takes them, with `__initial` (the +//! values it opened with) and `__targets` (the type and range it acts on). +//! +//! Behavior reference (public documentation): +//! <https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html>. + +use serde_json::{Map, Value, json}; +use vectorcraft_doc::text::{LIST_HANGING_INDENT, LIST_LEVELS, NumberStyle, ParaList}; + +use super::swatch_options::label; +use super::{DialogSpec, form}; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Bullets and Numbering. +pub const KIND: &str = "bulletsAndNumbering"; + +/// The field holding the values the dialog opened with. +const INITIAL: &str = "__initial"; +/// The field holding the `ids`, `start` and `end` the dialog's `text.setFormat` acts on. +const TARGETS: &str = "__targets"; +/// Set while the indent fields hold the hanging indent a preset filled in ([`pick`]). +const HANGING: &str = "__hanging"; +const CMD: &str = "text.setFormat"; + +/// The `tab` values, in the order the tabs are shown. +const TABS: [&str; 2] = ["bullets", "numbers"]; +/// The `listAlign` values, in the order Alignment lists them. +const ALIGNS: [&str; 2] = ["left", "right"]; +/// The indents `text.setFormat` keeps (points): a value typed outside them becomes the nearer end. +const INDENTS: std::ops::RangeInclusive<f64> = -1296.0..=1296.0; +/// The side of a preset button (points). +const PRESET: f32 = 34.0; +/// The keys a preset button with the keyboard focus keeps from egui's focus moves. +const ARROWS: egui::EventFilter = egui::EventFilter { tab: false, horizontal_arrows: true, vertical_arrows: false, escape: false }; + +/// The fields `ui.dialog.set` can set while the dialog does not hold them: the dialog leaves a +/// field out where the paragraphs differ, and holds no `bullet` or `number` for paragraphs in no +/// list or in another kind of list. +const INPUTS: [&str; 7] = ["list", "bullet", "number", "listAlign", "listLevel", "leftIndent", "firstLineIndent"]; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Bullets and Numbering").into(), + body, + confirm, + preview: true, + min_width: 380.0, + inputs: |_| &INPUTS, + ..DialogSpec::FORM +}; + +/// The kind of list `list` is, as the `list` field names it. +fn kind(list: Option<ParaList>) -> &'static str { + match list { + None => "none", + Some(l) if l.number.is_some() => "number", + Some(_) => "bullet", + } +} + +/// Open Bullets and Numbering on the paragraph attributes the Paragraph panel shows, on tab `tab` +/// (`bullets` or `numbers`; without it, Numbers when the paragraphs are a numbered list). +pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> { + use crate::panels::paragraph::{shared_para, shared_para_field}; + let key = "tab"; + let tab = match p.get(key) { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_str().filter(|t| TABS.contains(t)).ok_or_else(|| format!("`{key}` must be one of {}", TABS.join(", ")))?), + }; + crate::panels::character::text_style(app).ok_or("select some type first")?; + // While typing, the dialog acts on the paragraphs of the text being edited. + let mut targets = json!({}); + if let Some((id, a, b)) = crate::panels::character::text_editing(app) { + crate::panels::character::end_typing(app); + targets = json!({"ids": [id.0], "start": a, "end": b}); + } + let mut values = Map::new(); + let list = shared_para(app, |p| kind(p.list)); + if let Some(k) = list { + values.insert("list".into(), json!(k)); + } + if let Some(Some(b)) = shared_para(app, |p| p.list.filter(|l| l.number.is_none()).map(|l| l.bullet)) { + values.insert("bullet".into(), json!(b)); + } + if let Some(Some(n)) = shared_para(app, |p| p.list.and_then(|l| l.number)) { + values.insert("number".into(), json!(n)); + } + // A paragraph in no list counts as aligned left, the alignment a list starts with. + if let Some(a) = shared_para(app, |p| p.list.map(|l| l.align).unwrap_or_default()) { + values.insert("listAlign".into(), json!(a)); + } + // …and the paragraphs' list level, 1 for a paragraph in no list (the level a list starts at). + if let Some(l) = shared_para(app, |p| p.list.map_or(1, |l| l.depth())) { + values.insert("listLevel".into(), json!(l)); + } + if let Some(v) = shared_para_field(app, |p| p.left_indent) { + values.insert("leftIndent".into(), json!(v)); + } + if let Some(v) = shared_para_field(app, |p| p.first_line_indent) { + values.insert("firstLineIndent".into(), json!(v)); + } + let tab = tab.unwrap_or(if list == Some("number") { "numbers" } else { "bullets" }); + let mut fields = Value::Object(values.clone()); + fields[INITIAL] = Value::Object(values); + fields[TARGETS] = targets; + fields["tab"] = json!(tab); + app.ui.dialog = Some(Dialog::new(KIND, fields)); + Ok(Value::Null) +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let unit = app.session.general_unit(); + let numbers = d.str("tab") == "numbers"; + if let Some(tab) = widgets::tab_bar(ui, &[tl!("Bullets"), tl!("Numbers")], usize::from(numbers)).and_then(|i| TABS.get(i)) { + d.fields.insert("tab".into(), json!(tab)); + } + ui.add_space(8.0); + level(ui, d); + ui.add_space(8.0); + if d.str("tab") == "numbers" { + number_tab(ui, d); + } else { + bullet_tab(ui, d); + } + ui.add_space(10.0); + position(ui, d, unit); + // The type shows the dialog's values; with nothing changed it is as it was. + let p = params(d); + if changes(&p) { + form::preview_now(app, d, tl!("Bullets and Numbering"), CMD, Value::Object(p)); + } else if d.fields.remove(form::PREVIEWED).is_some() { + let _ = app.session.cancel_interaction(); + } + false +} + +/// The Level field: the paragraphs' list level, 1 to 9, blank where they differ. +fn level(ui: &mut egui::Ui, d: &mut Dialog) { + ui.horizontal(|ui| { + label(ui, tl!("Level:")); + let value = d.fields.get("listLevel").and_then(Value::as_f64); + if let Some(v) = widgets::range_field(ui, ("bullets-numbering", "listLevel"), value, 1.0..=LIST_LEVELS as f64, "", 0, 60.0) { + d.fields.insert("listLevel".into(), json!(v)); + } + }); +} + +/// The Bullets tab: the bullet presets in a row, None first, the one the dialog applies selected. +fn bullet_tab(ui: &mut egui::Ui, d: &mut Dialog) { + let presets = crate::panels::paragraph::bullet_presets(); + let shown = d.fields.get("bullet").and_then(Value::as_str); + let current = match d.fields.get("list").and_then(Value::as_str) { + Some("none") => Some(0), + Some("bullet") => presets.iter().position(|(_, key, _)| shown == Some(*key)), + _ => None, + }; + label(ui, tl!("Bullet Character:")); + let names = presets.map(|(name, _, _)| name); + let picked = preset_row(ui, "bullets", &names, current, |painter, rect, i, color| { + crate::panels::paragraph::paint_bullet(painter, rect.center(), presets.get(i).and_then(|p| p.2), color); + }); + match picked.and_then(|i| presets.get(i)) { + Some((_, _, None)) => pick(d, "none", None), + Some((_, key, Some(_))) => pick(d, "bullet", Some(*key)), + None => {} + } +} + +/// The Numbers tab: None and the number styles in a row, each shown by its first two numbers, the +/// one the dialog applies selected. +fn number_tab(ui: &mut egui::Ui, d: &mut Dialog) { + let shown = d.fields.get("number").and_then(|v| serde_json::from_value::<NumberStyle>(v.clone()).ok()); + let current = match d.fields.get("list").and_then(Value::as_str) { + Some("none") => Some(0), + Some("number") => shown.and_then(|s| NumberStyle::ALL.iter().position(|&n| n == s)).map(|i| i + 1), + _ => None, + }; + label(ui, tl!("Number Style:")); + // Screen readers name each style by its first three numbers, as the Numbered List presets do. + let samples: Vec<String> = NumberStyle::ALL.iter().map(|&s| crate::panels::paragraph::number_sample(s, 3)).collect(); + let names: Vec<&str> = std::iter::once(tl!("None")).chain(samples.iter().map(String::as_str)).collect(); + let picked = preset_row(ui, "numbers", &names, current, |painter, rect, i, color| match i.checked_sub(1).and_then(|k| NumberStyle::ALL.get(k)) { + None => crate::panels::paragraph::paint_bullet(painter, rect.center(), None, color), + Some(&style) => { + let first = format!("{} {}", style.format(1), style.format(2)); + painter.text(rect.center(), egui::Align2::CENTER_CENTER, first, egui::FontId::proportional(11.0), color); + } + }); + match picked { + Some(0) => pick(d, "none", None), + Some(i) => { + if let Some(key) = NumberStyle::ALL.get(i - 1).and_then(|s| serde_json::to_value(s).ok()).and_then(|v| v.as_str().map(str::to_string)) { + pick(d, "number", Some(&key)); + } + } + None => {} + } +} + +/// A row of preset buttons named `names`, `current` selected, each drawn by `paint` (painter, +/// button, index, color). ← and → move the choice along the row from the button with the keyboard +/// focus, and the focus with it. Returns the button clicked or moved to. +fn preset_row( + ui: &mut egui::Ui, + salt: &str, + names: &[&str], + current: Option<usize>, + paint: impl Fn(&egui::Painter, egui::Rect, usize, egui::Color32), +) -> Option<usize> { + let t = Tokens::get(ui.ctx()); + let ids: Vec<egui::Id> = (0..names.len()).map(|i| egui::Id::unique(("bullets-numbering-preset", salt, i))).collect(); + let mut picked = None; + let mut focused = None; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for (i, (&name, &id)) in names.iter().zip(&ids).enumerate() { + let (rect, _) = ui.allocate_exact_size(egui::vec2(PRESET, PRESET), egui::Sense::hover()); + let resp = ui.interact(rect, id, egui::Sense::click()); + let selected = current == Some(i); + let fill = if selected { + t.row_selected + } else if resp.hovered() { + t.hover + } else { + t.input + }; + ui.painter().rect_filled(rect, egui::CornerRadius::same(3), fill); + let (width, border) = if selected || resp.has_focus() { (1.5, t.accent) } else { (1.0, t.input_border) }; + ui.painter().rect_stroke(rect, egui::CornerRadius::same(3), egui::Stroke::new(width, border), egui::StrokeKind::Inside); + paint(ui.painter(), rect, i, t.text_strong); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, ui.is_enabled(), selected, name)); + if resp.has_focus() { + focused = Some(i); + // ← and → move the choice, not egui's focus. + ui.memory_mut(|m| m.set_focus_lock_filter(id, ARROWS)); + } + if resp.on_hover_text(name).clicked() { + picked = Some(i); + } + } + }); + if let Some(i) = focused { + let (back, on) = ui.input_mut(|inp| { + (inp.consume_key(egui::Modifiers::NONE, egui::Key::ArrowLeft), inp.consume_key(egui::Modifiers::NONE, egui::Key::ArrowRight)) + }); + let to = if back { + i.checked_sub(1) + } else if on { + Some(i + 1).filter(|&j| j < names.len()) + } else { + None + }; + if back || on { + // egui moves the focus with an arrow pressed on a button that has just taken the + // focus, before its focus-lock filter applies: the row moves it instead. + ui.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + } + if let Some(j) = to { + ui.memory_mut(|m| m.request_focus(ids[j])); + picked = Some(j); + } + } + picked +} + +/// Pick a preset of kind `list` (`none`, `bullet` or `number`) with key `key` (for `bullet` or +/// `number`). For paragraphs that were in no list and had no indents, the indent fields take the +/// hanging indent `text.setFormat` gives a paragraph that joins a list; None puts them back while +/// they still hold it. +fn pick(d: &mut Dialog, list: &str, key: Option<&str>) { + d.fields.insert("list".into(), json!(list)); + if let Some(key) = key { + d.fields.insert(list.into(), json!(key)); + } + let initial = d.fields.get(INITIAL).cloned().unwrap_or_default(); + let was = |k: &str| initial.get(k).and_then(Value::as_f64); + let now = |d: &Dialog| (d.fields.get("leftIndent").and_then(Value::as_f64), d.fields.get("firstLineIndent").and_then(Value::as_f64)); + let hanging = (Some(LIST_HANGING_INDENT), Some(-LIST_HANGING_INDENT)); + if list != "none" { + let unlisted = initial.get("list").and_then(Value::as_str) == Some("none"); + if unlisted && (was("leftIndent"), was("firstLineIndent")) == (Some(0.0), Some(0.0)) && now(d) == (Some(0.0), Some(0.0)) { + d.fields.insert("leftIndent".into(), json!(LIST_HANGING_INDENT)); + d.fields.insert("firstLineIndent".into(), json!(-LIST_HANGING_INDENT)); + d.fields.insert(HANGING.into(), json!(true)); + } + } else if d.fields.remove(HANGING).is_some() && now(d) == hanging { + d.fields.insert("leftIndent".into(), json!(0.0)); + d.fields.insert("firstLineIndent".into(), json!(0.0)); + } +} + +/// The Position group: Alignment, Left Indent and First Line Indent. +fn position(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit) { + widgets::section_header(ui, tl!("Position")); + egui::Grid::new("bullets-numbering-position").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { + label(ui, tl!("Alignment:")); + let names = [tl!("Left"), tl!("Right")]; + let current = d.fields.get("listAlign").and_then(Value::as_str).and_then(|a| ALIGNS.iter().position(|x| *x == a)); + // Already translated: shown as they are. + if let Some(i) = widgets::dropdown_names(ui, ("bullets-numbering", "listAlign"), current.map_or("", |i| names[i]), &names, 100.0) + && let Some(a) = ALIGNS.get(i) + { + d.fields.insert("listAlign".into(), json!(a)); + } + ui.end_row(); + label(ui, tl!("Left Indent:")); + form::length_within(ui, d, "leftIndent", unit, 90.0, Some(INDENTS)); + ui.end_row(); + label(ui, tl!("First Line Indent:")); + form::length_within(ui, d, "firstLineIndent", unit, 90.0, Some(INDENTS)); + ui.end_row(); + }); +} + +/// `text.setFormat` parameters: the fields that differ from the values the dialog opened with, +/// on the dialog's type and range. With a preset that puts the paragraphs in a list, the indents +/// the dialog shows go too, so that the hanging indent `text.setFormat` gives a paragraph joining a +/// list does not replace them. Alignment and Level go only when the paragraphs are in a list or +/// the preset puts them in one. +fn params(d: &Dialog) -> Map<String, Value> { + let initial = d.fields.get(INITIAL).and_then(Value::as_object); + let was = |key: &str| initial.and_then(|m| m.get(key)).and_then(Value::as_str); + let field = |key: &str| d.fields.get(key).and_then(Value::as_str); + let mut p = d.fields.get(TARGETS).and_then(Value::as_object).cloned().unwrap_or_default(); + match field("list") { + Some("none") if was("list") != Some("none") => { + p.insert("list".into(), json!("none")); + } + Some(kind @ ("bullet" | "number")) => match field(kind) { + Some(key) if was("list") != Some(kind) || was(kind) != Some(key) => { + p.insert(kind.into(), json!(key)); + } + None if was("list") != Some(kind) => { + p.insert("list".into(), json!(kind)); + } + _ => {} + }, + _ => {} + } + let listing = ["bullet", "number"].iter().any(|k| p.contains_key(*k)) || p.get("list").is_some_and(|l| l.as_str() != Some("none")); + // `text.setFormat` sets `listAlign` only on paragraphs in a list. + if let Some(a) = field("listAlign") + && was("listAlign") != Some(a) + && (listing || was("list") != Some("none")) + { + p.insert("listAlign".into(), json!(a)); + } + // …and `listLevel` too. + if let Some(l) = d.fields.get("listLevel").and_then(Value::as_f64) + && initial.and_then(|m| m.get("listLevel")).and_then(Value::as_f64) != Some(l) + && (listing || was("list") != Some("none")) + { + p.insert("listLevel".into(), json!(l)); + } + // For paragraphs in a list and paragraphs in none, an indent left as it was is not sent: + // `text.setFormat` then gives each paragraph that joins the list its hanging indent. + let mixed = initial.is_some_and(|m| !m.contains_key("list")); + for key in ["leftIndent", "firstLineIndent"] { + if !d.fields.contains_key(key) { + continue; + } + // A length typed with its unit ("6 mm") is sent in points. + let v = d.f64(key, 0.0); + let changed = initial.and_then(|m| m.get(key)).and_then(Value::as_f64) != Some(v); + if changed || (listing && !mixed) { + p.insert(key.into(), json!(v)); + } + } + p +} + +/// Does `p` change anything (more than the targets)? +fn changes(p: &Map<String, Value>) -> bool { + p.keys().any(|k| !matches!(k.as_str(), "ids" | "start" | "end")) +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> { + let p = params(d); + if !changes(&p) { + if app.session.in_interaction() { + let _ = app.session.cancel_interaction(); + } + app.ui.dialog = None; + return Ok(Value::Null); + } + form::commit_preview(app, CMD, Value::Object(p)) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_doc::text::{Bullet, ListAlign}; + use vectorcraft_engine::Session; + use vectorcraft_engine::doc::{NodeId, NodeKind, ParaStyle}; + + /// A selected area type object with two paragraphs. + fn app() -> (VectorcraftApp, u64) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + let id = app.run("text.create", json!({"x": 10, "y": 10, "text": "One\nTwo", "area": {"width": 200, "height": 100}})).unwrap()["id"] + .as_u64() + .unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + (app, id) + } + + fn paras(app: &VectorcraftApp, id: u64) -> (ParaStyle, ParaStyle) { + match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (t.para_at(0).clone(), t.para_at(1).clone()), + _ => panic!("not text"), + } + } + + /// The first paragraph's list and indents. + fn first(app: &VectorcraftApp, id: u64) -> (Option<ParaList>, f64, f64) { + let p = paras(app, id).0; + (p.list, p.left_indent, p.first_line_indent) + } + + fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + } + + fn set(app: &mut VectorcraftApp, key: &str, v: Value) { + app.ui.dialog.as_mut().unwrap().fields.insert(key.into(), v); + } + + fn pick_preset(app: &mut VectorcraftApp, list: &str, key: Option<&str>) { + pick(app.ui.dialog.as_mut().unwrap(), list, key); + } + + /// `ui.bulletsAndNumbering` opens on the selected type's values; a preset, Alignment and the + /// indents show on the type at once, and OK keeps them as one `text.setFormat`, which one Undo + /// takes back. + #[test] + fn ok_applies_the_marker_alignment_and_indents_as_one_undo_step() { + let (mut app, id) = app(); + let undo = app.session.active().unwrap().history.undo.len(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + let d = app.ui.dialog.clone().expect("Bullets and Numbering opened"); + assert_eq!((d.kind.as_str(), d.str("tab"), d.str("list"), d.str("listAlign")), (KIND, "bullets".into(), "none".into(), "left".into())); + assert_eq!((d.f64("leftIndent", 1.0), d.f64("firstLineIndent", 1.0)), (0.0, 0.0)); + pick_preset(&mut app, "bullet", Some("square")); + set(&mut app, "listAlign", json!("right")); + set(&mut app, "leftIndent", json!(24)); + set(&mut app, "firstLineIndent", json!(-12)); + frame(&mut app); + assert!(app.session.in_interaction(), "the preview runs"); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "the preview adds no undo step"); + let want = Some(ParaList::bulleted(Bullet::Square).aligned(ListAlign::Right)); + assert_eq!(first(&app, id), (want, 24.0, -12.0), "previewed on the type"); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none() && !app.session.in_interaction()); + assert_eq!((first(&app, id), paras(&app, id).1.list), ((want, 24.0, -12.0), want)); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "one undo step"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(first(&app, id), (None, 0.0, 0.0)); + // The Numbers tab's presets make a numbered list, keeping the alignment the dialog shows. + app.run("text.setFormat", json!({"list": "bullet", "listAlign": "right"})).unwrap(); + app.run("ui.bulletsAndNumbering", json!({"tab": "numbers"})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("tab"), "numbers"); + pick_preset(&mut app, "number", Some("upperRoman")); + super::super::confirm(&mut app).unwrap(); + assert_eq!(first(&app, id).0, Some(ParaList::numbered(NumberStyle::UpperRoman).aligned(ListAlign::Right))); + // OK with nothing changed adds no undo step, and an unknown tab is an error. + let undo = app.session.active().unwrap().history.undo.len(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("tab"), "numbers", "a numbered list opens on Numbers"); + frame(&mut app); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo); + assert!(app.run("ui.bulletsAndNumbering", json!({"tab": "levels"})).is_err()); + assert!(app.ui.dialog.is_none()); + app.run("select.set", json!({"ids": []})).unwrap(); + assert!(app.run("ui.bulletsAndNumbering", json!({})).is_err(), "no type selected"); + } + + /// Cancel puts back what the preview changed: the list, its alignment and the indents. + #[test] + fn cancel_puts_the_type_back_as_it_was() { + let (mut app, id) = app(); + app.run("text.setFormat", json!({"number": "arabic"})).unwrap(); + let before = first(&app, id); + assert_eq!(before, (Some(ParaList::numbered(NumberStyle::Arabic)), 18.0, -18.0)); + let undo = app.session.active().unwrap().history.undo.len(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + pick_preset(&mut app, "number", Some("lowerLetters")); + set(&mut app, "listAlign", json!("right")); + set(&mut app, "leftIndent", json!(30)); + frame(&mut app); + assert_eq!(first(&app, id), (Some(ParaList::numbered(NumberStyle::LowerLetters).aligned(ListAlign::Right)), 30.0, -18.0)); + // Another change previews again, still without an undo step. + set(&mut app, "leftIndent", json!(36)); + frame(&mut app); + assert_eq!(first(&app, id).1, 36.0); + set(&mut app, "leftIndent", json!(30)); + frame(&mut app); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "the preview adds no undo step"); + // Changed back in the dialog, the type is as it was. + set(&mut app, "listAlign", json!("left")); + set(&mut app, "leftIndent", json!(18)); + pick_preset(&mut app, "number", Some("arabic")); + frame(&mut app); + assert!(!app.session.in_interaction(), "no preview while nothing changes"); + assert_eq!(first(&app, id), before); + pick_preset(&mut app, "none", None); + frame(&mut app); + assert_eq!(first(&app, id).0, None, "None previews the list off"); + super::super::cancel(&mut app); + assert!(app.ui.dialog.is_none()); + assert_eq!(first(&app, id), before); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "Cancel leaves no undo step"); + } + + /// A preset picked for paragraphs in no list and without indents fills in the 18 pt and + /// -18 pt hanging indent, and None takes it out again; indents that are set stay, and so do + /// indents set back to 0 with the preset. + #[test] + fn a_preset_fills_in_the_hanging_indent_a_new_list_gets() { + let (mut app, id) = app(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + pick_preset(&mut app, "bullet", Some("dot")); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!((d.f64("leftIndent", 0.0), d.f64("firstLineIndent", 0.0)), (18.0, -18.0)); + pick_preset(&mut app, "number", Some("arabic")); + pick_preset(&mut app, "none", None); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!((d.f64("leftIndent", 1.0), d.f64("firstLineIndent", 1.0)), (0.0, 0.0)); + assert!(!changes(¶ms(&d))); + super::super::cancel(&mut app); + app.run("text.setFormat", json!({"leftIndent": 10})).unwrap(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + pick_preset(&mut app, "bullet", Some("dash")); + assert_eq!(app.ui.dialog.as_ref().unwrap().f64("leftIndent", 0.0), 10.0); + super::super::confirm(&mut app).unwrap(); + assert_eq!(first(&app, id), (Some(ParaList::bulleted(Bullet::Dash)), 10.0, 0.0)); + // Indents set back to 0 with the preset are kept: the hanging indent does not replace them. + let (mut app, id) = self::app(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + pick_preset(&mut app, "number", Some("arabic")); + set(&mut app, "leftIndent", json!(0)); + set(&mut app, "firstLineIndent", json!(0)); + frame(&mut app); + assert_eq!(first(&app, id), (Some(ParaList::numbered(NumberStyle::Arabic)), 0.0, 0.0), "previewed as shown"); + super::super::confirm(&mut app).unwrap(); + assert_eq!(first(&app, id), (Some(ParaList::numbered(NumberStyle::Arabic)), 0.0, 0.0)); + } + + /// Paragraphs that differ leave the preset unpicked and the fields blank, and OK sends only + /// what was changed: each paragraph keeps its own indents and list. + #[test] + fn differing_paragraphs_show_blank_fields_and_keep_what_is_left_alone() { + let (mut app, id) = app(); + app.run("text.setFormat", json!({"ids": [id], "bullet": "circle", "leftIndent": 30, "start": 0, "end": 0})).unwrap(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + let d = app.ui.dialog.clone().unwrap(); + for key in ["list", "bullet", "leftIndent"] { + assert!(!d.fields.contains_key(key), "{key} is blank"); + } + assert_eq!((d.str("listAlign"), d.f64("firstLineIndent", 1.0)), ("left".into(), 0.0)); + set(&mut app, "listAlign", json!("right")); + super::super::confirm(&mut app).unwrap(); + let (one, two) = paras(&app, id); + assert_eq!((one.list, one.left_indent), (Some(ParaList::bulleted(Bullet::Circle).aligned(ListAlign::Right)), 30.0)); + assert_eq!((two.list, two.left_indent), (None, 0.0), "the paragraph in no list stays out of one"); + } + + /// A preset picked for a list paragraph and a paragraph in no list, both without indents, gives + /// the paragraph that joins the list the hanging indent, and the list paragraph keeps its indents. + #[test] + fn a_preset_for_listed_and_unlisted_paragraphs_indents_the_one_joining_the_list() { + let (mut app, id) = app(); + app.run("text.setFormat", json!({"ids": [id], "bullet": "circle", "leftIndent": 0, "firstLineIndent": 0, "start": 0, "end": 0})).unwrap(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + let d = app.ui.dialog.clone().unwrap(); + assert!(!d.fields.contains_key("list"), "the list kinds differ"); + assert_eq!((d.f64("leftIndent", 1.0), d.f64("firstLineIndent", 1.0)), (0.0, 0.0)); + pick_preset(&mut app, "bullet", Some("dot")); + frame(&mut app); + assert_eq!(paras(&app, id).1.left_indent, 18.0, "previewed with the hanging indent"); + super::super::confirm(&mut app).unwrap(); + let (one, two) = paras(&app, id); + let dot = Some(ParaList::bulleted(Bullet::Dot)); + assert_eq!((one.list, one.left_indent, one.first_line_indent), (dot, 0.0, 0.0)); + assert_eq!((two.list, two.left_indent, two.first_line_indent), (dot, 18.0, -18.0)); + } + + /// Alignment changed for paragraphs that stay in no list sends nothing: there is no preview, + /// and OK adds no undo step. A preset picked with it takes the alignment. + #[test] + fn alignment_alone_on_paragraphs_in_no_list_adds_no_undo_step() { + let (mut app, id) = app(); + let undo = app.session.active().unwrap().history.undo.len(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + set(&mut app, "listAlign", json!("right")); + assert!(!changes(¶ms(app.ui.dialog.as_ref().unwrap()))); + frame(&mut app); + assert!(!app.session.in_interaction(), "no preview"); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "no undo step"); + assert_eq!(first(&app, id), (None, 0.0, 0.0)); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + set(&mut app, "listAlign", json!("right")); + pick_preset(&mut app, "bullet", Some("dot")); + super::super::confirm(&mut app).unwrap(); + assert_eq!(first(&app, id).0, Some(ParaList::bulleted(Bullet::Dot).aligned(ListAlign::Right))); + } + + /// An indent typed past the 1296 pt `text.setFormat` keeps becomes 1296 pt (or -1296 pt) in the + /// field, so the field shows the indent the preview and OK give the type. + #[test] + fn an_indent_typed_out_of_range_shows_the_indent_the_type_gets() { + let (mut app, id) = app(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let key = |k| egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + // Each frame returns where the label `find` was drawn. + let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>, find: &str| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + out.shapes.iter().find_map(|s| match &s.shape { + egui::Shape::Text(t) if t.galley.text() == find => Some(t.galley.rect.translate(t.pos.to_vec2())), + _ => None, + }) + }; + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + frame(&mut app, vec![], ""); + // The fields' column starts after the longest label, First Line Indent's. + let x = frame(&mut app, vec![], "First Line Indent:").expect("First Line Indent is drawn").right() + 30.0; + // Click the field beside the label `name` (unless `click` is false, as for a field the Tab + // before it reached, which has the keyboard focus with its text selected), type `text` over + // it and Tab to commit it. + let type_into = |app: &mut VectorcraftApp, name: &str, text: &str, click: bool| { + let label = frame(app, vec![], name).unwrap_or_else(|| panic!("{name} is drawn")); + let at = egui::pos2(x, label.center().y); + let press = + |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + if click { + frame(app, vec![egui::Event::PointerMoved(at)], ""); + frame(app, vec![press(true), press(false)], ""); + } + frame(app, vec![], ""); + frame(app, vec![egui::Event::Text(text.into())], ""); + frame(app, vec![key(egui::Key::Tab)], ""); + frame(app, vec![], ""); + }; + type_into(&mut app, "Left Indent:", "2000 pt", true); + type_into(&mut app, "First Line Indent:", "-5000 pt", false); + let d = app.ui.dialog.clone().expect("the dialog stays open"); + assert_eq!((d.f64("leftIndent", 0.0), d.f64("firstLineIndent", 0.0)), (1296.0, -1296.0)); + assert_eq!(first(&app, id), (None, 1296.0, -1296.0), "previewed as shown"); + super::super::confirm(&mut app).unwrap(); + assert_eq!(first(&app, id), (None, 1296.0, -1296.0)); + } + + /// `ui.dialog.set` sets the fields the dialog does not hold: a bullet or a number for type in + /// no list, and the fields left blank where the paragraphs differ. + #[test] + fn the_control_channel_sets_fields_the_dialog_does_not_hold() { + let ctx = egui::Context::default(); + let (mut app, id) = app(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + for key in ["bullet", "number"] { + assert!(!app.ui.dialog.as_ref().unwrap().fields.contains_key(key), "{key} is not held"); + } + let fields = json!([{"field": "list", "value": "number"}, {"field": "number", "value": "upperRoman"}]); + crate::tests_synthetic::control(&mut app, &ctx, "ui.dialog.set", json!({"fields": fields})); + super::super::confirm(&mut app).unwrap(); + assert_eq!(first(&app, id).0, Some(ParaList::numbered(NumberStyle::UpperRoman))); + app.run("text.setFormat", json!({"ids": [id], "bullet": "circle", "leftIndent": 30, "firstLineIndent": -6, "start": 0, "end": 0})).unwrap(); + app.run("text.setFormat", json!({"ids": [id], "listAlign": "right", "listLevel": 2, "start": 0, "end": 0})).unwrap(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + let held = app.ui.dialog.as_ref().unwrap().fields.clone(); + for key in INPUTS { + assert!(!held.contains_key(key), "{key} is blank"); + } + let fields = json!([ + {"field": "list", "value": "bullet"}, + {"field": "bullet", "value": "square"}, + {"field": "listAlign", "value": "left"}, + {"field": "listLevel", "value": 3}, + {"field": "leftIndent", "value": 24}, + {"field": "firstLineIndent", "value": -12}, + ]); + crate::tests_synthetic::control(&mut app, &ctx, "ui.dialog.set", json!({"fields": fields})); + super::super::confirm(&mut app).unwrap(); + let (one, two) = paras(&app, id); + let square = Some(ParaList::bulleted(Bullet::Square).at_level(3)); + assert_eq!((one.list, one.left_indent, one.first_line_indent), (square, 24.0, -12.0)); + assert_eq!((two.list, two.left_indent, two.first_line_indent), (square, 24.0, -12.0)); + } + + /// The Level field opens on the paragraphs' list level (1 for paragraphs in no list, blank + /// where they differ), shows a change on the type at once, and OK sends it, with a preset or + /// alone. + #[test] + fn the_level_field_sets_the_list_level() { + let (mut app, id) = app(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().f64("listLevel", 0.0), 1.0, "in no list: level 1"); + pick_preset(&mut app, "number", Some("arabic")); + set(&mut app, "listLevel", json!(3)); + frame(&mut app); + assert_eq!(first(&app, id).0, Some(ParaList::numbered(NumberStyle::Arabic).at_level(3)), "previewed on the type"); + super::super::confirm(&mut app).unwrap(); + assert_eq!(paras(&app, id).1.list, Some(ParaList::numbered(NumberStyle::Arabic).at_level(3))); + app.run("text.setFormat", json!({"ids": [id], "listLevel": 5, "start": 0, "end": 0})).unwrap(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + assert!(!app.ui.dialog.as_ref().unwrap().fields.contains_key("listLevel"), "levels 5 and 3 differ: blank"); + super::super::cancel(&mut app); + app.run("text.setFormat", json!({"ids": [id], "listLevel": 5})).unwrap(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().f64("listLevel", 0.0), 5.0); + assert!(!changes(¶ms(app.ui.dialog.as_ref().unwrap())), "nothing changed yet"); + set(&mut app, "listLevel", json!(2)); + assert_eq!(params(app.ui.dialog.as_ref().unwrap()).get("listLevel"), Some(&json!(2.0))); + super::super::confirm(&mut app).unwrap(); + assert_eq!((first(&app, id).0.map(|l| l.level), paras(&app, id).1.list.map(|l| l.level)), (Some(2), Some(2))); + } + + /// `text.setFormat` sets `listLevel` only on paragraphs in a list, so a Level change alone on + /// paragraphs in no list sends nothing and adds no undo step; with a preset it applies. + #[test] + fn level_alone_on_paragraphs_in_no_list_adds_no_undo_step() { + let (mut app, id) = app(); + let undo = app.session.active().unwrap().history.undo.len(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + set(&mut app, "listLevel", json!(3)); + assert!(!changes(¶ms(app.ui.dialog.as_ref().unwrap()))); + frame(&mut app); + assert!(!app.session.in_interaction(), "no preview"); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "no undo step"); + assert_eq!(first(&app, id), (None, 0.0, 0.0)); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + set(&mut app, "listLevel", json!(3)); + pick_preset(&mut app, "bullet", Some("dot")); + super::super::confirm(&mut app).unwrap(); + assert_eq!(first(&app, id).0, Some(ParaList::bulleted(Bullet::Dot).at_level(3))); + } + + /// The presets are radio buttons named for screen readers, the one the dialog applies chosen; + /// a click picks one, and ← and → move the choice from the focused one. + #[test] + fn the_presets_are_named_radio_buttons_that_the_arrow_keys_move_along() { + use egui::accesskit::{Role, Toggled}; + let names = ["None", "Filled Dot", "Dash"]; + let tree = crate::widgets::accessibility_tests::tree(|ui| { + let _ = preset_row(ui, "test", &names, Some(1), |_, _, _, _| {}); + }); + assert_eq!(crate::widgets::accessibility_tests::role_and_state(&tree, "Filled Dot"), (Role::RadioButton, Some(Toggled::True))); + assert_eq!(crate::widgets::accessibility_tests::role_and_state(&tree, "Dash"), (Role::RadioButton, Some(Toggled::False))); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); + let origin = std::cell::Cell::new(egui::Pos2::ZERO); + let run = |events: Vec<egui::Event>| { + let input = egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }; + let mut picked = None; + let mut out = ctx.run_ui(input, |ui| { + origin.set(ui.cursor().min); + picked = preset_row(ui, "test", &names, Some(1), |_, _, _, _| {}); + }); + out.textures_delta.clear(); + picked + }; + let key = |k| egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + assert_eq!(run(vec![]), None); + assert_eq!(run(vec![key(egui::Key::ArrowRight)]), None, "no preset has the focus"); + ctx.memory_mut(|m| m.request_focus(egui::Id::unique(("bullets-numbering-preset", "test", 1usize)))); + run(vec![]); + assert_eq!(run(vec![key(egui::Key::ArrowRight)]), Some(2)); + assert_eq!(run(vec![key(egui::Key::ArrowLeft)]), Some(1), "the focus moved to Dash"); + assert_eq!(run(vec![key(egui::Key::ArrowLeft)]), Some(0)); + assert_eq!(run(vec![key(egui::Key::ArrowLeft)]), None, "None is the first"); + // The middle of the third button, two buttons and their 4 pt gaps along. + let at = origin.get() + egui::vec2(PRESET * 2.5 + 8.0, PRESET * 0.5); + let click = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + run(vec![egui::Event::PointerMoved(at)]); + assert_eq!(run(vec![click(true), click(false)]), Some(2)); + } +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 75f2637a4..6da0c90d6 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -354,6 +354,13 @@ font-technology Variable Променливи Apply Bullets Прилагане на булети Numbered List Номериран списък Apply Numbers Прилагане на номериране + Bullets and Numbering… Булети и номериране… + Numbers Номера + Level: Ниво: + Bullet Character: Знак за булет: + Number Style: Стил на номерата: + Left Indent: Ляв отстъп: + First Line Indent: Отстъп на първия ред: Convert to Text Конвертиране в текст Filled Dot Плътна точка Dash Тире diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 4eadaff15..b7f8c6ed8 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -332,6 +332,12 @@ Apply Bullets Primijeni oznake Numbered List Numerisana lista Apply Numbers Primijeni brojeve + Bullets and Numbering… Oznake i numerisanje… + Numbers Brojevi + Bullet Character: Znak oznake: + Number Style: Stil broja: + Left Indent: Lijeva uvlaka: + First Line Indent: Uvlaka prvog reda: Convert to Text Pretvori u tekst Filled Dot Ispunjena tačka Dash Crtica diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index d598de47b..e891476f7 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -551,8 +551,10 @@ Built-in presets don't change: New… starts an editable copy. Els preajustos integrats no canvien: Nou… inicia una còpia editable. Bulge… Abombament… Bullet Punt de llista + Bullet Character: Caràcter del punt de llista: Bullets Punts de llista Bullets and Numbering Punts i numeració + Bullets and Numbering… Punts i numeració… Burasagari Burasagari Burlap Arpillera Business Card 3.5 × 2 in Targeta de visita 3,5 × 2 in @@ -1597,6 +1599,7 @@ digits Malayalam Malaiàlam First Artboard Primer tauler First Baseline Primera línia de base First commit Primera confirmació + First Line Indent: Sagnia de primera línia: First Page Primera pàgina First-line Left Indent Sagnia esquerra de primera línia Fisheye… Ull de peix… @@ -2170,6 +2173,7 @@ digits Malayalam Malaiàlam Left Grid: Quadrícula esquerra: Left in Front Esquerra al davant Left Indent Sagnia esquerra + Left Indent: Sagnia esquerra: Left Plane Pla esquerre Left to Right D’esquerra a dreta Left-Justified Tab Tabulació alineada a l’esquerra @@ -2697,8 +2701,10 @@ digits Malayalam Malaiàlam Number of Decimals: Nombre de decimals: Number of Recent Files to Display Nombre de fitxers recents que es mostraran Number of Recent Fonts Nombre de tipus de lletra recents + Number Style: Estil de número: Number: Número: Numbered List Llista numerada + Numbers Números Numbers Without Units Are Points Els nombres sense unitats són punts Object Objecte Object Highlighting Ressaltat d’objectes diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e24e74929..45fe55dd7 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -340,6 +340,13 @@ Apply Bullets Použít odrážky Numbered List Číslovaný seznam Apply Numbers Použít číslování + Bullets and Numbering… Odrážky a číslování… + Numbers Čísla + Level: Úroveň: + Bullet Character: Znak odrážky: + Number Style: Styl číslování: + Left Indent: Odsazení zleva: + First Line Indent: Odsazení prvního řádku: Convert to Text Převést na text Filled Dot Plná tečka Dash Pomlčka diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index c3fb0ffbe..9cdd8e5c6 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -299,6 +299,13 @@ Apply Bullets Anvend kugletegn Numbered List Nummereret liste Apply Numbers Anvend nummerering + Bullets and Numbering… Kugletegn og nummerering… + Numbers Tal + Level: Niveau: + Bullet Character: Kugletegn: + Number Style: Talformat: + Left Indent: Venstre indrykning: + First Line Indent: Indrykning af første linje: Convert to Text Konvertér til tekst Filled Dot Udfyldt prik Dash Tankestreg diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index f83f7f74a..f485f5c44 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -342,6 +342,13 @@ font-technology Variable Variabel Apply Bullets Aufzählungszeichen anwenden Numbered List Nummerierte Liste Apply Numbers Nummerierung anwenden + Bullets and Numbering… Aufzählungszeichen und Nummerierung… + Numbers Nummern + Level: Listenebene: + Bullet Character: Aufzählungszeichen: + Number Style: Nummernformat: + Left Indent: Einzug links: + First Line Indent: Einzug erste Zeile: Convert to Text In Text umwandeln Filled Dot Gefüllter Punkt Dash Strich diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 9e30d25f9..e7880f10b 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -342,6 +342,13 @@ font-technology Variable Μεταβλητές Apply Bullets Εφαρμογή κουκκίδων Numbered List Αριθμημένη λίστα Apply Numbers Εφαρμογή αρίθμησης + Bullets and Numbering… Κουκκίδες και αρίθμηση… + Numbers Αριθμοί + Level: Επίπεδο: + Bullet Character: Χαρακτήρας κουκκίδας: + Number Style: Στυλ αρίθμησης: + Left Indent: Αριστερή εσοχή: + First Line Indent: Εσοχή πρώτης γραμμής: Convert to Text Μετατροπή σε κείμενο Filled Dot Γεμάτη κουκκίδα Dash Παύλα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5122c5acb..a5f9f5d82 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -367,6 +367,13 @@ font-technology Variable Variables Apply Bullets Aplicar viñetas Numbered List Lista numerada Apply Numbers Aplicar numeración + Bullets and Numbering… Viñetas y numeración… + Numbers Números + Level: Nivel: + Bullet Character: Carácter de viñeta: + Number Style: Estilo de número: + Left Indent: Sangría izquierda: + First Line Indent: Sangría de primera línea: Convert to Text Convertir en texto Filled Dot Punto relleno Dash Guion diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 4265fcbde..c9c0d9198 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -337,6 +337,13 @@ Apply Bullets Käytä luettelomerkkejä Numbered List Numeroitu luettelo Apply Numbers Käytä numerointia + Bullets and Numbering… Luettelomerkit ja numerointi… + Numbers Numerot + Level: Taso: + Bullet Character: Luettelomerkki: + Number Style: Numerotyyli: + Left Indent: Vasen sisennys: + First Line Indent: Ensimmäisen rivin sisennys: Convert to Text Muunna tekstiksi Filled Dot Täytetty piste Dash Ajatusviiva diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 107f49a8b..3b9190e59 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -368,6 +368,13 @@ font-technology Variable Variables Apply Bullets Appliquer les puces Numbered List Liste numérotée Apply Numbers Appliquer la numérotation + Bullets and Numbering… Puces et numérotation… + Numbers Numéros + Level: Niveau : + Bullet Character: Caractère de puce : + Number Style: Style de numéro : + Left Indent: Retrait gauche : + First Line Indent: Retrait de première ligne : Convert to Text Convertir en texte Filled Dot Point plein Dash Tiret diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 43ca6a06d..83c7f8fa8 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -504,6 +504,13 @@ font-technology Variable Változó Apply Bullets Felsorolásjelek alkalmazása Numbered List Számozott lista Apply Numbers Számozás alkalmazása + Bullets and Numbering… Felsorolásjelek és számozás… + Numbers Számok + Level: Szint: + Bullet Character: Felsorolásjel: + Number Style: Számozás stílusa: + Left Indent: Bal behúzás: + First Line Indent: Első sor behúzása: Convert to Text Átalakítás szöveggé Filled Dot Kitöltött pont Dash Gondolatjel diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 22b8db56b..213d9584a 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -299,6 +299,13 @@ Apply Bullets Terapkan poin Numbered List Daftar bernomor Apply Numbers Terapkan penomoran + Bullets and Numbering… Poin dan penomoran… + Numbers Nomor + Level: Tingkat: + Bullet Character: Karakter poin: + Number Style: Gaya nomor: + Left Indent: Indentasi kiri: + First Line Indent: Indentasi baris pertama: Convert to Text Konversi ke teks Filled Dot Titik penuh Dash Tanda pisah diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 4f6375c4f..ba04b872e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -367,6 +367,13 @@ font-technology Variable Variabili Apply Bullets Applica punti elenco Numbered List Elenco numerato Apply Numbers Applica numerazione + Bullets and Numbering… Punti elenco e numerazione… + Numbers Numeri + Level: Livello: + Bullet Character: Carattere punto elenco: + Number Style: Stile numero: + Left Indent: Rientro sinistro: + First Line Indent: Rientro prima riga: Convert to Text Converti in testo Filled Dot Punto pieno Dash Trattino diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8c73a1ae0..24b36257f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -355,6 +355,13 @@ digits Malayalam マラヤーラム数字 Apply Bullets 箇条書きを適用 Numbered List 番号付きリスト Apply Numbers 番号付けを適用 + Bullets and Numbering… 箇条書きと番号付け… + Numbers 番号 + Level: レベル: + Bullet Character: 箇条書き記号: + Number Style: 番号の形式: + Left Indent: 左インデント: + First Line Indent: 1 行目インデント: Convert to Text テキストに変換 Filled Dot 黒丸 Dash ダッシュ diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 5d40de893..4b329f368 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -266,6 +266,13 @@ Apply Bullets ბურთულების გამოყენება Numbered List დანომრილი სია Apply Numbers დანომრვის გამოყენება + Bullets and Numbering… ბურთულები და დანომრვა… + Numbers ნომრები + Level: დონე: + Bullet Character: ბურთულის სიმბოლო: + Number Style: ნომრის სტილი: + Left Indent: მარცხენა შეწევა: + First Line Indent: პირველი სტრიქონის შეწევა: Convert to Text ტექსტად გარდაქმნა Filled Dot სავსე წერტილი Dash ტირე diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 30a409b72..856c8ba89 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -348,6 +348,13 @@ digits Malayalam 말라얄람어 Apply Bullets 글머리 기호 적용 Numbered List 번호 매기기 목록 Apply Numbers 번호 매기기 적용 + Bullets and Numbering… 글머리 기호 및 번호 매기기… + Numbers 번호 + Level: 수준: + Bullet Character: 글머리 기호 문자: + Number Style: 번호 스타일: + Left Indent: 왼쪽 들여쓰기: + First Line Indent: 첫 줄 들여쓰기: Convert to Text 텍스트로 변환 Filled Dot 채운 점 Dash 대시 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c771d8b17..46ba3105e 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -313,6 +313,13 @@ Apply Bullets Opsommingstekens toepassen Numbered List Genummerde lijst Apply Numbers Nummering toepassen + Bullets and Numbering… Opsommingstekens en nummering… + Numbers Nummers + Level: Niveau: + Bullet Character: Opsommingsteken: + Number Style: Nummerstijl: + Left Indent: Links inspringen: + First Line Indent: Eerste regel inspringen: Convert to Text Converteren naar tekst Filled Dot Gevulde stip Dash Streepje diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index bdd887ecd..270e7a356 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -300,6 +300,13 @@ Apply Bullets Zastosuj punktory Numbered List Lista numerowana Apply Numbers Zastosuj numerację + Bullets and Numbering… Punktory i numeracja… + Numbers Numery + Level: Poziom: + Bullet Character: Znak punktora: + Number Style: Styl numeracji: + Left Indent: Wcięcie z lewej: + First Line Indent: Wcięcie pierwszego wiersza: Convert to Text Konwertuj na tekst Filled Dot Pełna kropka Dash Myślnik diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 822a0d59e..0827c88e5 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -338,6 +338,13 @@ Apply Bullets Aplicar Marcadores Numbered List Lista Numerada Apply Numbers Aplicar Numeração + Bullets and Numbering… Marcadores e Numeração… + Numbers Números + Level: Nível: + Bullet Character: Caractere do Marcador: + Number Style: Estilo de Número: + Left Indent: Recuo Esquerdo: + First Line Indent: Recuo da Primeira Linha: Convert to Text Converter em Texto Filled Dot Ponto Preenchido Dash Travessão diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 54158fab4..8deafd1d5 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -365,6 +365,13 @@ font-technology Variable Вариативные Apply Bullets Применить маркеры Numbered List Нумерованный список Apply Numbers Применить нумерацию + Bullets and Numbering… Маркеры и нумерация… + Numbers Номера + Level: Уровень: + Bullet Character: Символ маркера: + Number Style: Стиль нумерации: + Left Indent: Отступ слева: + First Line Indent: Отступ первой строки: Convert to Text Преобразовать в текст Filled Dot Закрашенная точка Dash Тире diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index a872e969a..578feef9c 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -356,6 +356,13 @@ font-technology Variable Variabla Apply Bullets Använd punkter Numbered List Numrerad lista Apply Numbers Använd numrering + Bullets and Numbering… Punkter och numrering… + Numbers Nummer + Level: Nivå: + Bullet Character: Punkttecken: + Number Style: Nummerformat: + Left Indent: Vänsterindrag: + First Line Indent: Indrag för första raden: Convert to Text Konvertera till text Filled Dot Fylld punkt Dash Tankstreck diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 300167a97..6931e8489 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -351,6 +351,13 @@ font-technology Variable Değişken Apply Bullets Madde İşaretlerini Uygula Numbered List Numaralı Liste Apply Numbers Numaralandırmayı Uygula + Bullets and Numbering… Madde İşaretleri ve Numaralandırma… + Numbers Numaralar + Level: Düzey: + Bullet Character: Madde İşareti Karakteri: + Number Style: Numara Stili: + Left Indent: Sol Girinti: + First Line Indent: İlk Satır Girintisi: Convert to Text Metne Dönüştür Filled Dot Dolu Nokta Dash Tire diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 4ad4f5b4d..89189e12f 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -361,6 +361,13 @@ font-technology Variable Варіативні Apply Bullets Застосувати маркери Numbered List Нумерований список Apply Numbers Застосувати нумерацію + Bullets and Numbering… Маркери та нумерація… + Numbers Номери + Level: Рівень: + Bullet Character: Символ маркера: + Number Style: Стиль нумерації: + Left Indent: Відступ зліва: + First Line Indent: Відступ першого рядка: Convert to Text Перетворити на текст Filled Dot Заповнена крапка Dash Тире diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index b493f9c64..a58087cd1 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -422,6 +422,13 @@ font-technology Variable Biến thiên Apply Bullets Áp dụng dấu đầu dòng Numbered List Danh sách đánh số Apply Numbers Áp dụng đánh số + Bullets and Numbering… Dấu đầu dòng và đánh số… + Numbers Số + Level: Cấp độ: + Bullet Character: Ký tự đầu dòng: + Number Style: Kiểu đánh số: + Left Indent: Lề trái: + First Line Indent: Thụt lề dòng đầu: Convert to Text Chuyển thành văn bản Filled Dot Chấm đặc Dash Gạch ngang diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 99e5223df..ef0711e48 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -316,6 +316,13 @@ Apply Bullets 应用项目符号 Numbered List 编号列表 Apply Numbers 应用编号 + Bullets and Numbering… 项目符号和编号… + Numbers 编号 + Level: 级别: + Bullet Character: 项目符号字符: + Number Style: 编号样式: + Left Indent: 左缩进: + First Line Indent: 首行缩进: Convert to Text 转换为文本 Filled Dot 实心圆点 Dash 短划线 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index eb0cf06b1..733008a65 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -329,6 +329,13 @@ font-technology Variable 可變 Apply Bullets 套用項目符號 Numbered List 編號清單 Apply Numbers 套用編號 + Bullets and Numbering… 項目符號與編號… + Numbers 編號 + Level: 層級: + Bullet Character: 項目符號字元: + Number Style: 編號樣式: + Left Indent: 左縮排: + First Line Indent: 首行縮排: Convert to Text 轉換為文字 Filled Dot 實心圓點 Dash 短破折號 diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 2e71f9873..67e116216 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -270,6 +270,7 @@ fn run_ui_part3(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result< } "type.applyBullets" => crate::panels::paragraph::apply_bullets(app, p), "type.applyNumbers" => crate::panels::paragraph::apply_numbers(app, p), + "ui.bulletsAndNumbering" => crate::dialogs::bullets_numbering::open(app, p), _ => return None, }; Some(r) diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 5e985bae2..fe2807d94 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -234,6 +234,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{number?: \"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\"|\"none\"} Type › Bullets and Numbering › Apply Numbers: the selected type's paragraphs (or those the Type tool's selection touches) become a numbered list in that number style (text.setFormat {number}); without it, a numbered list keeps its style and other paragraphs are numbered 1, 2, 3 (text.setFormat {list: \"number\"}); \"none\" turns the list off", ), + ( + "ui.bulletsAndNumbering", + "Bullets and Numbering…", + "", + "{tab?: \"bullets\"|\"numbers\"} open Bullets and Numbering (More Options in the Paragraph panel's Bullets and Numbered List presets) on the paragraphs the Paragraph panel acts on, filled in with the values it shows, on that tab (without it, Numbers when the paragraphs are a numbered list, else Bullets) (dialog `bulletsAndNumbering`: tab (bullets|numbers), list (none|bullet|number: the kind of the preset picked), bullet (dot|dash|circle|square), number (arabic|upperLetters|lowerLetters|upperRoman|lowerRoman|twoDigits|threeDigits), listAlign (left|right), listLevel (1..9; 1 for paragraphs in no list), leftIndent, firstLineIndent (pt)): the type shows the dialog's values while it is open, OK runs text.setFormat with the values that changed (and the indents it shows when a preset puts the paragraphs in a list), as one undo step, and Cancel puts the type back; a preset picked for paragraphs in no list whose indents are 0 fills in 18 and -18 pt; an error with no type selected or an unknown tab", + ), ( "type.convertListToText", "Convert to Text", diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index 4c3241608..88fdcda6e 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -1,8 +1,9 @@ //! Paragraph panel: seven alignment buttons, Paragraph Direction (with the Indic options), the -//! Bullets and Numbered List buttons and their presets, indents, space before/after, Hyphenate, -//! Insert Kashidas (with the Indic options), Mojikumi Set and Kinsoku Set (with the East Asian -//! options); the panel menu turns Roman Hanging Punctuation on and off, opens Justification… and -//! Hyphenation… and picks the Single-line or Every-line Composer. +//! Bullets and Numbered List buttons and their presets (More Options opens Bullets and +//! Numbering…), indents, space before/after, Hyphenate, Insert Kashidas (with the Indic options), +//! Mojikumi Set and Kinsoku Set (with the East Asian options); the panel menu turns Roman Hanging +//! Punctuation on and off, opens Justification… and Hyphenation… and picks the Single-line or +//! Every-line Composer. use egui::{Ui, vec2}; use serde_json::{Value, json}; @@ -218,7 +219,7 @@ fn visit_paragraphs(app: &VectorcraftApp, mut f: impl FnMut(&ParaStyle)) { } /// A distance shared by every paragraph in scope, or `None` when they differ (blank spin field). -fn shared_para_field(app: &VectorcraftApp, field: impl Fn(&ParaStyle) -> f64) -> Option<f64> { +pub(crate) fn shared_para_field(app: &VectorcraftApp, field: impl Fn(&ParaStyle) -> f64) -> Option<f64> { let mut acc = Shared::None; let mut any = false; visit_paragraphs(app, |p| { @@ -251,7 +252,7 @@ pub(crate) fn shared_single_word_justify(app: &VectorcraftApp) -> Option<SingleW } /// A paragraph attribute shared by every paragraph in scope, or `None` when they differ. -fn shared_para<T: PartialEq + Copy>(app: &VectorcraftApp, field: impl Fn(&ParaStyle) -> T) -> Option<T> { +pub(crate) fn shared_para<T: PartialEq + Copy>(app: &VectorcraftApp, field: impl Fn(&ParaStyle) -> T) -> Option<T> { let mut acc = Shared::None; let mut any = false; visit_paragraphs(app, |p| { @@ -465,6 +466,25 @@ pub(crate) fn apply_numbers(app: &mut VectorcraftApp, p: &Value) -> Result<Value run_on_paragraphs(app, "text.setFormat", params) } +/// The Bullets presets, in the order they are listed: (name, `text.setFormat`'s `bullet`, bullet; +/// None for the preset that turns the list off). +pub(crate) fn bullet_presets() -> [(&'static str, &'static str, Option<vectorcraft_doc::text::Bullet>); 5] { + use vectorcraft_doc::text::Bullet; + [ + (tl!("None"), "none", None), + (tl!("Filled Dot"), "dot", Some(Bullet::Dot)), + (tl!("Dash"), "dash", Some(Bullet::Dash)), + (tl!("Hollow Circle"), "circle", Some(Bullet::Circle)), + (tl!("Filled Square"), "square", Some(Bullet::Square)), + ] +} + +/// The first `n` numbers of number style `style`, as the Numbered List presets show them ("1, 2, +/// 3"). +pub(crate) fn number_sample(style: vectorcraft_doc::text::NumberStyle, n: u32) -> String { + (1..=n).map(|k| style.format(k)).collect::<Vec<_>>().join(", ") +} + /// Type › Bullets and Numbering › Convert to Text (`type.convertListToText`): the markers of the /// list paragraphs of the selected type, or of those the Type tool's selection touches, become /// characters of the text and the lists are turned off (`text.convertListToText`, which also moves @@ -477,9 +497,9 @@ pub(crate) fn convert_list_to_text(app: &mut VectorcraftApp) -> Result<Value, St /// The Bullets button and the arrow beside it (Paragraph panel). The button makes the paragraphs /// the panel acts on a bulleted list (`text.setFormat {list: "bullet"}`) and shows pressed while /// they all are one. The arrow lists the presets (`{bullet}`): None turns the list off, and the -/// one every paragraph has is checked. +/// one every paragraph has is checked. More Options after them opens Bullets and Numbering on its +/// Bullets tab. pub fn bullets_button(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) { - use vectorcraft_doc::text::Bullet; let t = Tokens::get(ui.ctx()); let shown = shared_para(app, |p| p.list.filter(|l| l.number.is_none()).map(|l| l.bullet)); if widgets::icon_button(ui, "dc-para-bullets", tl!("Bullets"), matches!(shown, Some(Some(_))), size).clicked() { @@ -492,31 +512,28 @@ pub fn bullets_button(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) { crate::icons::paint(ui, "chevron-down", egui::Rect::from_center_size(rect.center(), vec2(10.0, 10.0)), t.text_dim); arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Bullets"))); let arrow = arrow.on_hover_text(tl!("Bullets")); - let presets = [ - (tl!("None"), "none", None), - (tl!("Filled Dot"), "dot", Some(Bullet::Dot)), - (tl!("Dash"), "dash", Some(Bullet::Dash)), - (tl!("Hollow Circle"), "circle", Some(Bullet::Circle)), - (tl!("Filled Square"), "square", Some(Bullet::Square)), - ]; + let mut more = false; let picked = egui::Popup::menu(&arrow).show(|ui| { let mut picked = None; - for (name, key, bullet) in presets { + for (name, key, bullet) in bullet_presets() { if bullet_row(ui, name, bullet, shown == Some(bullet)) { picked = Some(key); } } + more = more_options_row(ui); picked }); if let Some(key) = picked.and_then(|r| r.inner) { format(app, json!({ "bullet": key })); } + if more { + more_options(app, "bullets"); + } } /// A row of the Bullets presets: a check when `checked`, the bullet drawn (a slash for None), then /// its name. Whether it was clicked. fn bullet_row(ui: &mut Ui, name: &str, bullet: Option<vectorcraft_doc::text::Bullet>, checked: bool) -> bool { - use vectorcraft_doc::text::Bullet; let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(130.0, 22.0), egui::Sense::click()); if resp.hovered() { @@ -527,24 +544,53 @@ fn bullet_row(ui: &mut Ui, name: &str, bullet: Option<vectorcraft_doc::text::Bul if checked { painter.text(rect.left_center() + vec2(6.0, 0.0), egui::Align2::LEFT_CENTER, "✓", font.clone(), t.text_strong); } - let c = rect.left_center() + vec2(28.0, 0.0); - let stroke = egui::Stroke::new(1.5, t.text_strong); - match bullet { - None => painter.line_segment([c + vec2(-5.0, 5.0), c + vec2(5.0, -5.0)], stroke), - Some(Bullet::Dot) => painter.circle_filled(c, 3.0, t.text_strong), - Some(Bullet::Dash) => painter.line_segment([c - vec2(4.0, 0.0), c + vec2(4.0, 0.0)], stroke), - Some(Bullet::Circle) => painter.circle_stroke(c, 3.0, egui::Stroke::new(1.2, t.text_strong)), - Some(Bullet::Square) => painter.rect_filled(egui::Rect::from_center_size(c, vec2(6.0, 6.0)), 0.0, t.text_strong), - }; + paint_bullet(painter, rect.left_center() + vec2(28.0, 0.0), bullet, t.text_strong); painter.text(rect.left_center() + vec2(42.0, 0.0), egui::Align2::LEFT_CENTER, name, font, t.text_strong); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, ui.is_enabled(), checked, name)); resp.clicked() } +/// Draw `bullet` centered on `c` in `color` as the presets show it (a slash for None). +pub(crate) fn paint_bullet(painter: &egui::Painter, c: egui::Pos2, bullet: Option<vectorcraft_doc::text::Bullet>, color: egui::Color32) { + use vectorcraft_doc::text::Bullet; + let stroke = egui::Stroke::new(1.5, color); + match bullet { + None => painter.line_segment([c + vec2(-5.0, 5.0), c + vec2(5.0, -5.0)], stroke), + Some(Bullet::Dot) => painter.circle_filled(c, 3.0, color), + Some(Bullet::Dash) => painter.line_segment([c - vec2(4.0, 0.0), c + vec2(4.0, 0.0)], stroke), + Some(Bullet::Circle) => painter.circle_stroke(c, 3.0, egui::Stroke::new(1.2, color)), + Some(Bullet::Square) => painter.rect_filled(egui::Rect::from_center_size(c, vec2(6.0, 6.0)), 0.0, color), + }; +} + +/// More Options at the end of the Bullets and Numbered List presets, under a line. Whether it was +/// clicked. +fn more_options_row(ui: &mut Ui) -> bool { + let t = Tokens::get(ui.ctx()); + ui.separator(); + let (rect, resp) = ui.allocate_exact_size(vec2(130.0, 22.0), egui::Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(rect, egui::CornerRadius::same(4), t.hover); + } + let name = tl!("More Options"); + ui.painter().text(rect.left_center() + vec2(22.0, 0.0), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.5), t.text_strong); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), name)); + resp.clicked() +} + +/// More Options: open Bullets and Numbering on `tab` (`ui.bulletsAndNumbering`), or show why it +/// can't open. +fn more_options(app: &mut VectorcraftApp, tab: &str) { + if let Err(e) = app.run("ui.bulletsAndNumbering", json!({ "tab": tab })) { + app.status(e); + } +} + /// The Numbered List button and the arrow beside it (Paragraph panel). The button makes the /// paragraphs the panel acts on a numbered list (`text.setFormat {list: "number"}`) and shows /// pressed while they all are one. The arrow lists the presets (`{number}`), each shown by its -/// first three numbers: None turns the list off, and the one every paragraph has is checked. +/// first three numbers: None turns the list off, and the one every paragraph has is checked. More +/// Options after them opens Bullets and Numbering on its Numbers tab. pub fn numbers_button(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) { use vectorcraft_doc::text::NumberStyle; let t = Tokens::get(ui.ctx()); @@ -559,22 +605,26 @@ pub fn numbers_button(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) { crate::icons::paint(ui, "chevron-down", egui::Rect::from_center_size(rect.center(), vec2(10.0, 10.0)), t.text_dim); arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Numbered List"))); let arrow = arrow.on_hover_text(tl!("Numbered List")); + let mut more = false; let picked = egui::Popup::menu(&arrow).show(|ui| { let mut picked = None; if bullet_row(ui, tl!("None"), None, shown == Some(None)) { picked = Some(None); } for style in NumberStyle::ALL { - let sample = (1..=3).map(|n| style.format(n)).collect::<Vec<_>>().join(", "); - if number_row(ui, &sample, shown == Some(Some(style))) { + if number_row(ui, &number_sample(style, 3), shown == Some(Some(style))) { picked = Some(Some(style)); } } + more = more_options_row(ui); picked }); if let Some(style) = picked.and_then(|r| r.inner) { format(app, json!({ "number": style.map_or(json!("none"), |s| json!(s)) })); } + if more { + more_options(app, "numbers"); + } } /// A row of the Numbered List presets: a check when `checked`, then the style's first numbers diff --git a/crates/ui-egui/src/panels/paragraph/tests.rs b/crates/ui-egui/src/panels/paragraph/tests.rs index 5facaee19..4250de340 100644 --- a/crates/ui-egui/src/panels/paragraph/tests.rs +++ b/crates/ui-egui/src/panels/paragraph/tests.rs @@ -435,6 +435,36 @@ fn the_numbered_list_button_and_its_presets_set_the_list_and_reset_panel_clears_ assert_eq!(lists(&app), (None, None)); } +/// More Options at the end of the Bullets presets opens Bullets and Numbering on its Bullets +/// tab, and at the end of the Numbered List presets on its Numbers tab. +#[test] +fn more_options_opens_bullets_and_numbering_on_its_tab() { + crate::i18n::set_current(crate::i18n::Lang::EN); + let (mut app, _) = editing_text("One\nTwo"); + select_range(&mut app, 0, 0); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let numbers = std::cell::Cell::new(false); + let row = std::cell::Cell::new(egui::Rect::NOTHING); + let mut draw = |app: &mut VectorcraftApp, ui: &mut Ui| { + let r = ui.horizontal(|ui| if numbers.get() { numbers_button(app, ui, 28.0) } else { bullets_button(app, ui, 28.0) }); + row.set(r.response.rect); + }; + for (on_numbers, tab) in [(false, "bullets"), (true, "numbers")] { + numbers.set(on_numbers); + super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], &mut draw); + let arrow = row.get().min + vec2(28.0 + 7.0, 14.0); + super::super::tests_appearance::click(&ctx, &mut app, arrow, &mut draw); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], &mut draw); + let at = super::super::tests_appearance::text_rect(&texts, "More Options").center(); + super::super::tests_appearance::click(&ctx, &mut app, at, &mut draw); + let d = app.ui.dialog.take().unwrap_or_else(|| panic!("More Options opens the dialog from the {tab} presets")); + assert_eq!((d.kind.as_str(), d.str("tab")), ("bulletsAndNumbering", tab.to_string())); + } + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("bulletsAndNumbering")); +} + /// Type › Bullets and Numbering › Convert to Text (`type.convertListToText`) acts on the /// paragraphs the Type tool's selection touches: their numbers become characters of the text /// and their list goes, while the other paragraphs stay in the list, and the Type tool's caret diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index a1a8c7fb1..57a99e433 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -57,8 +57,11 @@ enum IndentMarker { struct IndentTarget { id: u64, start: usize, + /// The left indent as laid out: the paragraph's own plus its list level's ([`Self::level`]). left: f64, first_line: f64, + /// How much further in the paragraph's list level sets it than its own left indent. + level: f64, } fn append_indent_targets(node: &Node, range: std::ops::Range<usize>, targets: &mut Vec<IndentTarget>) { @@ -68,7 +71,8 @@ fn append_indent_targets(node: &Node, range: std::ops::Range<usize>, targets: &m starts.extend(plain.bytes().enumerate().filter_map(|(i, c)| (c == b'\n').then_some(i + 1))); targets.extend(range.filter_map(|i| { let para = text.para_at(i); - Some(IndentTarget { id: node.id.0, start: *starts.get(i)?, left: para.left_indent, first_line: para.first_line_indent }) + let left = para.laid_out_left_indent(); + Some(IndentTarget { id: node.id.0, start: *starts.get(i)?, left, first_line: para.first_line_indent, level: left - para.left_indent }) })); } @@ -113,13 +117,14 @@ fn bounded_marker_position(marker: IndentMarker, target: f64, span: f64, targets IndentMarker::Upper => { (targets.iter().map(|p| p.left - 1296.0).fold(0.0_f64, f64::max), targets.iter().map(|p| p.left + 1296.0).fold(span, f64::min)) } + // A paragraph's own left indent stays within 0 to 1296 pt, its list level's indent added. IndentMarker::Lower if both => ( - targets.iter().map(|p| -p.first_line).fold(0.0_f64, f64::max), - targets.iter().map(|p| span - p.first_line).fold(span.min(1296.0), f64::min), + targets.iter().map(|p| p.level.max(-p.first_line)).fold(0.0_f64, f64::max), + targets.iter().map(|p| (span - p.first_line).min(p.level + 1296.0)).fold(span, f64::min), ), IndentMarker::Lower => ( - targets.iter().map(|p| p.left + p.first_line - 1296.0).fold(0.0_f64, f64::max), - targets.iter().map(|p| p.left + p.first_line + 1296.0).fold(span.min(1296.0), f64::min), + targets.iter().map(|p| p.level.max(p.left + p.first_line - 1296.0)).fold(0.0_f64, f64::max), + targets.iter().map(|p| (p.left + p.first_line + 1296.0).min(p.level + 1296.0)).fold(span, f64::min), ), }; (low <= high).then(|| target.clamp(low, high)) @@ -209,28 +214,34 @@ fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para: app.status(e); } } - } else if both { - if let Err(e) = super::paragraph::run_on_paragraphs(app, "text.setFormat", json!({"leftIndent": target})) { - app.status(e); - } - } else if targets.iter().all(|p| p.left + p.first_line == marker_position(IndentMarker::Upper, para)) { - if let Err(e) = super::paragraph::run_on_paragraphs( - app, - "text.setFormat", - json!({"leftIndent": target, "firstLineIndent": marker_position(IndentMarker::Upper, para) - target}), - ) { - app.status(e); - } } else { + // The ruler shows each paragraph's left indent with its list level's added; the paragraph + // keeps its own. + let upper = marker_position(IndentMarker::Upper, para); + let level = targets.first().map(|p| p.level).filter(|l| targets.iter().all(|p| p.level == *l)); + let params = match level { + Some(level) if both => Some(json!({"leftIndent": target - level})), + Some(level) if targets.iter().all(|p| p.left + p.first_line == upper) => { + Some(json!({"leftIndent": target - level, "firstLineIndent": upper - target})) + } + _ => None, + }; + if let Some(params) = params { + if let Err(e) = super::paragraph::run_on_paragraphs(app, "text.setFormat", params) { + app.status(e); + } + return; + } super::character::end_typing(app); let commands: Vec<Value> = targets .iter() .rev() .map(|p| { - json!({"command": "text.setFormat", "params": { - "ids": [p.id], "start": p.start, "end": p.start, - "leftIndent": target, "firstLineIndent": p.left + p.first_line - target - }}) + let mut params = json!({"ids": [p.id], "start": p.start, "end": p.start, "leftIndent": target - p.level}); + if !both { + params["firstLineIndent"] = json!(p.left + p.first_line - target); + } + json!({"command": "text.setFormat", "params": params}) }) .collect(); if let Err(e) = app.run("command.batch", json!({"label": tl!("Left Indent"), "commands": commands})) { @@ -245,7 +256,9 @@ fn current(app: &VectorcraftApp) -> Option<(Node, Vec<TabStop>, f64)> { current_para(app).map(|(n, para, span)| (n, para.tabs, span)) } -/// As [`current`], with all the attributes of the paragraph whose stops the panel shows. +/// As [`current`], with all the attributes of the paragraph whose stops the panel shows, its left +/// indent as laid out ([`ParaStyle::laid_out_left_indent`]: its list level's indent added), where +/// the ruler shows it. fn current_para(app: &VectorcraftApp) -> Option<(Node, ParaStyle, f64)> { let editing = super::character::text_editing(app); let n = match editing { @@ -258,7 +271,8 @@ fn current_para(app: &VectorcraftApp) -> Option<(Node, ParaStyle, f64)> { TextKind::Area { frame } => frame.bounds().map_or(360.0, |b| b.width()), _ => 360.0, }; - let para = t.para_at(para).clone(); + let para = t.para_at(para); + let para = ParaStyle { left_indent: para.laid_out_left_indent(), ..para.clone() }; Some((n, para, span.max(72.0))) } @@ -638,6 +652,8 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { snap_to_unit_item(ui); return; }; + // `current_para` gives the left indent with the list level's indent added. + let left_indent = para.left_indent; let mut stops = para.tabs; let selected_marker: Option<IndentMarker> = pstate(ui.ctx(), "tabs-marker-sel"); // Clamp the selection to the last stop, as `show` does for the ruler. @@ -655,10 +671,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { apply(app, &stops); } // Repeat Tab: copies of the selected stop across the ruler, spaced by its distance from the - // previous stop, or from the paragraph's left indent when it is the first stop. + // previous stop, or from the paragraph's left indent plus its list level's indent when it is + // the first stop. if menu_item(ui, tl!("Repeat Tab"), sel < stops.len() && selected_marker.is_none(), false) { let step = match sel { - 0 => (stops[0].position - para.left_indent).abs(), + 0 => (stops[0].position - left_indent).abs(), _ => stops[sel].position - stops[sel - 1].position, } .max(1.0); diff --git a/crates/ui-egui/src/panels/tabs/tests.rs b/crates/ui-egui/src/panels/tabs/tests.rs index 3244ba243..bc71e3408 100644 --- a/crates/ui-egui/src/panels/tabs/tests.rs +++ b/crates/ui-egui/src/panels/tabs/tests.rs @@ -540,9 +540,32 @@ fn indent_markers_after_select_all_follow_text_that_reflows_into_the_next_frame( #[test] fn lower_marker_bounds_cover_each_touched_first_line() { - let targets = [IndentTarget { id: 1, start: 0, left: 24.0, first_line: -20.0 }, IndentTarget { id: 1, start: 4, left: 24.0, first_line: 280.0 }]; + let targets = [ + IndentTarget { id: 1, start: 0, left: 24.0, first_line: -20.0, level: 0.0 }, + IndentTarget { id: 1, start: 4, left: 24.0, first_line: 280.0, level: 0.0 }, + ]; assert_eq!(bounded_marker_position(IndentMarker::Lower, 40.0, 300.0, &targets, true), Some(20.0)); assert_eq!(bounded_marker_position(IndentMarker::Lower, 1200.0, 300.0, &targets, false), Some(300.0)); + // A paragraph at list level 2 is laid out 18 pt further in: its own left indent stays at 0 or more. + let listed = [IndentTarget { id: 1, start: 0, left: 18.0, first_line: 0.0, level: 18.0 }]; + assert_eq!(bounded_marker_position(IndentMarker::Lower, 5.0, 300.0, &listed, true), Some(18.0)); +} + +/// A list paragraph at level 2 is laid out 18 pt further in than its left indent: the ruler shows +/// its markers there, and a marker moved on the ruler sets the paragraph's own left indent. +#[test] +fn indent_markers_show_and_set_a_list_paragraphs_laid_out_indent() { + let mut app = indent_app(10.0, 6.0); + app.run("text.setFormat", json!({"list": "bullet", "listLevel": 2})).unwrap(); + let para = current_para(&app).unwrap().1; + assert_eq!((marker_position(IndentMarker::Lower, ¶), marker_position(IndentMarker::Upper, ¶)), (28.0, 34.0)); + let ctx = egui::Context::default(); + marker_event(&ctx, &mut app, 28.0, 40.0, false, true, false); + assert_eq!(indent_values(&app, 0), (22.0, 6.0), "the ruler's 40 pt is the paragraph's 22 pt and the level's 18"); + marker_event(&ctx, &mut app, 46.0, 70.0, true, false, false); + assert_eq!(indent_values(&app, 0), (22.0, 30.0)); + marker_event(&ctx, &mut app, 40.0, 20.0, false, false, false); + assert_eq!(indent_values(&app, 0), (2.0, 50.0), "the first line stays at 70 pt"); } #[test] @@ -1100,7 +1123,8 @@ fn panel_menu_does_not_change_stops_while_an_indent_marker_is_selected() { } /// Repeat Tab on the first stop spaces the copies by the stop's distance from the paragraph's -/// left indent, from the paragraph the caret is in while the Type tool edits text. +/// left indent (18 pt further in for each list level past the first), from the paragraph the +/// caret is in while the Type tool edits text. #[test] fn repeat_tab_measures_the_first_stop_from_the_left_indent() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); @@ -1119,6 +1143,11 @@ fn repeat_tab_measures_the_first_stop_from_the_left_indent() { app.session.execute("text.tabs.set", &json!({"stops": [{"position": 5}]})).unwrap(); choose(&ctx, &mut app, "Repeat Tab"); assert_eq!(positions(&app)[..4], [5.0, 20.0, 35.0, 50.0]); + // A list paragraph at level 2 is set 18 pt further in, at 38: copies every 12 pt. + app.session.execute("text.setFormat", &json!({"list": "bullet", "listLevel": 2})).unwrap(); + app.session.execute("text.tabs.set", &json!({"stops": [{"position": 50}]})).unwrap(); + choose(&ctx, &mut app, "Repeat Tab"); + assert_eq!(positions(&app)[..3], [50.0, 62.0, 74.0]); // The Type tool's caret in the second paragraph, indented 40 pt: its stop at 50 repeats // every 10 pt, and the first paragraph keeps its stop. let mut app = VectorcraftApp::new(Session::new(), Default::default()); diff --git a/docs/mcp.md b/docs/mcp.md index ab0d5d55a..6bff1f368 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5972,13 +5972,37 @@ the text. Return at the end of a list item starts another item in the same list. set, and paragraph styles carry it: a style captured from a paragraph without a list turns the list off. Reset Panel turns it off. The change is one undo step. +`text.setFormat {listAlign: "left"|"right"}` (Bullets and Numbering › Alignment) sets where the marker stands before +the first line's text. With `"left"`, the default, the marker starts where the first line starts, at the left indent +plus the first-line indent. With `"right"` the marker ends where the text starts, against the text. The text starts at +the same place with either alignment, so numbers of different widths aligned right end at the same place, and a number +that reaches past the left indent ends at the next tab stop, where the text starts. In a right-to-left list paragraph a +marker aligned right starts where the text ends. `listAlign` sets the paragraphs that are in a list, and those that the +same call puts in one; a paragraph in no list stays out of one. A list keeps its alignment when it changes between +bullets and numbers, and a list that is turned off and on again starts aligned left. An unknown value is an error and +changes nothing. The list saves the alignment (`"align": "right"`) only when it is right. + +`text.setFormat {listLevel: 1..9}` (Bullets and Numbering › Level) sets the level of the list paragraphs it touches, and +of those that the same call puts in a list; a paragraph in no list stays out of one. The value is a whole number from 1 +to 9 (`2` or `2.0`); any other value is an error and changes nothing. Each level past the first +sets the paragraph 18 pt further in than its left indent: its marker, its text and the lines it wraps to move together, +and in a right-to-left list paragraph they move in from the right. A list keeps its level when it changes between +bullets and numbers, and a list that is turned off and on again starts at level 1. The list saves the level +(`"level": 3`) only when it is not 1. In the Type tool, Tab with the caret at the start of a list paragraph (before its +first character, with nothing selected) runs `text.setFormat {listLevel}` with the next level, and Shift+Tab with the +level before; each is one undo step, and the text is unchanged. Tab at level 9 and Shift+Tab at level 1 do nothing. +Anywhere else, with characters selected, or in a paragraph in no list, Tab and Shift+Tab insert a tab. + A numbered paragraph's number counts the numbered paragraphs of its story up to and including it: the count runs through consecutive list paragraphs, through the frames of threaded type in thread order, and starts again at 1 after a paragraph in no list. An empty paragraph has no number and is not counted, and a bulleted paragraph keeps its bullet and -is not counted. Letters run from A to Z, then AA to ZZ, then AAA; roman numerals use IV, IX, XL, XC, CD and CM; 01 and +is not counted. Each level counts its own numbers: a list paragraph that has text sets the counts of the levels +deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a +shallower level goes on with that level's count. Letters run from A to Z, then AA to ZZ, then AAA; roman numerals use IV, IX, XL, XC, CD and CM; 01 and 001 keep every digit past 99 and 999; letters and roman numerals past 3999 are written in Arabic digits. In a right-to-left paragraph the number reads left to right with its period at its left. Each threaded frame saves the count -that its earlier frames carry into it (`listCount`, only when it is not 0). Type that is in no thread counts from 1: +that its earlier frames carry into it at level 1 (`listCount`, only when it is not 0) and at the levels past it +(`listLevelCounts`, level 2 first, only when one of them is not 0). Type that is in no thread counts from 1: after Remove Threading or Release Selection, in a frame converted to point type, and in every copy of a frame (pasted, duplicated, copied in the Layers panel, or taken out of a symbol or a placed file). A symbol made of a thread keeps the numbers in the symbol's art. @@ -5999,12 +6023,29 @@ Type › Bullets and Numbering › Apply Bullets and the canvas context menu's B same menus runs `type.applyNumbers {number?}`. Without `number` it runs `text.setFormat {list: "number"}`, and with it it runs `text.setFormat {number}`. +More Options at the end of the Paragraph panel's Bullets and Numbered List presets runs `ui.bulletsAndNumbering {tab?: "bullets"|"numbers"}`. It opens the Bullets and Numbering +dialog (`bulletsAndNumbering`) on the paragraphs the Paragraph panel acts on, on that tab; without `tab` it opens on +Numbers when the paragraphs are a numbered list and on Bullets otherwise. On both tabs the Level field, above the +presets, holds the paragraphs' list level (1 to 9; 1 for paragraphs in no list). The Bullets tab has the five bullet +presets and the Numbers tab has None and the seven number styles. The Position group has Alignment (Left or Right), +Left Indent and First Line Indent. The dialog opens with the values the Paragraph panel shows; where the paragraphs +differ, no preset is picked and the field is blank. Its fields are `tab`, `list` (`none`, `bullet` or `number`: the +kind of the preset picked), `bullet`, `number`, `listAlign`, `listLevel`, `leftIndent` and `firstLineIndent` (points), +and `ui.dialog.set` sets them, including those the dialog opened blank or without. The type shows the dialog's values while it is open. A preset picked for paragraphs that are in no list and have +no indents fills in 18 pt and -18 pt, and None takes them out again while they are unchanged. OK runs `text.setFormat` +with the values that changed, as one undo step, and Cancel puts the type back as it was. When a preset puts the +paragraphs in a list, OK also sends the indents the dialog shows, and the paragraphs take them, 0 included. OK does not +send an Alignment or Level change for paragraphs that stay in no list. With no type selected, or +with an unknown `tab`, `ui.bulletsAndNumbering` is an error. + Type › Bullets and Numbering › Convert to Text runs `type.convertListToText` on the selected type's paragraphs, or on those the Type tool's selection touches, and that runs `text.convertListToText {ids?, start?, end?}`. The marker of each list paragraph becomes characters at the start of the paragraph: the bullet, or the number and its period as the paragraph showed it, then a tab. They are set in the marker's font, size and color: the style of the paragraph's first -character without the attributes the marker leaves out. The paragraph leaves its list. When the marker's tab went to -the left indent, the paragraph gets a left-aligned tab stop without a leader there, in place of any stop there, so that +character without the attributes the marker leaves out. The paragraph leaves its list. A paragraph at a list level +past the first keeps the level's indent in its left indent, so its marker and text stay where they were. A marker aligned right +(`listAlign` `right`) is written the same way: its characters go to the first line's start, where a marker aligned left +is drawn, and the text after its tab stays where it was. When the marker's tab went to the left indent, the paragraph gets a left-aligned tab stop without a leader there, in place of any stop there, so that the text after the tab starts where it started after the marker. When the marker's tab went to a tab stop, the converted tab goes to the same place when that stop is left-aligned and no stop there or past it has a leader. When a stop there or past it has a leader or is not left-aligned, or a right, center or decimal stop lies between the marker @@ -6048,6 +6089,9 @@ the paragraph's text now starts; when the text before `at` starts no list, nothi ```json {"name":"run_command","arguments":{"command":"text.setFormat","params":{"bullet":"square"}}} {"name":"run_command","arguments":{"command":"text.setFormat","params":{"number":"upperRoman"}}} +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"listAlign":"right"}}} +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"listLevel":2,"start":4,"end":4}}} +{"name":"invoke_menu","arguments":{"command":"ui.bulletsAndNumbering","params":{"tab":"numbers"}}} {"name":"run_command","arguments":{"command":"text.convertListToText","params":{}}} ``` diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 17b1704cc..adc5f473a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1070,9 +1070,11 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur PDF export with type as text writes the hyphen that ends a line broken inside a word as a hyphen character where the line draws it, and the file opens with the hyphen in the line's text; kashidas are drawn as paths. The Paragraph panel's Insert Kashidas (with the Indic options; `text.setFormat {kashida}`) lengthens each Arabic word of a justified horizontal area type line with a kashida, a tatweel between its last pair of joined letters, before the word spaces widen: Short, Medium and Long kashidas are at most a quarter, half and a whole em long, Stylistic ones are whole tatweels up to an em, and None (the default) leaves the line as before; paragraph styles and the native file keep the setting. The Character panel menu's Kashidas switch (with the Indic options; `text.setFormat` / `text.setRangeStyle {kashidas}`) allows Kashidas on selected characters, on by default for new and old text. A pair of joined letters receives one only when both characters allow it; turning one off leaves other eligible pairs in the word available. Character styles and the native file keep the switch. [Public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/justify-text-using-kashida-and-hyphenation.html) documents the menu control and the separate paragraph length choices; the default and pair rule are VectorCraft choices. - The Paragraph panel's Bullets button, Type › Bullets and Numbering › Apply Bullets and the context menu's Apply Bullets (`text.setFormat {list, bullet}`, `type.applyBullets`) make paragraphs a bulleted list with the documented presets (none, filled dot, dash, hollow circle, filled square; [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): the bullet starts each paragraph's first line in its first character's font, size and color, the first line's text starts at the next tab stop after the bullet (the left indent counts as a stop), the lines it wraps to start at the left indent, a paragraph with no indents gets 18 pt and -18 pt, and Return continues the list; Reset Panel turns it off, and paragraph styles and the native file keep it. The Bullets and Numbering dialog, levels and list detection while typing are not built yet. + The Paragraph panel's Bullets button, Type › Bullets and Numbering › Apply Bullets and the context menu's Apply Bullets (`text.setFormat {list, bullet}`, `type.applyBullets`) make paragraphs a bulleted list with the documented presets (none, filled dot, dash, hollow circle, filled square; [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): the bullet starts each paragraph's first line in its first character's font, size and color, the first line's text starts at the next tab stop after the bullet (the left indent counts as a stop), the lines it wraps to start at the left indent, a paragraph with no indents gets 18 pt and -18 pt, and Return continues the list; Reset Panel turns it off, and paragraph styles and the native file keep it. The Paragraph panel's Bullets button, Type › Bullets and Numbering › Apply Bullets and the context menu's Apply Bullets (`text.setFormat {list, bullet}`, `type.applyBullets`) make paragraphs a bulleted list with the documented presets (none, filled dot, dash, hollow circle, filled square; [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): the bullet starts each paragraph's first line in its first character's font, size and color, the first line's text starts at the next tab stop after the bullet (the left indent counts as a stop), the lines it wraps to start at the left indent, a paragraph with no indents gets 18 pt and -18 pt, and Return continues the list; Reset Panel turns it off, and paragraph styles and the native file keep it. The Bullets and Numbering dialog, levels and Convert to Text are not built yet. The Paragraph panel's Numbered List button, Type › Bullets and Numbering › Apply Numbers and the context menu's Apply Numbers (`text.setFormat {list, number}`, `type.applyNumbers`) make paragraphs a numbered list with the documented presets (1, A, a, I, i, 01, 001, and None, which turns the list off; [Bullets and numbering](https://web.archive.org/web/20250807202418/https://helpx.adobe.com/illustrator/using/bullets-numbering.html), [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): each paragraph starts with its number and a period, counted through the list paragraphs of the story and its threaded frames, with the text and indents as in a bulleted list; letters go on from Z to AA, and roman numerals go on from III to IV, an empty paragraph is not numbered, and a paragraph in no list starts the count again at 1. + More Options in the Bullets and Numbered List presets (`ui.bulletsAndNumbering`) opens the Bullets and Numbering dialog ([Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): a Bullets tab and a Numbers tab with the presets, and the Position group's Alignment, Left Indent and First Line Indent, shown on the type while the dialog is open; OK applies them with `text.setFormat` (Alignment is its `listAlign`) as one undo step, and Cancel puts the type back. Alignment Left starts the marker at the first line's start, and Right ends it where the text starts. The cited page does not say where the marker sits for each choice, so these meanings are VectorCraft's own rule. Paragraph styles and the native file keep the alignment. + Tab and Shift+Tab with the caret at the start of a list paragraph (`text.setFormat {listLevel}`), and the Bullets and Numbering dialog's Level field (OK sends it as `listLevel`), move the paragraph down and up the list's nine levels ([Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html), [Bullets and numbering](https://web.archive.org/web/20250807202418/https://helpx.adobe.com/illustrator/using/bullets-numbering.html)): each level past the first sets the paragraph 18 pt further in, each level counts its own numbers from 1, and a paragraph back at a shallower level goes on with that level's count, through threaded frames too. Elsewhere Tab inserts a tab as before; one Undo takes a level change back, and paragraph styles and the native file keep the level. Convert to Text writes each level's numbers and keeps a deeper paragraph's indent. Type › Bullets and Numbering › Convert to Text (`type.convertListToText`, `text.convertListToText`) makes the bullets and numbers of the list paragraphs editable characters of their text ([Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html), [December 2022 release notes](https://web.archive.org/web/20260203094741/https://helpx.adobe.com/illustrator/using/whats-new/2023-1.html)): each marker becomes its characters and a tab at the start of its paragraph, in the marker's font, size and color, the list is turned off, and a left tab stop at the left indent keeps the text after the tab where it started; threaded type keeps the numbers its story counted. While no type is being edited, a press with Shift held makes new type with the horizontal or vertical counterpart of the Type, Area Type or Type on a Path tool chosen, and a press with Alt (Option) held makes it with Type on a Path in place of Area Type or the reverse, in the chosen tool's writing direction; with both keys held, both switch ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). The pointer shows the tool the press acts as, and the tool chosen stays selected. The Type tool starts a list from the characters [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html) names (`text.startList`): a space typed on its own after `1.`, `1)`, `A.` or `A)` at the start of a paragraph in no list makes the paragraph a numbered list in 1, 2, 3 or A, B, C, and a space typed after `*`, `+`, `#` or `>` makes it a bulleted list with the filled dot, the hollow circle, the filled square or the dash (a VectorCraft choice: the page does not say which bullet each character gives). The typed characters are removed, and one Undo puts them back as text with no list. The same characters later in a paragraph, and pasted list characters, start nothing. @@ -1084,7 +1086,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With Snap to Unit off and Shift not held, a tab stop placed by a click on the Tabs panel's ruler or dragged on it lands on the nearest whole point. A position typed in the X or Y field keeps its hundredths of a point. The Tabs ruler's upper first-line marker and lower left-indent marker show absolute positions and set paragraph indents (`text.setFormat`). Dragging the lower marker keeps the upper position, while Ctrl-dragging it (Command on macOS) moves both; marker drags snap like tab stops, and each release is one undo step. With differing left indents, only the upper marker is shown and its absolute target applies to every touched paragraph. When a drag on the Tabs panel's ruler or a value typed in its X field moves a tab stop past another stop, the moved stop stays selected. - The Tabs panel menu's Clear All Tabs (`text.tabs.clear`), Delete Tab and Repeat Tab (`text.tabs.set`) can be chosen while the panel's ruler shows tab stops. Delete Tab removes the stop selected on the ruler. Repeat Tab replaces the stops after the selected stop with up to 20 copies of it. The copies are spaced by the selected stop's distance from the stop before it, or from the paragraph's left indent when it is the first stop, and no copy is placed at 2,000 pt or beyond. Clear All Tabs removes every stop, so the default stops every ½ inch apply again. + The Tabs panel menu's Clear All Tabs (`text.tabs.clear`), Delete Tab and Repeat Tab (`text.tabs.set`) can be chosen while the panel's ruler shows tab stops. Delete Tab removes the stop selected on the ruler. Repeat Tab replaces the stops after the selected stop with up to 20 copies of it. The copies are spaced by the selected stop's distance from the stop before it, or from the paragraph's left indent (18 pt further in for each list level past the first) when it is the first stop, and no copy is placed at 2,000 pt or beyond. Clear All Tabs removes every stop, so the default stops every ½ inch apply again. - **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale), Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale), Catalan (every `ca-*` locale) and Polish (every `pl-*` locale; AI-drafted, native review pending) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian and Finnish also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). Interface text in characters the interface's fonts lack, drawn in a bundled Japanese, Georgian or Devanagari face or in an installed font that covers them, sits on the baseline of the Latin text beside it, monospace text included (#5816; `crates/ui-egui/src/ui_fonts.rs`, `crates/ui-egui/src/theme.rs`). Character panel menu › Standard Vertical Roman Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {standardVerticalRoman}`, off by default) stands the selected Latin letters, digits and other half-width characters upright one below the other in vertical type instead of rotating them ([formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); spaces keep their width, tate-chu-yoko blocks stay across the column, and Character Style Options gives it its own checkbox beside Character Alignment. From eaab4fd516d3bfe788300273c9eeba0315cab5f1 Mon Sep 17 00:00:00 2001 From: onion3130 <onion3130@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:11:17 -0500 Subject: [PATCH 134/160] Trim to hermetic font test only (Source Sans 3, not Inter) --- crates/engine/src/cmd/typecmd/area_tests.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index 0e2ba3d1f..0891bec5c 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -1865,9 +1865,10 @@ fn variable_font_settings_are_a_character_attribute_saved_only_when_set() { s.execute("text.setStyle", &json!({"style": "Bold"})).unwrap(); assert!(runs(&s).iter().all(|r| r.style.font_axes.is_empty())); s.execute("text.setFormat", &json!({"fontAxes": {"wdth": 75}})).unwrap(); - s.execute("text.replaceFont", &json!({"from": {"family": FAMILY}, "to": {"family": "Inter"}})).unwrap(); + s.execute("text.replaceFont", &json!({"from": {"family": FAMILY}, "to": {"family": "Source Sans 3"}})).unwrap(); assert!(runs(&s).iter().all(|r| r.style.font_axes.is_empty())); - // A static font has no axes to set. + // A static font has no axes to set (Source Sans 3, not Inter: an installed Inter + // Variable would shadow the bundled static and give it axes). s.execute("text.setFormat", &json!({"fontAxes": {"wght": 650}})).unwrap(); assert_eq!(saved(&s), None); // A missing font keeps them as given, for when it is installed. @@ -1889,7 +1890,7 @@ fn variable_font_settings_are_a_character_attribute_saved_only_when_set() { {"tag": "wdth", "name": "Width", "min": 75.0, "default": 100.0, "max": 100.0, "value": 100.0, "hidden": false}, ]) ); - assert_eq!(s.execute("text.fontList", &json!({"family": "Inter"})).unwrap()["axes"], json!([])); + assert_eq!(s.execute("text.fontList", &json!({"family": "Source Sans 3"})).unwrap()["axes"], json!([])); } #[test] From 4b665ad13cda098be0c560dae9d9eb81c459c7c9 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sun, 8 Nov 2026 13:03:28 -0800 Subject: [PATCH 135/160] Plug-ins: clearing every cache is tested in a binary of its own `plugin.clearCache` without an id empties the cache of every plug-in installed in the process. In the engine's library tests it could empty the cache of another storage test running at the same time, and its `bytesFreed` could count that test's cache. It now runs in `crates/engine/tests/plugin_clear_cache.rs`, and the library test checks that clearing one plug-in's cache leaves the other plug-in's cache. The storage fuzz test starts each case with an empty plug-in store, deletes the store when the case ends, and installs `fuzz.storage` with `replace: true`, so the cases that shrink a failure install their module. docs/mcp.md says that files `plugin.remove` can't delete stay in the folder renamed with `.removed` until the plug-in is removed again. --- crates/engine/src/tests/plugins.rs | 22 ++++---- crates/engine/tests/plugin_clear_cache.rs | 65 +++++++++++++++++++++++ crates/engine/tests/plugin_fuzz.rs | 17 +++++- docs/mcp.md | 3 +- docs/target-app-parity.md | 2 +- 5 files changed, 93 insertions(+), 16 deletions(-) create mode 100644 crates/engine/tests/plugin_clear_cache.rs diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index dad65195f..87a11ba47 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -1086,10 +1086,11 @@ fn replace_storing(s: &mut Session, id: &str, first: &str) -> Value { /// A plug-in's settings and cache are kept in the plug-in store under its id: the next session /// has them, replacing the plug-in keeps them, another plug-in can't read them, `plugin.clearCache` -/// empties the cache alone and `plugin.remove` deletes both. The caps and the bytes each call is -/// charged follow ([`plugin_storage_is_capped_and_survives_a_damaged_settings_file`], -/// [`plugin_storage_calls_are_charged_the_files_they_read_and_write`]) in the same test, since -/// clearing every plug-in's cache would empty theirs. +/// with its id empties its cache alone and `plugin.remove` deletes both. The caps and the bytes +/// each call is charged follow ([`plugin_storage_is_capped_and_survives_a_damaged_settings_file`], +/// [`plugin_storage_calls_are_charged_the_files_they_read_and_write`]) in the same test. +/// `plugin.clearCache` without an id empties the cache of every plug-in installed in the process, +/// so it is tested in a binary of its own (`tests/plugin_clear_cache.rs`). #[test] fn plugins_keep_settings_and_a_cache_of_their_own_in_the_store() { let folder = crate::tests_links::Folder::new("plugin-storage"); @@ -1116,16 +1117,13 @@ fn plugins_keep_settings_and_a_cache_of_their_own_in_the_store() { install_storing(&mut next, other, READS); assert!(next.execute("plugin.run", &json!({"id": other, "ids": []})).is_err()); - // Clearing the cache leaves the settings. - assert_eq!(run(&mut next, "plugin.clearCache", json!({"id": id})), json!({"cleared": [id], "bytesFreed": 3})); - assert_eq!(run(&mut next, "plugin.info", json!({"id": id}))["cacheBytes"], json!(0)); - run(&mut next, "plugin.run", json!({"id": id, "ids": []})); + // Clearing the cache leaves the settings, and the other plug-in's cache. replace_storing(&mut next, other, WRITES); run(&mut next, "plugin.run", json!({"id": other, "ids": []})); - let all = run(&mut next, "plugin.clearCache", json!({})); - let cleared = all["cleared"].as_array().unwrap(); - assert!(cleared.contains(&json!(id)) && cleared.contains(&json!(other)) && all["bytesFreed"] == json!(3), "{all}"); - assert_eq!(run(&mut next, "plugin.info", json!({"id": other}))["cacheBytes"], json!(0)); + assert_eq!(run(&mut next, "plugin.clearCache", json!({"id": id})), json!({"cleared": [id], "bytesFreed": 3})); + assert_eq!(run(&mut next, "plugin.info", json!({"id": id}))["cacheBytes"], json!(0)); + assert_eq!(run(&mut next, "plugin.info", json!({"id": other}))["cacheBytes"], json!(3)); + run(&mut next, "plugin.run", json!({"id": id, "ids": []})); assert!(next.execute("plugin.clearCache", &json!({"id": "test.engine.storing.missing"})).is_err()); run(&mut next, "plugin.remove", json!({"id": id})); diff --git a/crates/engine/tests/plugin_clear_cache.rs b/crates/engine/tests/plugin_clear_cache.rs new file mode 100644 index 000000000..74ba69d64 --- /dev/null +++ b/crates/engine/tests/plugin_clear_cache.rs @@ -0,0 +1,65 @@ +//! `plugin.clearCache` without an id empties the cache of every installed plug-in and leaves their +//! settings. +//! +//! A test binary of its own: installed plug-ins are process-wide, so clearing every plug-in's cache +//! in the engine's other tests would empty the caches those tests check. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use serde_json::{Value, json}; +use vectorcraft_engine::Session; +use vectorcraft_plugins::wat as tpl; + +/// Sets "zoom" to "hello" and writes "hel" to the cache entry "tile". +const WRITES: &str = "(if (call $vc_settings_set (i32.const 0x2000) (i32.const 4) (i32.const 0x2100) (i32.const 5)) (then unreachable)) + (if (call $vc_cache_write (i32.const 0x2004) (i32.const 4) (i32.const 0x2100) (i32.const 3)) (then unreachable))"; + +/// Traps unless "zoom" is "hello". +const READS: &str = + "(if (i32.ne (call $vc_settings_get (i32.const 0x2000) (i32.const 4) (i32.const 0x3000) (i32.const 16)) (i32.const 5)) (then unreachable)) + (if (i32.ne (i32.load (i32.const 0x3000)) (i32.load (i32.const 0x2100))) (then unreachable))"; + +fn run(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +/// Installs a filter `id` of API level 2 that lists `settings` and `cache` and whose `vc_run` runs +/// `first`, in place of any module installed with its id (`replace: true`). +fn install_storing(s: &mut Session, id: &str, first: &str) { + let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":["settings","cache"]}}"#); + let data = format!(r#"{} (data (i32.const 0x2000) "zoomtile") (data (i32.const 0x2100) "hello")"#, tpl::STORAGE_IMPORTS); + let wasm = wat::parse_str(tpl::with_imports(&manifest, &data, first, r#"{"objects":[]}"#)).unwrap(); + let data = vectorcraft_format::base64_encode(&wasm); + run(s, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"], "replace": true})); +} + +#[test] +fn clearing_every_cache_empties_each_plugins_cache_and_keeps_its_settings() { + let folder = std::env::temp_dir().join(format!("vc-plugin-clear-cache-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&folder); + std::fs::create_dir_all(&folder).unwrap(); + let ids = ["test.clearcache.a", "test.clearcache.b"]; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 200})).unwrap(); + s.attach_plugin_store(folder.clone()); + for id in ids { + install_storing(&mut s, id, WRITES); + run(&mut s, "plugin.run", json!({"id": id, "ids": []})); + assert_eq!(run(&mut s, "plugin.info", json!({"id": id}))["cacheBytes"], json!(3)); + } + + assert_eq!(run(&mut s, "plugin.clearCache", json!({})), json!({"cleared": ids, "bytesFreed": 6})); + for id in ids { + assert_eq!(run(&mut s, "plugin.info", json!({"id": id}))["cacheBytes"], json!(0)); + // The settings stay: a module that reads them runs. + install_storing(&mut s, id, READS); + run(&mut s, "plugin.run", json!({"id": id, "ids": []})); + } + // Nothing is left to clear. + assert_eq!(run(&mut s, "plugin.clearCache", json!({})), json!({"cleared": ids, "bytesFreed": 0})); + + for id in ids { + run(&mut s, "plugin.remove", json!({"id": id})); + } + let _ = std::fs::remove_dir_all(&folder); +} diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index a7e45f690..5087ad2e5 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -681,7 +681,17 @@ proptest! { use vectorcraft_plugins::host::STORAGE_BUDGET; use vectorcraft_plugins::storage::Meter; const FUNCS: [&str; 6] = ["vc_settings_get", "vc_settings_set", "vc_cache_read", "vc_cache_write", "vc_settings_delete", "vc_cache_clear"]; - let folder = std::env::temp_dir().join(format!("vectorcraft-plugin-fuzz-storage-{}", std::process::id())); + // Each case starts with an empty store, and the store is deleted when the case ends, also + // when it fails. + struct Store(std::path::PathBuf); + impl Drop for Store { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let store = Store(std::env::temp_dir().join(format!("vectorcraft-plugin-fuzz-storage-{}", std::process::id()))); + let _ = std::fs::remove_dir_all(&store.0); + let folder = store.0.clone(); let mut s = Session::new(); s.attach_plugin_store(folder.clone()); let storing = |id: &str| format!(r#"{{"id":"{id}","name":"F","kind":"filter","api":2,"permissions":["settings","cache"]}}"#); @@ -704,8 +714,11 @@ proptest! { let text: String = key.iter().map(|b| format!("\\{b:02x}")).collect(); let imports = format!(r#"{} (data (i32.const 0x2000) "{text}")"#, tpl::STORAGE_IMPORTS); let wasm = wat::parse_str(tpl::with_imports(&storing("fuzz.storage"), &imports, &format!("(local $r i32) {body}"), r#"{"objects":[]}"#)).unwrap(); + // A failed case leaves its module installed, and the cases that shrink it install others + // under the same id. let r = catch_quiet(|| { - s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": grant})).map_err(|e| e.to_string())?; + s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": grant, "replace": true})) + .map_err(|e| e.to_string())?; let p = vectorcraft_plugins::registry::get("fuzz.storage").ok_or("not installed")?; p.run(b"{\"objects\":[]}", b"{}").map_err(|e| e.to_string()) }); diff --git a/docs/mcp.md b/docs/mcp.md index 6bff1f368..a95fab813 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6489,7 +6489,8 @@ under its id. One that lists `settings` or `cache` keeps settings or a cache of (VectorCraft's own caps: 256 settings and 1 MiB, 512 MiB of cache): `plugin.list` and `plugin.info` give the cache's size as `cacheBytes`, `plugin.clearCache {id?}` empties the cache of one plug-in or of all of them and returns `{cleared: [id], bytesFreed}`, replacing a plug-in keeps both, and `plugin.remove` deletes both (also for an id that -isn't installed but still has them). A module with other bytes than the plug-in installed with its id, or, with none +isn't installed but still has them). Files it can't delete stay in the plug-in's folder renamed with `.removed`, where +no plug-in reaches them, until a plug-in with that id is removed again ([plugins.md](plugins.md#settings-and-cache)). A module with other bytes than the plug-in installed with its id, or, with none installed, one whose id has settings or a cache in the `Plug-ins` folder (`needsReplace: true` in its `plugin.inspect` entry), installs only with `replace: true`: ask the user first, since it takes that plug-in's place and gets its settings and cache. Without it, `plugin.install` returns diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index adc5f473a..396fd7305 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1542,7 +1542,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged. - **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. -- **Plug-in settings and cache (G17, #2191):** at plug-in API level 2 a plug-in with the `settings` permission keeps settings of its own (`env.vc_settings_get`, `_set`, `_delete`) and one with `cache` a cache the user can clear (`env.vc_cache_read`, `_write`, `_clear`), under its id in the `Plug-ins` folder. Keys are plain names, never paths; VectorCraft's own caps refuse a write past 256 settings, 1 MiB of settings or 512 MiB of cache; a damaged settings file is moved aside and the settings start empty. `plugin.clearCache` empties a cache, `plugin.list` and `plugin.info` give `cacheBytes`, replacing a plug-in keeps its storage and `plugin.remove` deletes it. Another module under an id that is installed or has storage installs only with `replace: true`, which the app asks the user for; each storage call counts the whole settings file or cache entry it reads and writes toward the run's 1 GiB. Regressions: `crates/plugins/src/storage.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. +- **Plug-in settings and cache (G17, #2191):** at plug-in API level 2 a plug-in with the `settings` permission keeps settings of its own (`env.vc_settings_get`, `_set`, `_delete`) and one with `cache` a cache the user can clear (`env.vc_cache_read`, `_write`, `_clear`), under its id in the `Plug-ins` folder. Keys are plain names, never paths; VectorCraft's own caps refuse a write past 256 settings, 1 MiB of settings or 512 MiB of cache; a damaged settings file is moved aside and the settings start empty. `plugin.clearCache` empties a cache, `plugin.list` and `plugin.info` give `cacheBytes`, replacing a plug-in keeps its storage and `plugin.remove` deletes it. Another module under an id that is installed or has storage installs only with `replace: true`, which the app asks the user for; each storage call counts the whole settings file or cache entry it reads and writes toward the run's 1 GiB. Regressions: `crates/plugins/src/storage.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_clear_cache.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one. From 48fdfeffd84992511e656696f8676a7560fa96b8 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sun, 8 Nov 2026 13:17:38 -0800 Subject: [PATCH 136/160] Type: docs say Escape returns to the tool whose double-click chose the Type tool Only a double-click on type from another tool records the tool Escape and Cmd+Enter return to, and the Type tool keeps it until another tool is chosen. Choosing the Type tool from the toolbar records none, so Escape goes to the Selection tool. The type and UI checklists and the doc comments on prev_tool, end_editing and claims_key now say so. docs/type-parity.md held four copies of the point, area and path type row, three of them with older Escape wording. They are one row again, with the Type Orientation sentence that only one copy had. --- crates/tools/src/text.rs | 10 ++++++---- crates/tools/src/text/tool.rs | 4 ++-- docs/type-parity.md | 5 +---- docs/ui-parity.md | 2 +- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 78fc10a1b..02ebd1d2b 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -160,8 +160,10 @@ fn next_edit_session() -> u64 { pub struct TypeTool { vertical: bool, mode: Mode, - /// The tool that was active before type editing began, which Escape and Cmd+Enter return to. - /// Clicks that move editing to other text keep it. + /// The tool whose double-click on type chose this tool, which Escape and Cmd+Enter return to. + /// The canvas sets it; choosing the Type tool another way (the toolbar) leaves it unset, and + /// Escape then goes to the Selection tool. It is kept until another tool is chosen, through + /// clicks that end editing or move it to other text. prev_tool: Option<String>, /// Text object being edited. editing: Option<NodeId>, @@ -412,8 +414,8 @@ impl TypeTool { out } - /// Stop editing and go back to the tool that was active before type editing began (or to the - /// Selection tool), and leave the type selected. + /// Stop editing and go back to the tool whose double-click on type chose this tool (or to the + /// Selection tool when none did), and leave the type selected. fn end_editing(&mut self, cx: &ToolContext) -> Vec<Action> { let target = self.prev_tool.take().unwrap_or_else(|| "selection".into()); let mut out = self.finish(cx); diff --git a/crates/tools/src/text/tool.rs b/crates/tools/src/text/tool.rs index be4815467..dd561bc61 100644 --- a/crates/tools/src/text/tool.rs +++ b/crates/tools/src/text/tool.rs @@ -25,8 +25,8 @@ impl Tool for TypeTool { fn wants_text(&self) -> bool { self.editing.is_some() } - /// Escape while editing is the tool's: it goes back to the tool that was active before type - /// editing began, or to the Selection tool when there was none (#1686). + /// Escape while editing is the tool's: it goes back to the tool whose double-click on type + /// chose this tool, or to the Selection tool when none did (#1686). fn claims_key(&self, _: &ToolContext, key: ToolKey) -> bool { key == ToolKey::Escape && self.editing.is_some() } diff --git a/docs/type-parity.md b/docs/type-parity.md index b7e7fffab..818ea9fd3 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -21,10 +21,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Mouse wheel over the closed font-family field | done | #5253: Character, Properties and Control step selected text through the family list using saved font filters (excluding search text), one undo step per notch; the style is matched and the list stops at either end. Escape and leaving the field keep the change. Order, bounds and Undo grouping are VectorCraft's choices; scores unchanged. | | Soft return (forced line break, Shift+Enter) | done | Shift+Enter inserts a soft return (U+2028), breaking the line without starting a new paragraph; paragraph spacing (space before/after) and first-line indents apply only at paragraph ends; continuation lines in lists do not repeat the marker; soft return lines in justified text do not spread (#2754) | | Native font-family labels | partial | With Show Font Names in English off, localized legacy names are used when typographic names contain no native label (#2208); loaded-font and catalog-only regressions. Choosing among multiple native languages is unchanged. | -| Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches back to the tool that was active before type editing began (kept while clicks move editing to other text), or to the Selection tool when there was none (#1802), and Cmd+Enter (Ctrl+Enter on Windows and Linux) does the same unless a command is bound to it; `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands; the Type and Vertical Type tools make area type when a closed path's outline is clicked and type on a path when an open one is, with pointers that say which, as the public help describes ([Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html), [Create type on a path](https://web.archive.org/web/20220127090635/https://helpx.adobe.com/illustrator/using/creating-type-path.html)) | -| Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches back to the tool that double-clicked into the type, or to the Selection tool (#1802), and Cmd+Enter (Ctrl+Enter on Windows and Linux) does the same unless a command is bound to it; `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands; the Type and Vertical Type tools make area type when a closed path's outline is clicked and type on a path when an open one is, with pointers that say which, as the public help describes ([Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html), [Create type on a path](https://web.archive.org/web/20220127090635/https://helpx.adobe.com/illustrator/using/creating-type-path.html)) | -| Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802); `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands; Type › Type Orientation sets every frame of a thread, and a thread's frames share one writing direction | -| Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802), and Cmd+Enter (Ctrl+Enter on Windows and Linux) does the same unless a command is bound to it; `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands; the Type and Vertical Type tools make area type when a closed path's outline is clicked and type on a path when an open one is, with pointers that say which, as the public help describes ([Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html), [Create type on a path](https://web.archive.org/web/20220127090635/https://helpx.adobe.com/illustrator/using/creating-type-path.html)) | +| Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches back to the tool whose double-click on type chose the Type tool (kept until another tool is chosen, so later edits with the Type tool return there too), or to the Selection tool when the Type tool was chosen another way, such as from the toolbar (#1802), and Cmd+Enter (Ctrl+Enter on Windows and Linux) does the same unless a command is bound to it; `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands; Type › Type Orientation sets every frame of a thread, and a thread's frames share one writing direction; the Type and Vertical Type tools make area type when a closed path's outline is clicked and type on a path when an open one is, with pointers that say which, as the public help describes ([Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html), [Create type on a path](https://web.archive.org/web/20220127090635/https://helpx.adobe.com/illustrator/using/creating-type-path.html)) | | Area Type Options (rows, columns, inset, first baseline, Text Flow by rows or by columns, Auto Size, Shrink Text to Fit) | done | Text Flow fills the cells down each column first (By Columns, the default, as files from before it open) or across each row first (By Rows), in the dialog and `text.areaOptions {flow}` (#4978); the commands that make or restyle type say when it does not fit its frame; MCP `add_text` takes Auto Size and Shrink Text to Fit (#1471) | | Threaded text, Fit Headline, Text Wrap | done | Fit Headline sees wrapping in a frame one line tall (#1444); a thread holds area type and type on a path in any mix, as Illustrator links type in an area or along a path ([help](https://helpx.adobe.com/illustrator/using/manage-text-area.html)) | | Threading from the ports: selected area type and type on a path show an in port and an out port, empty, with an arrow on a side where the frame is threaded, or with a red "+" in the out port when the text doesn't fit; a click on either port loads the text; a click on a closed path, area type or type on a path threads into it, a click on empty canvas makes a frame of the same kind, shape and size there, a drag draws a rectangular frame; the frame placed comes after the clicked one from its out port and before it from its in port (the story then starts there); Escape or another tool cancels; a double-click on a threaded port breaks the thread there | partial | #2572: the Selection tool, `text.thread.add {from, to?, rect?, at?, before?}`, as the reference app's help describes ports and threading ([archived help, Threading text between objects](https://web.archive.org/web/20210119051048/https://helpx.adobe.com/illustrator/using/creating-text.html), [create text threads](https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/create-text-threads-between-objects.html)); area type's in port sits on the left side below the top-left corner, type on a path's just before its start bracket; Show Text Threads draws its lines between the same ports; with more than 1,000 such objects selected no ports are drawn. a double-click on a port where the frame is threaded breaks the thread there, the story flowing into the frames before the break and the frames after it left empty, still threaded with each other when two or more (`text.thread.break`, [archived help](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html)). Remaining: the ports only take clicks with the Selection tool, and the loaded cursor borrows the I-beam until it's drawn | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 62f6e496d..186e50edd 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -289,7 +289,7 @@ tools, never checked side by side) isn't captured row by row. | Artboard tool: the canvas right-click menu has Lock Artboards (Lock Selected, Lock All Except Selected, Lock All) and Unlock Artboards (Unlock Selected, Unlock All) | done | #2531; `artboard.setProps` with `locked` for each artboard named; the lock itself is #2592 | | Document Setup → Edit Artboards applies valid settings, closes the dialog and activates the Artboard tool | done | Pointer-click tests cover successful handoff, invalid settings, OK, Cancel and Undo in `dialogs/document_setup.rs`. | | Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture | done | Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. | -| Escape while typing ends the edit, keeps the type selected and switches back to the tool that was active before type editing began (kept while clicks move editing to other text), or to the Selection tool when there was none | done | #1802 (#1686, #1656) | +| Escape while typing ends the edit, keeps the type selected and switches back to the tool whose double-click on type chose the Type tool (kept until another tool is chosen, so later edits with the Type tool return there too), or to the Selection tool when the Type tool was chosen another way, such as from the toolbar | done | #1802 (#1686, #1656) | | Selection tool: selected area type and type on a path show an in port and an out port (empty, an arrow where threaded, the red "+" of overflow); a click on either loads the text cursor; a click on a closed path, on empty canvas or a drag threads the text there, after the clicked frame from its out port, before it from its in port; Escape or another tool drops it; a double-click on a threaded port breaks the thread there | partial | #2572 (`text.thread.add`, `before` for the in port); the out port sits on the frame's right side above the bottom-right corner handle and the in port on its left side below the top-left one, so the handles can still be grabbed; a double-click on a threaded port breaks the thread (`text.thread.break`, [archived help](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html)); the loaded cursor borrows the I-beam | | Shift-click selects several artboards (Artboard tool, Artboards panel); they move, Alt-copy and delete together | done | `artboard.move`/`artboard.delete` `indices` (#1139); in the panel, Shift- and Ctrl/Cmd-click work with any tool by switching to the Artboard tool (#5698); panel menu, trash and Properties share selected-set deletion; all-selected retains artboard 1 and a single board cannot be deleted, with pointer-event and Undo/Redo coverage | | Artboards panel: drag a row above or below another to reorder the artboards (as Move Up / Move Down do) | done | #4687; a line shows where the row lands, `artboard.reorder` in one undo step, the moved artboard becomes active; public forum answers describe the drag | From 1e21154e4d99d5dc2d06f0026348c949bd8f032e Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 9 Nov 2026 04:19:25 +0700 Subject: [PATCH 137/160] Symbols: symbol.instance.setAppearance refuses a fill or stroke an object does not draw Squashed from #6232 onto main. --- crates/effects/src/lib.rs | 4 +- crates/effects/src/reshape.rs | 8 +- crates/engine/src/cmd/brushsym.rs | 2 +- crates/engine/src/cmd/brushsym/dynamic.rs | 60 ++++ crates/engine/src/tests/dynamic_symbols.rs | 309 +++++++++++++++++++++ crates/ui-egui/src/i18n/bg.tsv | 10 + crates/ui-egui/src/i18n/bs.tsv | 10 + crates/ui-egui/src/i18n/ca.tsv | 10 + crates/ui-egui/src/i18n/cs.tsv | 10 + crates/ui-egui/src/i18n/da.tsv | 10 + crates/ui-egui/src/i18n/de.tsv | 10 + crates/ui-egui/src/i18n/el.tsv | 10 + crates/ui-egui/src/i18n/es.tsv | 10 + crates/ui-egui/src/i18n/fi.tsv | 10 + crates/ui-egui/src/i18n/fr.tsv | 10 + crates/ui-egui/src/i18n/hi.tsv | 10 + crates/ui-egui/src/i18n/hu.tsv | 10 + crates/ui-egui/src/i18n/id.tsv | 10 + crates/ui-egui/src/i18n/it.tsv | 10 + crates/ui-egui/src/i18n/ja.tsv | 10 + crates/ui-egui/src/i18n/ka.tsv | 10 + crates/ui-egui/src/i18n/ko.tsv | 10 + crates/ui-egui/src/i18n/nl.tsv | 10 + crates/ui-egui/src/i18n/pl.tsv | 10 + crates/ui-egui/src/i18n/pt-br.tsv | 10 + crates/ui-egui/src/i18n/ru.tsv | 10 + crates/ui-egui/src/i18n/sv.tsv | 10 + crates/ui-egui/src/i18n/tr.tsv | 10 + crates/ui-egui/src/i18n/uk.tsv | 10 + crates/ui-egui/src/i18n/vi.tsv | 10 + crates/ui-egui/src/i18n/zh-hans.tsv | 10 + crates/ui-egui/src/i18n/zh-hant.tsv | 10 + docs/mcp.md | 11 +- docs/target-app-parity.md | 3 +- 34 files changed, 659 insertions(+), 8 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 7bba3e8dd..84c83d2e7 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -80,8 +80,8 @@ pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects}; pub use reshape::{ - SYMBOL_ART_MAX_NODES, expand_outlined, has_gradient_copies, has_item_geometry, has_transform_art, has_transform_copies, has_transform_options, - image_paint, needs_outline, outline_art, outline_text, reshape, + SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_transform_art, has_transform_copies, + has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape, }; pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve}; pub use solids::{ diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 85f2849d9..7fabc0490 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -133,7 +133,13 @@ fn gives_shape(e: &Effect) -> bool { /// Does `item`, a fill or stroke of an image, paint something: does it paint and carry an effect /// that gives it a shape ([`gives_shape`])? fn shapes_image(item: &AppearanceItem) -> bool { - crate::paints(item) && item.effects().iter().any(gives_shape) + crate::paints(item) && gives_image_shape(item.effects()) +} + +/// Do `effects`, those of an image's fill or stroke, give that fill or stroke a shape to paint +/// ([`gives_shape`])? +pub fn gives_image_shape(effects: &[Effect]) -> bool { + effects.iter().any(gives_shape) } /// The fills and strokes of `items` (an image's own) that paint something ([`shapes_image`]), each diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 5dcb82136..53f373a3b 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -263,7 +263,7 @@ pub fn specs() -> Vec<CommandSpec> { "Symbol Instance Appearance", [], None, - "{id?|ids?: [one id] (default: the one selected object), path: [i…] (the object in the symbol's art: child indices in paint order from the art's root down through its groups; [] the root), fill?: \"#rrggbb\"|{…paint params of paint.setFill: color, none, swatch, tint, gradient, keepModel}|null (null: the symbol's own again), stroke?: as fill, opacity?: 0..100|null, blend?: name|null} change the appearance of one object of a dynamic symbol instance's art, for that instance only: it stays linked, so editing the symbol reshapes it and keeps the change; refused for a static symbol or a path naming no object → {id, overrides: how many objects the instance changes}", + "{id?|ids?: [one id] (default: the one selected object), path: [i…] (the object in the symbol's art: child indices in paint order from the art's root down through its groups; [] the root), fill?: \"#rrggbb\"|{…paint params of paint.setFill: color, none, swatch, tint, gradient, keepModel}|null (null: the symbol's own again), stroke?: as fill, opacity?: 0..100|null, blend?: name|null} change the appearance of one object of a dynamic symbol instance's art, for that instance only: it stays linked, so editing the symbol reshapes it and keeps the change; refused for a static symbol, a path naming no object, or a fill or stroke other than null for an object that does not draw it (a guide, blend, envelope, Repeat, Intertwine group, gradient mesh or placed document; a symbol instance's stroke, and its fill unless a solid color or none, which stains its art; an image's fill or stroke without a Convert to Shape or Outline Object effect), with nothing of the call applied; opacity, blend and null are taken for every object → {id, overrides: how many objects the instance changes}", has_selection, symbol_instance_set_appearance ), diff --git a/crates/engine/src/cmd/brushsym/dynamic.rs b/crates/engine/src/cmd/brushsym/dynamic.rs index 6197e57be..89c7c3c52 100644 --- a/crates/engine/src/cmd/brushsym/dynamic.rs +++ b/crates/engine/src/cmd/brushsym/dynamic.rs @@ -129,6 +129,64 @@ fn path_param(p: &Value, art: &Node, cmd: &str) -> Result<Vec<u32>> { Ok(path) } +/// The refusal of `key` (`fill` or `stroke`) for `n`, an object of a dynamic symbol's art, when `n` +/// does not draw that paint of its own; `None` when it does. Paths (not guides), +/// compound paths, type, groups and layers draw them; a symbol instance draws its fill as a stain +/// on its art ([`solid_fill_check`]) and no stroke; an image draws one only when that fill or +/// stroke has a Convert to Shape or Outline Object effect. +fn paint_refusal(n: &Node, key: &str, cmd: &str) -> Option<EngineError> { + Some(match &n.kind { + NodeKind::Path { guide: true, .. } => bad(cmd, format!("{key} cannot be set on a guide")), + NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_) | NodeKind::Group { .. } | NodeKind::Layer { .. } => return None, + NodeKind::Image(_) => { + let effects = if key == "fill" { n.appearance.fill().map(|f| &f.effects) } else { n.appearance.stroke().map(|s| &s.effects) }; + if effects.is_some_and(|e| vectorcraft_render::effects::gives_image_shape(e)) { + return None; + } + bad(cmd, format!("{key} cannot be set on an image without a Convert to Shape or Outline Object effect on it")) + } + NodeKind::SymbolInstance { .. } if key == "fill" => return None, + NodeKind::SymbolInstance { .. } => bad(cmd, "stroke cannot be set on a symbol instance"), + NodeKind::Blend { .. } => bad(cmd, format!("{key} cannot be set on a blend")), + NodeKind::Envelope { .. } => bad(cmd, format!("{key} cannot be set on an envelope")), + NodeKind::Mesh(_) => bad(cmd, format!("{key} cannot be set on a gradient mesh")), + NodeKind::Repeat(_) => bad(cmd, format!("{key} cannot be set on a Repeat")), + NodeKind::PlacedDocument(_) => bad(cmd, format!("{key} cannot be set on a placed document")), + NodeKind::Intertwine { .. } => bad(cmd, format!("{key} cannot be set on an Intertwine group")), + }) +} + +/// Refuse a `fill` or `stroke` other than null that the object at `path` of `art` does not draw +/// ([`paint_refusal`]); null, which takes a change back, is taken for every object. +fn check_paint_kinds(p: &Value, art: &Node, path: &[u32], cmd: &str) -> Result<()> { + let Some(n) = symbol_overrides::part(art, path) else { return Ok(()) }; + for key in ["fill", "stroke"] { + if p.get(key).is_some_and(|v| !v.is_null()) + && let Some(e) = paint_refusal(n, key, cmd) + { + return Err(e); + } + } + Ok(()) +} + +/// Refuse `fill`, the paint `p` asks for the object at `path` of `art`, when that object is a +/// symbol instance and `fill` is a gradient or a pattern: an instance stains its art with a solid +/// fill only, so such a fill draws nothing. +fn solid_fill_check(p: &Value, art: &Node, path: &[u32], fill: Option<&vectorcraft_color::Paint>, cmd: &str) -> Result<()> { + let asked = p.get("fill").is_some_and(|v| !v.is_null()); + let is_instance = symbol_overrides::part(art, path).is_some_and(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })); + let draws_nothing = |f: &vectorcraft_color::Paint| match f { + vectorcraft_color::Paint::Gradient(_) => true, + vectorcraft_color::Paint::Pattern { .. } => true, + vectorcraft_color::Paint::None | vectorcraft_color::Paint::Solid { .. } => false, + }; + if asked && is_instance && fill.is_some_and(draws_nothing) { + return Err(bad(cmd, "fill on a symbol instance must be a solid color or none")); + } + Ok(()) +} + /// `fill` or `stroke` of `p` for override `o`: absent leaves it, null clears it, a colour string or /// an object of `paint.setFill`'s paint params sets it. fn paint_change(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<Option<Option<vectorcraft_color::Paint>>> { @@ -170,9 +228,11 @@ pub(super) fn symbol_instance_set_appearance(s: &mut Session, p: &Value) -> Resu } let (id, art) = dynamic_instance(s, p, C)?; let path = path_param(p, &art, C)?; + check_paint_kinds(p, &art, &path, C)?; let d = &s.doc()?.doc; let old = d.node(id).and_then(|n| n.extras().symbol_overrides.iter().find(|o| o.path == path).cloned()); let new = changed(s, p, old.unwrap_or_else(|| SymbolOverride { path: path.clone(), ..Default::default() }), C)?; + solid_fill_check(p, &art, &path, new.fill.as_ref(), C)?; let count = edit_part(s, id, &path, "Symbol Instance Appearance", |o| *o = new)?; Ok(json!({ "id": id.0, "overrides": count })) } diff --git a/crates/engine/src/tests/dynamic_symbols.rs b/crates/engine/src/tests/dynamic_symbols.rs index 9ec00cd24..b946f7192 100644 --- a/crates/engine/src/tests/dynamic_symbols.rs +++ b/crates/engine/src/tests/dynamic_symbols.rs @@ -4,6 +4,7 @@ //! Fill or Stroke changes it. use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::Rect; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; @@ -107,6 +108,314 @@ fn static_symbols_and_bad_targets_are_refused() { assert_eq!(overrides(&s, inst), 0); } +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// The frontmost object of the active layer. +fn top(s: &Session) -> NodeId { + let d = &s.doc().unwrap().doc; + d.node(d.default_layer().unwrap()).unwrap().children().unwrap().last().unwrap().id +} + +/// Every pixel of the page. +fn page(s: &Session) -> Vec<[u8; 4]> { + let r = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 100.0), 1.0, true); + (0..r.height).flat_map(|y| (0..r.width).map(move |x| (x, y))).map(|(x, y)| r.pixel(x, y)).collect() +} + +/// A dynamic symbol "Art" of the frontmost object, made in place: its instance. +fn art_of_top(s: &mut Session) -> NodeId { + let t = top(s); + id(&s.execute("symbol.new", &json!({"name": "Art", "ids": [t.0], "type": "dynamic"})).unwrap()) +} + +/// Put a red fill and stroke on the root of instance `inst`'s art directly, past the command. +fn paint_root_red(s: &mut Session, inst: NodeId) { + let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + s.edit("paint", |d, _| { + let o = vectorcraft_doc::symbol_overrides::SymbolOverride { path: vec![], fill: Some(red.clone()), stroke: Some(red), ..Default::default() }; + d.node_mut(inst).ok_or(EngineError::NoNode(inst))?.extras_mut().symbol_overrides.push(o); + Ok(()) + }) + .unwrap(); +} + +/// A 30×30 red image placed with its frame from (50, 20) to (80, 50). +fn place_image(s: &mut Session) { + let png = crate::tests_links::png(30, 30, [255, 0, 0]); + let data = vectorcraft_format::base64_encode(&png); + s.execute("file.place", &json!({"name": "red.png", "dataBase64": data, "at": [65, 35], "link": false})).unwrap(); +} + +fn select_top(s: &mut Session, n: usize) { + let d = &s.doc().unwrap().doc; + let kids = d.node(d.default_layer().unwrap()).unwrap().children().unwrap(); + let ids: Vec<u64> = kids[kids.len() - n..].iter().map(|c| c.id.0).collect(); + s.execute("select.set", &json!({ "ids": ids })).unwrap(); +} + +#[test] +fn objects_that_draw_no_fill_or_stroke_of_their_own_refuse_one() { + let folder = crate::tests_links::Folder::new("dynamic-symbol-kinds"); + let source = folder.file("placed.vectorcraft"); + let mut doc = vectorcraft_doc::Document::new(40.0, 40.0); + let layer = doc.layers[0].id; + let look = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0); + let rect = vectorcraft_doc::Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), look); + doc.insert(Some(layer), 0, rect).unwrap(); + crate::tests_links::write(&source, &vectorcraft_format::save_file(&doc)); + type Make = Box<dyn Fn(&mut Session)>; + let cases: Vec<(&str, &str, Make)> = vec![ + ( + "guide", + "a guide", + Box::new(|s| { + square(s, 10.0); + select_top(s, 1); + s.execute("view.guides.make", &json!({})).unwrap(); + }), + ), + ( + "blend", + "a blend", + Box::new(|s| { + let (a, b) = (square(s, 10.0), square(s, 60.0)); + s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "steps": 1})).unwrap(); + }), + ), + ( + "envelope", + "an envelope", + Box::new(|s| { + square(s, 10.0); + select_top(s, 1); + s.execute("object.envelope.makeWithWarp", &json!({"style": "arch", "bend": 50})).unwrap(); + }), + ), + ("radial Repeat", "a Repeat", Box::new(|s| repeat(s, "object.repeat.radial"))), + ("grid Repeat", "a Repeat", Box::new(|s| repeat(s, "object.repeat.grid"))), + ("mirror Repeat", "a Repeat", Box::new(|s| repeat(s, "object.repeat.mirror"))), + ( + "Intertwine", + "an Intertwine group", + Box::new(|s| { + square(s, 10.0); + square(s, 30.0); + select_top(s, 2); + s.execute("object.intertwine.make", &json!({})).unwrap(); + }), + ), + ( + "gradient mesh", + "a gradient mesh", + Box::new(|s| { + let r = square(s, 10.0); + s.execute("object.mesh.create", &json!({"ids": [r.0], "rows": 1, "cols": 1})).unwrap(); + }), + ), + ("image", "an image", Box::new(place_image)), + ( + "placed document", + "a placed document", + Box::new(move |s| { + let r = s.execute("file.place", &json!({"path": source, "at": [40, 40]})).unwrap(); + assert_eq!(r["linked"], true, "{r}"); + }), + ), + ]; + // Every case runs, and the failure lists each object that fails. + let mut failures = Vec::new(); + for (label, kind, make) in cases { + let mut s = session(); + make(&mut s); + let inst = art_of_top(&mut s); + if let Err(why) = refuses_own_paint(&mut s, inst, kind) { + failures.push(format!("{label}: {why}")); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +/// Check that the root of instance `inst`'s art, an object of `kind` that draws no fill or stroke +/// of its own, refuses both, takes null, opacity and blend, and draws no stored fill or stroke. +fn refuses_own_paint(s: &mut Session, inst: NodeId, kind: &str) -> std::result::Result<(), String> { + let set = |s: &mut Session, p: &Value| s.execute("symbol.instance.setAppearance", p); + let undo = undo_len(s); + for key in ["fill", "stroke"] { + for paint in [json!("#ff0000"), json!({"none": true})] { + // With a valid opacity too: nothing of the call is applied. + let p = json!({"id": inst.0, "path": [], key: paint, "opacity": 50}); + match set(s, &p) { + Ok(_) => return Err(format!("{p} was taken")), + Err(e) if !e.to_string().contains(&format!("{key} cannot be set on {kind}")) => return Err(format!("{p}: {e}")), + Err(_) => {} + } + } + } + if (overrides(s, inst), undo_len(s)) != (0, undo) { + return Err("a refused call changed the instance or added an undo step".into()); + } + // Null (taking a change back), opacity and blend are taken. + let clear = json!({"id": inst.0, "path": [], "fill": null, "stroke": null}); + for p in [clear, json!({"id": inst.0, "path": [], "opacity": 50, "blend": "multiply"})] { + set(s, &p).map_err(|e| format!("{p}: {e}"))?; + } + if overrides(s, inst) != 1 { + return Err("opacity and blend were not stored".into()); + } + // The renderer draws no fill or stroke of the object's own: a stored one changes nothing. + let before = page(s); + paint_root_red(s, inst); + if page(s) != before { + return Err("a stored fill and stroke changed the canvas".into()); + } + Ok(()) +} + +fn repeat(s: &mut Session, cmd: &str) { + square(s, 10.0); + select_top(s, 1); + s.execute(cmd, &json!({})).unwrap(); +} + +#[test] +fn a_symbol_instance_in_the_art_takes_a_fill_that_stains_it_and_no_stroke() { + let mut s = session(); + pair(&mut s, "static"); + let inst = art_of_top(&mut s); + let undo = undo_len(&s); + let e = s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "stroke": "#ff0000"})).unwrap_err().to_string(); + assert!(e.contains("stroke cannot be set on a symbol instance"), "{e}"); + assert_eq!((overrides(&s, inst), undo_len(&s)), (0, undo)); + // A stroke stored past the command draws nothing. + let before = page(&s); + let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + s.edit("paint", |d, _| { + let o = vectorcraft_doc::symbol_overrides::SymbolOverride { path: vec![], stroke: Some(red), ..Default::default() }; + d.node_mut(inst).ok_or(EngineError::NoNode(inst))?.extras_mut().symbol_overrides.push(o); + Ok(()) + }) + .unwrap(); + assert!(page(&s) == before, "a stroke on a symbol instance draws nothing"); + // The fill stains the nested instance's art. + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": "#ff0000"})).unwrap(); + assert_eq!(pixel(&s, 30, 30), RED, "the fill stains the art"); +} + +#[test] +fn a_symbol_instance_in_the_art_refuses_a_gradient_fill() { + let mut s = session(); + pair(&mut s, "static"); + let inst = art_of_top(&mut s); + let undo = undo_len(&s); + let gradient = json!({"stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#00ff00"}]}); + let p = json!({"id": inst.0, "path": [], "fill": {"gradient": gradient}, "opacity": 50}); + let e = s.execute("symbol.instance.setAppearance", &p).unwrap_err().to_string(); + assert!(e.contains("fill on a symbol instance must be a solid color or none"), "{e}"); + assert_eq!((overrides(&s, inst), undo_len(&s)), (0, undo), "nothing changes and no undo step"); + // A gradient fill stored past the command draws nothing. + let before = page(&s); + let paint = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default()))); + s.edit("paint", |d, _| { + let o = vectorcraft_doc::symbol_overrides::SymbolOverride { path: vec![], fill: Some(paint), ..Default::default() }; + d.node_mut(inst).ok_or(EngineError::NoNode(inst))?.extras_mut().symbol_overrides.push(o); + Ok(()) + }) + .unwrap(); + assert!(page(&s) == before, "a gradient fill on a symbol instance draws nothing"); + // A solid fill and none are taken. + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": {"none": true}})).unwrap(); + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": "#ff0000"})).unwrap(); + assert_eq!(pixel(&s, 30, 30), RED); +} + +#[test] +fn a_symbol_instance_in_the_art_refuses_a_pattern_fill() { + let mut s = session(); + let tile = id(&s.execute("shape.rectangle", &json!({"x": 150, "y": 60, "width": 10, "height": 10})).unwrap()); + s.execute("object.pattern.make", &json!({"ids": [tile.0], "name": "Dots", "edit": false})).unwrap(); + pair(&mut s, "static"); + let inst = art_of_top(&mut s); + let undo = undo_len(&s); + let p = json!({"id": inst.0, "path": [], "fill": {"swatch": "Dots"}, "opacity": 50}); + let e = s.execute("symbol.instance.setAppearance", &p).unwrap_err().to_string(); + assert!(e.contains("fill on a symbol instance must be a solid color or none"), "{e}"); + assert_eq!((overrides(&s, inst), undo_len(&s)), (0, undo), "nothing changes and no undo step"); + // The pattern draws on a path. + let probe = square(&mut s, 110.0); + let before = page(&s); + s.execute("paint.setFill", &json!({"ids": [probe.0], "swatch": "Dots"})).unwrap(); + assert!(page(&s) != before, "the pattern draws on a path"); + // A pattern fill stored on the nested instance past the command draws nothing. + let before = page(&s); + let paint = s.doc().unwrap().doc.swatch("Dots").unwrap().paint.clone(); + assert!(matches!(paint, Paint::Pattern { .. }), "{paint:?}"); + s.edit("paint", |d, _| { + let o = vectorcraft_doc::symbol_overrides::SymbolOverride { path: vec![], fill: Some(paint), ..Default::default() }; + d.node_mut(inst).ok_or(EngineError::NoNode(inst))?.extras_mut().symbol_overrides.push(o); + Ok(()) + }) + .unwrap(); + assert!(page(&s) == before, "a pattern fill on a symbol instance draws nothing"); +} + +#[test] +fn null_takes_back_an_old_change_an_object_does_not_draw() { + let mut s = session(); + let (a, b) = (square(&mut s, 10.0), square(&mut s, 60.0)); + s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "steps": 1})).unwrap(); + let inst = art_of_top(&mut s); + // A change stored before the command refused one, as an older document may hold. + paint_root_red(&mut s, inst); + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": null})).unwrap(); + assert!(node(&s, inst).extras().symbol_overrides[0].fill.is_none()); + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "stroke": null})).unwrap(); + assert_eq!(overrides(&s, inst), 0); +} + +#[test] +fn paths_type_groups_and_shaped_image_paint_take_a_fill_and_stroke() { + let set = |s: &mut Session, inst: NodeId, path: Value| { + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": path, "fill": "#ff0000", "stroke": "#ff0000"})).unwrap() + }; + // A group's own fill paints over its members. + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + assert_eq!(set(&mut s, inst, json!([]))["overrides"], 1); + assert_eq!(pixel(&s, 30, 30), RED, "the group's own fill"); + // A compound path, type and a clipping path. + let mut s = session(); + let (a, b) = (square(&mut s, 10.0), square(&mut s, 60.0)); + s.execute("object.compoundPath.make", &json!({"ids": [a.0, b.0]})).unwrap(); + let inst = art_of_top(&mut s); + set(&mut s, inst, json!([])); + assert_eq!(pixel(&s, 30, 30), RED, "the compound path"); + let mut s = session(); + s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap(); + let inst = art_of_top(&mut s); + set(&mut s, inst, json!([])); + let mut s = session(); + let (a, b) = (square(&mut s, 10.0), square(&mut s, 20.0)); + s.execute("object.clippingMask.make", &json!({"ids": [a.0, b.0]})).unwrap(); + let inst = art_of_top(&mut s); + assert!(matches!(&node(&s, inst).kind, NodeKind::SymbolInstance { .. })); + set(&mut s, inst, json!([0])); + set(&mut s, inst, json!([])); + // An image whose fill has Convert to Shape: the fill paints 9 pt past its frame. + let mut s = session(); + place_image(&mut s); + s.execute("appearance.addFill", &json!({})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#00ff00", "item": 0})).unwrap(); + s.execute("effect.apply", &json!({"effect": "convertToShape.rectangle", "item": 0})).unwrap(); + assert_eq!(pixel(&s, 44, 35), [0, 255, 0, 255]); + let inst = art_of_top(&mut s); + let e = s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "stroke": "#ff0000"})).unwrap_err().to_string(); + assert!(e.contains("stroke cannot be set on an image without a Convert to Shape or Outline Object effect on it"), "{e}"); + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": "#ff0000"})).unwrap(); + assert_eq!(pixel(&s, 44, 35), RED, "the shaped fill takes the instance's color"); +} + #[test] fn redefining_the_symbol_keeps_the_changes_whose_objects_remain() { let mut s = session(); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 6da0c90d6..db56ce309 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6266,6 +6266,16 @@ breakText Glyphs Глифи @msg path must be a list of child indices path трябва да е списък с индекси на дъщерни обекти @msg no object at path {shown} in the symbol's art в графиката на символа няма обект на пътя {shown} @msg give fill, stroke, opacity and/or blend задайте fill, stroke, opacity и/или blend +@msg {key} cannot be set on a guide {key} не може да се задава за водач +@msg {key} cannot be set on a blend {key} не може да се задава за преливане +@msg {key} cannot be set on an envelope {key} не може да се задава за плик +@msg {key} cannot be set on a gradient mesh {key} не може да се задава за мрежа с градиент +@msg {key} cannot be set on a Repeat {key} не може да се задава за повтаряне +@msg {key} cannot be set on a placed document {key} не може да се задава за вмъкнат документ +@msg {key} cannot be set on an Intertwine group {key} не може да се задава за група с преплитане +@msg stroke cannot be set on a symbol instance stroke не може да се задава за екземпляр на символ +@msg fill on a symbol instance must be a solid color or none fill на екземпляр на символ трябва да е плътен цвят или none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} не може да се задава за изображение без ефект „Конвертиране в фигура“ или „Преобразуване на обекта в контур“ @msg pass newName, registration, exportType, nineSlice and/or type задайте newName, registration, exportType, nineSlice и/или type Text Flow Поток на текста By Rows По редове diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index b7f8c6ed8..820737059 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6315,6 +6315,16 @@ shape Open Otvoren @msg path must be a list of child indices path mora biti lista indeksa podređenih objekata @msg no object at path {shown} in the symbol's art nema objekta na putanji {shown} u grafici simbola @msg give fill, stroke, opacity and/or blend navedite fill, stroke, opacity i/ili blend +@msg {key} cannot be set on a guide {key} se ne može postaviti na vodilicu +@msg {key} cannot be set on a blend {key} se ne može postaviti na stapanje +@msg {key} cannot be set on an envelope {key} se ne može postaviti na omotnicu +@msg {key} cannot be set on a gradient mesh {key} se ne može postaviti na gradijentnu mrežu +@msg {key} cannot be set on a Repeat {key} se ne može postaviti na ponavljanje +@msg {key} cannot be set on a placed document {key} se ne može postaviti na postavljeni dokument +@msg {key} cannot be set on an Intertwine group {key} se ne može postaviti na Intertwine grupu +@msg stroke cannot be set on a symbol instance stroke se ne može postaviti na instancu simbola +@msg fill on a symbol instance must be a solid color or none fill instance simbola mora biti puna boja ili none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} se ne može postaviti na sliku bez efekta „Pretvori u oblik“ ili „Objekat u obrisu“ @msg pass newName, registration, exportType, nineSlice and/or type navedite newName, registration, exportType, nineSlice i/ili type Text Flow Tok teksta By Rows Po redovima diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index e891476f7..048e034e9 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6431,6 +6431,16 @@ breakText Glyphs Glifs @msg path must be a list of child indices path ha de ser una llista d’índexs de fills @msg no object at path {shown} in the symbol's art no hi ha cap objecte al camí {shown} de l’art del símbol @msg give fill, stroke, opacity and/or blend indiqueu fill, stroke, opacity i/o blend +@msg {key} cannot be set on a guide {key} no es pot definir en una guia +@msg {key} cannot be set on a blend {key} no es pot definir en una fusió +@msg {key} cannot be set on an envelope {key} no es pot definir en un embolcall +@msg {key} cannot be set on a gradient mesh {key} no es pot definir en una malla de degradat +@msg {key} cannot be set on a Repeat {key} no es pot definir en una repetició +@msg {key} cannot be set on a placed document {key} no es pot definir en un document col·locat +@msg {key} cannot be set on an Intertwine group {key} no es pot definir en un grup entrelligat +@msg stroke cannot be set on a symbol instance stroke no es pot definir en una instància de símbol +@msg fill on a symbol instance must be a solid color or none el fill d’una instància de símbol ha de ser un color sòlid o none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} no es pot definir en una imatge sense l’efecte Converteix en forma o Contorneja l’objecte @msg pass newName, registration, exportType, nineSlice and/or type indiqueu newName, registration, exportType, nineSlice i/o type Text Flow Flux del text By Rows Per files diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 45fe55dd7..bfc7083b7 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6449,6 +6449,16 @@ breakText Glyphs Glyfy @msg path must be a list of child indices path musí být seznam indexů potomků @msg no object at path {shown} in the symbol's art v kresbě symbolu není na cestě {shown} žádný objekt @msg give fill, stroke, opacity and/or blend zadejte fill, stroke, opacity a/nebo blend +@msg {key} cannot be set on a guide {key} nelze nastavit u vodítka +@msg {key} cannot be set on a blend {key} nelze nastavit u prolnutí +@msg {key} cannot be set on an envelope {key} nelze nastavit u obálky +@msg {key} cannot be set on a gradient mesh {key} nelze nastavit u přechodové sítě +@msg {key} cannot be set on a Repeat {key} nelze nastavit u opakování +@msg {key} cannot be set on a placed document {key} nelze nastavit u umístěného dokumentu +@msg {key} cannot be set on an Intertwine group {key} nelze nastavit u propletené skupiny +@msg stroke cannot be set on a symbol instance stroke nelze nastavit u instance symbolu +@msg fill on a symbol instance must be a solid color or none fill instance symbolu musí být plná barva nebo none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} nelze nastavit u obrazu bez efektu Převést na tvar nebo Převést objekt na obrys @msg pass newName, registration, exportType, nineSlice and/or type zadejte newName, registration, exportType, nineSlice a/nebo type Text Flow Tok textu By Rows Po řádcích diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 9cdd8e5c6..2d15c2b63 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6280,6 +6280,16 @@ breakText Glyphs Glyffer @msg path must be a list of child indices path skal være en liste over underordnede indekser @msg no object at path {shown} in the symbol's art intet objekt ved stien {shown} i symbolets grafik @msg give fill, stroke, opacity and/or blend angiv fill, stroke, opacity og/eller blend +@msg {key} cannot be set on a guide {key} kan ikke angives for en hjælpelinje +@msg {key} cannot be set on a blend {key} kan ikke angives for en blanding +@msg {key} cannot be set on an envelope {key} kan ikke angives for en kuvert +@msg {key} cannot be set on a gradient mesh {key} kan ikke angives for et gradientnet +@msg {key} cannot be set on a Repeat {key} kan ikke angives for en gentagelse +@msg {key} cannot be set on a placed document {key} kan ikke angives for et placeret dokument +@msg {key} cannot be set on an Intertwine group {key} kan ikke angives for en sammenflettet gruppe +@msg stroke cannot be set on a symbol instance stroke kan ikke angives for en symbolforekomst +@msg fill on a symbol instance must be a solid color or none fill på en symbolforekomst skal være en ensfarvet farve eller none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} kan ikke angives for et billede uden effekten Konvertér til form eller Objekt til kontur @msg pass newName, registration, exportType, nineSlice and/or type angiv newName, registration, exportType, nineSlice og/eller type Text Flow Tekstflow By Rows Efter rækker diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index f485f5c44..309dce8a7 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6035,6 +6035,16 @@ breakText Glyphs Glyphen @msg path must be a list of child indices path muss eine Liste von Kindindizes sein @msg no object at path {shown} in the symbol's art kein Objekt am Pfad {shown} in der Grafik des Symbols @msg give fill, stroke, opacity and/or blend geben Sie fill, stroke, opacity und/oder blend an +@msg {key} cannot be set on a guide {key} kann nicht für eine Hilfslinie gesetzt werden +@msg {key} cannot be set on a blend {key} kann nicht für eine Angleichung gesetzt werden +@msg {key} cannot be set on an envelope {key} kann nicht für eine Verzerrungshülle gesetzt werden +@msg {key} cannot be set on a gradient mesh {key} kann nicht für ein Verlaufsgitter gesetzt werden +@msg {key} cannot be set on a Repeat {key} kann nicht für eine Wiederholung gesetzt werden +@msg {key} cannot be set on a placed document {key} kann nicht für ein platziertes Dokument gesetzt werden +@msg {key} cannot be set on an Intertwine group {key} kann nicht für eine Verflechten-Gruppe gesetzt werden +@msg stroke cannot be set on a symbol instance stroke kann nicht für eine Symbolinstanz gesetzt werden +@msg fill on a symbol instance must be a solid color or none fill einer Symbolinstanz muss eine Vollfarbe oder none sein +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} kann nicht für ein Bild ohne den Effekt „In Form umwandeln“ oder „Objekt in Pfade umwandeln“ gesetzt werden @msg pass newName, registration, exportType, nineSlice and/or type geben Sie newName, registration, exportType, nineSlice und/oder type an Text Flow Textfluss By Rows Nach Zeilen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e7880f10b..40bd8f2c9 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6401,6 +6401,16 @@ breakText Glyphs Γλύφοι @msg path must be a list of child indices το path πρέπει να είναι λίστα δεικτών θυγατρικών αντικειμένων @msg no object at path {shown} in the symbol's art δεν υπάρχει αντικείμενο στη διαδρομή {shown} στο γραφικό του συμβόλου @msg give fill, stroke, opacity and/or blend δώστε fill, stroke, opacity ή/και blend +@msg {key} cannot be set on a guide το {key} δεν μπορεί να οριστεί σε οδηγό +@msg {key} cannot be set on a blend το {key} δεν μπορεί να οριστεί σε ανάμειξη +@msg {key} cannot be set on an envelope το {key} δεν μπορεί να οριστεί σε περίβλημα +@msg {key} cannot be set on a gradient mesh το {key} δεν μπορεί να οριστεί σε πλέγμα ντεγκραντέ +@msg {key} cannot be set on a Repeat το {key} δεν μπορεί να οριστεί σε επανάληψη +@msg {key} cannot be set on a placed document το {key} δεν μπορεί να οριστεί σε τοποθετημένο έγγραφο +@msg {key} cannot be set on an Intertwine group το {key} δεν μπορεί να οριστεί σε ομάδα διαπλοκής +@msg stroke cannot be set on a symbol instance το stroke δεν μπορεί να οριστεί σε στιγμιότυπο συμβόλου +@msg fill on a symbol instance must be a solid color or none το fill ενός στιγμιότυπου συμβόλου πρέπει να είναι συμπαγές χρώμα ή none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it το {key} δεν μπορεί να οριστεί σε εικόνα χωρίς το εφέ «Μετατροπή σε σχήμα» ή «Μετατροπή αντικειμένου σε περιγράμματα» @msg pass newName, registration, exportType, nineSlice and/or type δώστε newName, registration, exportType, nineSlice ή/και type Text Flow Ροή κειμένου By Rows Κατά γραμμές diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a5f9f5d82..6c5ac8015 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6391,6 +6391,16 @@ breakText Glyphs Glifos @msg path must be a list of child indices path debe ser una lista de índices de hijos @msg no object at path {shown} in the symbol's art no hay ningún objeto en la ruta {shown} del arte del símbolo @msg give fill, stroke, opacity and/or blend indique fill, stroke, opacity y/o blend +@msg {key} cannot be set on a guide {key} no se puede definir en una guía +@msg {key} cannot be set on a blend {key} no se puede definir en una fusión +@msg {key} cannot be set on an envelope {key} no se puede definir en una envolvente +@msg {key} cannot be set on a gradient mesh {key} no se puede definir en una malla de degradado +@msg {key} cannot be set on a Repeat {key} no se puede definir en una repetición +@msg {key} cannot be set on a placed document {key} no se puede definir en un documento colocado +@msg {key} cannot be set on an Intertwine group {key} no se puede definir en un grupo entrelazado +@msg stroke cannot be set on a symbol instance stroke no se puede definir en una instancia de símbolo +@msg fill on a symbol instance must be a solid color or none el fill de una instancia de símbolo debe ser un color sólido o none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} no se puede definir en una imagen sin el efecto Convertir en forma o Contornear objeto @msg pass newName, registration, exportType, nineSlice and/or type indique newName, registration, exportType, nineSlice y/o type Text Flow Flujo de texto By Rows Por filas diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index c9c0d9198..f563bfcb8 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6479,6 +6479,16 @@ breakText Glyphs Merkit @msg path must be a list of child indices path-arvon on oltava luettelo alikohteiden indeksejä @msg no object at path {shown} in the symbol's art symbolin kuvituksessa ei ole objektia polussa {shown} @msg give fill, stroke, opacity and/or blend anna fill, stroke, opacity ja/tai blend +@msg {key} cannot be set on a guide {key}-arvoa ei voi asettaa apuviivalle +@msg {key} cannot be set on a blend {key}-arvoa ei voi asettaa sulautukselle +@msg {key} cannot be set on an envelope {key}-arvoa ei voi asettaa kuorelle +@msg {key} cannot be set on a gradient mesh {key}-arvoa ei voi asettaa liukuväriverkolle +@msg {key} cannot be set on a Repeat {key}-arvoa ei voi asettaa toistolle +@msg {key} cannot be set on a placed document {key}-arvoa ei voi asettaa sijoitetulle dokumentille +@msg {key} cannot be set on an Intertwine group {key}-arvoa ei voi asettaa lomitetulle ryhmälle +@msg stroke cannot be set on a symbol instance stroke-arvoa ei voi asettaa symbolin ilmentymälle +@msg fill on a symbol instance must be a solid color or none symbolin ilmentymän fill-arvon on oltava tasainen väri tai none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key}-arvoa ei voi asettaa kuvalle, jolla ei ole tehostetta Muunna muodoksi tai Muunna objekti ääriviivoiksi @msg pass newName, registration, exportType, nineSlice and/or type anna newName, registration, exportType, nineSlice ja/tai type Text Flow Tekstin kulku By Rows Riveittäin diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 3b9190e59..01d2695be 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6444,6 +6444,16 @@ breakText Glyphs Glyphes @msg path must be a list of child indices path doit être une liste d'indices d'enfants @msg no object at path {shown} in the symbol's art aucun objet au chemin {shown} dans l'illustration du symbole @msg give fill, stroke, opacity and/or blend indiquez fill, stroke, opacity et/ou blend +@msg {key} cannot be set on a guide {key} ne peut pas être défini sur un repère +@msg {key} cannot be set on a blend {key} ne peut pas être défini sur un dégradé de formes +@msg {key} cannot be set on an envelope {key} ne peut pas être défini sur une enveloppe +@msg {key} cannot be set on a gradient mesh {key} ne peut pas être défini sur un filet de dégradé +@msg {key} cannot be set on a Repeat {key} ne peut pas être défini sur une répétition +@msg {key} cannot be set on a placed document {key} ne peut pas être défini sur un document importé +@msg {key} cannot be set on an Intertwine group {key} ne peut pas être défini sur un groupe entrelacé +@msg stroke cannot be set on a symbol instance stroke ne peut pas être défini sur une instance de symbole +@msg fill on a symbol instance must be a solid color or none le fill d’une instance de symbole doit être une couleur unie ou none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} ne peut pas être défini sur une image sans l’effet Convertir en forme ou Vectoriser l’objet @msg pass newName, registration, exportType, nineSlice and/or type indiquez newName, registration, exportType, nineSlice et/ou type Text Flow Flux du texte By Rows Par lignes diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 5db92a70e..7fb1484c4 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -6294,6 +6294,16 @@ @msg give dataBase64 dataBase64 दें @msg give either at (a click) or area (a path drawn around the overlaps) या तो (एक क्लिक) या क्षेत्र (ओवरलैप के चारों ओर खींचा गया एक पाथ) देना @msg give fill, stroke, opacity and/or blend भरने, स्ट्रोक, अपारदर्शिता और/या मिश्रण देते हैं +@msg {key} cannot be set on a guide गाइड पर {key} सेट नहीं किया जा सकता +@msg {key} cannot be set on a blend ब्लेंड पर {key} सेट नहीं किया जा सकता +@msg {key} cannot be set on an envelope एन्वेलप पर {key} सेट नहीं किया जा सकता +@msg {key} cannot be set on a gradient mesh ग्रेडिएंट मेश पर {key} सेट नहीं किया जा सकता +@msg {key} cannot be set on a Repeat रिपीट पर {key} सेट नहीं किया जा सकता +@msg {key} cannot be set on a placed document प्लेस्ड डॉक्यूमेंट पर {key} सेट नहीं किया जा सकता +@msg {key} cannot be set on an Intertwine group इंटरट्वाइन समूह पर {key} सेट नहीं किया जा सकता +@msg stroke cannot be set on a symbol instance सिंबल इंस्टेंस पर stroke सेट नहीं किया जा सकता +@msg fill on a symbol instance must be a solid color or none सिंबल इंस्टेंस का fill ठोस रंग या none होना चाहिए +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it शेप में बदलें या रूपरेखा ऑब्जेक्ट इफ़ेक्ट के बिना इमेज पर {key} सेट नहीं किया जा सकता @msg give formats or a preset, not both फ़ॉर्मैट या एक पूर्व निर्धारित देना, दोनों ही नहीं @msg give horizontal or vertical क्षैतिज या ऊर्ध्वाधर देना @msg give newName or delete newName या delete दें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 83c7f8fa8..4034d962d 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6433,6 +6433,16 @@ breakText Glyphs Glifák @msg path must be a list of child indices a path gyermekindexek listája kell legyen @msg no object at path {shown} in the symbol's art a szimbólum grafikájában nincs objektum a(z) {shown} útvonalon @msg give fill, stroke, opacity and/or blend adja meg a fill, stroke, opacity és/vagy blend értékét +@msg {key} cannot be set on a guide a {key} nem állítható be segédvonalon +@msg {key} cannot be set on a blend a {key} nem állítható be összemosáson +@msg {key} cannot be set on an envelope a {key} nem állítható be burkolón +@msg {key} cannot be set on a gradient mesh a {key} nem állítható be színátmenetes hálón +@msg {key} cannot be set on a Repeat a {key} nem állítható be ismétlésen +@msg {key} cannot be set on a placed document a {key} nem állítható be elhelyezett dokumentumon +@msg {key} cannot be set on an Intertwine group a {key} nem állítható be összefonási csoporton +@msg stroke cannot be set on a symbol instance a stroke nem állítható be szimbólumpéldányon +@msg fill on a symbol instance must be a solid color or none a szimbólumpéldány fill értéke csak egyszínű szín vagy none lehet +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it a {key} nem állítható be olyan képen, amelyen nincs Átalakítás alakzattá vagy Objektum útvonallá alakítása effektus @msg pass newName, registration, exportType, nineSlice and/or type adja meg a newName, registration, exportType, nineSlice és/vagy type értékét Text Flow Szövegfolyás By Rows Soronként diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 213d9584a..cc322cb6c 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6280,6 +6280,16 @@ breakText Glyphs Glif @msg path must be a list of child indices path harus berupa daftar indeks anak @msg no object at path {shown} in the symbol's art tidak ada objek di jalur {shown} dalam karya seni simbol @msg give fill, stroke, opacity and/or blend berikan fill, stroke, opacity dan/atau blend +@msg {key} cannot be set on a guide {key} tidak dapat diatur pada panduan +@msg {key} cannot be set on a blend {key} tidak dapat diatur pada blend +@msg {key} cannot be set on an envelope {key} tidak dapat diatur pada amplop +@msg {key} cannot be set on a gradient mesh {key} tidak dapat diatur pada mesh gradien +@msg {key} cannot be set on a Repeat {key} tidak dapat diatur pada perulangan +@msg {key} cannot be set on a placed document {key} tidak dapat diatur pada dokumen yang ditempatkan +@msg {key} cannot be set on an Intertwine group {key} tidak dapat diatur pada grup jalinan +@msg stroke cannot be set on a symbol instance stroke tidak dapat diatur pada instans simbol +@msg fill on a symbol instance must be a solid color or none fill pada instans simbol harus berupa warna solid atau none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} tidak dapat diatur pada gambar tanpa efek Konversi ke bentuk atau Kerangka objek @msg pass newName, registration, exportType, nineSlice and/or type berikan newName, registration, exportType, nineSlice dan/atau type Text Flow Alur teks By Rows Per baris diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ba04b872e..470224ce3 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6069,6 +6069,16 @@ breakText Glyphs Glifi @msg path must be a list of child indices path deve essere un elenco di indici figlio @msg no object at path {shown} in the symbol's art nessun oggetto al percorso {shown} nella grafica del simbolo @msg give fill, stroke, opacity and/or blend specifica fill, stroke, opacity e/o blend +@msg {key} cannot be set on a guide {key} non può essere impostato su una guida +@msg {key} cannot be set on a blend {key} non può essere impostato su una fusione +@msg {key} cannot be set on an envelope {key} non può essere impostato su un involucro +@msg {key} cannot be set on a gradient mesh {key} non può essere impostato su una trama sfumatura +@msg {key} cannot be set on a Repeat {key} non può essere impostato su una ripetizione +@msg {key} cannot be set on a placed document {key} non può essere impostato su un documento inserito +@msg {key} cannot be set on an Intertwine group {key} non può essere impostato su un gruppo intrecciato +@msg stroke cannot be set on a symbol instance stroke non può essere impostato su un’istanza di simbolo +@msg fill on a symbol instance must be a solid color or none il fill di un’istanza di simbolo deve essere un colore pieno o none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} non può essere impostato su un’immagine senza l’effetto Converti in forma o Contorna oggetto @msg pass newName, registration, exportType, nineSlice and/or type specifica newName, registration, exportType, nineSlice e/o type Text Flow Flusso del testo By Rows Per righe diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 24b36257f..2041ea41a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6134,6 +6134,16 @@ breakText Glyphs 字形 @msg path must be a list of child indices path は子のインデックスのリストにしてください @msg no object at path {shown} in the symbol's art シンボルのアートのパス {shown} にオブジェクトがありません @msg give fill, stroke, opacity and/or blend fill、stroke、opacity、blend のいずれかを指定してください +@msg {key} cannot be set on a guide ガイドには {key} を設定できません +@msg {key} cannot be set on a blend ブレンドには {key} を設定できません +@msg {key} cannot be set on an envelope エンベロープには {key} を設定できません +@msg {key} cannot be set on a gradient mesh グラデーションメッシュには {key} を設定できません +@msg {key} cannot be set on a Repeat リピートには {key} を設定できません +@msg {key} cannot be set on a placed document 配置したドキュメントには {key} を設定できません +@msg {key} cannot be set on an Intertwine group 織り交ぜグループには {key} を設定できません +@msg stroke cannot be set on a symbol instance シンボルインスタンスには stroke を設定できません +@msg fill on a symbol instance must be a solid color or none シンボルインスタンスの fill は単色または none にしてください +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it 「シェイプに変換」または「オブジェクトのアウトライン」効果のない画像には {key} を設定できません @msg pass newName, registration, exportType, nineSlice and/or type newName、registration、exportType、nineSlice、type のいずれかを指定してください Text Flow テキストの流れ By Rows 行方向 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 4b329f368..ec39a4fe4 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6447,6 +6447,16 @@ breakText Glyphs გლიფები @msg path must be a list of child indices path უნდა იყოს შვილობილი ინდექსების სია @msg no object at path {shown} in the symbol's art სიმბოლოს გრაფიკაში გზაზე {shown} ობიექტი არ არის @msg give fill, stroke, opacity and/or blend მიუთითეთ fill, stroke, opacity და/ან blend +@msg {key} cannot be set on a guide {key} ვერ დაყენდება დამხმარე ხაზზე +@msg {key} cannot be set on a blend {key} ვერ დაყენდება გადასვლაზე +@msg {key} cannot be set on an envelope {key} ვერ დაყენდება გარსზე +@msg {key} cannot be set on a gradient mesh {key} ვერ დაყენდება გრადიენტულ ბადეზე +@msg {key} cannot be set on a Repeat {key} ვერ დაყენდება გამეორებაზე +@msg {key} cannot be set on a placed document {key} ვერ დაყენდება განთავსებულ დოკუმენტზე +@msg {key} cannot be set on an Intertwine group {key} ვერ დაყენდება გადახლართვის ჯგუფზე +@msg stroke cannot be set on a symbol instance stroke ვერ დაყენდება სიმბოლოს ეგზემპლარზე +@msg fill on a symbol instance must be a solid color or none სიმბოლოს ეგზემპლარის fill უნდა იყოს ერთფეროვანი ფერი ან none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} ვერ დაყენდება გამოსახულებაზე „ფიგურად გარდაქმნის“ ან „ობიექტის კონტურებად გარდაქმნის“ ეფექტის გარეშე @msg pass newName, registration, exportType, nineSlice and/or type მიუთითეთ newName, registration, exportType, nineSlice და/ან type Text Flow ტექსტის დინება By Rows სტრიქონების მიხედვით diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 856c8ba89..c49988f5e 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6030,6 +6030,16 @@ breakText Glyphs 글리프 @msg path must be a list of child indices path는 자식 인덱스 목록이어야 합니다 @msg no object at path {shown} in the symbol's art 심볼 아트의 경로 {shown}에 개체가 없습니다 @msg give fill, stroke, opacity and/or blend fill, stroke, opacity 및/또는 blend를 지정하세요 +@msg {key} cannot be set on a guide 안내선에는 {key}을(를) 설정할 수 없습니다 +@msg {key} cannot be set on a blend 블렌드에는 {key}을(를) 설정할 수 없습니다 +@msg {key} cannot be set on an envelope 엔벨로프에는 {key}을(를) 설정할 수 없습니다 +@msg {key} cannot be set on a gradient mesh 그레이디언트 메시에는 {key}을(를) 설정할 수 없습니다 +@msg {key} cannot be set on a Repeat 반복에는 {key}을(를) 설정할 수 없습니다 +@msg {key} cannot be set on a placed document 가져온 문서에는 {key}을(를) 설정할 수 없습니다 +@msg {key} cannot be set on an Intertwine group 교차 그룹에는 {key}을(를) 설정할 수 없습니다 +@msg stroke cannot be set on a symbol instance 심볼 인스턴스에는 stroke를 설정할 수 없습니다 +@msg fill on a symbol instance must be a solid color or none 심볼 인스턴스의 fill은 단색 또는 none이어야 합니다 +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it 모양으로 변환 또는 개체 윤곽선화 효과가 없는 이미지에는 {key}을(를) 설정할 수 없습니다 @msg pass newName, registration, exportType, nineSlice and/or type newName, registration, exportType, nineSlice 및/또는 type을 지정하세요 Text Flow 텍스트 흐름 By Rows 행 방향 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 46ba3105e..38252fd32 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6326,6 +6326,16 @@ breakText Glyphs Glyphs @msg path must be a list of child indices path moet een lijst met onderliggende indexen zijn @msg no object at path {shown} in the symbol's art geen object op pad {shown} in de illustratie van het symbool @msg give fill, stroke, opacity and/or blend geef fill, stroke, opacity en/of blend op +@msg {key} cannot be set on a guide {key} kan niet worden ingesteld op een hulplijn +@msg {key} cannot be set on a blend {key} kan niet worden ingesteld op een vermenging +@msg {key} cannot be set on an envelope {key} kan niet worden ingesteld op een envelop +@msg {key} cannot be set on a gradient mesh {key} kan niet worden ingesteld op een verloopraster +@msg {key} cannot be set on a Repeat {key} kan niet worden ingesteld op een herhaling +@msg {key} cannot be set on a placed document {key} kan niet worden ingesteld op een geplaatst document +@msg {key} cannot be set on an Intertwine group {key} kan niet worden ingesteld op een verstrengelde groep +@msg stroke cannot be set on a symbol instance stroke kan niet worden ingesteld op een symboolinstantie +@msg fill on a symbol instance must be a solid color or none fill van een symboolinstantie moet een egale kleur of none zijn +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} kan niet worden ingesteld op een afbeelding zonder het effect Converteren naar vorm of Object omtrekken @msg pass newName, registration, exportType, nineSlice and/or type geef newName, registration, exportType, nineSlice en/of type op Text Flow Tekstdoorloop By Rows Per rij diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 270e7a356..ddeded23a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6304,6 +6304,16 @@ breakText Glyphs Glify @msg path must be a list of child indices path musi być listą indeksów elementów podrzędnych @msg no object at path {shown} in the symbol's art brak obiektu w ścieżce {shown} w grafice symbolu @msg give fill, stroke, opacity and/or blend podaj fill, stroke, opacity i/lub blend +@msg {key} cannot be set on a guide nie można ustawić {key} dla linii pomocniczej +@msg {key} cannot be set on a blend nie można ustawić {key} dla przejścia +@msg {key} cannot be set on an envelope nie można ustawić {key} dla koperty +@msg {key} cannot be set on a gradient mesh nie można ustawić {key} dla siatki gradientowej +@msg {key} cannot be set on a Repeat nie można ustawić {key} dla powtórzenia +@msg {key} cannot be set on a placed document nie można ustawić {key} dla umieszczonego dokumentu +@msg {key} cannot be set on an Intertwine group nie można ustawić {key} dla grupy przeplatania +@msg stroke cannot be set on a symbol instance nie można ustawić stroke dla wystąpienia symbolu +@msg fill on a symbol instance must be a solid color or none fill wystąpienia symbolu musi być jednolitym kolorem lub none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it nie można ustawić {key} dla obrazu bez efektu Konwertuj na kształt lub Kontur obiektu @msg pass newName, registration, exportType, nineSlice and/or type podaj newName, registration, exportType, nineSlice i/lub type Text Flow Przepływ tekstu By Rows Wierszami diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 0827c88e5..70006dd0e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6367,6 +6367,16 @@ breakText Glyphs Glifos @msg path must be a list of child indices path deve ser uma lista de índices de filhos @msg no object at path {shown} in the symbol's art nenhum objeto no caminho {shown} na arte do símbolo @msg give fill, stroke, opacity and/or blend informe fill, stroke, opacity e/ou blend +@msg {key} cannot be set on a guide {key} não pode ser definido em uma guia +@msg {key} cannot be set on a blend {key} não pode ser definido em uma mesclagem +@msg {key} cannot be set on an envelope {key} não pode ser definido em um envelope +@msg {key} cannot be set on a gradient mesh {key} não pode ser definido em uma malha de gradiente +@msg {key} cannot be set on a Repeat {key} não pode ser definido em uma repetição +@msg {key} cannot be set on a placed document {key} não pode ser definido em um documento colocado +@msg {key} cannot be set on an Intertwine group {key} não pode ser definido em um grupo entrelaçado +@msg stroke cannot be set on a symbol instance stroke não pode ser definido em uma instância de símbolo +@msg fill on a symbol instance must be a solid color or none o fill de uma instância de símbolo deve ser uma cor sólida ou none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} não pode ser definido em uma imagem sem o efeito Converter em Forma ou Contornar Objeto @msg pass newName, registration, exportType, nineSlice and/or type informe newName, registration, exportType, nineSlice e/ou type # Strings the sibling catalogs translate that pt-BR did not yet cover (translated 2026-10-31). on the canvas to create one na tela para criar um diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8deafd1d5..86839abb5 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6426,6 +6426,16 @@ breakText Glyphs Глифы @msg path must be a list of child indices path должен быть списком индексов дочерних объектов @msg no object at path {shown} in the symbol's art в графике символа нет объекта по пути {shown} @msg give fill, stroke, opacity and/or blend укажите fill, stroke, opacity и/или blend +@msg {key} cannot be set on a guide {key} нельзя задать для направляющей +@msg {key} cannot be set on a blend {key} нельзя задать для перехода +@msg {key} cannot be set on an envelope {key} нельзя задать для оболочки +@msg {key} cannot be set on a gradient mesh {key} нельзя задать для градиентной сетки +@msg {key} cannot be set on a Repeat {key} нельзя задать для повторения +@msg {key} cannot be set on a placed document {key} нельзя задать для помещенного документа +@msg {key} cannot be set on an Intertwine group {key} нельзя задать для группы переплетения +@msg stroke cannot be set on a symbol instance stroke нельзя задать для экземпляра символа +@msg fill on a symbol instance must be a solid color or none fill экземпляра символа должен быть сплошным цветом или none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} нельзя задать для изображения без эффекта «Преобразовать в фигуру» или «Обвести объект» @msg pass newName, registration, exportType, nineSlice and/or type укажите newName, registration, exportType, nineSlice и/или type Text Flow Поток текста By Rows По строкам diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 578feef9c..a2f701c56 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6501,6 +6501,16 @@ breakText Glyphs Glyfer @msg path must be a list of child indices path måste vara en lista med underordnade index @msg no object at path {shown} in the symbol's art inget objekt vid sökvägen {shown} i symbolens bild @msg give fill, stroke, opacity and/or blend ange fill, stroke, opacity och/eller blend +@msg {key} cannot be set on a guide {key} kan inte anges för en stödlinje +@msg {key} cannot be set on a blend {key} kan inte anges för en blandning +@msg {key} cannot be set on an envelope {key} kan inte anges för ett kuvert +@msg {key} cannot be set on a gradient mesh {key} kan inte anges för ett gradientnät +@msg {key} cannot be set on a Repeat {key} kan inte anges för en upprepning +@msg {key} cannot be set on a placed document {key} kan inte anges för ett placerat dokument +@msg {key} cannot be set on an Intertwine group {key} kan inte anges för en sammanflätad grupp +@msg stroke cannot be set on a symbol instance stroke kan inte anges för en symbolinstans +@msg fill on a symbol instance must be a solid color or none fill för en symbolinstans måste vara en enfärgad färg eller none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} kan inte anges för en bild utan effekten Konvertera till form eller Konvertera objekt till konturer @msg pass newName, registration, exportType, nineSlice and/or type ange newName, registration, exportType, nineSlice och/eller type Text Flow Textflöde By Rows Radvis diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 6931e8489..1ed96a44c 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6406,6 +6406,16 @@ breakText Glyphs Glifler @msg path must be a list of child indices path alt öğe dizinlerinden oluşan bir liste olmalıdır @msg no object at path {shown} in the symbol's art sembolün çiziminde {shown} yolunda nesne yok @msg give fill, stroke, opacity and/or blend fill, stroke, opacity ve/veya blend verin +@msg {key} cannot be set on a guide {key} bir kılavuzda ayarlanamaz +@msg {key} cannot be set on a blend {key} bir karışımda ayarlanamaz +@msg {key} cannot be set on an envelope {key} bir zarfta ayarlanamaz +@msg {key} cannot be set on a gradient mesh {key} bir degrade örgüde ayarlanamaz +@msg {key} cannot be set on a Repeat {key} bir tekrarda ayarlanamaz +@msg {key} cannot be set on a placed document {key} yerleştirilmiş bir belgede ayarlanamaz +@msg {key} cannot be set on an Intertwine group {key} bir iç içe geçirme grubunda ayarlanamaz +@msg stroke cannot be set on a symbol instance stroke bir sembol örneğinde ayarlanamaz +@msg fill on a symbol instance must be a solid color or none bir sembol örneğinin fill değeri düz renk veya none olmalıdır +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key}, Şekle Dönüştür veya Anahat Nesnesi efekti olmayan bir görüntüde ayarlanamaz @msg pass newName, registration, exportType, nineSlice and/or type newName, registration, exportType, nineSlice ve/veya type verin Text Flow Metin Akışı By Rows Satırlara Göre diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 89189e12f..cfe40daff 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6049,6 +6049,16 @@ breakText Glyphs Гліфи @msg path must be a list of child indices path має бути списком індексів дочірніх об’єктів @msg no object at path {shown} in the symbol's art у графіці символу немає об’єкта за шляхом {shown} @msg give fill, stroke, opacity and/or blend вкажіть fill, stroke, opacity та/або blend +@msg {key} cannot be set on a guide {key} не можна задати для напрямної +@msg {key} cannot be set on a blend {key} не можна задати для переходу +@msg {key} cannot be set on an envelope {key} не можна задати для оболонки +@msg {key} cannot be set on a gradient mesh {key} не можна задати для градієнтної сітки +@msg {key} cannot be set on a Repeat {key} не можна задати для повторення +@msg {key} cannot be set on a placed document {key} не можна задати для розміщеного документа +@msg {key} cannot be set on an Intertwine group {key} не можна задати для групи переплетення +@msg stroke cannot be set on a symbol instance stroke не можна задати для екземпляра символу +@msg fill on a symbol instance must be a solid color or none fill екземпляра символу має бути суцільним кольором або none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} не можна задати для зображення без ефекту «Перетворити на фігуру» або «Обвести об’єкт» @msg pass newName, registration, exportType, nineSlice and/or type вкажіть newName, registration, exportType, nineSlice та/або type Text Flow Потік тексту By Rows За рядками diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index a58087cd1..a59521044 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6050,6 +6050,16 @@ breakText Glyphs Glyph @msg path must be a list of child indices path phải là danh sách chỉ số con @msg no object at path {shown} in the symbol's art không có đối tượng tại đường dẫn {shown} trong hình của ký hiệu @msg give fill, stroke, opacity and/or blend hãy cung cấp fill, stroke, opacity và/hoặc blend +@msg {key} cannot be set on a guide không thể đặt {key} cho đường căn +@msg {key} cannot be set on a blend không thể đặt {key} cho hòa trộn +@msg {key} cannot be set on an envelope không thể đặt {key} cho envelope +@msg {key} cannot be set on a gradient mesh không thể đặt {key} cho lưới chuyển sắc +@msg {key} cannot be set on a Repeat không thể đặt {key} cho lặp lại +@msg {key} cannot be set on a placed document không thể đặt {key} cho tài liệu đã đặt +@msg {key} cannot be set on an Intertwine group không thể đặt {key} cho nhóm đan xen +@msg stroke cannot be set on a symbol instance không thể đặt stroke cho thực thể ký hiệu +@msg fill on a symbol instance must be a solid color or none fill của thực thể ký hiệu phải là màu đặc hoặc none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it không thể đặt {key} cho hình ảnh không có hiệu ứng Chuyển thành hình dạng hoặc Đường bao đối tượng @msg pass newName, registration, exportType, nineSlice and/or type hãy cung cấp newName, registration, exportType, nineSlice và/hoặc type Text Flow Luồng văn bản By Rows Theo hàng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index ef0711e48..5cde122a9 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6290,6 +6290,16 @@ breakText Glyphs 字形 @msg path must be a list of child indices path 必须是子项索引列表 @msg no object at path {shown} in the symbol's art 符号图稿中路径 {shown} 处没有对象 @msg give fill, stroke, opacity and/or blend 请提供 fill、stroke、opacity 和/或 blend +@msg {key} cannot be set on a guide 无法为参考线设置 {key} +@msg {key} cannot be set on a blend 无法为混合设置 {key} +@msg {key} cannot be set on an envelope 无法为封套设置 {key} +@msg {key} cannot be set on a gradient mesh 无法为渐变网格设置 {key} +@msg {key} cannot be set on a Repeat 无法为重复设置 {key} +@msg {key} cannot be set on a placed document 无法为置入的文档设置 {key} +@msg {key} cannot be set on an Intertwine group 无法为交织编组设置 {key} +@msg stroke cannot be set on a symbol instance 无法为符号实例设置 stroke +@msg fill on a symbol instance must be a solid color or none 符号实例的 fill 必须是纯色或 none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it 无法为没有“转换为形状”或“轮廓化对象”效果的图像设置 {key} @msg pass newName, registration, exportType, nineSlice and/or type 请提供 newName、registration、exportType、nineSlice 和/或 type Text Flow 文本流向 By Rows 按行 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 733008a65..d61ffb375 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6087,6 +6087,16 @@ breakText Glyphs 字符 @msg path must be a list of child indices path 必須是子項索引清單 @msg no object at path {shown} in the symbol's art 符號圖稿中路徑 {shown} 處沒有物件 @msg give fill, stroke, opacity and/or blend 請提供 fill、stroke、opacity 和/或 blend +@msg {key} cannot be set on a guide 無法為參考線設定 {key} +@msg {key} cannot be set on a blend 無法為漸變設定 {key} +@msg {key} cannot be set on an envelope 無法為封套設定 {key} +@msg {key} cannot be set on a gradient mesh 無法為漸層網格設定 {key} +@msg {key} cannot be set on a Repeat 無法為重複設定 {key} +@msg {key} cannot be set on a placed document 無法為置入的文件設定 {key} +@msg {key} cannot be set on an Intertwine group 無法為交織群組設定 {key} +@msg stroke cannot be set on a symbol instance 無法為符號範例設定 stroke +@msg fill on a symbol instance must be a solid color or none 符號範例的 fill 必須是純色或 none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it 無法為沒有「轉換為形狀」或「外框物件」效果的影像設定 {key} @msg pass newName, registration, exportType, nineSlice and/or type 請提供 newName、registration、exportType、nineSlice 和/或 type Text Flow 文字流向 By Rows 依列 diff --git a/docs/mcp.md b/docs/mcp.md index 6bff1f368..69deb6502 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1797,9 +1797,14 @@ already walks Image Trace. Short agent entry points: art for itself only and stays linked. `symbol.instance.setAppearance {id?, path, fill?, stroke?, opacity?, blend?}` names the object by `path`, child indices in paint order from the art's root down through its groups (`[]` is the root; `[1, 0]` is the first object of the art's second member when that member is a group). `fill` and `stroke` take - a colour string or the paint params of `paint.setFill` (`color`, `none`, `swatch`, `tint`, `gradient`), `opacity` is - 0..100 and `blend` a blend mode name; `null` gives one back to the symbol. It is refused for a static symbol and for - a path that names no object. On the canvas (VectorCraft's own rule), a Direct Selection click on an instance of a + a colour string or the paint params of `paint.setFill` (`color`, `none`, `swatch`, `tint`, `gradient`). Paths, + compound paths, type, groups and layers take both. A symbol instance takes a solid color or `none` as its fill, + which stains its art, and no gradient or pattern fill and no stroke. An image takes a fill or stroke only when that + fill or stroke has a Convert to Shape or Outline Object effect. A guide, blend, envelope, Repeat, Intertwine group, + gradient mesh or placed document takes neither. A refused fill or stroke (VectorCraft's own rule) gives an error + that names the key and the object, and nothing of the call is applied; `opacity`, `blend` and `null` are taken for + every object, so a stored change can be taken back. `opacity` is 0..100 and `blend` a blend mode name; `null` gives + one back to the symbol. It is refused for a static symbol and for a path that names no object. On the canvas (VectorCraft's own rule), a Direct Selection click on an instance of a dynamic symbol selects the path, compound path or type object of its art under the pointer and outlines it (`symbol.instance.selectPart {id, path}`; Alt or Shift selects the instance whole). While it is selected, `paint.setFill` and `paint.setStroke` with a solid color or none, `paint.invert` and `paint.complement` on a solid diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index adc5f473a..4f8072436 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -738,6 +738,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Symbol libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html)): Window › Symbol Libraries and the Symbols panel's Symbol Libraries Menu open each library in a library panel of its own, as swatch and graphic style libraries do. The built-in library, Basic Shapes, is original art generated in code (`crates/engine/src/cmd/symbollib.rs`). A click on a symbol adds it to the document's Symbols as one undo step, with its Symbol Options (size, registration, export type, 9-slice guides) and every resource it uses (the symbols it nests, patterns, swatches, brushes, graphic styles, images), and makes it the current symbol; a symbol the document already has is not added again and another symbol of the same name keeps its name, the library's comes in numbered. Thumbnails, in the library panels and the Symbols panel, draw a symbol with the symbols it nests and the patterns and images it uses. Dragging a library symbol onto the canvas also places an instance there, in the same step ([placing from a library](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html)). Save Symbols (the library menu) and Save Symbol Library… (the Symbols panel menu) write the document's symbols as a `.vectorcraft` document holding only them, into the user library folder (listed under User Defined) or a file; Other Library… reads the symbols of any document VectorCraft opens; a file it can't read is an error and opens no panel. Tests: `crates/engine/src/tests/symbollib.rs`, `crates/ui-egui/src/panels/tests_symbol_libraries.rs`, `panels::symbols::tests::thumbnails_show_the_symbols_a_symbol_nests`, `canvas::tests::a_library_symbol_dropped_on_the_canvas_is_added_and_placed_there_in_one_step`. - **9-slice scaling** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/maintain-proportions-while-scaling-symbols.html)): a scaled instance of a symbol with Enable Guides for 9-Slice Scaling on stays an instance and keeps the parts of its art outside the guides at their size. New guides sit a quarter of the symbol's width and height in from each edge ([reference](https://help.adobe.com/archive/en/illustrator/cs6/illustrator_reference.pdf), Use 9-slice scaling section); saved guides keep their values. The rest is VectorCraft's own rule: the corners keep both sizes, the top and bottom edges stretch in width, the left and right edges in height and the center in both; an instance smaller than its two outer cells on an axis shrinks them in proportion and leaves the center empty; the cells are sized along the symbol's axes before the instance's rotation, shear or reflection. A path with solid paint that crosses a guide is cut there and stays one path, its stroke at its weight with Scale Strokes & Effects on or off; type, images, live objects and gradient or pattern paint that cross a guide are drawn in each cell they reach, clipped to it, except those that draw symbol art (a nested instance, type with an inline graphic, a placed document, a brushed stroke, or a pattern whose tile draws one of these), which are placed by the cell their center is in; an object in one cell is placed by that cell and stays as it is. The canvas, bounds, the Eyedropper, Break Link to Symbol and the PDF, EPS, DXF, EMF/WMF and SVG exports place the art the same way; SVG writes such an instance as its placed art instead of a `<use>`, and DXF and WMF, which do not clip, cut every path that crosses a guide and place other such objects by the cell their center is in. Tests: `symbol_nine_slice::tests` in `crates/doc`, `crates/engine/src/tests/symbol_nine_slice.rs`, `a_scaled_nine_slice_instance_keeps_its_corners` (PDF), `a_scaled_nine_slice_instance_is_written_as_the_canvas_draws_it` (SVG), `a_scaled_nine_slice_instance_is_sampled_as_it_is_drawn` (Eyedropper). - **Symbol editing mode** ([Edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): a double-click on a symbol instance, the Control bar's Edit Symbol button, a double-click on a symbol in the Symbols panel and the panel menu's Edit Symbol (`symbol.edit`) open the symbol's art on a temporary isolated layer, where the instance shows it (from the panel: centered on the active artboard, or in the view in a document without artboards), with the rest of the document dimmed. Each edit there changes every instance at once; the instances keep their own transforms and stay linked (Break Link to Symbol still expands one), and a dynamic symbol's instances keep their appearance changes to the objects the art still has. Escape, the isolation bar's back button, the Control bar's Exit Symbol Editing Mode and a double-click outside the art leave the mode (`symbol.exitEditing`); leaving without an edit adds no undo step. Saving leaves the editing layer out. Tests: `crates/engine/src/tests/symboledit.rs`, `select::tests::a_double_click_on_a_symbol_instance_edits_its_symbol`, `panels::symbols::tests::edit_symbol_from_the_panel_centers_the_art_on_the_active_artboard_or_the_view`. + - **Dynamic symbol appearance follows what an object draws** (VectorCraft's own rule): `symbol.instance.setAppearance` refuses a fill or stroke for an object of the art that does not draw it (a guide, blend, envelope, Repeat, Intertwine group, gradient mesh or placed document; a nested symbol instance's stroke, and its fill unless a solid color or none, which stains its art; an image's fill or stroke without a Convert to Shape or Outline Object effect) with an error that names the key and the object, and the call changes nothing. Opacity, blend and `null`, which takes a stored change back, are taken for every object. Tests: `crates/engine/src/tests/dynamic_symbols.rs`. - Symbol editing mode keeps every instance in step: instances of a dynamic symbol inside other symbols' art, pattern tiles, graph designs, stored brushes and opacity masks keep their appearance changes on the objects an edit reorders and drop the changes to objects it deletes. The view does not draw the instance the edit was entered from. Area type in the symbol's art takes the text wrap that moving a wrap object gives it. Editing another symbol at a place that fails keeps the current edit open, and only the editing layer or art on it can be isolated. Tests: `crates/engine/src/tests/symboledit.rs`, `isolation::symbol_editing_does_not_draw_the_instance_it_was_entered_from`, `panels::symbols::tests::double_clicking_a_symbol_in_the_panel_edits_it_in_either_view`, `the_panel_menus_edit_symbol_edits_the_instances_symbol_or_the_panels`, `the_control_bars_edit_symbol_edits_the_selected_instances_symbol`, `chrome::tests::the_control_bar_exits_symbol_editing_mode`. - **Dynamic symbol instances on the canvas** ([Symbols, archived copy](https://web.archive.org/web/20240206075757/https://helpx.adobe.com/illustrator/using/symbols.html)): each instance of a dynamic symbol can have an appearance of its own while it shares the symbol's shape. The rest of this entry is VectorCraft's own rules. A Direct Selection click on an instance of a dynamic symbol selects the path, compound path or type object of the symbol's art under the pointer and outlines it (`symbol.instance.selectPart`); the Swatches and Color panels, the Control bar, the Eyedropper and the other fill and stroke controls then change that object's solid color fill or stroke for this instance only, each as one undo step, and the symbol's art and its other instances stay as they were. Alt or Shift picks the instance whole. A gradient or pattern for the object, an Eyedropper source that is not a path, compound path or type, a click on any other object of the art (a group, image, blend or nested instance) and the commands that change the selected objects (opacity, moving, Delete, Group, Hide, Lock, Arrange, Expand, the gradient commands, the Appearance panel's rows and others) are refused with a status message. Copy, Export Selection, Fit to Selection, Liquify, Isolate, Add to Library and Save Selection use the whole instance (`docs/mcp.md`), and another selection tool selects the instance whole again. Tests: `crates/engine/src/tests/dynamic_symbols.rs`, `direct::tests::a_click_on_a_dynamic_instance_picks_the_object_under_the_pointer`. - **9-slice guides on the canvas** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/maintain-proportions-while-scaling-symbols.html)): while a symbol with Enable Guides for 9-Slice Scaling on is edited in symbol editing mode, the canvas draws its two horizontal and two vertical guides as dotted lines across its art, with any tool and with ruler guides hidden. With the Selection tool the pointer near a guide shows the Move cursor, and dragging the guide moves it (`symbol.options`'s `nineSliceGuides`) as one undo step; Escape during the drag puts it back. VectorCraft's own rules: a guide within 4 screen pixels takes the press before the art and the bounding box; it stays within the art's box (guides outside the art are not supported yet) and stops short of the opposite guide; the drag does not snap. Tests: `nineslice::tests`, `symbol_nine_slice::tests::the_edited_symbols_guides_cross_its_art_where_the_editing_layer_shows_it`, `a_moved_guide_stays_in_the_art_and_short_of_the_opposite_guide`, `canvas::tests::the_edited_symbols_nine_slice_guides_are_drawn_and_dragged_on_the_canvas`. @@ -1791,7 +1792,7 @@ A VectorCraft addition, not a feature of the reference app, so no area score mov ## Dynamic symbols (2026-11-01) -Symbols area (#4938), from the public symbol documentation ([create and place symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html), [edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html)). Symbol Options has Symbol Type (Dynamic Symbol / Static Symbol; the dialog's default is Dynamic, `symbol.new`'s `type` defaults to static so scripts behave as before). An instance of a dynamic symbol keeps its own fill, stroke, opacity and blend for any object of the symbol's art, named by a child-index path (`symbol.instance.setAppearance`), and stays an instance: the canvas, SVG, PDF, EPS, legacy `.ai`, EMF/WMF and DXF writers, effects and Expand draw the art with those changes. Redefining the symbol reshapes every instance and keeps each change whose object the new art still has; Break Link bakes the changes into the art; Edit Symbol expands the symbol's own art; Reset (`symbol.instance.reset`: the Properties panel's Quick Actions and the Symbols panel menu) and making the symbol static drop them, and Replace Symbol drops them when it gives the instance a different symbol. The type is stored on the symbol and the changes on the instance in the native format, both left out when unset, so documents without them save byte for byte as before. Remaining: on-canvas editing beyond a solid fill and stroke on a path, compound path or type object inside a dynamic instance; reading and writing the type and changes in `.ai` files. Evidence: `crates/engine/src/tests/dynamic_symbols.rs`, `crates/doc/src/symbol_overrides.rs` tests. Score unchanged at 87% (estimated); hours 5–9 → 4–8. +Symbols area (#4938), from the public symbol documentation ([create and place symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html), [edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html)). Symbol Options has Symbol Type (Dynamic Symbol / Static Symbol; the dialog's default is Dynamic, `symbol.new`'s `type` defaults to static so scripts behave as before). An instance of a dynamic symbol keeps its own opacity and blend for any object of the symbol's art, and its own fill and stroke for an object that draws them, named by a child-index path (`symbol.instance.setAppearance`), and stays an instance: the canvas, SVG, PDF, EPS, legacy `.ai`, EMF/WMF and DXF writers, effects and Expand draw the art with those changes. Redefining the symbol reshapes every instance and keeps each change whose object the new art still has; Break Link bakes the changes into the art; Edit Symbol expands the symbol's own art; Reset (`symbol.instance.reset`: the Properties panel's Quick Actions and the Symbols panel menu) and making the symbol static drop them, and Replace Symbol drops them when it gives the instance a different symbol. The type is stored on the symbol and the changes on the instance in the native format, both left out when unset, so documents without them save byte for byte as before. Remaining: on-canvas editing beyond a solid fill and stroke on a path, compound path or type object inside a dynamic instance; reading and writing the type and changes in `.ai` files. Evidence: `crates/engine/src/tests/dynamic_symbols.rs`, `crates/doc/src/symbol_overrides.rs` tests. Score unchanged at 87% (estimated); hours 5–9 → 4–8. ## Document Tools add-on: Export All Open Documents (2026-11-01) From ef65f0fed423859d581baecc84462d3e853a8a3e Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 9 Nov 2026 04:19:35 +0700 Subject: [PATCH 138/160] Preferences: the settings filter builds without a clippy warning on macOS Squashed from #6235 onto main. --- crates/ui-egui/src/prefs_dialog.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index b64d54e67..733aaf200 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -57,10 +57,12 @@ pub(crate) struct Setting { /// preferences, each category's in the order the dialog lists them. fn settings() -> impl Iterator<Item = Setting> { let ui_fields = UI_FIELDS.iter().map(|&(key, category, label)| Setting { key, category, section: "", label }); + const SYSTEM_TITLE_BAR: bool = cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))); + const DISPLAY_SERVER_CHOICE: bool = cfg!(target_os = "linux"); let shown = |key: &str| match key { - "systemTitleBar" => cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))), + "systemTitleBar" => SYSTEM_TITLE_BAR, "appTitleBar" => APP_TITLE_BAR_SHOWN, - "linuxDisplayServer" => cfg!(target_os = "linux"), + "linuxDisplayServer" => DISPLAY_SERVER_CHOICE, _ => true, }; let prefs = From 88626a9194c353416713eba3a8eabf1e5c73c11d Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sun, 8 Nov 2026 11:47:40 -0800 Subject: [PATCH 139/160] 3D export: Revolve mesh normals are unit length and short walls face out Revolve gave each side face a normal as long as its profile edge, and the mesh kept that length, so the glTF, OBJ and USD files of a revolved object carried normals of any length (100 for a wall 100 pt tall). A face whose edge was shorter than about 0.71 pt fell back to its corners' normal, which turns inward when the profile is drawn the other way, and its triangles then faced into the solid. The mesh now scales every given normal to unit length before choosing between it and the corners' normal. --- crates/three-d/src/mesh.rs | 7 ++++--- crates/three-d/src/mesh/tests.rs | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/three-d/src/mesh.rs b/crates/three-d/src/mesh.rs index 45b559f77..2f2f69844 100644 --- a/crates/three-d/src/mesh.rs +++ b/crates/three-d/src/mesh.rs @@ -100,14 +100,15 @@ fn up(v: V3) -> [f64; 3] { [v.0, -v.1, v.2] } -/// `faces` (view space) as one mesh, each cut into triangles turned to its normal (a face given no -/// normal takes its corners' own). +/// `faces` (view space) as one mesh, each cut into triangles turned to its normal, which is written +/// at unit length whatever length it is given (a face given no normal takes its corners' own). fn mesh(faces: &[Face3]) -> Result<Mesh, String> { let mut out = Mesh::default(); let mut work = 0usize; for (contours, normal) in faces { let geometric = newell(contours); - let n = if normal.dot(*normal) > 0.5 { *normal } else { geometric.unit() }; + let given = normal.unit(); + let n = if given.dot(given) > 0.5 { given } else { geometric.unit() }; if n.dot(n) < 0.5 { continue; } diff --git a/crates/three-d/src/mesh/tests.rs b/crates/three-d/src/mesh/tests.rs index 1247926de..4be1f0a13 100644 --- a/crates/three-d/src/mesh/tests.rs +++ b/crates/three-d/src/mesh/tests.rs @@ -251,6 +251,23 @@ fn a_partial_revolve_caps_its_ends_with_sectors() { assert!((area(&m) - exact).abs() / exact < 0.01, "{} vs {exact}", area(&m)); } +#[test] +fn a_revolved_surface_has_unit_normals_facing_out_whatever_its_length_and_direction() { + // A wall 100 pt tall and one 0.5 pt tall, each drawn up and down the page. + for (from, to) in [(0.0, 100.0), (100.0, 0.0), (0.0, 0.5), (0.5, 0.0)] { + let mut b = BezPath::new(); + b.move_to((20.0, from)); + b.line_to((20.0, to)); + let p = PathData::from_bezpath(&b); + let r = p.bounds().unwrap(); + let o = Revolve { offset: 10.0, edge: Edge::Left, segments: 64, ..Revolve::default() }; + let m = revolve_mesh(&p, r, o).unwrap(); + assert!(m.normals.iter().all(|n| (dot(*n, *n) - 1.0).abs() < 1e-9), "{from} to {to}: a normal is not unit length"); + assert!(closed(&m), "{from} to {to}"); + assert!(outward(&m), "{from} to {to}: a face turns inward"); + } +} + #[test] fn inflate_puffs_the_front_toward_the_viewer() { let mut b = BezPath::new(); From 61f83dc43988d7eb919b91822c33dba011e554c8 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sun, 8 Nov 2026 12:47:41 -0800 Subject: [PATCH 140/160] 3D export: the revolve normals test checks each wall has triangles An empty mesh passed the unit-normal, closed and outward checks without testing a wall. The test now asserts that each revolve makes triangles. --- crates/three-d/src/mesh/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/three-d/src/mesh/tests.rs b/crates/three-d/src/mesh/tests.rs index 4be1f0a13..3251cbb3e 100644 --- a/crates/three-d/src/mesh/tests.rs +++ b/crates/three-d/src/mesh/tests.rs @@ -262,6 +262,7 @@ fn a_revolved_surface_has_unit_normals_facing_out_whatever_its_length_and_direct let r = p.bounds().unwrap(); let o = Revolve { offset: 10.0, edge: Edge::Left, segments: 64, ..Revolve::default() }; let m = revolve_mesh(&p, r, o).unwrap(); + assert!(!m.is_empty(), "{from} to {to}: the revolve made no triangles"); assert!(m.normals.iter().all(|n| (dot(*n, *n) - 1.0).abs() < 1e-9), "{from} to {to}: a normal is not unit length"); assert!(closed(&m), "{from} to {to}"); assert!(outward(&m), "{from} to {to}: a face turns inward"); From 0e7bd1003338d8cf40312e02ce88c590846dcf9b Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sun, 8 Nov 2026 12:52:29 -0800 Subject: [PATCH 141/160] SVG export: outlined type keeps its text as an accessible name Type that SVG text can't draw is written as outlines: vertical type, ruby, right-to-left text, type on a path with an effect other than Rainbow, an end bracket, Align to Path, Spacing, hanging punctuation, bullets or inline graphics. Only glyphs bent along a path gave the outlined group a `role="img"` and `aria-label`; every other case left screen readers and search with no text. Each of them now names the group with its text. Convert to Outlines still writes the paths alone, as before. Inline graphics' placeholder characters stay out of the name, and empty type gets none. An object whose id is its name also gets `data-name`, so opening the file again keeps the name instead of taking the label. --- crates/svg/src/export/text.rs | 19 +++++++--- crates/svg/tests/it/roundtrip.rs | 60 ++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs index c9d9027b8..19bbbd5be 100644 --- a/crates/svg/src/export/text.rs +++ b/crates/svg/src/export/text.rs @@ -1,4 +1,4 @@ -use super::output::{xml_attr_valid, xml_text}; +use super::output::{xml_attr_escape, xml_attr_valid, xml_text}; use super::{ Affine, AppearanceItem, Arc, BTreeSet, BezPath, BlendMode, CharStyle, FillRule, FontDb, FontFace, GradientKind, HashMap, Justify, Node, NodeId, NodeKind, Paint, PathData, Props, Rect, StrokeAlign, Styling, TextKind, TextObject, Writer, base64_encode, css, css_string, fmt_num, @@ -166,10 +166,19 @@ impl Writer<'_> { } let id = self.id_attr(n); let a = self.attrs(&css::transparency(n)); - // Glyphs bent along a path (joined scripts on a curve) can only be outlines: the group - // names its text for screen readers and search. - let label = if lay.glyphs.iter().any(|g| g.bend.is_some()) { - format!(" role=\"img\" aria-label=\"{}\"", xml_attr_valid(&t.plain_text())) + // Text outlined because SVG text can't draw it (bent glyphs, vertical type, ruby, effects on + // a path and the like) names its text for screen readers and search; Convert to Outlines + // leaves only the paths, except for bent glyphs. Inline graphics are written apart from the + // glyphs, so their placeholder characters stay out of the name; empty text has none. + let text: String = t.plain_text().chars().filter(|&c| c != vectorcraft_doc::text::INLINE_CHAR).collect(); + let label = if !text.trim().is_empty() && (!self.opts.outline_text || lay.glyphs.iter().any(|g| g.bend.is_some())) { + // SVG import names an object by its `aria-label` before its id, so an id that stands for + // the name is written as `data-name` too, and the object keeps its name. + let name = match self.names.get(&n.id) { + Some(named) if !id.is_empty() && !id.contains(" data-name=\"") => format!(" data-name=\"{}\"", xml_attr_escape(named)), + _ => String::new(), + }; + format!("{name} role=\"img\" aria-label=\"{}\"", xml_attr_valid(&text)) } else { String::new() }; diff --git a/crates/svg/tests/it/roundtrip.rs b/crates/svg/tests/it/roundtrip.rs index 8f479f348..b13ecf160 100644 --- a/crates/svg/tests/it/roundtrip.rs +++ b/crates/svg/tests/it/roundtrip.rs @@ -527,6 +527,66 @@ fn text_on_path_effects_svg_cannot_express_are_outlined() { } } +/// Type that SVG text can't draw is outlined with its text as the group's accessible name: type on +/// a path with an effect other than Rainbow, vertical type, right-to-left text, and type on a path +/// with inline graphics. With Convert to Outlines the paths carry no name, and empty type gets none +/// either. +#[test] +fn text_outlined_for_svg_keeps_an_accessible_name() { + use vectorcraft_doc::PathEffect; + let mut curve = vectorcraft_geom::BezPath::new(); + curve.move_to((20.0, 150.0)); + curve.curve_to((60.0, 40.0), (140.0, 40.0), (180.0, 150.0)); + let style = || CharStyle { size: 14.0, ..CharStyle::default() }; + let svg_of = |t: TextObject, outline_text: bool| { + let mut d = Document::new(200.0, 200.0); + let id = d.alloc_id(); + let opts = ExportOptions { outline_text, ..ExportOptions::default() }; + export(&doc_with(vec![Node::new(id, NodeKind::Text(Box::new(t)))]), &opts) + }; + let mut skewed = TextObject::point(Point::ZERO, "On a curve", style()); + skewed.kind = TextKind::OnPath { path: PathData::from_bezpath(&curve), start: 0.1, end: None }; + skewed.path_effect = PathEffect::Skew; + let mut vertical = TextObject::point(Point::new(50.0, 20.0), "On a curve", style()); + vertical.vertical = true; + let mut rtl = TextObject::point(Point::new(20.0, 50.0), "On a curve", style()); + rtl.runs[0].style.char_direction = Some(vectorcraft_doc::ParaDirection::RightToLeft); + for (name, t) in [("skew", skewed), ("vertical", vertical), ("rtl", rtl)] { + let svg = svg_of(t.clone(), false); + assert!(!svg.contains("<text"), "{name}: {svg}"); + assert!(svg.contains("role=\"img\" aria-label=\"On a curve\""), "{name}: {svg}"); + assert!(!svg_of(t, true).contains("aria-label"), "{name} with Convert to Outlines"); + } + let mut empty = TextObject::point(Point::new(50.0, 20.0), "", style()); + empty.vertical = true; + assert!(!svg_of(empty, false).contains("aria-label")); + // An inline graphic's placeholder character stays out of the name; type on a path holding + // only inline graphics gets none. + let inline = || vectorcraft_doc::TextRun::inline(vectorcraft_doc::InlineArt::new("Mark"), style()); + let mut mixed = TextObject::point(Point::ZERO, "On a curve", style()); + mixed.kind = TextKind::OnPath { path: PathData::from_bezpath(&curve), start: 0.1, end: None }; + mixed.runs.insert(0, inline()); + let mut only = mixed.clone(); + only.runs = vec![inline()]; + let svg = svg_of(mixed, false); + assert!(svg.contains("aria-label=\"On a curve\""), "{svg}"); + assert!(!svg.contains('\u{FFFC}'), "{svg}"); + let svg = svg_of(only, false); + assert!(!svg.contains("aria-label"), "{svg}"); + // A named object keeps its name when the file is opened again, whether the name is its id or not. + for name in ["Heading", "Main heading"] { + let mut t = TextObject::point(Point::new(50.0, 20.0), "Hello", style()); + t.vertical = true; + let mut d = Document::new(200.0, 200.0); + let mut node = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + node.name = Some(name.to_string()); + let svg = export(&doc_with(vec![node]), &ExportOptions::default()); + assert!(svg.contains("aria-label=\"Hello\""), "{svg}"); + let r = import(&svg).unwrap(); + assert!(art(&r).iter().any(|n| n.name.as_deref() == Some(name)), "{name}: {svg}"); + } +} + /// Type on a path whose punctuation hangs outside its brackets (Optical Margin Alignment or Roman /// Hanging Punctuation) keeps its look as outlines; with the option on and nothing at its ends to /// hang, it stays a `<textPath>`. From 83248b9bb47363ccf0bf01e5a9082de9a48b1710 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sun, 8 Nov 2026 13:05:38 -0800 Subject: [PATCH 142/160] SVG Filters: export writes a filter live only when import can read it back SVG export wrote an SVG filter as a live <filter> when its SVG 1.1 form was at most 64 KB, without counting the region and id it writes into it. A filter close to the limit came back over it, SVG import dropped it, and the reopened object had no filter. Export now leaves room for the id and the measured region, checks the SVG 1.1 form the way import does, and counts each character past U+00FF as the character reference Latin-1 output writes. A filter that does not fit is exported as the other pixel effects are. The SVG 1.1 form and its check are kept for the filters seen last, as checked filters already are, so export does not check one filter again for each object. --- crates/effects/src/svg_filter.rs | 73 +++++++++++++++++++++----- crates/effects/src/svg_filter/tests.rs | 45 ++++++++++++++-- crates/engine/src/tests/svgfilter.rs | 19 +++++++ crates/svg/tests/it/import_live.rs | 56 ++++++++++++++++++++ docs/mcp.md | 3 +- 5 files changed, 179 insertions(+), 17 deletions(-) diff --git a/crates/effects/src/svg_filter.rs b/crates/effects/src/svg_filter.rs index 0c84cde69..325ce9b38 100644 --- a/crates/effects/src/svg_filter.rs +++ b/crates/effects/src/svg_filter.rs @@ -57,16 +57,27 @@ fn numbers(v: &str) -> Option<Vec<f64>> { /// Filter `xml` as it is drawn and written ([`rebuilt`]) when it passes [`check`], `None` when it /// doesn't. Remembered for the filters seen last, so drawing does not check a filter again each time. fn checked(xml: &str) -> Option<Arc<str>> { - use std::hash::BuildHasher; - type Seen = (std::collections::hash_map::RandomState, std::collections::HashMap<(u64, usize), Option<Arc<str>>>, usize); static SEEN: std::sync::OnceLock<std::sync::Mutex<Seen>> = std::sync::OnceLock::new(); - let seen = SEEN.get_or_init(Default::default); + remembered(&SEEN, xml, |xml| checked_form(xml).ok().map(|(_, form)| Arc::from(form))) +} + +/// The results [`remembered`] keeps: the hash's seed, the results by key, and their size in bytes. +type Seen = (std::collections::hash_map::RandomState, std::collections::HashMap<(u64, usize), Option<Arc<str>>>, usize); + +/// `make(xml)`, remembered in `seen` for the filters seen last. +fn remembered( + seen: &'static std::sync::OnceLock<std::sync::Mutex<Seen>>, + xml: &str, + make: impl FnOnce(&str) -> Option<Arc<str>>, +) -> Option<Arc<str>> { + use std::hash::BuildHasher; + let seen = seen.get_or_init(Default::default); // Keyed by a hash that a file can't aim at (randomly seeded) and the length. let key = seen.lock().ok().map(|s| (s.0.hash_one(xml), xml.len())); if let Some(hit) = key.and_then(|k| seen.lock().ok().and_then(|s| s.1.get(&k).cloned())) { return hit; } - let form: Option<Arc<str>> = checked_form(xml).ok().map(|(_, form)| Arc::from(form)); + let form = make(xml); if let (Some(k), Ok(mut s)) = (key, seen.lock()) && !s.1.contains_key(&k) { @@ -83,7 +94,7 @@ fn checked(xml: &str) -> Option<Arc<str>> { form } -/// The most check results [`checked`] keeps, and the most bytes of filters it keeps. +/// The most results [`remembered`] keeps in one place, and the most bytes of filters it keeps there. const MAX_CHECKED: usize = 65_536; const MAX_CHECKED_BYTES: usize = 32 << 20; @@ -279,9 +290,30 @@ const LATER_BLEND_MODES: [&str; 11] = /// The `feComposite` operators of SVG 1.1. const SVG11_OPERATORS: [&str; 6] = ["over", "in", "out", "atop", "xor", "arithmetic"]; -/// The longest form SVG output writes ([`svg11`]), leaving room within [`MAX_DRAWN_XML`] for the -/// region and id written into it. -const MAX_WRITTEN_XML: usize = MAX_DRAWN_XML - 1024; +/// The longest id SVG output gives a filter it writes: a unique-id prefix of up to 13 characters, +/// `filter-` and a counter of up to 20 digits, with room to spare. +const WRITTEN_ID_ROOM: usize = 64; + +/// The longest text `{}` writes for a finite `f64`: a sign, `0.`, 307 zeros and 17 digits, as for +/// `-f64::MIN_POSITIVE`. +const MAX_NUMBER_TEXT: usize = 327; + +/// The most the four region attributes written into a filter ([`SvgFilterFx::written_xml`]) add to +/// it: each of `x`, `y`, `width` and `height` as ` height="…"` with the longest number. +const REGION_ROOM: usize = 4 * (" height=\"\"".len() + MAX_NUMBER_TEXT); + +/// The longest form SVG output writes ([`svg11`]): room is left within [`SvgFilter::MAX_XML`], the +/// longest filter SVG import reads, for the id written into it. Measured with [`written_len`]. +const MAX_WRITTEN_XML: usize = SvgFilter::MAX_XML - WRITTEN_ID_ROOM; + +/// How long `s` is as SVG output can write it and import reads it back: SVG output with the Latin-1 +/// encoding writes each character past U+00FF as `&#x…;`, which import keeps as text, so the +/// longer of the two lengths is counted for each such character. +fn written_len(s: &str) -> usize { + // `&#x`, one hex digit per four significant bits, and `;`. + let reference = |c: u32| 4 + ((u32::BITS - c.leading_zeros()) as usize).div_ceil(4); + s.chars().map(|c| if u32::from(c) < 0x100 { c.len_utf8() } else { reference(u32::from(c)) }).sum() +} /// Filter `xml` (a rebuilt one, [`rebuilt`]) as SVG 1.1 output writes it, in the form an SVG 1.1 /// viewer draws as resvg draws `xml`: @@ -413,7 +445,7 @@ fn svg11(xml: &str) -> Option<String> { } out.push_str("</filter>"); } - (out.len() <= MAX_WRITTEN_XML).then_some(out) + (written_len(&out) <= MAX_WRITTEN_XML).then_some(out) } /// The result names of a filter's SVG 1.1 form ([`svg11`]): the names its elements use, and the @@ -1278,13 +1310,28 @@ impl SvgFilterFx { /// whose box in the document is `bounds`: in its SVG 1.1 form ([`svg11`]), `None` when SVG 1.1 /// can't hold it ([`Self::writes_as_svg11`]). pub fn written_xml(&self, bounds: Rect) -> Option<String> { - self.with_region(&svg11(&self.xml)?, (bounds - self.origin).scale_from_origin(1.0 / self.scale)) + let out = self.with_region(&self.svg11_form()?, (bounds - self.origin).scale_from_origin(1.0 / self.scale))?; + (written_len(&out) <= MAX_WRITTEN_XML).then_some(out) } - /// Can SVG 1.1 output write the filter as a `<filter>` element ([`svg11`])? When it can't, SVG - /// output treats it as it treats the other pixel effects. + /// Can SVG 1.1 output write the filter as a `<filter>` element ([`svg11`]) that SVG import + /// reads back? When it can't, SVG output treats it as it treats the other pixel effects. pub fn writes_as_svg11(&self) -> bool { - svg11(&self.xml).is_some() + self.svg11_form().is_some() + } + + /// The filter's SVG 1.1 form ([`svg11`]) when, with any region and id that SVG output writes + /// into it, it is a filter SVG import reads ([`check`]): at most [`SvgFilter::MAX_XML`] bytes, + /// [`MAX_PRIMITIVES`] primitives and [`MAX_DEPTH`] levels deep, and drawn. Remembered for the + /// filters seen last, so export does not check a filter again for each object. + fn svg11_form(&self) -> Option<Arc<str>> { + static FORMS: std::sync::OnceLock<std::sync::Mutex<Seen>> = std::sync::OnceLock::new(); + // `measured` follows from the XML, so the XML is the whole key. + let region = if self.measured { REGION_ROOM } else { 0 }; + remembered(&FORMS, &self.xml, |xml| { + let form = svg11(xml)?; + (written_len(&form) + region <= MAX_WRITTEN_XML && check(&form).is_ok()).then(|| Arc::from(form)) + }) } /// How finely the filter runs on a raster whose pixels are `space.px` points wide, for an diff --git a/crates/effects/src/svg_filter/tests.rs b/crates/effects/src/svg_filter/tests.rs index 4d0a60394..df1956f3f 100644 --- a/crates/effects/src/svg_filter/tests.rs +++ b/crates/effects/src/svg_filter/tests.rs @@ -457,9 +457,48 @@ fn a_filter_at_the_size_limit_draws_with_its_measured_region() { assert_eq!(xml.len(), SvgFilter::MAX_XML); assert_eq!(check(&xml), Ok("r".into())); let f = filter(json!({ "xml": xml })); - let written = f.written_xml(Rect::new(0.123456789, 0.123456789, 1000.987654321, 1000.987654321)).unwrap(); - assert!(written.len() > SvgFilter::MAX_XML, "the region written out is longer: {} bytes", written.len()); - assert!(with_id(&written, DRAWN_ID).is_some(), "the drawn form is read"); + let drawn = f.drawn_xml(Rect::new(0.123456789, 0.123456789, 1000.987654321, 1000.987654321)).unwrap(); + assert!(drawn.len() > SvgFilter::MAX_XML, "the region written out is longer: {} bytes", drawn.len()); + assert!(with_id(&drawn, DRAWN_ID).is_some(), "the drawn form is read"); +} + +/// SVG output writes a filter live only when the form it writes, with its region and id, is one +/// SVG import reads back: at most [`SvgFilter::MAX_XML`] bytes. A filter at the limit whose region +/// is measured against the box, one at the limit whose id is a character long, and one whose +/// shadows make its SVG 1.1 form longer are left to be written as other pixel effects are. +#[test] +fn svg_output_writes_a_filter_only_in_a_form_import_reads() { + let at_limit = |open: &str, close: &str| { + let xml = format!("{open}{}{close}", "a".repeat(SvgFilter::MAX_XML - open.len() - close.len())); + assert_eq!((xml.len(), check(&xml).is_ok()), (SvgFilter::MAX_XML, true)); + filter(json!({ "xml": xml })) + }; + let measured = at_limit( + r#"<filter id="r" filterUnits="userSpaceOnUse" x="-1%" y="-1%" width="1%" height="1%"><feOffset result=""#, + r#""/><feOffset/></filter>"#, + ); + let short_id = at_limit(r#"<filter id="r"><feOffset result=""#, r#""/><feOffset/></filter>"#); + let shadows = format!(r#"<filter id="s">{}<feOffset result="{}"/></filter>"#, "<feDropShadow/>".repeat(12), "a".repeat(SvgFilter::MAX_XML - 400)); + assert!(check(&shadows).is_ok() && shadows.len() <= SvgFilter::MAX_XML); + for f in [measured, short_id, filter(json!({ "xml": shadows }))] { + assert!(!f.writes_as_svg11() && f.written_xml(Rect::new(0.0, 0.0, 10.0, 10.0)).is_none(), "{} bytes", f.xml().len()); + } + // Room for the id and the longest region: still written, and read back. + let open = r#"<filter id="r" filterUnits="userSpaceOnUse" x="-1%" y="-1%" width="1%" height="1%"><feOffset result=""#; + let close = r#""/><feOffset/></filter>"#; + let xml = format!("{open}{}{close}", "a".repeat(MAX_WRITTEN_XML - REGION_ROOM - open.len() - close.len())); + let f = filter(json!({ "xml": xml })); + assert!(f.writes_as_svg11()); + let id = format!("u_vc_unique_-filter-{}", usize::MAX); + let out = with_id(&f.written_xml(Rect::new(0.123456789, 0.123456789, 1000.987654321, 1000.987654321)).unwrap(), &id).unwrap(); + assert!(out.len() <= SvgFilter::MAX_XML && check(&out).is_ok(), "{} bytes", out.len()); + // The longest numbers a region can take still fit. + let far = f.written_xml(Rect::new(-f64::MIN_POSITIVE, -f64::MIN_POSITIVE, f64::MAX / 4.0, f64::MAX / 4.0)).unwrap(); + assert!(with_id(&far, &id).is_some_and(|out| out.len() <= SvgFilter::MAX_XML)); + for v in [-f64::MIN_POSITIVE, -5e-324, -2.225073858507201e-308, f64::MIN, -1.0 / 3.0] { + assert!(format!("{v}").len() <= MAX_NUMBER_TEXT, "{v:e}"); + } + assert_eq!(format!("{}", -f64::MIN_POSITIVE).len(), MAX_NUMBER_TEXT); } /// Only a type selector naming a filter or one of its elements, or `*`, lets a style sheet reach diff --git a/crates/engine/src/tests/svgfilter.rs b/crates/engine/src/tests/svgfilter.rs index c9f5cef44..fcb4581c2 100644 --- a/crates/engine/src/tests/svgfilter.rs +++ b/crates/engine/src/tests/svgfilter.rs @@ -461,6 +461,25 @@ fn svg_export_writes_the_filter_when_it_is_the_last_effect() { assert!(!out.contains("<feOffset dx=\"3\""), "{out}"); } +/// A filter at the size limit whose written form, with the id SVG export gives it, would be longer +/// than SVG import reads is exported as an image, which opens again as the canvas draws it. +#[test] +fn svg_export_draws_a_filter_too_long_to_read_back_as_an_image() { + let (mut s, id) = blue_square(); + let (open, close) = + (r##"<filter id="r"><feFlood flood-color="#ff0000" result=""##, r#""/><feComposite in2="SourceGraphic" operator="in"/></filter>"#); + let xml = format!("{open}{}{close}", "a".repeat(vectorcraft_doc::SvgFilter::MAX_XML - open.len() - close.len())); + run(&mut s, "effect.apply", json!({"effect": "svg.filter", "ids": [id.0], "params": {"xml": xml}})); + assert_eq!(&render(&s).pixel(100, 100)[..3], &[255, 0, 0]); + let out = svg(&mut s); + assert!(out.contains("<image") && !out.contains("<filter"), "{} bytes", out.len()); + let (back, warnings) = vectorcraft_svg::import_with_report(&out).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let again = vectorcraft_render::Renderer::new().render_region(&back, vectorcraft_geom::Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true); + let px = again.pixel(100, 100); + assert!(px[0] > 240 && px[1] < 15 && px[2] < 15, "{px:?}"); +} + /// A filter SVG 1.1 can't hold (a blend mode SVG 1.1 lacks) is exported as an image, as the canvas /// draws it; an SVG 1.1 one stays a filter. #[test] diff --git a/crates/svg/tests/it/import_live.rs b/crates/svg/tests/it/import_live.rs index 925c17176..1d10c5d9f 100644 --- a/crates/svg/tests/it/import_live.rs +++ b/crates/svg/tests/it/import_live.rs @@ -657,6 +657,62 @@ fn an_exported_filter_image_has_an_xlink_reference() { } } +/// SVG export writes a filter only in a form SVG import reads back. A filter at the size limit, +/// whose id or region written out would make it longer, is left out of the SVG with the other pixel +/// effects (exports that turn pixel effects into images draw it as an image), and the SVG opens +/// without a filter that import can't read. +#[test] +fn an_exported_filter_is_one_import_reads_back() { + let max = vectorcraft_doc::SvgFilter::MAX_XML; + let close = r#""/><feComposite in2="SourceGraphic" operator="in"/></filter>"#; + let short_id = r##"<filter id="r"><feFlood flood-color="#ff0000" result=""##; + let measured = + r##"<filter id="r" filterUnits="userSpaceOnUse" x="-10%" y="-10%" width="120%" height="120%"><feFlood flood-color="#ff0000" result=""##; + for open in [short_id, measured] { + let xml = format!("{open}{}{close}", "a".repeat(max - open.len() - close.len())); + let d = filtered_rect(&xml); + assert_eq!(effects(&d).len(), 1); + let svg = export(&d, &ExportOptions::default()); + assert!(!svg.contains("<filter"), "{open}"); + let (_, w) = import_with_report(&svg).unwrap(); + assert!(w.is_empty(), "{open}: {w:?}"); + } + // A shorter one is written and read back. + let xml = format!("{short_id}{}{close}", "a".repeat(max - 64 - short_id.len() - close.len())); + let svg = export(&filtered_rect(&xml), &ExportOptions::default()); + let (back, w) = import_with_report(&svg).unwrap(); + assert!(w.is_empty(), "{w:?}"); + assert_eq!(effects(&back).iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["svg.filter"]); +} + +/// With the Latin-1 encoding, export writes each character past U+00FF as a character reference, +/// which import counts as text. A filter is measured with those references in either encoding: one +/// that passes the limit with them is left out of both, and one that fits with them is written live +/// in Latin-1 and read back. +#[test] +fn an_exported_filter_counts_the_references_latin1_writes() { + let open = r##"<filter id="r"><feFlood flood-color="#ff0000" result=""##; + let close = r#""/><feComposite in2="SourceGraphic" operator="in"/></filter>"#; + let max = vectorcraft_doc::SvgFilter::MAX_XML; + let latin1 = ExportOptions { encoding: vectorcraft_svg::Encoding::Latin1, ..ExportOptions::default() }; + // `国` takes three bytes as UTF-8 and eight as `国`. + let xml = format!("{open}{}{close}", "\u{56FD}".repeat((max / 2 - open.len() - close.len()) / 3)); + let d = filtered_rect(&xml); + assert_eq!(effects(&d).len(), 1); + assert!(!export(&d, &ExportOptions::default()).contains("<filter"), "its references pass the limit"); + let svg = export(&d, &latin1); + assert!(!svg.contains("<filter"), "its references pass the limit"); + let (_, w) = import_with_report(&svg).unwrap(); + assert!(w.is_empty(), "{w:?}"); + // One that fits with its references. + let xml = format!("{open}{}{close}", "\u{56FD}".repeat((max - 64 - open.len() - close.len()) / 8)); + let svg = export(&filtered_rect(&xml), &latin1); + assert!(svg.contains("国"), "the references are written"); + let (back, w) = import_with_report(&svg).unwrap(); + assert!(w.is_empty(), "{w:?}"); + assert_eq!(effects(&back).iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["svg.filter"]); +} + /// SVG 1.1 has no `feDropShadow`: export writes the SVG 1.1 primitives it stands for, which draw /// as the canvas draws the shadow and import back as a filter that draws the same. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 69deb6502..1bffce7c9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3230,7 +3230,8 @@ names no earlier result as the previous primitive's result; and an `feComposite` operator SVG 1.1 lacks, or `edgeMode` on a primitive other than `feConvolveMatrix`, left out, as the canvas draws them. A filter with an `feBlend` mode SVG 1.1 lacks (`overlay` and the others), an `feDropShadow` whose `flood-color` is `currentColor` or with `primitiveUnits="objectBoundingBox"`, or more than 64 primitives once -its shadows are expanded, has no SVG 1.1 form. In that case, otherwise, and with the SVG Tiny 1.2 profile, the object +its shadows are expanded, has no SVG 1.1 form. Neither does a filter whose written form, with its region and id, is +one SVG import would not read, such as one longer than 64 KB. In that case, otherwise, and with the SVG Tiny 1.2 profile, the object becomes an image. SVG import keeps a filter that the live effects (Drop Shadow, the glows, Gaussian Blur, Feather) can't express as an `svg.filter` effect holding the filter's XML, with `scale` and `origin` from the object's user space, unless that user space is turned, skewed or scaled past 0.001 to 1000 points From 973503a2b11b66c378a955ef0e6bbcc0893f7416 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sun, 8 Nov 2026 13:20:48 -0800 Subject: [PATCH 143/160] Batch: Override Export's folder for Export for Screens no longer warns of one shared file A data set batch whose Override Export gives each data set its own export folder no longer reports that every data set saves over the same file. --- crates/engine/src/cmd/fileio/action_batch.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index 3b56a2f59..479877992 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -120,7 +120,7 @@ const ONE_FILE_WARNING: &str = "each data set saves over the same file, which ke /// `path`; a Save step without a `path` when the document has a file of its own (saved, and not /// converted from an older version or restored by Data Recovery) and the step gives no `format` or /// the document's own; an Export for Screens step whose `folder`, given or from its -/// `exportPreset`, names a folder, whatever the overrides. +/// `exportPreset`, names a folder, when Override Export does not give each data set its own folder. /// Other steps return their data instead of writing a file. pub fn batch_overwrites_one_file(s: &Session, p: &Value) -> bool { if p.get("source").and_then(Value::as_str) != Some("datasets") { @@ -150,8 +150,8 @@ pub fn batch_overwrites_one_file(s: &Session, p: &Value) -> bool { "document.export" | "document.exportSelection" => !export && path, // The batch does not point these exports into `destinationFolder`. "document.exportPdf" | "document.exportEps" | "document.exportDxf" | "document.exportForWeb" | "document.exportForOffice" => path, - // Override Export sets `path`, which Export for Screens does not read: its `folder` stays. - "document.exportForScreens" => super::screens::with_preset(s, ¶ms).is_ok_and(|p| names(p.get("folder"))), + // Override Export gives Export for Screens a folder for each data set; otherwise its `folder` stays. + "document.exportForScreens" => !export && super::screens::with_preset(s, ¶ms).is_ok_and(|p| names(p.get("folder"))), _ => false, } }) @@ -1965,7 +1965,15 @@ mod tests { assert!(!batch_overwrites_one_file(&saved, &save(Some("svg"))), "another format without a path: the data comes back"); assert!(!with(json!({"commands": [{"command": "file.saveCopy", "params": {"folder": "/x"}}]})), "a folder alone: the data comes back"); let screens = json!([{"command": "document.exportForScreens", "params": {"folder": "/x"}}]); - assert!(with(json!({"destination": "folder", "destinationFolder": "/out", "commands": screens})), "Override Export leaves its folder"); + assert!( + !with(json!({"destination": "folder", "destinationFolder": "/out", "commands": screens})), + "Override Export gives each data set its own folder" + ); + assert!( + with(json!({"destination": "folder", "destinationFolder": "/out", "overrideExport": false, "commands": screens})), + "without Override Export the step keeps its folder" + ); + assert!(with(json!({"commands": screens})), "without a destination folder the step keeps its folder"); assert!(!with(json!({"commands": [{"command": "document.exportForScreens", "params": {}}]}))); let preset = json!([{"command": "document.exportForScreens", "params": {"exportPreset": "Logo Package"}}]); assert!(!with(json!({"commands": preset})), "a preset without a folder: the data comes back"); From 1bfd916d63331dfcea5f43b93d1c3c8227c60b8c Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sun, 8 Nov 2026 13:21:48 -0800 Subject: [PATCH 144/160] Type: Strong type in a translucent brushed path keeps the path's opacity Crisp and Strong reshape each edge pixel by the type's coverage, drawn in black. Type in the art of a scatter brush on an undashed path drew that coverage with the path's opacity folded in, so Strong made fully covered pixels opaque at 50% path opacity. The coverage is now drawn at full opacity; the pixels keep the folded opacity. The evidence entry for the Anti-aliasing Method notes that scores and hours are unchanged. --- crates/render/src/renderer/text.rs | 4 ++++ crates/render/src/tests/typeedges.rs | 35 ++++++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/render/src/renderer/text.rs b/crates/render/src/renderer/text.rs index fd7a857d5..b4a0dd0d2 100644 --- a/crates/render/src/renderer/text.rs +++ b/crates/render/src/renderer/text.rs @@ -484,7 +484,11 @@ impl Renderer { AppearanceItem::Stroke(st) => st.opacity = 1.0, } } + // The coverage leaves out the opacity folded in from the object the type is drawn in + // (a translucent brushed path), which the pixels already take. + let folded = std::mem::replace(&mut r.alpha, 1.0); let covered = draw(r, frame, &inked, &ink); + r.alpha = folded; for (p, c) in pm.data_mut().iter_mut().zip(covered.data()) { *p = edge_pixel(*p, c.a, t.anti_alias); } diff --git a/crates/render/src/tests/typeedges.rs b/crates/render/src/tests/typeedges.rs index f5aac9c7c..e6230a767 100644 --- a/crates/render/src/tests/typeedges.rs +++ b/crates/render/src/tests/typeedges.rs @@ -642,6 +642,41 @@ fn type_with_a_brush_stroke_is_drawn_as_sharp() { } } +/// Strong and Crisp type in the art of a scatter brush, on an undashed path at 50% opacity, keep +/// that opacity inside the characters: the coverage the method reshapes the edges by is drawn +/// without the path's opacity. +#[test] +fn type_in_a_translucent_scatter_brush_stroke_keeps_its_opacity() { + use vectorcraft_brush::{Brush, BrushKind, Scatter}; + let draw = |m: TypeAntiAlias| { + let mut d = Document::new(f64::from(W), f64::from(H)); + let mut t = TextObject::point(Point::new(0.0, 0.0), "H", CharStyle { size: 40.0, ..Default::default() }); + t.anti_alias = m; + let art = Node::new(vectorcraft_doc::NodeId(0), NodeKind::Text(Box::new(t))); + let mut lib = vectorcraft_brush::library(&d); + lib.push(Brush { name: "Letters".into(), kind: BrushKind::Scatter(Scatter { art, spacing: (200.0, 200.0), ..Default::default() }) }); + vectorcraft_brush::store(&mut d, &lib); + let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0); + if let Some(st) = ap.stroke_mut() { + st.brush = Some("Letters".into()); + } + let line = vectorcraft_geom::shapes::line(Point::new(20.0, 45.0), Point::new(240.0, 45.0)); + let mut n = Node::path(d.alloc_id(), line, ap); + n.opacity = 0.5; + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + alphas(&d, &RenderOptions::default()) + }; + let most = |a: &[u8]| a.iter().copied().max().unwrap_or(0); + let sharp = draw(TypeAntiAlias::Sharp); + assert!((126..=129).contains(&most(&sharp)), "half opaque: {}", most(&sharp)); + for m in [TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + let drawn = draw(m); + assert!((126..=129).contains(&most(&drawn)), "{m:?} keeps the path's opacity: {}", most(&drawn)); + assert_ne!(drawn, sharp, "{m:?} changes the edges"); + } +} + /// 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] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index afc3f30e1..f42b5c3c6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1037,7 +1037,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Character panel menu's Show Font Height Options shows a Set Font Height Reference dropdown under Font Size (`text.setFormat` or `text.setRangeStyle` `{fontHeightReference}`). With Em Box, the default, the font size is the size of the em square; with Cap Height, x-Height or ICF Box, the characters are set at the em size at which the font's OS/2 cap height, its x height or the height of its ideographic character face equals the font size. With a font that has no such value, or one outside 0.1 to 2 em, the size measures the em box. SVG, PDF and legacy `.ai` export and the CSS Properties rules write the em size the characters are drawn at, and DXF export writes 0.7 times that em size as each TEXT entity's height (group 40, the height of its capitals). Behavior reference: [Vary font height](https://web.archive.org/web/20251118174722/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/vary-font-height.html). 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, 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. + 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. Scores and hours unchanged. The OpenType panel's Stylistic Alternates and Titling Alternates checkboxes turn `salt` and `titl` on and off for the selected type, and its Figure menu lists Default Figure, Tabular Lining, Proportional Lining, Proportional Oldstyle and Tabular Oldstyle; Proportional Lining sets `pnum` and `lnum` and turns `tnum` and `onum` off, Tabular Lining turns `pnum` off and keeps `lnum`, Proportional Oldstyle turns `lnum` off and keeps `pnum`, and Default Figure and Tabular Oldstyle turn `pnum` and `lnum` off. Behavior reference: [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) and [Use swashes, titling alternates, or stylistic alternates](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/use-swashes-titling-alternates-or-stylistic-alternates.html) (public documentation). Tests: `panels::opentype::tests`. The OpenType panel's Position menu lists Default Position, Superscript, Subscript, Numerator and Denominator, as [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) describes; Superscript, Subscript, Numerator and Denominator set `sups`, `subs`, `numr` and `dnom` for the selected type and turn the other three off, and Default Position turns all four off. The menu shows Mixed when the characters it acts on differ, and an entry chosen then applies to all of them. These two rules are VectorCraft's own, as is setting the feature on type whose font lacks the glyphs, which then draws unchanged. The panel's menus and checkboxes change only their own features on each selected character, so characters keep their other OpenType features (`text.setStyle` and `text.setRangeStyle` `{featureChanges}`). Tests: `panels::opentype::tests`, `features::tests::numerators_and_denominators_are_kept_and_shaped`. The Glyphs panel's Show menu and panel menu have Alternates for Current Selection, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes: the grid lists the alternate glyphs of the character selected with the Type tool, and a double-click draws that character with the glyph as one undo step (`text.glyphAlternates`, `text.setGlyphAlternate`). The alternates come from the font's `GSUB` single and alternate substitutions of `aalt`, then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99` and a few other alternate features, each glyph once. The text keeps the character, native files keep the choice with the face it was made in, and type set in another font or version draws the default glyph. The list uses the font of the character's text even when the panel browses another font, and the panel shows a note when the character has no alternates, its font is missing or no character is selected; these rules are VectorCraft's own. Tests: `panels::glyphs::tests`, `tests::alternates`, `tests::textedit::a_glyph_alternate_replaces_one_character_through_undo_and_reopen`. From 4fbbfd4dbbefff8f8bae4edfb94eb1ead93752e1 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze <david.machakhelidze@gmail.com> Date: Mon, 9 Nov 2026 01:28:23 +0400 Subject: [PATCH 145/160] i18n(ka): update the Georgian catalog --- crates/ui-egui/src/i18n/ka.tsv | 73 ++++++++++++++++++++++++++++------ 1 file changed, 61 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 4b329f368..54f771131 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -74,7 +74,7 @@ CMYK Color CMYK ფერი RGB Color RGB ფერი File Info… ფაილის ინფორმაცია… - Print… დაბეჭდვა… + Print… ბეჭდვა… Edit რედაქტირება Undo დაბრუნება Redo ხელახლა შესრულება @@ -1259,7 +1259,7 @@ lighting Light სინათლე Dashed ტირეებიანი Delete Selected Item მონიშნული ელემენტის წაშლა Double-click to edit the characters' paint ორჯერ დაწკაპეთ სიმბოლოების შეღებვის რედაქტირებისთვის - Click to edit დააწკაპეთ რედაქტირებისთვის + Click to edit დაწკაპეთ დასარედაქტირებლად Double-click to edit ორჯერ დაწკაპეთ რედაქტირებისთვის Double-click to target the contents ორჯერ დაწკაპეთ შიგთავსის ასარჩევად Drag onto art to apply this appearance ჩაავლეთ ნახატზე ამ იერსახის გამოსაყენებლად @@ -3006,7 +3006,7 @@ theme Appearance იერსახე Reverse Order რიგის შებრუნება Show All Layers ყველა შრის ჩვენება Unlock All Layers ყველა შრის განბლოკვა - English: USA English: USA + English: USA ინგლისური: აშშ Move გადატანა Character Style სიმბოლოს სტილი Paragraph Style აბზაცის სტილი @@ -3423,7 +3423,7 @@ effect Points წერტილები Discard Empty Type ცარიელი ტექსტის მოცილება Copyright საავტორო უფლება Other სხვა - Print დაბეჭდვა + Print ბეჭდვა Solid მთლიანი Difference სხვაობა Flatten Transparency გამჭვირვალობის გაბრტყელება @@ -3659,7 +3659,7 @@ font-technology Variable ცვლადი Flip: არეკვლა: Spacing: ინტერვალი: Output გამოტანა - Export… ექსპორტი… + Export… ექსპორტირება… Import… იმპორტი… Load Profile… პროფილის ჩატვირთვა… Embedded ჩაშენებული @@ -4672,7 +4672,7 @@ font-technology Variable ცვლადი Bicubic ბიკუბური Minimum მინიმალური Run Length Run Length - Roman Roman + Roman რომაული No conversion გარდაქმნის გარეშე Convert to destination სამიზნეში გარდაქმნა Convert to destination (preserve numbers) სამიზნეში გარდაქმნა (რიცხვების შენარჩუნებით) @@ -6134,7 +6134,7 @@ anchor Points: წერტილები: Close Path: ტრაექტორიის შეკვრა: Keep Handles სახელურების შენარჩუნება Straight სწორი - Stretch Curve მრუდის გაჭიმვა + Stretch Curve მრუდის გაწელვა @msg direct-select the anchor points to remove პირდაპირ მონიშნეთ მოსაცილებელი საყრდენი წერტილები @msg select segments, or paths, to add points to მონიშნეთ სეგმენტები ან ტრაექტორიები წერტილების დასამატებლად @msg select open paths to close მონიშნეთ შესაკრავი ღია ტრაექტორიები @@ -6279,7 +6279,7 @@ anchor Points: წერტილები: Join Point Text წერტილის ტექსტის შეერთება Remove Breaks წყვეტების წაშლა Split Text at Cursor ტექსტის დაყოფა კურსორთან - Set Alignment, Keep Position სწორების დაყენება მდებარეობის შენარჩუნებით + Set Alignment, Keep Position სწორების დაყენება პოზიციის შენარჩუნებით Reset Text Transform ტექსტის გარდაქმნის ჩამოყრა Replace Missing Fonts… დაკარგული შრიფტების ჩანაცვლება… Replace Missing Fonts დაკარგული შრიფტების ჩანაცვლება @@ -6290,7 +6290,7 @@ anchor Points: წერტილები: Break Text into Words ტექსტის დაყოფა სიტყვებად Break Text into Glyphs ტექსტის დაყოფა გლიფებად Reset Text Transform and Scale ტექსტის გარდაქმნისა და მასშტაბის ჩამოყრა - Set Alignment, Keep Position: სწორების დაყენება მდებარეობის შენარჩუნებით: + Set Alignment, Keep Position: სწორების დაყენება პოზიციის შენარჩუნებით: Break Text into: ტექსტის დაყოფა: Split at Cursor დაყოფა კურსორთან Reset Transform გარდაქმნის ჩამოყრა @@ -6299,7 +6299,7 @@ anchor Points: წერტილები: breakText Lines სტრიქონები breakText Words სიტყვები breakText Glyphs გლიფები -@msg Click in type with a Type tool to split it დასაყოფად დააწკაპუნეთ ტექსტში ტექსტის ხელსაწყოთი +@msg Click in type with a Type tool to split it დასაყოფად დაწკაპეთ ტექსტში ტექსტის ხელსაწყოთი @msg Too many type objects to change at once ერთდროულად შესაცვლელად ძალიან ბევრი ტექსტური ობიექტია @msg Too many pieces to make at once ერთდროულად შესაქმნელად ძალიან ბევრი ნაწილია @msg Select horizontal point or area type whose text fits აირჩიეთ ჰორიზონტალური წერტილის ან არის ტექსტი, რომლის ტექსტიც ეტევა @@ -6399,9 +6399,9 @@ breakText Glyphs გლიფები Fit to Corners and Ends კუთხეებსა და ბოლოებზე მორგება Remove Gaps ღრეჩოების მოცილება @msg that makes too many pieces (at most 100000): make the dashes longer ეს ძალიან ბევრ ნაწილს იძლევა (მაქსიმუმ 100000): გაზარდეთ ტირეების სიგრძე -@msg select paths, or segments, to divide აირჩიეთ დასაყოფი კონტურები ან სეგმენტები +@msg select paths, or segments, to divide აირჩიეთ დასაყოფი ტრაექტორიები ან სეგმენტები # Path Precision Tools add-on: Expand Dashes into Paths. - Expand Dashes into Paths ტირეების კონტურებად გაშლა + Expand Dashes into Paths ტირეების ტრაექტორიებად გაშლა @msg select paths with dashed strokes to expand აირჩიეთ გასაშლელი ტირეებიანი ხაზის კონტურები # Document Tools add-on (Object › Plug-ins › Document Tools): Save a Timestamped Copy. Document Tools დოკუმენტის ხელსაწყოები @@ -6490,6 +6490,7 @@ font-script Japanese იაპონური Japanese Double Postcard იაპონური ორმაგი ღია ბარათი Japanese Envelope #3 იაპონური კონვერტი #3 Japanese Envelope #4 იაპონური კონვერტი #4 +@msg image '{key}' has no pixels and was left out გამოსახულებას '{key}' პიქსელები არ აქვს და გამოტოვდა @msg not an RTF file: it doesn't start with {\\rtf RTF ფაილი არ არის: არ იწყება {\\rtf-ით # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools ტექსტის ხელსაწყოები @@ -6501,6 +6502,54 @@ font-script Japanese იაპონური Bow რკალი Spike პიკი Skip Spaces ჰარების გამოტოვება + +# The strings the report listed as missing for ka. + 1 Layer შრე: 1 + Art and Bright Colors მხატვრული და კაშკაშა ფერები + Automate ავტომატიზაცია + Color Table (4 colors) ფერების ცხრილი (ფერი: 4) + Current Colors (0) მიმდინარე ფერები (0) + Differences from VectorCraft Default: განსხვავებები VectorCraft-ის ნაგულისხმევთან: + Differences from [Default]: განსხვავებები [ნაგულისხმევი]-თან: + Dot Gain 15% Dot Gain 15% + Expand 1 Selected 1 მონიშნული ობიექტის გაშლა + Graphic Styles (4) გრაფიკული სტილები (4) + Gray Gamma 2.2 Gray Gamma 2.2 + Part of ArtCraft. MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono, Noto Sans SC (OFL). Icons: Lucide (ISC) + VectorCraft. ArtCraft-ის ნაწილი. MIT OR Apache-2.0. შრიფტები: Source Sans 3, Inter, JetBrains Mono, Noto Sans SC (OFL). ხატულები: Lucide (ISC) + VectorCraft. + Phone 375×812 ტელეფონი 375×812 + Photo Low Fidelity დაბალი სიზუსტის ფოტო + Print & Proof ბეჭდვა და კორექტურა + Printing & Proofing ბეჭდვა და კორექტურა + RECENT (1) ბოლოდროინდელი (1) + Reset Automation Automation-ის ჩამოყრა + Reset Essentials Classic Essentials Classic-ის ჩამოყრა + Reset Layout Layout-ის ჩამოყრა + Reset Painting Painting-ის ჩამოყრა + Reset Printing and Proofing Printing and Proofing-ის ჩამოყრა + Reset Tracing Tracing-ის ჩამოყრა + Reset Typography Typography-ის ჩამოყრა + Reset Web Web-ის ჩამოყრა + Restricted Colors შეზღუდული ფერები + Right-click an item to move it into this group. ამ ჯგუფში გადასატანად დაწკაპეთ ელემენტზე მარჯვენა ღილაკით. + Scale X მასშტაბი X + Scale X: მასშტაბი X: + Scale Y მასშტაბი Y + Scale Y: მასშტაბი Y: + Selected Styles Only (0) მხოლოდ მონიშნული სტილები (0) + Selected Swatches Only (0) მხოლოდ მონიშნული ფერის ნიმუშები (0) + Spot Colors (0) სპოტ-ფერები (0) + Tablet 1024×768 პლანშეტი 1024×768 + Video 1920×1080 ვიდეო 1920×1080 + Video 3840×2160 ვიდეო 3840×2160 + Web 1280×720 ვები 1280×720 + [High Resolution] [მაღალი გარჩევადობა] + [Low Resolution] [დაბალი გარჩევადობა] + [Medium Resolution] [საშუალო გარჩევადობა] + [PDF/X-1a:2001] [PDF/X-1a:2001] + [PDF/X-3:2002] [PDF/X-3:2002] + [PDF/X-4:2010] [PDF/X-4:2010] + [VectorCraft Default] [VectorCraft-ის ნაგულისხმევი] +@msg give corners of the path: anchors between two straight sides მიუთითეთ ტრაექტორიის კუთხეები: საყრდენი წერტილები ორ სწორ გვერდს შორის Release Type on a Path… ტექსტის ტრაექტორიიდან მოხსნა… Release Type on a Path ტექსტის ტრაექტორიიდან მოხსნა Text and Path ტექსტი და ტრაექტორია From 4851736364a490bdfb1beedda236bd00f038a0e2 Mon Sep 17 00:00:00 2001 From: Adam Belis <adambelis@Adams-MacBook-Pro.local> Date: Sun, 8 Nov 2026 21:24:02 +0100 Subject: [PATCH 146/160] M2.x: blend Align-to-Path smooth on curved spines Spine::at_length computed tangents from flattened polyline chords, which are piecewise constant and jump discretely between chords on curved spines. Steps oriented to the spine with Align to Path stuttered at large step counts. Store the original PathData in Spine and compute the tangent from the original cubic at the true arc-length position via ParamCurveDeriv, instead of flattened chord directions. Adds a regression test walking 200 samples along a curved spine and asserting each tangent step stays under 0.1 radians. --- crates/doc/src/blend.rs | 63 ++++++++++++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 14 deletions(-) diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index bc44b7bf4..850d5cc6d 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -19,7 +19,7 @@ use std::f64::consts::PI; use std::sync::Arc; use vectorcraft_color::{Color, Gradient, GradientPaint, GradientStop, Paint}; -use vectorcraft_geom::kurbo::ParamCurveArclen; +use vectorcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveDeriv}; use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2}; use crate::appearance::{Appearance, AppearanceItem, Dash, StrokeLayer, WidthProfile}; @@ -904,6 +904,7 @@ pub struct Spine { cum: Vec<f64>, anchors: Vec<f64>, closed: bool, + path: PathData, } impl Spine { @@ -943,7 +944,7 @@ impl Spine { if pts.len() < 2 { return None; } - Some(Self { pts, cum, anchors, closed: sp.closed }) + Some(Self { pts, cum, anchors, closed: sp.closed, path: path.clone() }) } pub fn length(&self) -> f64 { *self.cum.last().unwrap_or(&0.0) @@ -1002,20 +1003,31 @@ impl Spine { pub fn at(&self, f: f64) -> (Point, f64) { self.at_length(f.clamp(0.0, 1.0) * self.length()) } - /// Point and tangent angle (radians) at arc length `s` (clamped to the spine). + /// Point and tangent angle (radians) at arc length `s` (clamped to the spine). The point and + /// tangent come from the original curve at that arc length, not from the flattened polyline: a + /// flattened chord's direction is piecewise constant, so steps oriented to a curved spine with + /// `Align to Path` jump between chords instead of following the curve smoothly. pub fn at_length(&self, s: f64) -> (Point, f64) { - let last = self.pts.len().saturating_sub(2); let s = if s.is_finite() { s.clamp(0.0, self.length()) } else { 0.0 }; - let i = match self.cum.binary_search_by(|c| c.total_cmp(&s)) { - Ok(i) => i.min(last), - Err(i) => i.saturating_sub(1).min(last), - }; - let (Some(p0), Some(p1), Some(c0), Some(c1)) = (self.pts.get(i), self.pts.get(i + 1), self.cum.get(i), self.cum.get(i + 1)) else { - return (self.pts.first().copied().unwrap_or_default(), 0.0); - }; - let u = ((s - c0) / (c1 - c0).max(1e-12)).clamp(0.0, 1.0); - let d = *p1 - *p0; - (p0.lerp(*p1, u), d.y.atan2(d.x)) + let Some(sp) = self.path.subpaths.first() else { return (self.pts.first().copied().unwrap_or_default(), 0.0) }; + let n = sp.segment_count(); + let mut start = 0.0; + for i in 0..n { + let c = sp.segment(i); + let len = c.arclen(1e-9); + if s <= start + len || i + 1 == n { + let t = c.inv_arclen((s - start).max(0.0), 1e-9).clamp(1e-9, 1.0 - 1e-9); + let e = c.eval(t); + let tv = c.deriv().eval(t); + return (Point::new(e.x, e.y), tv.y.atan2(tv.x)); + } + start += len; + } + let c = sp.segment(n - 1); + let t = c.inv_arclen(c.arclen(1e-9), 1e-9).clamp(1e-9, 1.0 - 1e-9); + let e = c.eval(t); + let tv = c.deriv().eval(t); + (Point::new(e.x, e.y), tv.y.atan2(tv.x)) } } @@ -1446,6 +1458,29 @@ mod tests { assert_eq!(out[0].geometric_bounds().unwrap().center(), Point::new(5.0, 5.0), "keys stay put"); } + #[test] + fn orientations_follow_a_curved_spine_smoothly() { + // A curved spine: from (0, 0) with handles (+50, +50) / (-50, -50) to (100, 0) with handles + // (+50, -50) / (-50, +50) — the classic up-bow arc. + let spine = PathData::single(SubPath::new( + vec![Anchor::smooth(Point::new(0.0, 0.0), Point::new(50.0, 50.0)), Anchor::smooth(Point::new(100.0, 0.0), Point::new(50.0, -50.0))], + false, + )); + let sp = Spine::new(&spine).unwrap(); + // Many steps along the bow: each step's tangent comes from the curve at its arc length, + // not from a flattened chord — so the orientations must sweep the true angle, smoothly. + let len = sp.length(); + let mut prev: Option<f64> = None; + for i in 1..=200 { + let (pt, ang) = sp.at_length(len * i as f64 / 201.0); + if let Some(p) = prev { + let step = (ang - p).abs().min(2.0 * std::f64::consts::PI - (ang - p).abs()); + assert!(step < 0.1, "tangent step {} is too big at {} ({}, {})", step, i, pt.x, pt.y); + } + prev = Some(ang); + } + } + #[test] fn easing_keeps_the_ends_and_stays_monotone() { use crate::live::{BlendEase, BlendEaseType as K}; From 5589049a47002db4cc86b7a4309181cc827f4a97 Mon Sep 17 00:00:00 2001 From: adambelis <adambelis@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:57:19 +0100 Subject: [PATCH 147/160] M2.1: blend Align-to-Path smooth on curved spines Spine::at_length computed tangents from flattened polyline chords, which are piecewise constant and jump discretely between chords on curved spines. Steps oriented to the spine with Align to Path stuttered at large step counts. Fix: store the original PathData in Spine and compute the tangent from the original cubic at the true arc-length position via ParamCurveDeriv, proportionally mapping flattened arc-length s onto each segment's curve arclength using anchor_length values, instead of using piecewise-constant chord directions. Reviewed-by: craft-house-rules Co-authored-by: Craft Bot <bot@storytold.dev> --- crates/doc/src/blend.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 850d5cc6d..0af701405 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -1014,17 +1014,20 @@ impl Spine { let mut start = 0.0; for i in 0..n { let c = sp.segment(i); - let len = c.arclen(1e-9); - if s <= start + len || i + 1 == n { - let t = c.inv_arclen((s - start).max(0.0), 1e-9).clamp(1e-9, 1.0 - 1e-9); + let end = self.anchor_length(i + 1).unwrap_or(self.length()); + if s <= end || i + 1 == n { + let local_s = (s - start).max(0.0) / (end - start).max(1e-12) * c.arclen(1e-9); + let t = c.inv_arclen(local_s, 1e-9).clamp(1e-9, 1.0 - 1e-9); let e = c.eval(t); let tv = c.deriv().eval(t); return (Point::new(e.x, e.y), tv.y.atan2(tv.x)); } - start += len; + start = end; } let c = sp.segment(n - 1); - let t = c.inv_arclen(c.arclen(1e-9), 1e-9).clamp(1e-9, 1.0 - 1e-9); + let end = self.length(); + let local_s = (s - start).max(0.0) / (end - start).max(1e-12) * c.arclen(1e-9); + let t = c.inv_arclen(local_s, 1e-9).clamp(1e-9, 1.0 - 1e-9); let e = c.eval(t); let tv = c.deriv().eval(t); (Point::new(e.x, e.y), tv.y.atan2(tv.x)) From b8d23e280ba5c94cdb56ae1de818d62e0e5e8633 Mon Sep 17 00:00:00 2001 From: adambelis <adambelis@users.noreply.github.com> Date: Sun, 8 Nov 2026 22:11:19 +0100 Subject: [PATCH 148/160] M2.1: blend Align-to-Path smooth on curved spines Spine::at_length computed tangents from flattened polyline chords, which are piecewise constant and jump discretely between chords on curved spines. Steps oriented to the spine with Align to Path stuttered at large step counts. Fix: store the original PathData in Spine and compute the tangent from the original cubic at the true arc-length position via ParamCurveDeriv, proportionally mapping flattened arc-length s onto each segment's curve arclength using anchor_length values, instead of using piecewise-constant chord directions. Added missing docstrings for Spine::new and Spine::length to satisfy docstring coverage requirements. Co-authored-by: Craft Bot <bot@storytold.dev> --- crates/doc/src/blend.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 0af701405..cd54c2401 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -908,6 +908,8 @@ pub struct Spine { } impl Spine { + /// Build a spine from the first subpath of `path`: flatten it to a polyline, record cumulative + /// arc lengths, and the arc length at each anchor. pub fn new(path: &PathData) -> Option<Self> { let sp = path.subpaths.first()?; let mut pts: Vec<Point> = Vec::new(); @@ -946,6 +948,7 @@ impl Spine { } Some(Self { pts, cum, anchors, closed: sp.closed, path: path.clone() }) } + /// The spine's flattened length in arc length. pub fn length(&self) -> f64 { *self.cum.last().unwrap_or(&0.0) } From cb5300415a0c0751080ba362039e528d4b04bbf2 Mon Sep 17 00:00:00 2001 From: adambelis <adambelis@users.noreply.github.com> Date: Sun, 8 Nov 2026 22:20:41 +0100 Subject: [PATCH 149/160] M2.1: blend Align-to-Path smooth on curved spines Spine::at_length computed tangents from flattened polyline chords, which are piecewise constant and jump discretely between chords on curved spines. Steps oriented to the spine with Align to Path stuttered at large step counts. Fix: store the original PathData in Spine and compute the tangent from the original cubic at the true arc-length position via ParamCurveDeriv, proportionally mapping flattened arc-length s onto each segment's curve arclength using anchor_length values, instead of using piecewise-constant chord directions. Updated segment-selection condition in Spine::at_length to skip segments whose flattened end is not greater than start before checking whether s is within the segment. Preserve the existing final-segment fallback so at_length(0.0) selects a nonzero segment and uses its tangent. Added missing docstrings for Spine::new and Spine::length to satisfy docstring coverage requirements. Co-authored-by: Craft Bot <bot@storytold.dev> --- crates/doc/src/blend.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index cd54c2401..b1c7d5760 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -1018,6 +1018,10 @@ impl Spine { for i in 0..n { let c = sp.segment(i); let end = self.anchor_length(i + 1).unwrap_or(self.length()); + if end <= start { + // Skip segments whose flattened end is not greater than start + continue; + } if s <= end || i + 1 == n { let local_s = (s - start).max(0.0) / (end - start).max(1e-12) * c.arclen(1e-9); let t = c.inv_arclen(local_s, 1e-9).clamp(1e-9, 1.0 - 1e-9); From 2709a5191f6d3faca1c7ce89fe7f825bd5b074fd Mon Sep 17 00:00:00 2001 From: adambelis <adambelis@users.noreply.github.com> Date: Sun, 8 Nov 2026 22:41:42 +0100 Subject: [PATCH 150/160] M2.1: blend Align-to-Path smooth on curved spines Spine::at_length computed tangents from flattened polyline chords, which are piecewise constant and jump discretely between chords on curved spines. Steps oriented to the spine with Align to Path stuttered at large step counts. Fix: store the original PathData in Spine and compute the tangent from the original cubic at the true arc-length position via ParamCurveDeriv, proportionally mapping flattened arc-length s onto each segment's curve arclength using anchor_length values, instead of using piecewise-constant chord directions. Updated segment-selection condition in Spine::at_length to skip segments whose flattened end is not greater than start before checking whether s is within the segment. Preserve the existing final-segment fallback so at_length(0.0) selects a nonzero segment and uses its tangent. Added zero-derivative fallback tangent for blend-step sampling to keep Align to Path from passing a zero tangent to atan2. Added missing docstrings for Spine::new and Spine::length to satisfy docstring coverage requirements. Co-authored-by: Craft Bot <bot@storytold.dev> --- crates/doc/src/blend.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index b1c7d5760..852edbe2a 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -1018,15 +1018,19 @@ impl Spine { for i in 0..n { let c = sp.segment(i); let end = self.anchor_length(i + 1).unwrap_or(self.length()); - if end <= start { - // Skip segments whose flattened end is not greater than start - continue; - } if s <= end || i + 1 == n { let local_s = (s - start).max(0.0) / (end - start).max(1e-12) * c.arclen(1e-9); let t = c.inv_arclen(local_s, 1e-9).clamp(1e-9, 1.0 - 1e-9); let e = c.eval(t); let tv = c.deriv().eval(t); + // Handle zero curve derivative: use limiting tangent from nearby parameter + let tv = if tv.x.abs() < 1e-12 && tv.y.abs() < 1e-12 { + // Use tangent at adjacent parameter as fallback + let t2 = t.clamp(0.5, 0.5).max(t.min(1.0 - 1e-3).min(1.0 - 1e-3)).clamp(1e-3, 1.0 - 1e-3); + c.deriv().eval(t2) + } else { + tv + }; return (Point::new(e.x, e.y), tv.y.atan2(tv.x)); } start = end; @@ -1037,6 +1041,14 @@ impl Spine { let t = c.inv_arclen(local_s, 1e-9).clamp(1e-9, 1.0 - 1e-9); let e = c.eval(t); let tv = c.deriv().eval(t); + // Handle zero curve derivative: use limiting tangent from nearby parameter + let tv = if tv.x.abs() < 1e-12 && tv.y.abs() < 1e-12 { + // Use tangent at adjacent parameter as fallback + let t2 = t.clamp(0.5, 0.5).max(t.min(1.0 - 1e-3).min(1.0 - 1e-3)).clamp(1e-3, 1.0 - 1e-3); + c.deriv().eval(t2) + } else { + tv + }; (Point::new(e.x, e.y), tv.y.atan2(tv.x)) } } From 623f7449bcedfe45a42381fc70b63afa6d19d1d6 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sun, 8 Nov 2026 13:45:14 -0800 Subject: [PATCH 151/160] Type: the Escape return target is also cleared when another document or window becomes active Switching documents, or windows of a document, deactivates the Type tool, which clears the tool its double-click came from, so Escape after a later edit goes to the Selection tool. The field's comment and the two checklist rows now say so. --- crates/tools/src/text.rs | 5 +++-- docs/type-parity.md | 2 +- docs/ui-parity.md | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 02ebd1d2b..bd0c229dd 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -162,8 +162,9 @@ pub struct TypeTool { mode: Mode, /// The tool whose double-click on type chose this tool, which Escape and Cmd+Enter return to. /// The canvas sets it; choosing the Type tool another way (the toolbar) leaves it unset, and - /// Escape then goes to the Selection tool. It is kept until another tool is chosen, through - /// clicks that end editing or move it to other text. + /// Escape then goes to the Selection tool. Clicks that end editing or move it to other text + /// keep it. Choosing another tool clears it, and so does deactivating this tool when another + /// document or another window of the document becomes active. prev_tool: Option<String>, /// Text object being edited. editing: Option<NodeId>, diff --git a/docs/type-parity.md b/docs/type-parity.md index 818ea9fd3..d9fe790bd 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -21,7 +21,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Mouse wheel over the closed font-family field | done | #5253: Character, Properties and Control step selected text through the family list using saved font filters (excluding search text), one undo step per notch; the style is matched and the list stops at either end. Escape and leaving the field keep the change. Order, bounds and Undo grouping are VectorCraft's choices; scores unchanged. | | Soft return (forced line break, Shift+Enter) | done | Shift+Enter inserts a soft return (U+2028), breaking the line without starting a new paragraph; paragraph spacing (space before/after) and first-line indents apply only at paragraph ends; continuation lines in lists do not repeat the marker; soft return lines in justified text do not spread (#2754) | | Native font-family labels | partial | With Show Font Names in English off, localized legacy names are used when typographic names contain no native label (#2208); loaded-font and catalog-only regressions. Choosing among multiple native languages is unchanged. | -| Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches back to the tool whose double-click on type chose the Type tool (kept until another tool is chosen, so later edits with the Type tool return there too), or to the Selection tool when the Type tool was chosen another way, such as from the toolbar (#1802), and Cmd+Enter (Ctrl+Enter on Windows and Linux) does the same unless a command is bound to it; `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands; Type › Type Orientation sets every frame of a thread, and a thread's frames share one writing direction; the Type and Vertical Type tools make area type when a closed path's outline is clicked and type on a path when an open one is, with pointers that say which, as the public help describes ([Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html), [Create type on a path](https://web.archive.org/web/20220127090635/https://helpx.adobe.com/illustrator/using/creating-type-path.html)) | +| Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches back to the tool whose double-click on type chose the Type tool (kept until another tool is chosen or another document or window becomes active, so later edits with the Type tool return there too), or to the Selection tool when there is no such tool, as when the Type tool was chosen from the toolbar or after such a switch (#1802), and Cmd+Enter (Ctrl+Enter on Windows and Linux) does the same unless a command is bound to it; `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands; Type › Type Orientation sets every frame of a thread, and a thread's frames share one writing direction; the Type and Vertical Type tools make area type when a closed path's outline is clicked and type on a path when an open one is, with pointers that say which, as the public help describes ([Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html), [Create type on a path](https://web.archive.org/web/20220127090635/https://helpx.adobe.com/illustrator/using/creating-type-path.html)) | | Area Type Options (rows, columns, inset, first baseline, Text Flow by rows or by columns, Auto Size, Shrink Text to Fit) | done | Text Flow fills the cells down each column first (By Columns, the default, as files from before it open) or across each row first (By Rows), in the dialog and `text.areaOptions {flow}` (#4978); the commands that make or restyle type say when it does not fit its frame; MCP `add_text` takes Auto Size and Shrink Text to Fit (#1471) | | Threaded text, Fit Headline, Text Wrap | done | Fit Headline sees wrapping in a frame one line tall (#1444); a thread holds area type and type on a path in any mix, as Illustrator links type in an area or along a path ([help](https://helpx.adobe.com/illustrator/using/manage-text-area.html)) | | Threading from the ports: selected area type and type on a path show an in port and an out port, empty, with an arrow on a side where the frame is threaded, or with a red "+" in the out port when the text doesn't fit; a click on either port loads the text; a click on a closed path, area type or type on a path threads into it, a click on empty canvas makes a frame of the same kind, shape and size there, a drag draws a rectangular frame; the frame placed comes after the clicked one from its out port and before it from its in port (the story then starts there); Escape or another tool cancels; a double-click on a threaded port breaks the thread there | partial | #2572: the Selection tool, `text.thread.add {from, to?, rect?, at?, before?}`, as the reference app's help describes ports and threading ([archived help, Threading text between objects](https://web.archive.org/web/20210119051048/https://helpx.adobe.com/illustrator/using/creating-text.html), [create text threads](https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/create-text-threads-between-objects.html)); area type's in port sits on the left side below the top-left corner, type on a path's just before its start bracket; Show Text Threads draws its lines between the same ports; with more than 1,000 such objects selected no ports are drawn. a double-click on a port where the frame is threaded breaks the thread there, the story flowing into the frames before the break and the frames after it left empty, still threaded with each other when two or more (`text.thread.break`, [archived help](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html)). Remaining: the ports only take clicks with the Selection tool, and the loaded cursor borrows the I-beam until it's drawn | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 186e50edd..5f8aa7dd1 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -289,7 +289,7 @@ tools, never checked side by side) isn't captured row by row. | Artboard tool: the canvas right-click menu has Lock Artboards (Lock Selected, Lock All Except Selected, Lock All) and Unlock Artboards (Unlock Selected, Unlock All) | done | #2531; `artboard.setProps` with `locked` for each artboard named; the lock itself is #2592 | | Document Setup → Edit Artboards applies valid settings, closes the dialog and activates the Artboard tool | done | Pointer-click tests cover successful handoff, invalid settings, OK, Cancel and Undo in `dialogs/document_setup.rs`. | | Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture | done | Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. | -| Escape while typing ends the edit, keeps the type selected and switches back to the tool whose double-click on type chose the Type tool (kept until another tool is chosen, so later edits with the Type tool return there too), or to the Selection tool when the Type tool was chosen another way, such as from the toolbar | done | #1802 (#1686, #1656) | +| Escape while typing ends the edit, keeps the type selected and switches back to the tool whose double-click on type chose the Type tool (kept until another tool is chosen or another document or window becomes active, so later edits with the Type tool return there too), or to the Selection tool when there is no such tool, as when the Type tool was chosen from the toolbar or after such a switch | done | #1802 (#1686, #1656) | | Selection tool: selected area type and type on a path show an in port and an out port (empty, an arrow where threaded, the red "+" of overflow); a click on either loads the text cursor; a click on a closed path, on empty canvas or a drag threads the text there, after the clicked frame from its out port, before it from its in port; Escape or another tool drops it; a double-click on a threaded port breaks the thread there | partial | #2572 (`text.thread.add`, `before` for the in port); the out port sits on the frame's right side above the bottom-right corner handle and the in port on its left side below the top-left one, so the handles can still be grabbed; a double-click on a threaded port breaks the thread (`text.thread.break`, [archived help](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html)); the loaded cursor borrows the I-beam | | Shift-click selects several artboards (Artboard tool, Artboards panel); they move, Alt-copy and delete together | done | `artboard.move`/`artboard.delete` `indices` (#1139); in the panel, Shift- and Ctrl/Cmd-click work with any tool by switching to the Artboard tool (#5698); panel menu, trash and Properties share selected-set deletion; all-selected retains artboard 1 and a single board cannot be deleted, with pointer-event and Undo/Redo coverage | | Artboards panel: drag a row above or below another to reorder the artboards (as Move Up / Move Down do) | done | #4687; a line shows where the row lands, `artboard.reorder` in one undo step, the moved artboard becomes active; public forum answers describe the drag | From 863023d7deaad8e383c366a1c022ad4f43409042 Mon Sep 17 00:00:00 2001 From: bflatastic <bombayapirat@gmail.com> Date: Mon, 9 Nov 2026 04:43:44 +0700 Subject: [PATCH 152/160] Blend: a vanished spine tangent takes the curve's direction nearby; the unreachable copy after the loop goes --- crates/doc/src/blend.rs | 46 ++++++++++++++++++++--------------------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 852edbe2a..d134a873f 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -1022,34 +1022,16 @@ impl Spine { let local_s = (s - start).max(0.0) / (end - start).max(1e-12) * c.arclen(1e-9); let t = c.inv_arclen(local_s, 1e-9).clamp(1e-9, 1.0 - 1e-9); let e = c.eval(t); - let tv = c.deriv().eval(t); - // Handle zero curve derivative: use limiting tangent from nearby parameter - let tv = if tv.x.abs() < 1e-12 && tv.y.abs() < 1e-12 { - // Use tangent at adjacent parameter as fallback - let t2 = t.clamp(0.5, 0.5).max(t.min(1.0 - 1e-3).min(1.0 - 1e-3)).clamp(1e-3, 1.0 - 1e-3); - c.deriv().eval(t2) - } else { - tv - }; + let tv = c.deriv().eval(t).to_vec2(); + // Where a handle sits on its anchor the derivative vanishes there: take the + // direction between points a little before and after instead. + let tv = if tv.hypot2() < 1e-24 { c.eval((t + 1e-3).min(1.0)) - c.eval((t - 1e-3).max(0.0)) } else { tv }; return (Point::new(e.x, e.y), tv.y.atan2(tv.x)); } start = end; } - let c = sp.segment(n - 1); - let end = self.length(); - let local_s = (s - start).max(0.0) / (end - start).max(1e-12) * c.arclen(1e-9); - let t = c.inv_arclen(local_s, 1e-9).clamp(1e-9, 1.0 - 1e-9); - let e = c.eval(t); - let tv = c.deriv().eval(t); - // Handle zero curve derivative: use limiting tangent from nearby parameter - let tv = if tv.x.abs() < 1e-12 && tv.y.abs() < 1e-12 { - // Use tangent at adjacent parameter as fallback - let t2 = t.clamp(0.5, 0.5).max(t.min(1.0 - 1e-3).min(1.0 - 1e-3)).clamp(1e-3, 1.0 - 1e-3); - c.deriv().eval(t2) - } else { - tv - }; - (Point::new(e.x, e.y), tv.y.atan2(tv.x)) + // Only a spine without segments gets here: the loop returns at the last one. + (self.pts.first().copied().unwrap_or_default(), 0.0) } } @@ -1503,6 +1485,22 @@ mod tests { } } + /// Where the spine starts at an anchor whose handle sits on it, the first step still faces along + /// the curve (towards the next handle), not along a direction of zero length. + #[test] + fn a_handle_on_its_anchor_still_gives_the_curve_direction() { + let spine = PathData::single(SubPath::new( + vec![Anchor::corner(Point::new(0.0, 0.0)), Anchor::smooth(Point::new(100.0, 0.0), Point::new(150.0, -50.0))], + false, + )); + let sp = Spine::new(&spine).unwrap(); + let (p, angle) = sp.at_length(0.0); + assert!(p.x.abs() < 1e-6 && p.y.abs() < 1e-6, "{p:?}"); + assert!((angle - std::f64::consts::FRAC_PI_4).abs() < 0.05, "faces the in handle at (50, 50): {angle}"); + let (_, end) = sp.at_length(sp.length()); + assert!(end.is_finite(), "{end}"); + } + #[test] fn easing_keeps_the_ends_and_stays_monotone() { use crate::live::{BlendEase, BlendEaseType as K}; From d58000b5dfbd0bd4dc306db4d78083425aa027f8 Mon Sep 17 00:00:00 2001 From: bflatastic <bombayapirat@gmail.com> Date: Mon, 9 Nov 2026 04:52:48 +0700 Subject: [PATCH 153/160] Objects on Path: the stretched-group test allows the spine's rounding --- crates/doc/src/objects_on_path.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/objects_on_path.rs b/crates/doc/src/objects_on_path.rs index 67c9c0ee8..cace68ce6 100644 --- a/crates/doc/src/objects_on_path.rs +++ b/crates/doc/src/objects_on_path.rs @@ -572,7 +572,8 @@ mod tests { place(&mut g, &symbols); g.transform(Affine::new([2.0, 0.0, 0.0, 1.0, 0.0, 0.0]), false); let rec = g.extras().objects_on_path.as_deref().cloned().unwrap(); - assert_eq!((rec.spacing, rec.start), (Some(40.0), 20.0)); + // The spine measures arc length along the curve, so within rounding of the exact values. + assert!(rec.spacing.is_some_and(|d| (d - 40.0).abs() < 1e-9) && (rec.start - 20.0).abs() < 1e-9, "{rec:?}"); place(&mut g, &symbols); let x = |id| g.children().and_then(|c| c.iter().find(|c| c.id == id).cloned()).unwrap().geometric_bounds().unwrap().center().x; assert!((x(NodeId(2)) - 20.0).abs() < 1e-9 && (x(NodeId(3)) - 60.0).abs() < 1e-9, "{} {}", x(NodeId(2)), x(NodeId(3))); From e3e716c4412371980316dd68a169b315463fce0f Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sun, 8 Nov 2026 14:05:55 -0800 Subject: [PATCH 154/160] Tests: translucent brushed type compares each pixel with full opacity The test for Crisp and Strong type in a scatter brush stroke at 50% opacity now checks that every pixel is half of what the same stroke draws at full opacity. It fails without the coverage fix for Crisp as well as for Strong. --- crates/render/src/tests/typeedges.rs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/crates/render/src/tests/typeedges.rs b/crates/render/src/tests/typeedges.rs index e6230a767..fae0207d0 100644 --- a/crates/render/src/tests/typeedges.rs +++ b/crates/render/src/tests/typeedges.rs @@ -642,13 +642,13 @@ fn type_with_a_brush_stroke_is_drawn_as_sharp() { } } -/// Strong and Crisp type in the art of a scatter brush, on an undashed path at 50% opacity, keep -/// that opacity inside the characters: the coverage the method reshapes the edges by is drawn -/// without the path's opacity. +/// Strong and Crisp type in the art of a scatter brush, on an undashed path at 50% opacity, draw +/// every pixel at half of what the same path draws at full opacity: the coverage the method +/// reshapes the edges by is drawn without the path's opacity. #[test] fn type_in_a_translucent_scatter_brush_stroke_keeps_its_opacity() { use vectorcraft_brush::{Brush, BrushKind, Scatter}; - let draw = |m: TypeAntiAlias| { + let draw = |m: TypeAntiAlias, opacity: f32| { let mut d = Document::new(f64::from(W), f64::from(H)); let mut t = TextObject::point(Point::new(0.0, 0.0), "H", CharStyle { size: 40.0, ..Default::default() }); t.anti_alias = m; @@ -662,18 +662,19 @@ fn type_in_a_translucent_scatter_brush_stroke_keeps_its_opacity() { } let line = vectorcraft_geom::shapes::line(Point::new(20.0, 45.0), Point::new(240.0, 45.0)); let mut n = Node::path(d.alloc_id(), line, ap); - n.opacity = 0.5; + n.opacity = opacity; let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); alphas(&d, &RenderOptions::default()) }; let most = |a: &[u8]| a.iter().copied().max().unwrap_or(0); - let sharp = draw(TypeAntiAlias::Sharp); + let sharp = draw(TypeAntiAlias::Sharp, 0.5); assert!((126..=129).contains(&most(&sharp)), "half opaque: {}", most(&sharp)); for m in [TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { - let drawn = draw(m); - assert!((126..=129).contains(&most(&drawn)), "{m:?} keeps the path's opacity: {}", most(&drawn)); - assert_ne!(drawn, sharp, "{m:?} changes the edges"); + let (half, full) = (draw(m, 0.5), draw(m, 1.0)); + assert_ne!(half, sharp, "{m:?} changes the edges"); + let off = half.iter().zip(&full).filter(|&(&h, &f)| (2 * i32::from(h) - i32::from(f)).abs() > 6).count(); + assert_eq!(off, 0, "{m:?}: pixels not at half of full opacity"); } } From 1940d3e529e049470e8e9faa09ad9f4ede684ae4 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sun, 8 Nov 2026 14:06:24 -0800 Subject: [PATCH 155/160] .ai import: opacity mask art in pattern and brush art takes document ids A pattern's or a brush's art was copied into the document with new ids, but the art of an opacity mask inside it kept the ids of the reader that read the definition. Those ids could repeat ids of objects in the layers. The mask art now takes document ids, as a symbol's art already does. --- crates/eps/src/import/ai/read/directives.rs | 22 ++++++-- crates/eps/src/import/ai/tests.rs | 60 +++++++++++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index 68f981751..ef27e5f74 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -503,7 +503,15 @@ impl<'a> Reader<'a> { let Some(tile) = head.tile.filter(|t| whole && sane(*t) && t.width() > 0.0 && t.height() > 0.0 && !frame.children.is_empty()) else { return; }; - let art: Vec<Arc<Node>> = frame.children.iter().map(|n| Arc::new(self.doc.reid(n))).collect(); + let art: Vec<Arc<Node>> = frame + .children + .iter() + .map(|n| { + let mut kept = self.doc.reid(n); + self.reid_masks(&mut kept, 0); + Arc::new(kept) + }) + .collect(); // The objects of the art linked to graphic styles, by their ids in the document. if !sub.style_links.is_empty() { let mut ids = HashMap::new(); @@ -671,7 +679,7 @@ impl<'a> Reader<'a> { named } - /// Give the art of the opacity masks in `n` (a copy of a symbol's art or of a clipping path with ids of this + /// Give the art of the opacity masks in `n` (a copy of a symbol's, a pattern's or a brush's art, or of a clipping path, with ids of this /// document, [`vectorcraft_doc::Document::reid`], which leaves masks as they are) ids of this /// document too. pub(super) fn reid_masks(&mut self, n: &mut Node, depth: usize) { @@ -783,7 +791,15 @@ impl<'a> Reader<'a> { && sub.doc.images.is_empty(); let Some(frame) = sub.frames.pop().filter(|f| whole && !f.children.is_empty()) else { return }; self.brush_art_looks.insert(name.clone(), sub.drawn_looks); - let mut art: Vec<Arc<Node>> = frame.children.iter().map(|n| Arc::new(self.doc.reid(n))).collect(); + let mut art: Vec<Arc<Node>> = frame + .children + .iter() + .map(|n| { + let mut kept = self.doc.reid(n); + self.reid_masks(&mut kept, 0); + Arc::new(kept) + }) + .collect(); let node = match art.len() { 1 => art.pop().map(Arc::unwrap_or_clone), _ => Some(Node::group(self.doc.alloc_id(), art)), diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 07236c2c6..5125adae0 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -5974,6 +5974,37 @@ fn an_object_in_a_brushs_art_with_a_style_this_reads_comes_in_as_itself() { assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); } +/// The art of an opacity mask in a brush's art takes ids of the document, as the rest of the art +/// does: no object of the brush's art shares an id with an object of the layers. +#[test] +fn a_brushs_opacity_mask_art_takes_ids_of_the_document() { + fn ids(n: &Node, out: &mut Vec<vectorcraft_doc::NodeId>) { + out.push(n.id); + if let Some(m) = n.extras().mask.as_ref() { + ids(&m.art, out); + } + for c in n.children().into_iter().flatten() { + ids(c, out); + } + } + let square = rect_path(0.0, 0.0, 10.0, 10.0); + let mask_art: String = format!("1 g\n{square}f\n").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 section = format!("%AI8_BeginBrushPattern\n(Dot art)\n0 A\n{RED}{square}f\n{mask}%AI8_EndBrushPattern\n"); + let shapes: String = (0..12).map(|i| format!("{RED}{}f\n", rect_path(f64::from(i) * 20.0, 0.0, f64::from(i) * 20.0 + 10.0, 10.0))).collect(); + let r = read_ok(&format!("{section}{}{}", plugin_object(SCATTER, "Spray", SPRAY), layer(&shapes))); + let lib = vectorcraft_brush::library(&r.doc); + let spray = lib.iter().find(|b| b.name == "Spray").unwrap_or_else(|| panic!("{:?}", r.warnings)); + let art = spray.art(); + assert!(art.iter().any(|n| n.extras().mask.is_some()), "the mask is read: {art:#?}"); + let mut all = Vec::new(); + for n in r.doc.layers.iter().map(|l| &**l).chain(art) { + ids(n, &mut all); + } + let unique: std::collections::HashSet<_> = all.iter().copied().collect(); + assert_eq!(unique.len(), all.len(), "{all:?}"); +} + /// An art styles section inside a symbol's, a pattern's or a brush's art is skipped: the file's own /// art styles still style the layers' objects read after the definition. #[test] @@ -6196,6 +6227,35 @@ fn an_object_in_a_patterns_art_with_a_listed_style_is_linked() { assert_eq!(square.extras().graphic_style, Some(1), "{square:#?}"); } +/// The art of an opacity mask in a pattern's art takes ids of the document, as the rest of the art +/// does: no object of the pattern's art shares an id with an object of the layers. +#[test] +fn a_patterns_opacity_mask_art_takes_ids_of_the_document() { + fn ids(n: &Node, out: &mut Vec<vectorcraft_doc::NodeId>) { + out.push(n.id); + if let Some(m) = n.extras().mask.as_ref() { + ids(&m.art, out); + } + for c in n.children().into_iter().flatten() { + ids(c, out); + } + } + let square = rect_path(0.0, 0.0, 10.0, 10.0); + let mask_art: String = format!("1 g\n{square}f\n").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 pattern = format!("%AI3_BeginPattern: (P)\n(P) 0 0 10 10\n0 A\n0 Xw\n{RED}{square}f\n{mask}E\n%AI3_EndPattern\n"); + let shapes: String = (0..12).map(|i| format!("{RED}{}f\n", rect_path(f64::from(i) * 20.0, 0.0, f64::from(i) * 20.0 + 10.0, 10.0))).collect(); + let r = read_ok(&format!("{pattern}{}", layer(&shapes))); + let def = r.doc.pattern("P").unwrap_or_else(|| panic!("{:?}", r.warnings)); + assert!(def.art.iter().any(|n| n.extras().mask.is_some()), "the mask is read: {:#?}", def.art); + let mut all = Vec::new(); + for n in r.doc.layers.iter().chain(&def.art) { + ids(n, &mut all); + } + let unique: std::collections::HashSet<_> = all.iter().copied().collect(); + assert_eq!(unique.len(), all.len(), "{all:?}"); +} + #[test] fn an_object_in_a_version_7_patterns_tile_with_a_listed_style_is_linked() { let defs = styles_and_list(&[fill_style("Blue", "0 0 0 0 0 0 1 Xa\n")], &["Blue"]); From 8e45313501b377f9c3be7e92bb6af29d92f186e3 Mon Sep 17 00:00:00 2001 From: bflatastic <bombayapirat@gmail.com> Date: Mon, 9 Nov 2026 05:06:28 +0700 Subject: [PATCH 156/160] Blend: the spine's ends are its exact end points --- crates/doc/src/blend.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index d134a873f..d25be2c41 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -1020,7 +1020,7 @@ impl Spine { let end = self.anchor_length(i + 1).unwrap_or(self.length()); if s <= end || i + 1 == n { let local_s = (s - start).max(0.0) / (end - start).max(1e-12) * c.arclen(1e-9); - let t = c.inv_arclen(local_s, 1e-9).clamp(1e-9, 1.0 - 1e-9); + let t = c.inv_arclen(local_s, 1e-9).clamp(0.0, 1.0); let e = c.eval(t); let tv = c.deriv().eval(t).to_vec2(); // Where a handle sits on its anchor the derivative vanishes there: take the From 5c17cfd038fa550b4e637fc863c530f250787c7f Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sun, 8 Nov 2026 14:20:47 -0800 Subject: [PATCH 157/160] Tests: warped placed gradients check each warp's bounds and movement The Warp gradient test now compares the baked bounds with the square's outline mapped through warp_point, for every style and parameter set. It requires the gradient to move exactly when those bounds move, and pins 39 of the 45 cases as moving the bounds. --- crates/effects/src/bake/tests.rs | 42 ++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index e95fc29b7..4819727f9 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -250,26 +250,58 @@ fn baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds() { "squeeze", "twist", ]; + // The square's warped bounds, from `warp_point` on 401 points along each side: the square's + // corners are (-1, -1) and (1, 1) in the warp's box coordinates, and a vertical warp swaps + // the axes. + let warped_bounds = |style: &str, p: &serde_json::Value| { + let Some(style) = crate::WarpStyle::from_id(style) else { panic!("warp.{style}: a style") }; + let share = |key: &str, default: f64| p.get(key).and_then(serde_json::Value::as_f64).unwrap_or(default) / 100.0; + let vertical = p.get("orientation").and_then(serde_json::Value::as_str) == Some("vertical"); + let (bend, dh, dv) = (share("bend", 50.0), share("horizontal", 0.0), share("vertical", 0.0)); + let mut r: Option<Rect> = None; + for k in 0..=400 { + let s = f64::from(k) / 200.0 - 1.0; + for (x, y) in [(s, -1.0), (s, 1.0), (-1.0, s), (1.0, s)] { + let (x2, y2) = if vertical { + let (a, b) = crate::warp_point(style, bend, dh, dv, 1.0, y, x); + (b, a) + } else { + crate::warp_point(style, bend, dh, dv, 1.0, x, y) + }; + let q = Point::new(50.0 + x2 * 50.0, 50.0 + y2 * 50.0); + r = Some(r.map_or(Rect::from_points(q, q), |r| r.union_pt(q))); + } + } + r.unwrap_or(square) + }; + let near = + |a: Rect, b: Rect, tol: f64| (a.x0 - b.x0).abs() < tol && (a.y0 - b.y0).abs() < tol && (a.x1 - b.x1).abs() < tol && (a.y1 - b.y1).abs() < tol; let mut moved = 0; for style in styles { for params in [json!({"bend": 30.0}), json!({"bend": -40.0, "orientation": "vertical"}), json!({"bend": 20.0, "horizontal": 30.0, "vertical": -20.0})] { + // A warp that bends the square's sides inward keeps its bounds (within 0.5 pt), and the + // gradient stays with them. + let expected = warped_bounds(style, ¶ms); + let moves = !near(expected, square, 0.5); + moved += usize::from(moves); for linear in [across, down] { let what = format!("warp.{style} {params} {linear:?}"); let (b, g) = baked(&placed(linear), fx(&format!("warp.{style}"), params.clone())); + assert!(near(b, expected, 0.1), "{what}: bounds {b:?}, want {expected:?}"); + let geom = g.geom.unwrap(); + let shifted = geom.start.distance(linear.start).max(geom.end.distance(linear.end)); + assert_eq!(shifted > 0.1, moves, "{what}: the gradient moves {shifted} with bounds {b:?}"); let want = GradientGeom { start: follow(linear.start, b), end: follow(linear.end, b), ..linear }; - assert_geom_near(g.geom.unwrap(), want, &what); - if b != square { - moved += 1; - } + assert_geom_near(geom, want, &what); } let what = format!("warp.{style} {params}"); // A fitted gradient stays fitted, and fits the warped outline. assert_eq!(baked(&fitted, fx(&format!("warp.{style}"), params)).1.geom, None, "{what}"); } } - assert!(moved >= 80, "most of the warps change the bounds: {moved}"); + assert_eq!(moved, 39, "the warps that move the bounds, of 45"); // With no bend and no distortion the gradient stays where it is. let (_, g) = baked(&placed(across), fx("warp.arc", json!({"bend": 0.0}))); assert_geom_near(g.geom.unwrap(), across, "no bend"); From 9a312e8adae1210b07a080e003ec94951cebda0d Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sun, 8 Nov 2026 14:23:43 -0800 Subject: [PATCH 158/160] Type: type in a composite font is measured in its own document's definition - A file read for opening is laid out in the composite fonts registered then. Opening a document and reverting the front one now measure again the type set in a font named like a composite font that an open document defines, the font database holds or the session registered earlier. Type in other fonts, missing ones included, keeps the bounds it was read with. - Switching to a document or closing the front one measures again the type set in a composite font whose definition changed, and any type without bounds. - A document reverted while in the back measures its type in composite fonts when it comes to the front, not in the front document's. - Text holding an inline graphic whose symbol's type was measured again takes the graphic's new size. - A failed or panicked command puts the font database's composite fonts back with the documents. - Setting a composite font to the definition an export lends keeps it after the export ends. --- crates/engine/src/cmd/compositefonts.rs | 27 +++- crates/engine/src/cmd/compositefonts/tests.rs | 129 ++++++++++++++++ crates/engine/src/lib.rs | 3 + crates/engine/src/session/documents.rs | 142 +++++++++++++----- crates/engine/src/session/execute.rs | 3 + crates/text/src/fontdb/composite.rs | 18 ++- crates/text/src/tests/composite.rs | 7 + 7 files changed, 286 insertions(+), 43 deletions(-) diff --git a/crates/engine/src/cmd/compositefonts.rs b/crates/engine/src/cmd/compositefonts.rs index 2b356ab85..887e90515 100644 --- a/crates/engine/src/cmd/compositefonts.rs +++ b/crates/engine/src/cmd/compositefonts.rs @@ -71,24 +71,39 @@ pub fn specs() -> Vec<CommandSpec> { /// other open documents (or an earlier active document) defined. Run after each command and when /// another document takes the front, so an undo, a revert, an opened or closed file or a document /// switch draws type in the active document's own composite fonts, and type naming one it does -/// not define shows as missing. Whether a composite font was added, changed or removed. -pub(crate) fn sync(s: &mut Session) -> bool { +/// not define shows as missing. The lowercased names of the composite fonts added, changed or +/// removed. +pub(crate) fn sync(s: &mut Session) -> Vec<String> { let db = FontDb::global(); let mut names = Vec::new(); - let mut changed = false; + let mut changed = Vec::new(); for f in s.active().map(|d| d.doc.composite_fonts.to_vec()).unwrap_or_default() { - changed |= db.set_composite_font(&f); - names.push(f.name.to_lowercase()); + let name = f.name.to_lowercase(); + if db.set_composite_font(&f) { + changed.push(name.clone()); + } + names.push(name); } let others = s.docs.iter().flat_map(|d| d.doc.composite_fonts.iter().map(|f| f.name.to_lowercase())); let stale: Vec<String> = s.composite_names.iter().cloned().chain(others).filter(|n| !names.contains(n)).collect(); for gone in stale { - changed |= db.remove_composite_font(&gone); + if db.remove_composite_font(&gone) { + changed.push(gone); + } } + s.composite_names_registered.extend(names.iter().chain(&changed).cloned()); s.composite_names = names; changed } +/// The lowercased names of the composite fonts the open documents define, the font database holds +/// and the session has registered before: the names type may have been laid out in. +pub(crate) fn names_in_use(s: &Session) -> std::collections::HashSet<String> { + let defined = s.docs.iter().flat_map(|d| d.doc.composite_fonts.iter().map(|f| f.name.to_lowercase())); + let held = FontDb::global().composite_font_names().into_iter().map(|n| n.to_lowercase()); + defined.chain(held).chain(s.composite_names_registered.iter().cloned()).collect() +} + /// Leave out a file's unusable composite fonts before registering and measuring its type. pub(crate) fn filter_loaded(doc: &mut vectorcraft_doc::Document) { let db = FontDb::global(); diff --git a/crates/engine/src/cmd/compositefonts/tests.rs b/crates/engine/src/cmd/compositefonts/tests.rs index 670a85ee2..ae8bc5f8b 100644 --- a/crates/engine/src/cmd/compositefonts/tests.rs +++ b/crates/engine/src/cmd/compositefonts/tests.rs @@ -395,6 +395,135 @@ fn opened_document_measures_missing_composite_font_as_missing() { assert_eq!(actual.cached_bounds, Some(expected)); } +/// The native file of a document whose composite font `name` sets Roman at full size, with type +/// "Ab12" set in it → (the file, the type's id, its width). +fn full_roman_file(name: &str) -> (Vec<u8>, u64, f64) { + let mut s = session(); + let mut full = font(name); + full["parts"]["roman"]["size"] = json!(100); + s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": name})).unwrap()["id"].as_u64().unwrap(); + let width = text(&s, id).cached_bounds.unwrap().width(); + (vectorcraft_format::save_file(&s.doc().unwrap().doc), id, width) +} + +/// Type in a missing font that no composite font is named after keeps the bounds it was measured +/// with when its file was read: opening the document does not lay it out again. +#[test] +fn opening_leaves_type_in_an_unrelated_missing_font_as_measured() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Unrelated")]})).unwrap(); + let id = + s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab", "size": 20, "font": "No Such Family CF"})).unwrap()["id"].as_u64().unwrap(); + let mut doc = (*s.doc().unwrap().doc).clone(); + let kept = vectorcraft_geom::Rect::new(1.0, 2.0, 3.0, 4.0); + if let NodeKind::Text(t) = &mut doc.node_mut(NodeId(id)).unwrap().kind { + t.cached_bounds = Some(kept); + } + s.add_document(doc, None); + assert_eq!(text(&s, id).cached_bounds, Some(kept)); +} + +/// Text holding an inline graphic whose symbol's art is type in a composite font is measured with +/// the graphic at the size the opened document's own definition gives it. +#[test] +fn opening_measures_inline_graphics_of_composite_type_in_its_own_definition() { + let mut s = session(); + let mut full = font("CF Inline"); + full["parts"]["roman"]["size"] = json!(100); + s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap(); + let art = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Inline"})).unwrap()["id"].as_u64().unwrap(); + s.execute("symbol.new", &json!({"name": "CF Inline Mark", "ids": [art]})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 150, "text": "x", "size": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.insertInline", &json!({"id": id, "at": 0, "symbol": "CF Inline Mark"})).unwrap(); + let width = text(&s, id).cached_bounds.unwrap().width(); + let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc); + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Inline")]})).unwrap(); + let file = crate::cmd::fileio::read_for_open("inline.vectorcraft", &bytes, None, &json!({}), false, false).unwrap(); + crate::cmd::fileio::open_read(&mut s, file).unwrap(); + assert!((text(&s, id).cached_bounds.unwrap().width() - width).abs() < 1e-6); +} + +/// Type read for opening while a composite font of its name was registered is measured again when +/// it opens, even after the document that defined the font has closed in between. +#[test] +fn opening_measures_type_read_in_a_composite_font_closed_since() { + let mut s = session(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Gone"})).unwrap()["id"].as_u64().unwrap(); + let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Gone")]})).unwrap(); + let file = crate::cmd::fileio::read_for_open("gone.vectorcraft", &bytes, None, &json!({}), false, false).unwrap(); + assert!(s.close_document(1)); + crate::cmd::fileio::open_read(&mut s, file).unwrap(); + let actual = text(&s, id); + let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds; + assert_eq!(actual.cached_bounds, Some(expected)); +} + +/// A file read for opening lays out its type in the composite fonts registered then (the front +/// document's, which defines the name with Roman at half size); once the file opens, its type is +/// measured again in its own. +#[test] +fn an_opened_file_measures_its_type_in_its_own_composite_fonts() { + let (bytes, id, width) = full_roman_file("CF Opened"); + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Opened")]})).unwrap(); + let file = crate::cmd::fileio::read_for_open("opened.vectorcraft", &bytes, None, &json!({}), false, false).unwrap(); + crate::cmd::fileio::open_read(&mut s, file).unwrap(); + let actual = text(&s, id); + assert!((actual.cached_bounds.unwrap().width() - width).abs() < 1e-6); + let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds; + assert_eq!(actual.cached_bounds, Some(expected)); +} + +/// A document replaced while in the back (File › Revert) is measured in its own composite fonts +/// when it comes to the front, not in the front document's. +#[test] +fn a_document_replaced_in_the_back_is_measured_in_its_own_composite_fonts() { + let (bytes, id, width) = full_roman_file("CF Replaced"); + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Replaced")]})).unwrap(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Replaced")]})).unwrap(); + assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap())); + assert!(s.set_active(0)); + let actual = text(&s, id); + assert!((actual.cached_bounds.unwrap().width() - width).abs() < 1e-6); + let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds; + assert_eq!(actual.cached_bounds, Some(expected)); +} + +/// So is one that comes to the front because the front document closes. +#[test] +fn a_document_replaced_in_the_back_is_measured_when_the_front_one_closes() { + let (bytes, id, width) = full_roman_file("CF Closed"); + let mut s = session(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Closed")]})).unwrap(); + assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap())); + assert!(s.close_document(1)); + let actual = text(&s, id); + assert!((actual.cached_bounds.unwrap().width() - width).abs() < 1e-6); + let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds; + assert_eq!(actual.cached_bounds, Some(expected)); +} + +/// A failed batch takes back the composite fonts it set, in the font database too. +#[test] +fn a_failed_batch_takes_back_its_composite_fonts() { + let db = vectorcraft_text::FontDb::global(); + let mut s = session(); + let steps = json!([ + {"command": "type.compositeFonts.set", "params": {"fonts": [font("CF Failed Batch")]}}, + {"command": "no.such.command"}, + ]); + assert!(s.execute("command.batch", &json!({"commands": steps})).is_err()); + assert!(names(&s).is_empty()); + assert!(db.composite_font("CF Failed Batch").is_none()); +} + /// A loaded composite font cannot replace an installed family used by existing type. #[test] fn loaded_composite_font_cannot_take_an_installed_family_name() { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 27972ef20..03082e7f5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -175,6 +175,9 @@ pub struct Session { /// The composite fonts (lowercase names) that the open documents defined when the font /// database was last brought in step with them ([`cmd::compositefonts::sync`]). pub(crate) composite_names: Vec<String>, + /// Every composite font (lowercase name) this session has registered in the font database: + /// type read for opening may have been laid out in one that is gone by the time it opens. + pub(crate) composite_names_registered: std::collections::HashSet<String>, /// While a command that changes nothing in the document runs (a query, or one of /// [`command_sets::JOURNALED_READS`]): it reads the instance of an object selected in a symbol /// instance's art. diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index b0619c992..20fd05355 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -1,3 +1,7 @@ +use std::collections::HashSet; + +use vectorcraft_doc::{Node, TextObject}; + use super::{ Arc, Clipboard, Color, CommandInfo, DocState, Document, DrawMode, EngineError, GradientPaint, Journal, NodeKind, PaintDefaults, Prefs, Result, Session, ViewInfo, cmd, command_specs, file_access, @@ -84,6 +88,7 @@ impl Session { batch_steps: None, batch_paint_docs: Vec::new(), composite_names: Vec::new(), + composite_names_registered: HashSet::new(), reading: false, place_hand_off: None, queue_hand_off: None, @@ -182,10 +187,8 @@ impl Session { self.reset_tool_for_doc_switch(); } self.active = Some(index); - // Type is drawn in the composite fonts of the document now in front, and measured again - // when they differ from those of the document before. - if switched && cmd::compositefonts::sync(self) { - self.refresh_document_text_bounds(index); + if switched { + self.sync_front_composite_fonts(); } true } else { @@ -193,6 +196,16 @@ impl Session { } } + /// Register the composite fonts of the document now in front ([`cmd::compositefonts::sync`]), + /// after a switch or a close brought it there. Its type set in a composite font that differs + /// from the one of that name before (added, changed or removed) is measured again, and type not + /// measured yet (in a document replaced while in the back) is measured now. + fn sync_front_composite_fonts(&mut self) { + let changed: HashSet<String> = cmd::compositefonts::sync(self).into_iter().collect(); + let Some(index) = self.active else { return }; + self.refresh_document_text_bounds(index, |t| t.cached_bounds.is_none() || sets_in_any(t, &changed)); + } + /// The name [`Session::next_untitled`] gives next (New Document's Name field). pub fn peek_untitled(&self) -> String { format!("Untitled-{}", self.untitled_counter + 1) @@ -211,19 +224,44 @@ impl Session { pub(crate) fn refresh_text_bounds(doc: &mut Document) { // Inline graphics in text take their size from their symbols' art (not saved either). doc.resolve_inline_art(); - let trees = doc.layers.iter_mut().chain(doc.symbols.iter_mut().map(|s| &mut s.art)); - let trees = trees.chain(doc.graph_designs.iter_mut().map(|g| &mut g.art)); - for tree in trees.chain(doc.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) { - refresh_tree_text_bounds(tree); + let mut uncached = |t: &TextObject| t.cached_bounds.is_none(); + for tree in text_trees_mut(doc) { + each_text_where(tree, &mut uncached, &mut cmd::typecmd::refresh_bounds_for_first_frame); } } - /// [`Session::refresh_text_bounds`] for open document `index`, as a change that is not an - /// edit: a saved document stays saved, and its undo history keeps its documents as they are. - fn refresh_document_text_bounds(&mut self, index: usize) { - if let Some(st) = self.docs.get_mut(index) { - st.change_unsaved(|doc| Self::refresh_text_bounds(Arc::make_mut(doc))); + /// Measure the type in open document `index` that `stale` picks (in its trees as + /// [`Session::refresh_text_bounds`] walks them), as a change that is not an edit: a saved + /// document stays saved, and its undo history keeps its documents as they are. The document is + /// left as it is when `stale` picks nothing. + fn refresh_document_text_bounds(&mut self, index: usize, mut stale: impl FnMut(&TextObject) -> bool) { + let Some(st) = self.docs.get_mut(index) else { return }; + if !text_trees(&st.doc).any(|n| has_text_where(n, &mut stale)) { + return; } + st.change_unsaved(|doc| { + let doc = Arc::make_mut(doc); + for tree in text_trees_mut(doc) { + each_text_where(tree, &mut stale, &mut cmd::typecmd::refresh_bounds_for_first_frame); + } + // Inline graphics take their size from their symbols' art, whose type may just have + // been measured again: the text holding one whose size changed is measured again too. + if !doc.resolve_inline_art().is_empty() { + for tree in text_trees_mut(doc) { + each_text_where(tree, &mut |t| t.cached_bounds.is_none(), &mut cmd::typecmd::refresh_bounds_for_first_frame); + } + } + }); + } + + /// Register the composite fonts of open document `index`, which is in front, and measure its + /// type not measured yet and its type set in a font of a name that a composite font of an open + /// document or of the font database has, or had before: that type may have been laid out in + /// another document's composite fonts (those registered while its file was read for opening). + fn sync_opened_composite_fonts(&mut self, index: usize) { + let mut names = cmd::compositefonts::names_in_use(self); + names.extend(cmd::compositefonts::sync(self)); + self.refresh_document_text_bounds(index, |t| t.cached_bounds.is_none() || sets_in_any(t, &names)); } /// Add a document and make it active. Between `edit.undoGroup.begin` and `edit.undoGroup.end`, @@ -234,6 +272,8 @@ impl Session { // (importers and the native reader have seen to theirs already, with a warning). doc.limit_depth(); cmd::compositefonts::filter_loaded(&mut doc); + // Inline graphics in text take their size from their symbols' art (not saved). + doc.resolve_inline_art(); // Guides from files saved before guides had layers go on a layer. doc.adopt_guides(); cmd::dimension::conform(&mut doc); @@ -244,8 +284,7 @@ impl Session { self.docs.push(st); let i = self.docs.len() - 1; self.active = Some(i); - cmd::compositefonts::sync(self); - self.refresh_document_text_bounds(i); + self.sync_opened_composite_fonts(i); if self.depth == 0 && let Some(label) = self.explicit_undo_group.clone() { @@ -262,11 +301,26 @@ impl Session { } doc.limit_depth(); cmd::compositefonts::filter_loaded(&mut doc); + doc.resolve_inline_art(); doc.adopt_guides(); cmd::dimension::conform(&mut doc); + // In the back, type set in a font named like a composite font is measured when the document + // comes to the front and its own composite fonts are registered, not in the front + // document's. + let mut back_names = None; if self.active == Some(index) { // Pending tool work (typing, a drag) belongs to the content being thrown away. self.reset_tool_for_doc_switch(); + } else { + let mut names = cmd::compositefonts::names_in_use(self); + names.extend(doc.composite_fonts.iter().map(|f| f.name.to_lowercase())); + for tree in text_trees_mut(&mut doc) { + each_text_where(tree, &mut |t| t.cached_bounds.is_some() && sets_in_any(t, &names), &mut |t| { + t.cached_bounds = None; + t.cached_baselines.clear(); + }); + } + back_names = Some(names); } let old = &self.docs[index]; let mut st = DocState::new(doc, old.path.clone()); @@ -297,12 +351,10 @@ impl Session { if let Some(stash) = &mut self.batch_stash { stash.push(old); } - // Type in the composite fonts of a document in the back is measured again when it comes to - // the front. - if self.active == Some(index) { - cmd::compositefonts::sync(self); + match back_names { + Some(names) => self.refresh_document_text_bounds(index, |t| t.cached_bounds.is_none() && !sets_in_any(t, &names)), + None => self.sync_opened_composite_fonts(index), } - self.refresh_document_text_bounds(index); true } @@ -319,7 +371,7 @@ impl Session { stash.push(old); } self.active = if self.docs.is_empty() { None } else { Some(index.min(self.docs.len() - 1)) }; - cmd::compositefonts::sync(self); + self.sync_front_composite_fonts(); true } @@ -350,27 +402,49 @@ impl Session { } } -/// Lay out the type in tree `n` (opacity mask art included) whose bounds aren't cached. Only the -/// nodes on the way to such type are copied. -fn refresh_tree_text_bounds(n: &mut Arc<vectorcraft_doc::Node>) { - fn stale(n: &vectorcraft_doc::Node) -> bool { - matches!(&n.kind, NodeKind::Text(t) if t.cached_bounds.is_none()) - || n.extras().mask.as_deref().is_some_and(|m| stale(&m.art)) - || n.children().is_some_and(|ch| ch.iter().any(|c| stale(c))) - } - if !stale(n) { +/// Whether some of type `t` is set in a font named (in any case) one of `names`, lowercased. +fn sets_in_any(t: &TextObject, names: &HashSet<String>) -> bool { + !names.is_empty() && t.runs.iter().any(|r| names.contains(&r.style.font_family.to_lowercase())) +} + +/// The trees of `doc` that hold type: its layers, symbol definitions, graph designs and pattern +/// tiles. +fn text_trees(doc: &Document) -> impl Iterator<Item = &Arc<Node>> { + let trees = doc.layers.iter().chain(doc.symbols.iter().map(|s| &s.art)); + let trees = trees.chain(doc.graph_designs.iter().map(|g| &g.art)); + trees.chain(doc.patterns.iter().flat_map(|p| p.art.iter())) +} + +/// [`text_trees`], to change. +fn text_trees_mut(doc: &mut Document) -> impl Iterator<Item = &mut Arc<Node>> { + let trees = doc.layers.iter_mut().chain(doc.symbols.iter_mut().map(|s| &mut s.art)); + let trees = trees.chain(doc.graph_designs.iter_mut().map(|g| &mut g.art)); + trees.chain(doc.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) +} + +/// Whether tree `n` (opacity mask art included) holds type that `pick` picks. +fn has_text_where(n: &Node, pick: &mut dyn FnMut(&TextObject) -> bool) -> bool { + matches!(&n.kind, NodeKind::Text(t) if pick(t)) + || n.extras().mask.as_deref().is_some_and(|m| has_text_where(&m.art, pick)) + || n.children().is_some_and(|ch| ch.iter().any(|c| has_text_where(c, pick))) +} + +/// Run `act` on the type in tree `n` (opacity mask art included) that `pick` picks. Only the nodes +/// on the way to such type are copied. +fn each_text_where(n: &mut Arc<Node>, pick: &mut dyn FnMut(&TextObject) -> bool, act: &mut dyn FnMut(&mut TextObject)) { + if !has_text_where(n, pick) { return; } let n = Arc::make_mut(n); if let NodeKind::Text(t) = &mut n.kind - && t.cached_bounds.is_none() + && pick(t) { - cmd::typecmd::refresh_bounds_for_first_frame(t); + act(t); } if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) { - refresh_tree_text_bounds(&mut m.art); + each_text_where(&mut m.art, pick, act); } for c in n.children_mut().into_iter().flatten() { - refresh_tree_text_bounds(c); + each_text_where(c, pick, act); } } diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 5ad84885f..e05b9962b 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -131,6 +131,9 @@ impl Session { let r = if self.depth == 0 { self.run_guarded(id, run) } else { self.run_nested(run) }; if r.is_err() && self.depth == 0 { self.batch_paint_docs.clear(); + // A failed or panicked command's documents are put back: type is drawn in their + // composite fonts again, not in those the command set. + cmd::compositefonts::sync(self); } let r = r?; if self.depth == 0 { diff --git a/crates/text/src/fontdb/composite.rs b/crates/text/src/fontdb/composite.rs index faee695ca..0e1d9afa8 100644 --- a/crates/text/src/fontdb/composite.rs +++ b/crates/text/src/fontdb/composite.rs @@ -31,6 +31,13 @@ impl FontDb { { let mut map = self.composites.write().unwrap_or_else(|e| e.into_inner()); if map.get(&key).is_some_and(|f| **f == *font) { + // The same definition a guard lends: it stays when the guard drops. + let mut loans = self.composite_loans.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(loan) = loans.get_mut(&key) + && map.get(&key).is_some_and(|have| Arc::ptr_eq(have, &loan.font)) + { + loan.adopted = true; + } return false; } map.insert(key, Arc::new(font.clone())); @@ -194,7 +201,7 @@ impl FontDb { } let font = Arc::new((*f).clone()); let previous = map.insert(key.clone(), font.clone()); - loans.insert(key.clone(), CompositeLoan { font: font.clone(), previous, guards: 1 }); + loans.insert(key.clone(), CompositeLoan { font: font.clone(), previous, guards: 1, adopted: false }); lent.push((key, font)); changed = true; } @@ -300,6 +307,9 @@ pub(super) struct CompositeLoan { previous: Option<Arc<CompositeFont>>, /// How many guards lend it. guards: usize, + /// Whether [`FontDb::set_composite_font`] set the same definition while it was lent: the last + /// guard to drop then leaves it in place. + adopted: bool, } /// The composite fonts [`FontDb::lend_composite_fonts`] made available, given back when it drops. @@ -327,9 +337,11 @@ impl Drop for LentCompositeFonts<'_> { continue; } let previous = loan.previous.take(); + let adopted = loan.adopted; loans.remove(key); - // Given back unless a font of that name has been set or removed since. - if map.get(key).is_some_and(|have| Arc::ptr_eq(have, font)) { + // Given back unless a font of that name has been set (the same definition included) + // or removed since. + if !adopted && map.get(key).is_some_and(|have| Arc::ptr_eq(have, font)) { match previous { Some(p) => map.insert(key.clone(), p), None => map.remove(key), diff --git a/crates/text/src/tests/composite.rs b/crates/text/src/tests/composite.rs index 370fa4557..729afa0d5 100644 --- a/crates/text/src/tests/composite.rs +++ b/crates/text/src/tests/composite.rs @@ -129,6 +129,13 @@ fn lent_composite_fonts_last_as_long_as_a_guard() { db.set_composite_font(&replaced); drop(lent); assert_eq!(db.composite_font("Lent").unwrap().parts.roman.size, 60.0); + // So does the same definition set while lent, after every guard lending it drops. + assert!(db.remove_composite_font("Lent")); + let (one, two) = (db.lend_composite_fonts(&fonts[..1]), db.lend_composite_fonts(&fonts[..1])); + assert!(!db.set_composite_font(&fonts[0])); + drop(one); + drop(two); + assert!(db.composite_font("Lent").is_some()); // A guard whose font was removed doesn't give back a later guard's loan of that name. assert!(db.remove_composite_font("Lent")); let old = db.lend_composite_fonts(&fonts[..1]); From ff409bcb90d0fdf30a9833c6c1ec9ec815b5615a Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sun, 8 Nov 2026 15:16:45 -0800 Subject: [PATCH 159/160] Tests: the filter plug-in shortcut test accepts the macOS chord text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit each_filter_plugin_takes_a_shortcut_of_its_own required the exported row to end in "+Alt+Shift+F7". Export Text writes each shortcut as the dialog shows it, which is "⌥⇧⌘F7" on macOS, so the test failed there. The test now compares the row with menus::pretty_shortcut, as export_text_lists_the_dialogs_rows_with_their_shortcuts does. --- crates/ui-egui/src/shortcuts/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index cdbdeeeba..d02576fd1 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -2566,7 +2566,7 @@ fn each_filter_plugin_takes_a_shortcut_of_its_own() { let lines: Vec<&str> = text.lines().filter(|l| l.starts_with("Object › Plug-ins › Same Name")).collect(); assert_eq!(lines.len(), 2, "{text}"); assert_eq!(lines[0], "Object › Plug-ins › Same Name\t"); - assert!(lines[1].starts_with("Object › Plug-ins › Same Name…\t") && lines[1].ends_with("+Alt+Shift+F7"), "{}", lines[1]); + assert_eq!(lines[1], format!("Object › Plug-ins › Same Name…\t{}", crate::menus::pretty_shortcut(chord))); // A chord an Effect menu item holds is a conflict. crate::keymap::set_override(&mut app.ui.shortcut_overrides, "effect:distort.transform", "Cmd+Alt+Shift+F8"); let conflicts = crate::shortcut_editor::conflicts_with(&app.ui.shortcut_overrides, "Cmd+Alt+Shift+F8", &key_one); From 4fadb4456672eaaaad85c6bf679a99ac8ea3499a Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sun, 8 Nov 2026 15:33:33 -0800 Subject: [PATCH 160/160] Type: a refused composite font edit leaves no new font in the font database The session now lists the new fonts' names before the edit, so sync removes a name the document does not define when the edit is refused. --- crates/engine/src/cmd/compositefonts.rs | 3 +++ crates/engine/src/cmd/compositefonts/tests.rs | 15 +++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/crates/engine/src/cmd/compositefonts.rs b/crates/engine/src/cmd/compositefonts.rs index 887e90515..3925612a1 100644 --- a/crates/engine/src/cmd/compositefonts.rs +++ b/crates/engine/src/cmd/compositefonts.rs @@ -187,6 +187,9 @@ fn replace(s: &mut Session, label: &str, after: Vec<CompositeFont>) -> Result<() } let changed: Vec<String> = before.iter().filter(|b| !after.contains(b)).chain(after.iter().filter(|a| !before.contains(a))).map(|f| f.name.to_lowercase()).collect(); + // A refused or panicked edit leaves the document without the new fonts: `sync` forgets a name + // listed here that the document doesn't define. + s.composite_names.extend(after.iter().map(|f| f.name.to_lowercase())); let db = FontDb::global(); for f in &before { if !after.iter().any(|a| CompositeFont::same_name(&a.name, &f.name)) { diff --git a/crates/engine/src/cmd/compositefonts/tests.rs b/crates/engine/src/cmd/compositefonts/tests.rs index ae8bc5f8b..f51f066df 100644 --- a/crates/engine/src/cmd/compositefonts/tests.rs +++ b/crates/engine/src/cmd/compositefonts/tests.rs @@ -524,6 +524,21 @@ fn a_failed_batch_takes_back_its_composite_fonts() { assert!(db.composite_font("CF Failed Batch").is_none()); } +/// A composite font whose edit is refused, because it makes selected type too wide for the canvas, +/// is not left in the font database. +#[test] +fn a_refused_edit_takes_back_its_new_composite_font() { + let db = vectorcraft_text::FontDb::global(); + let mut s = session(); + let x = crate::MAX_COORD - 1000.0; + s.execute("text.create", &json!({"x": x, "y": 50, "text": "MMMMMMMM", "size": 100, "font": "CF Refused Edit"})).unwrap(); + let wide = json!({"name": "CF Refused Edit", "parts": {"kanji": {"font": "Source Sans 3", "size": 400}}}); + let e = s.execute("type.compositeFonts.set", &json!({"fonts": [wide]})).unwrap_err().to_string(); + assert!(e.contains("exceed the canvas"), "{e}"); + assert!(names(&s).is_empty()); + assert!(db.composite_font("CF Refused Edit").is_none()); +} + /// A loaded composite font cannot replace an installed family used by existing type. #[test] fn loaded_composite_font_cannot_take_an_installed_family_name() {