From c6e29fa22780d339c0c9e1da405e89bd420a0831 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 17:40:14 -0800 Subject: [PATCH 01/20] PDF export: PDF/X-1a, X-3 and 1.3 keep the art under the hidden part of cropped art Flattening for PDF/X-1a, PDF/X-3, PDF 1.3 and EPS rasterized a picture, gradient, pattern, mesh or raster effect over its whole frame when a clipping mask cropped it. The hidden part came out as opaque white and covered the art under it, on the same page and on the next one. - Flattening rasterizes clipped art only where its clipping path shows it, nested clip groups included. - Art in a clipping layer is flattened as the layer's clipping path shows it. A flattened group that also holds art from outside the layer takes the place of that art, so the layer's clipping path no longer cuts it. Documents in which no clip group hides flattened art, and no clipping layer holds it, export the same bytes as before. --- crates/engine/src/cmd/fileio/tests_eps.rs | 22 ++ crates/engine/src/cmd/fileio/tests_pdfx.rs | 315 +++++++++++++++++++++ crates/engine/src/cmd/flatten.rs | 4 +- crates/engine/src/cmd/flatten/apply.rs | 97 ++++++- crates/engine/src/cmd/flatten/commands.rs | 2 +- crates/engine/src/cmd/flatten/compose.rs | 26 +- docs/mcp.md | 2 + docs/target-app-parity.md | 1 + 8 files changed, 459 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/fileio/tests_eps.rs b/crates/engine/src/cmd/fileio/tests_eps.rs index af918d33ee..8e2c1d429a 100644 --- a/crates/engine/src/cmd/fileio/tests_eps.rs +++ b/crates/engine/src/cmd/fileio/tests_eps.rs @@ -170,6 +170,28 @@ fn transparency_is_flattened_with_the_flattener_preset() { assert!(e.contains("preset"), "{e}"); } +/// A picture cropped by a clipping path, in a clip group at 80%, over a square that only the +/// hidden part of the picture covers: the file prints the square, and its image is as large as +/// the crop, with the default preset (a rectangle of image) and with High Resolution (an image +/// clipped to its regions). +#[test] +fn flattening_cropped_art_keeps_the_art_under_its_hidden_part() { + use super::tests_pdfx::{Art, CROP, cropped, drawn, lost, shown_image_sizes}; + let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + for preset in ["medium", "high"] { + let (mut s, _) = cropped("rgb", 400.0, Art::Image { see_through: false }); + s.execute("transparency.set", &json!({"opacity": 80})).unwrap(); + let canvas = drawn(&s.doc().unwrap().doc); + let opts = json!({"artboards": [0], "flattenerPreset": preset, "previewFormat": "none", "thumbnails": false}); + let r = s.execute("document.exportEps", &opts).unwrap(); + // What the file prints, not the document it carries. + let back = vectorcraft_eps::import_with(&b64(&r), false).unwrap().document; + assert_eq!(lost(&canvas, &drawn(&back)), 0, "{preset}: art went missing"); + let sizes = shown_image_sizes(&back); + assert!(!sizes.is_empty() && sizes.iter().all(|(w, h)| *w <= CROP[2] + 1.0 && *h <= CROP[3] + 1.0), "{preset}: the images show {sizes:?}"); + } +} + #[test] fn overprints_are_kept_or_discarded() { let mut s = session(100.0, 100.0, 1); diff --git a/crates/engine/src/cmd/fileio/tests_pdfx.rs b/crates/engine/src/cmd/fileio/tests_pdfx.rs index abd1bbe4a3..4063e090d6 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfx.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfx.rs @@ -186,3 +186,318 @@ fn pdfx_1a_flattening_keeps_the_inks_behind_an_image_in_a_cmyk_document() { let ink = image_inks_at(&back, vectorcraft_geom::Point::new(88.0, 50.0)).expect("a CMYK image behind the see-through pixels"); assert!(ink.iter().zip(teal).all(|(a, b)| (a - b).abs() <= 1.5 / 255.0), "{ink:?} is not {teal:?}"); } + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +/// The art a clip group crops in [`cropped`]. +#[derive(Clone, Copy)] +pub(super) enum Art { + /// A 240 × 160 image, with see-through pixels or without. + Image { see_through: bool }, + /// A rectangle with a gradient fill. + Gradient, + /// A rectangle with a drop shadow. + Shadow, +} + +/// Where [`cropped`] puts the cropped art, [x, y, width, height]. +const FRAME: [f64; 4] = [40.0, 40.0, 240.0, 160.0]; +/// The part of [`FRAME`] the clipping path of [`cropped`] shows. +pub(super) const CROP: [f64; 4] = [50.0, 50.0, 80.0, 60.0]; + +/// A `mode` document whose first artboard is `width` wide, with a dark square and, over it, `art` +/// filling [`FRAME`] in a clip group that shows [`CROP`] of it. The square lies inside the frame +/// and outside the crop: only the hidden part of the art covers it. → the session with the clip +/// group selected, and the group's id. +pub(super) fn cropped(mode: &str, width: f64, art: Art) -> (Session, u64) { + cropped_by(mode, width, art, false) +} + +/// [`cropped`], with the art and its clipping path in a clipping layer above the square's layer +/// when `layer`. → the session and the clip group's or the layer's id. +fn cropped_by(mode: &str, width: f64, art: Art, layer: bool) -> (Session, u64) { + let [x, y, w, h] = FRAME; + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": width, "height": 300, "colorMode": mode})); + run(&mut s, "shape.rectangle", json!({"x": 180, "y": 90, "width": 60, "height": 60})); + run(&mut s, "paint.setFill", json!({"color": "#203040"})); + run(&mut s, "paint.setStroke", json!({"none": true})); + let layer = layer.then(|| run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap()); + let art = match art { + Art::Image { see_through } => { + let mut png = vec![]; + image::RgbaImage::from_fn(240, 160, |x, y| { + image::Rgba([200, (x % 256) as u8, (y % 256) as u8, if see_through && x < 60 { 128 } else { 255 }]) + }) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let placed = + run(&mut s, "file.place", json!({"name": "photo.png", "dataBase64": vectorcraft_format::base64_encode(&png), "rect": FRAME})); + placed["ids"][0].clone() + } + Art::Gradient | Art::Shadow => { + let id = run(&mut s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].clone(); + run(&mut s, "paint.setStroke", json!({"none": true})); + if matches!(art, Art::Gradient) { + let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]); + run(&mut s, "paint.setFill", json!({"gradient": {"kind": "linear", "stops": stops}})); + } else { + run(&mut s, "paint.setFill", json!({"color": "#c04020"})); + run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [id]})); + } + id + } + }; + let [x, y, w, h] = CROP; + let clip = run(&mut s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].clone(); + if let Some(layer) = layer { + run(&mut s, "layer.clippingMask.toggle", json!({"id": layer})); + run(&mut s, "select.set", json!({"ids": [art]})); + return (s, layer); + } + run(&mut s, "select.set", json!({"ids": [art, clip]})); + let group = run(&mut s, "object.clippingMask.make", json!({}))["id"].as_u64().unwrap(); + (s, group) +} + +/// `document.exportPdf` with `p`, read back. +fn read_back(s: &mut Session, p: &Value) -> vectorcraft_doc::Document { + let v = run(s, "document.exportPdf", p.clone()); + vectorcraft_pdf::import(&vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()).unwrap() +} + +/// `doc`'s artboards drawn on white at 72 ppi. +pub(super) fn drawn(doc: &vectorcraft_doc::Document) -> vectorcraft_render::Rendered { + let pages = doc.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b)).unwrap(); + vectorcraft_render::Renderer::new().render_region(doc, pages, 1.0, true) +} + +/// How many pixels show art in `reference` and are blank in `variant`. +pub(super) fn lost(reference: &vectorcraft_render::Rendered, variant: &vectorcraft_render::Rendered) -> usize { + assert_eq!((reference.width, reference.height), (variant.width, variant.height)); + let (reference, variant) = (reference.pixels.as_chunks::<4>().0, variant.pixels.as_chunks::<4>().0); + reference.iter().zip(variant).filter(|(r, v)| r[..3].iter().any(|c| *c < 200) && v[..3].iter().all(|c| *c >= 250)).count() +} + +/// The width and height of what each of `doc`'s images shows: its frame inside the clipping +/// paths around it. +pub(super) fn shown_image_sizes(doc: &vectorcraft_doc::Document) -> Vec<(f64, f64)> { + use vectorcraft_geom::Rect; + fn visit(n: &vectorcraft_doc::Node, clip: Option, out: &mut Vec<(f64, f64)>) { + let within = |r: Rect| clip.map_or(r, |c| c.intersect(r)); + if let vectorcraft_doc::NodeKind::Image(im) = &n.kind { + let frame = im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)); + let shown = within(frame); + out.push((shown.width(), shown.height())); + } + let clip = if n.clips() { n.visual_bounds().map(within) } else { clip }; + for c in n.children().into_iter().flatten() { + visit(c, clip, out); + } + } + let mut out = vec![]; + for l in &doc.layers { + visit(l, None, &mut out); + } + out +} + +/// Checks that the PDF/X-1a, PDF/X-3 and PDF 1.3 files of `s` (with `extra` options) show all the +/// art the file with transparency shows, and that no image in them shows more than [`CROP`]. Adds +/// what is wrong to `wrong`. +fn check_flat_files(s: &mut Session, extra: &Value, what: &str, wrong: &mut Vec) { + let reference = drawn(&read_back(s, extra)); + for flat in [json!({"standard": "pdfX1a"}), json!({"standard": "pdfX3"}), json!({"compatibility": "1.3", "preserveEditing": false})] { + let mut p = flat.clone(); + super::pdf::merge(&mut p, extra); + let back = read_back(s, &p); + let lost = lost(&reference, &drawn(&back)); + if lost > 0 { + wrong.push(format!("{what}, {flat}: {lost} pixels of art went missing")); + } + let sizes = shown_image_sizes(&back); + // An image reaches a pixel past its regions, and a group rasterized whole a point past + // its visible bounds. + if sizes.is_empty() || sizes.iter().any(|(w, h)| *w > CROP[2] + 3.0 || *h > CROP[3] + 3.0) { + wrong.push(format!("{what}, {flat}: the images show {sizes:?}")); + } + } +} + +/// Art cropped by a clipping path, in a PDF file without transparency: the part of the art the +/// clipping path hides is not rasterized, so art that lies under the hidden part alone still +/// shows. Each case is one way the cropped art comes to be flattened. +#[test] +fn flattening_cropped_art_for_pdfx_keeps_the_art_under_its_hidden_part() { + let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let opaque = Art::Image { see_through: false }; + type Change = fn(&mut Session, u64); + let cases: [(&str, Art, Change); 9] = [ + ("an image with see-through pixels", Art::Image { see_through: true }, |_, _| {}), + ("a clip group with opacity", opaque, |s, _| { + run(s, "transparency.set", json!({"opacity": 80})); + }), + ("a clip group with a blend mode", opaque, |s, _| { + run(s, "transparency.set", json!({"blendMode": "multiply"})); + }), + ("a see-through object over the crop", opaque, |s, _| { + run(s, "shape.rectangle", json!({"x": 60, "y": 60, "width": 40, "height": 30})); + run(s, "paint.setFill", json!({"color": "#ffffff"})); + run(s, "transparency.set", json!({"opacity": 60})); + }), + ("a gradient", Art::Gradient, |s, _| { + run(s, "transparency.set", json!({"opacity": 80})); + }), + ("a raster effect", Art::Shadow, |_, _| {}), + ("a clip group inside a clip group", Art::Image { see_through: true }, |s, group| { + let outer = run(s, "shape.rectangle", json!({"x": 45, "y": 45, "width": 85, "height": 65}))["id"].clone(); + run(s, "select.set", json!({"ids": [group, outer]})); + run(s, "object.clippingMask.make", json!({})); + }), + ("a clip group inside a group with opacity", opaque, |s, _| { + run(s, "object.group", json!({})); + run(s, "transparency.set", json!({"opacity": 80})); + }), + ("an even-odd compound clipping path", opaque, |s, group| { + run(s, "transparency.set", json!({"opacity": 80})); + let doc = s.doc_mut().unwrap(); + let mut d = (*doc.doc).clone(); + let clip = d.node(vectorcraft_doc::NodeId(group)).unwrap().children().unwrap()[0].id; + let mut ring = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(50.0, 50.0, 130.0, 110.0)); + ring.subpaths.extend(vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(70.0, 65.0, 110.0, 95.0)).subpaths); + if let vectorcraft_doc::NodeKind::Path { path, rule, .. } = &mut d.node_mut(clip).unwrap().kind { + (*path, *rule) = (ring, vectorcraft_geom::FillRule::EvenOdd); + } + doc.doc = d.into(); + }), + ]; + let mut wrong = vec![]; + for mode in ["cmyk", "rgb"] { + for (what, art, change) in cases { + let (mut s, group) = cropped(mode, 400.0, art); + change(&mut s, group); + check_flat_files(&mut s, &json!({}), &format!("{mode}: {what}"), &mut wrong); + } + } + assert!(wrong.is_empty(), "{wrong:#?}"); +} + +/// The same with the Medium and Low Resolution presets, and with a balance of 0, which rasterizes +/// the whole group of the cropped art over the group's visible bounds. +#[test] +fn flattening_cropped_art_for_pdfx_keeps_the_art_under_its_hidden_part_with_each_preset() { + let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut wrong = vec![]; + for extra in [json!({"flattenerPreset": "medium"}), json!({"flattenerPreset": "low"}), json!({"flattener": {"balance": 0}})] { + for (art, name) in [(Art::Image { see_through: true }, "image"), (Art::Gradient, "gradient")] { + let (mut s, _) = cropped("cmyk", 400.0, art); + run(&mut s, "transparency.set", json!({"opacity": 80})); + check_flat_files(&mut s, &extra, &format!("{name}, {extra}"), &mut wrong); + } + } + // A cropped rectangle with a solid fill, rasterized whole. + let (mut s, group) = cropped("cmyk", 400.0, Art::Gradient); + let art = s.doc().unwrap().doc.node(vectorcraft_doc::NodeId(group)).unwrap().children().unwrap()[1].id.0; + run(&mut s, "paint.setFill", json!({"ids": [art], "color": "#c04020"})); + run(&mut s, "transparency.set", json!({"ids": [group], "opacity": 80})); + check_flat_files(&mut s, &json!({"flattener": {"balance": 0}}), "solid fill, balance 0", &mut wrong); + assert!(wrong.is_empty(), "{wrong:#?}"); +} + +/// The hidden part of cropped art on one artboard lies over the next artboard: that page keeps +/// its art. +#[test] +fn flattening_cropped_art_for_pdfx_keeps_the_next_page_under_its_hidden_part() { + let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + // The crop is on the first artboard, and the square on the second. + let (mut s, _) = cropped("cmyk", 150.0, Art::Image { see_through: true }); + run(&mut s, "artboard.new", json!({"x": 170, "y": 0, "width": 230, "height": 300})); + assert_eq!(read_back(&mut s, &json!({"standard": "pdfX1a"})).artboards.len(), 2); + let mut wrong = vec![]; + check_flat_files(&mut s, &json!({}), "two artboards", &mut wrong); + assert!(wrong.is_empty(), "{wrong:#?}"); +} + +/// Art cropped by a clipping layer, over a square in the layer below: the square keeps showing, +/// also with a second square in that layer under both the crop and the hidden part, or with a +/// see-through rectangle over both in a layer above. +#[test] +fn flattening_art_of_a_clipping_layer_for_pdfx_keeps_the_art_under_its_hidden_part() { + let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + type Change = fn(&mut Session, u64); + let cases: [(&str, Change); 3] = [ + ("alone", |_, _| {}), + ("over a square under the crop's edge", |s, _| { + let below = s.doc().unwrap().doc.layers[0].id.0; + run(s, "layer.setCurrent", json!({"id": below})); + run(s, "shape.rectangle", json!({"x": 110, "y": 60, "width": 50, "height": 30})); + run(s, "paint.setFill", json!({"color": "#406020"})); + }), + ("under a see-through rectangle over the crop's edge", |s, _| { + run(s, "layer.new", json!({})); + run(s, "shape.rectangle", json!({"x": 110, "y": 60, "width": 50, "height": 30})); + run(s, "paint.setFill", json!({"color": "#406020"})); + run(s, "transparency.set", json!({"opacity": 60})); + }), + ]; + let mut wrong = vec![]; + for mode in ["cmyk", "rgb"] { + for art in [Art::Image { see_through: true }, Art::Gradient] { + for (what, change) in cases { + let (mut s, layer) = cropped_by(mode, 400.0, art, true); + run(&mut s, "transparency.set", json!({"opacity": 80})); + change(&mut s, layer); + check_flat_files(&mut s, &json!({}), &format!("{mode}: a clipping layer {what}"), &mut wrong); + } + } + } + assert!(wrong.is_empty(), "{wrong:#?}"); +} + +/// Documents in which no clip group hides art export the same bytes as they did when the +/// flattener rasterized hidden art too. +#[test] +fn flat_pdf_files_of_art_that_no_clip_group_hides_are_unchanged() { + let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + use crate::cmd::flatten::with_clips_ignored as before; + // Written at one time, so two files differ only by what they draw. + let bytes = |s: &mut Session, p: &Value| super::pdf::encode_at("document.exportPdf", &s.doc().unwrap().doc, p, Some(1_700_000_000)).unwrap().0; + let files = [ + json!({"standard": "pdfX1a"}), + json!({"standard": "pdfX3"}), + json!({"compatibility": "1.3", "preserveEditing": false}), + json!({"standard": "pdfX1a", "flattener": {"balance": 0}}), + json!({"standard": "pdfX1a", "flattenerPreset": "low"}), + ]; + // Transparency without a clip group. + let mut plain = session(); + // A clip group at 80% whose clipping path is as large as its art, over the edge of a square. + let mut fitted = Session::new(); + run(&mut fitted, "file.new", json!({"width": 400, "height": 300, "colorMode": "cmyk"})); + run(&mut fitted, "shape.rectangle", json!({"x": 180, "y": 90, "width": 160, "height": 160})); + run(&mut fitted, "paint.setFill", json!({"color": "#203040"})); + let mut png = vec![]; + image::RgbaImage::from_fn(240, 160, |x, y| image::Rgba([200, x as u8, y as u8, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let photo = + run(&mut fitted, "file.place", json!({"name": "photo.png", "dataBase64": vectorcraft_format::base64_encode(&png), "rect": FRAME}))["ids"][0] + .clone(); + let [x, y, w, h] = FRAME; + let clip = run(&mut fitted, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].clone(); + run(&mut fitted, "select.set", json!({"ids": [photo, clip]})); + run(&mut fitted, "object.clippingMask.make", json!({})); + run(&mut fitted, "transparency.set", json!({"opacity": 80})); + // Cropped art that is not flattened: nothing on it is see-through. + let (mut opaque, _) = cropped("cmyk", 400.0, Art::Image { see_through: false }); + for (what, s) in [("no clip group", &mut plain), ("a clipping path as large as its art", &mut fitted), ("opaque cropped art", &mut opaque)] { + for p in &files { + assert!(bytes(s, p) == before(|| bytes(s, p)), "{what}, {p}"); + } + } + // Art that a clip group hides is what changes. + let (mut s, _) = cropped("cmyk", 400.0, Art::Image { see_through: true }); + assert!(bytes(&mut s, &files[0]) != before(|| bytes(&mut s, &files[0]))); +} diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index 2a12eb9c5e..f950f8c1da 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -49,9 +49,9 @@ mod presets; mod report; pub(crate) use analysis::{outlined_text, see_through_images}; -#[cfg(test)] -pub(crate) use apply::with_every_region; pub(crate) use apply::{expand_transparent_tile_images, flatten_document, flatten_expanded, has_transparent_tile_images}; +#[cfg(test)] +pub(crate) use apply::{with_clips_ignored, with_every_region}; pub use commands::specs; pub use options::FlattenOptions; pub use presets::{FlattenerPreset, PRESET_EXTS, PRESET_FORMAT}; diff --git a/crates/engine/src/cmd/flatten/apply.rs b/crates/engine/src/cmd/flatten/apply.rs index c70f4dca6c..c9459ed76c 100644 --- a/crates/engine/src/cmd/flatten/apply.rs +++ b/crates/engine/src/cmd/flatten/apply.rs @@ -107,7 +107,9 @@ pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: let art: Vec = baked.layers[0].children().into_iter().flatten().map(|a| (**a).clone()).collect(); let mut sc = Scratch { brushes: vectorcraft_brush::library(&baked), doc: baked.clone() }; let plain: Vec = art.iter().map(|n| sc.plain(n)).collect(); - let reaches: Vec> = plain.iter().map(reach).collect(); + // Each object as its clip groups and clipping layers show it. + let clipped: Vec = plain.iter().zip(roots).map(|(n, id)| clipped_by_ancestors(src, *id, n)).collect(); + let reaches: Vec> = plain.iter().zip(&clipped).map(|(n, c)| reach(n).zip(reach(c)).map(|(a, b)| a.intersect(b))).collect(); let see_through: Vec = plain.iter().map(|n| o.shows_transparency(&baked, n) || o.draws_see_through_image(n)).collect(); let cmyk = src.color_mode == ColorMode::Cmyk; let mut out = Plan { flat: vec![], kept: vec![], transparent: roots.iter().zip(&see_through).filter(|(_, t)| **t).map(|(id, _)| *id).collect() }; @@ -117,7 +119,7 @@ pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: vectorcraft_doc::cancel::check()?; let ids: Vec = g.iter().map(|&i| roots[i]).collect(); let group = g.iter().any(|&i| see_through[i]).then(|| { - let plain: Vec<&Node> = g.iter().map(|&i| &plain[i]).collect(); + let plain: Vec<&Node> = g.iter().map(|&i| &clipped[i]).collect(); flatten_group(&plain, o, cmyk) }); let Some((regions, area)) = group.flatten() else { @@ -126,7 +128,9 @@ pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: }; let raster = match area.as_ref().filter(|_| render) { Some(a) => { - let art: Vec = g.iter().map(|&i| art[i].clone()).collect(); + // Drawn as planned: the image can land outside the clip groups and clipping layers + // around its art. + let art: Vec = g.iter().map(|&i| clipped_by_ancestors(src, roots[i], &art[i])).collect(); Some(render_raster(&baked, &art, a.rect, a.ppi, a.clip.clone(), o)?) } None => None, @@ -140,6 +144,39 @@ pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: Ok(out) } +/// `plain` (object `id` of `src` as plain art) inside a clip group for each clip group and +/// clipping layer around `id`, innermost first, each clipped by that group's or layer's clipping +/// path. `plain` itself when nothing around it clips. +fn clipped_by_ancestors(src: &Document, id: NodeId, plain: &Node) -> Node { + let mut out = plain.clone(); + if !clips_count() { + return out; + } + let ancestry = src.ancestry(id).unwrap_or_default(); + for pair in ancestry.windows(2).rev() { + let Some(above) = src.node(pair[0]).filter(|n| n.clips()) else { continue }; + let Some(clip) = above.children().and_then(|c| c.first()) else { continue }; + // The clipping path itself is not clipped art. + if clip.id == pair[1] { + continue; + } + // Its outline, unpainted: the group or layer around the object still paints the path. + // Nothing to clip by hides the clipped art, as in the compositor. + let Some((outline, rule)) = effects::clip_outline(clip) else { + out.visible = false; + return out; + }; + let mut path = Node::path(clip.id, PathData::from_bezpath(&outline), Appearance::default()); + if let NodeKind::Path { rule: r, .. } = &mut path.kind { + *r = rule; + } + let mut g = Node::group(above.id, vec![Arc::new(path), Arc::new(out)]); + g.set_clips(true); + out = g; + } + out +} + /// The regions and the image area replacing one group of overlapping objects (`plain`: the art as /// composited; `cmyk`: in a CMYK document); `None` when it paints nothing. fn flatten_group(plain: &[&Node], o: &FlattenOptions, cmyk: bool) -> Option<(Regions, Option)> { @@ -163,8 +200,10 @@ fn flatten_group(plain: &[&Node], o: &FlattenOptions, cmyk: bool) -> Option<(Reg let mut complex: Vec = vec![]; let mut gradients_only = !all; for (k, r) in regions.iter().enumerate() { - if all || r.sources.iter().any(|&i| b.kinds[i] != Kind::Vector) { - gradients_only &= r.sources.iter().all(|&i| b.kinds[i] == Kind::Gradient); + // Art outside the clipping path of its clip group draws nothing there. + let shown = |i: &&usize| b.kinds[**i] == Kind::Vector || !(clips_count() && b.hidden(**i, &r.sources)); + if all || r.sources.iter().filter(shown).any(|&i| b.kinds[i] != Kind::Vector) { + gradients_only &= r.sources.iter().filter(shown).all(|&i| b.kinds[i] == Kind::Gradient); complex.push(k); } else if let Some(fill) = region_fill(&elems, &r.sources, o, cmyk) { vector.push((k, fill)); @@ -227,6 +266,35 @@ pub(crate) fn with_every_region(f: impl FnOnce() -> T) -> T { out } +#[cfg(test)] +thread_local! { + /// Groups flattened on this thread rasterize art where its clip group or clipping layer hides + /// it too ([`with_clips_ignored`]). + static CLIPS_IGNORED: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Whether a region outside the clipping path of a clip group or clipping layer leaves out the +/// art it hides there. +#[cfg(test)] +fn clips_count() -> bool { + !CLIPS_IGNORED.with(std::cell::Cell::get) +} + +#[cfg(not(test))] +fn clips_count() -> bool { + true +} + +/// `f` with every group it flattens rasterizing art where its clip group or clipping layer hides +/// it too. +#[cfg(test)] +pub(crate) fn with_clips_ignored(f: impl FnOnce() -> T) -> T { + CLIPS_IGNORED.with(|e| e.set(true)); + let out = f(); + CLIPS_IGNORED.with(|e| e.set(false)); + out +} + /// The flat colour of a region covered by `sources` (CMYK in a `cmyk` document); `None` where /// nothing paints it. fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool) -> Option { @@ -424,7 +492,7 @@ pub(super) fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan let one_pass = separate(d, &plan.flat.iter().flat_map(|f| f.roots.iter().copied()).collect::>()); for f in plan.flat { // A group always has objects. - let Some(&top) = f.roots.last() else { continue }; + let Some(top) = place_of(d, &f.roots) else { continue }; let mut children = vec![]; // The image goes under the regions, reaching a pixel past its own: their edges then fall on // matching pixels instead of on what is behind the group. @@ -494,6 +562,23 @@ pub(super) fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan Ok((ids, vector, rasterized)) } +/// The object of `roots` (in paint order) whose place their flattened group takes: the topmost one +/// that no clip group or clipping layer clips unless it clips every object of `roots`, else the +/// topmost one. +fn place_of(d: &Document, roots: &[NodeId]) -> Option { + let top = roots.last().copied(); + if !clips_count() || roots.len() < 2 { + return top; + } + let clips = |id: NodeId| -> Vec { + let ancestry = d.ancestry(id).unwrap_or_default(); + ancestry.iter().rev().skip(1).filter(|a| d.node(**a).is_some_and(Node::clips)).copied().collect() + }; + let all: Vec> = roots.iter().map(|r| clips(*r)).collect(); + let shared = |mine: &Vec| mine.iter().all(|c| all.iter().all(|theirs| theirs.contains(c))); + roots.iter().zip(&all).rev().find(|(_, mine)| shared(mine)).map(|(r, _)| *r).or(top) +} + /// An image of `r` (in a clip group when it is clipped). fn raster_node(d: &mut Document, r: Raster) -> Node { let key = unique_key(d, "flattened"); diff --git a/crates/engine/src/cmd/flatten/commands.rs b/crates/engine/src/cmd/flatten/commands.rs index a2edde1f0f..d528e40d8b 100644 --- a/crates/engine/src/cmd/flatten/commands.rs +++ b/crates/engine/src/cmd/flatten/commands.rs @@ -10,7 +10,7 @@ pub fn specs() -> Vec { "Flatten Transparency…", ["Object"], None, - "{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach; objects without transparency stay; a frame of threaded text that is outlined or flattened leaves its thread with the text it shows → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", + "{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach (only where a clip group's or clipping layer's clipping path shows them); objects without transparency stay; a frame of threaded text that is outlined or flattened leaves its thread with the text it shows → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", has_doc, flatten ), diff --git a/crates/engine/src/cmd/flatten/compose.rs b/crates/engine/src/cmd/flatten/compose.rs index 0598905348..fef23f86ae 100644 --- a/crates/engine/src/cmd/flatten/compose.rs +++ b/crates/engine/src/cmd/flatten/compose.rs @@ -53,6 +53,11 @@ enum Body { pub(super) struct Builder { pub(super) shapes: Vec, pub(super) kinds: Vec, + /// Per shape, the clipping path of the innermost clip group it is in (a clipping path's own + /// entry is the clip group around its group). + pub(super) clip_of: Vec>, + /// The clipping path of the clip group being built. + pub(super) clip: Option, /// Building a CMYK document's art. pub(super) cmyk: bool, } @@ -72,9 +77,24 @@ impl Builder { path.bounds()?; self.shapes.push(po::Shape::new(path, rule, self.shapes.len() as u64)); self.kinds.push(kind); + self.clip_of.push(self.clip); Some(self.shapes.len() - 1) } + /// Whether a clip group hides shape `shape` in the region `sources` cover (ascending): the + /// region lies outside one of the clipping paths around the shape. + pub(super) fn hidden(&self, shape: usize, sources: &[usize]) -> bool { + let around = |shape: usize| self.clip_of.get(shape).copied().flatten(); + let mut clip = around(shape); + while let Some(c) = clip { + if sources.binary_search(&c).is_err() { + return true; + } + clip = around(c); + } + false + } + pub(super) fn elem(&mut self, n: &Node) -> Option { if !n.visible { return None; @@ -93,7 +113,11 @@ impl Builder { } else { (None, &children[..]) }; - Body::Group { clip, children: rest.iter().filter_map(|c| self.elem(c)).collect() } + let outer = self.clip; + self.clip = clip.or(outer); + let children = rest.iter().filter_map(|c| self.elem(c)).collect(); + self.clip = outer; + Body::Group { clip, children } } NodeKind::Path { guide: true, .. } => return None, NodeKind::Path { .. } | NodeKind::Compound { .. } => { diff --git a/docs/mcp.md b/docs/mcp.md index bc20483938..face503682 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3061,6 +3061,8 @@ one undo step. The targets split into groups of overlapping objects; groups with Each other group becomes one group of atomic regions: paths filled with the flat colour the art showed there (over white, which blend modes do not read; over nothing, keeping alpha, with `preserveAlpha: true`), plus one image where gradients, patterns, images, opacity masks or raster effects reach (clipped to those regions with `clipComplexRegions`, a rectangle without). +The image leaves out the part of such art that the clipping path of a clip group or clipping layer hides, so art under +the hidden part of a cropped picture stays as it is. `preset` is `high`, `medium` (the default) or `low`; option keys (`balance` 0–100, `lineArtPpi`, `gradientPpi`, `textToOutlines`, `strokesToOutlines`, `clipComplexRegions`, `antiAlias`, `preserveAlpha`, `preserveOverprints`) override it, at the top level or in `options`. `balance: 0` rasterizes everything; lower balances rasterize groups diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6bf45e9c82..5e291ded52 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1413,6 +1413,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur In a `.ai` opened from its editing data, Bas Relief in an art style takes its Detail, Smoothness and Light direction from the style's settings block, and an object other than type with it opens as itself with the live effect. A settings block that VectorCraft can't read (malformed, another effect's, a value out of range or a key it doesn't know) keeps the object's drawn look (part of #637; [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646); `crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`). PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); a PDF 1.5 file with such a page is written as PDF 1.6 with PDF 1.5's encryption, PDF/X files and PDF 1.3 and 1.4 files write it in points (in PDF 1.3 and 1.4, a page past 32,767 pt that needs a real number past 32,767 is refused with an error naming it), a page whose short side is 3 pt or longer but would be under 3 units at that `UserUnit` gets a smaller one that keeps the short side at 3 units, and the export's warnings give each such page's size and how it is written (#6382). PDF export warns when a PDF/X file has no bleed on a side where the document's bleed has one, and writes the file as asked. + PDF/X-1a, PDF/X-3 and PDF 1.3 files keep the art under the hidden part of a picture, gradient, pattern, mesh or raster effect that a clipping mask or clipping layer crops: flattening rasterizes only the part of such art that its clipping path shows, on its own page and on the next one (`crates/engine/src/cmd/fileio/tests_pdfx.rs`). Placed (linked) files in a `.ai`'s editing data come in 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 (a file found next to the document, at its path, or in the folder the editing data names relative to the document's, such as `src/`, is read as the document opens, and its link takes that file's size, modification time and hash). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); 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 made taller with a warning in the open result (#1392). Placed (linked) files in a `.ai`'s editing data come in 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 (a file found next to the document or at its path is read as the document opens). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); 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 made taller with a warning in the open result (#1392). A placed PDF or `.ai` file in a `.ai`'s editing data comes in as a placed document instead of a linked image: it shows the page and Crop to box its dictionary names, and the preview the file keeps until its file is found, then the page as vectors. A placed file in opacity-mask art keeps showing its preview (`crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/placed_document.rs`). From ef6d14dc2240931ec02dc508fd027c7a04b62167 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 19:28:17 -0800 Subject: [PATCH 02/20] Dimension tool: both ends of a dimension follow the art it measures A linear dimension clicked from an anchor to a point that is not an anchor (a point on a segment, or a point a Smart Guide gave) followed the art at the anchor end only, so scaling the art turned it into a long slanted dimension. - A point on a segment is stored by its curve parameter on that segment, and stays on it when the segment's anchors or handles are edited. - Any other point is stored against anchors (and, for a path whose anchors lie on one line, handles) of the path under it, or of the path at the other end, and moves, scales, turns and reflects with that path. Beside a path whose anchors and handles all lie on one line, such as a straight line, it keeps its distance along and across the path: a reflection moves it as a half turn does, and a scale that is not uniform moves it as a uniform scale does. - When linked art is reflected, a linear dimension's line goes to the reflected side, except for art whose anchors and handles lie on one line. - Deleting the art, changing its anchor count or whether it is closed, turning an angle's arm into a curve, or making a measured circle or arc no longer circular leaves the dimension where it was, no longer linked, instead of removing it. Undo links it again. Docs: docs/dimensions.md, docs/target-app-parity.md. --- crates/doc/src/dimension.rs | 314 ++++++++++++++++++- crates/engine/src/cmd/dimension.rs | 35 ++- crates/engine/src/cmd/expand.rs | 2 +- crates/engine/tests/it/dimensions.rs | 430 ++++++++++++++++++++++++++- crates/tools/src/dimension.rs | 92 +++++- 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/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.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 + docs/dimensions.md | 21 +- docs/target-app-parity.md | 1 + 34 files changed, 896 insertions(+), 26 deletions(-) diff --git a/crates/doc/src/dimension.rs b/crates/doc/src/dimension.rs index 301e1beb8e..89df899743 100644 --- a/crates/doc/src/dimension.rs +++ b/crates/doc/src/dimension.rs @@ -1,7 +1,7 @@ //! Persistent dimension recipes. Children are ordinary paths and type for all renderers/exporters. use crate::{Document, NodeId, Unit}; use serde::{Deserialize, Serialize}; -use vectorcraft_geom::{Affine, Point, Vec2}; +use vectorcraft_geom::{Affine, Anchor, PathData, Point, Vec2}; /// What a dimension measures. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -306,6 +306,12 @@ pub enum DimensionSource { a: Option, b: Option, vertex: Option, + /// The first point when it is not an anchor, held against a path's anchors. + #[serde(default, rename = "freeA", skip_serializing_if = "Option::is_none")] + free_a: Option, + /// The second point when it is not an anchor. + #[serde(default, rename = "freeB", skip_serializing_if = "Option::is_none")] + free_b: Option, }, } @@ -315,7 +321,11 @@ impl DimensionSource { Self::Segment { edge } | Self::Arc { edge } => vec![edge.node], Self::Angle { first, second } => vec![first.node, second.node], Self::Circle { node } => vec![*node], - Self::Points { a, b, vertex } => [a, b, vertex].into_iter().filter_map(|p| p.as_ref().map(|r| r.node)).collect(), + Self::Points { a, b, vertex, free_a, free_b } => [a, b, vertex] + .into_iter() + .filter_map(|p| p.as_ref().map(|r| r.node)) + .chain([free_a, free_b].into_iter().filter_map(|p| p.as_ref().map(FreePoint::node))) + .collect(), } } /// Anchor indexes cannot safely follow a topology replacement. Ordinary moves and handle @@ -332,6 +342,34 @@ impl DimensionSource { old.is_some() && old != counts(after) }) } + /// The map that took the measured art from `before` to `after`, when every path it measures + /// went through one [`art_transform`] that reflects it (a negative determinant). None when a + /// measured path is unchanged, or when two of them went through different maps. + pub fn reflection(&self, before: &Document, after: &Document) -> Option { + let mut first: Option = None; + for id in self.nodes() { + let (old, new) = (before.node(id)?, after.node(id)?); + if std::ptr::eq(old, new) || old.path_data() == new.path_data() { + return None; + } + let xf = fitted(before, after, id, |xf| xf.determinant() < 0.0)?; + match first { + None => first = Some(xf), + Some(f) => { + // The linear parts and the translations, each against its own size. + let (a, b) = (f.as_coeffs(), xf.as_coeffs()); + let differ = |range: std::ops::Range, tolerance: f64| { + let size = a[range.clone()].iter().map(|c| c.abs()).fold(1.0, f64::max); + a[range.clone()].iter().zip(&b[range]).any(|(p, q)| (p - q).abs() > tolerance * size) + }; + if differ(0..4, 1e-9) || differ(4..6, 1e-9) { + return None; + } + } + } + } + first + } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -348,6 +386,235 @@ impl PointRef { } } +/// The point of an anchor that an [`AnchorWeight`] names: the anchor, or one of its handles. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AnchorPart { + #[default] + Point, + In, + Out, +} + +impl AnchorPart { + fn of(self, a: &Anchor) -> Point { + match self { + Self::Point => a.p, + Self::In => a.h_in, + Self::Out => a.h_out, + } + } +} + +/// One anchor's share of a [`FreePoint`]. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AnchorWeight { + pub subpath: usize, + pub anchor: usize, + /// The anchor itself, or its in or out handle. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub part: AnchorPart, + pub weight: f64, +} + +/// A measured point that is not an anchor (a point on a segment, or a point a Smart Guide gave), +/// linked to one path so that it moves with it. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum FreePoint { + /// A point on a segment at curve parameter `t`, 0 at the segment's first anchor and 1 at its + /// last. It stays on the segment when the segment's anchors or handles are edited. + OnSegment { edge: SegmentRef, t: f64 }, + /// A weighted sum of two or three anchors or handles of one path, the weights adding up to 1, + /// plus `across` times the vector from the first anchor to the second turned a quarter turn, + /// from (x, y) to (-y, x), so that a vector pointing right in the y-down document space + /// turns to point down (two anchors only). When the + /// path is moved, scaled, rotated, reflected or sheared, a point held by three moves with + /// it. A point held by two and `across`, beside a straight path, moves, turns and scales + /// uniformly with it, and a reflection or a non-uniform scale moves it as a half turn or a + /// uniform scale would. + Anchors { + node: NodeId, + anchors: Vec, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + across: f64, + }, +} + +/// A point of a path: (subpath, anchor, part, place). +type PathPoint = (usize, usize, AnchorPart, Point); + +/// Every anchor of `path` in order, each followed by its in and out handles when `handles` is set. +fn path_points(path: &PathData, handles: bool) -> impl Iterator + Clone + '_ { + let parts: &'static [AnchorPart] = if handles { &[AnchorPart::Point, AnchorPart::In, AnchorPart::Out] } else { &[AnchorPart::Point] }; + path.subpaths + .iter() + .enumerate() + .flat_map(move |(si, sp)| sp.anchors.iter().enumerate().flat_map(move |(ai, a)| parts.iter().map(move |&part| (si, ai, part, part.of(a))))) +} + +/// Three points that span `points`: the first, the one farthest from it, and the one farthest +/// from the line through those two. None when every point is in one place. +fn spanning(points: impl Iterator + Clone) -> Option<[PathPoint; 3]> { + let first = points.clone().next()?; + let o = first.3; + let second = points.clone().max_by(|x, y| x.3.distance(o).total_cmp(&y.3.distance(o)))?; + let u = second.3 - o; + let span = u.hypot(); + if !span.is_finite() || span <= 1e-6 { + return None; + } + let third = points.max_by(|x, y| u.cross(x.3 - o).abs().total_cmp(&u.cross(y.3 - o).abs()))?; + Some([first, second, third]) +} + +/// The map that takes the anchors and handles of path `node` in `before` to where they are in +/// `after`, when one affine map does that for every one of them: the path was moved, scaled, +/// rotated, reflected or sheared as a whole. None when its anchors changed in number, moved in +/// another way, or lie on one line with all their handles, as on a straight line: a reflection +/// across a line through such a path puts its points where a turn can put them too. +pub fn art_transform(before: &Document, after: &Document, node: NodeId) -> Option { + fitted(before, after, node, |_| true) +} + +/// [`art_transform`] when the map passes `wanted`. The map is found from three points, and +/// `wanted` is asked before every other point is checked against it. +fn fitted(before: &Document, after: &Document, node: NodeId, wanted: impl Fn(&Affine) -> bool) -> Option { + let (old, new) = (before.node(node)?.path_data()?, after.node(node)?.path_data()?); + if old.subpaths.len() != new.subpaths.len() || old.subpaths.iter().zip(&new.subpaths).any(|(o, n)| o.anchors.len() != n.anchors.len()) { + return None; + } + let [o, f, g] = spanning(path_points(old, true))?; + let place = |q: PathPoint| new.subpaths.get(q.0).and_then(|sp| sp.anchors.get(q.1)).map(|a| q.2.of(a)); + let (o2, f2, g2) = (place(o)?, place(f)?, place(g)?); + let (u, v) = (f.3 - o.3, g.3 - o.3); + let span = u.hypot(); + if u.cross(v).abs() <= 1e-9 * span * span { + return None; + } + let basis = |u: Vec2, v: Vec2| Affine::new([u.x, u.y, v.x, v.y, 0.0, 0.0]); + let linear = basis(f2 - o2, g2 - o2) * basis(u, v).inverse(); + let xf = Affine::translate(o2.to_vec2()) * linear * Affine::translate(-o.3.to_vec2()); + if !xf.as_coeffs().iter().all(|c| c.is_finite()) || !wanted(&xf) { + return None; + } + let tolerance = 1e-6 * span.max(1.0); + path_points(old, true).zip(path_points(new, true)).all(|(o, n)| (xf * o.3).distance(n.3) <= tolerance).then_some(xf) +} + +impl FreePoint { + /// Largest weight: a point far outside the anchors that hold it is not held. + const MAX_WEIGHT: f64 = 1e4; + + /// The path it is linked to. + pub fn node(&self) -> NodeId { + match self { + Self::OnSegment { edge, .. } => edge.node, + Self::Anchors { node, .. } => *node, + } + } + + /// `p` held against path `node`: three anchors that span the path; when all its anchors lie + /// on one line or in one place, as on an arch, three of its anchors and handles; when its + /// handles lie on that line too, or so nearly that the weights would be too large, two anchors + /// and `p`'s distance across that line. None when none of these holds `p`. + pub fn on(doc: &Document, node: NodeId, p: Point) -> Option { + let path = doc.node(node)?.path_data()?; + let weight = |(subpath, anchor, part, _): PathPoint, weight| AnchorWeight { subpath, anchor, part, weight }; + for handles in [false, true] { + let Some([o, f, g]) = spanning(path_points(path, handles)) else { continue }; + let (u, v) = (f.3 - o.3, g.3 - o.3); + let span = u.hypot(); + let det = u.cross(v); + if det.abs() > 1e-9 * span * span { + let s = (p - o.3).cross(v) / det; + let t = u.cross(p - o.3) / det; + let anchors = vec![weight(o, 1.0 - s - t), weight(f, s), weight(g, t)]; + // A nearly flat set of points gives weights too large to keep: the next fit is tried. + if let Some(held) = (Self::Anchors { node, anchors, across: 0.0 }).holding(doc, p, span) { + return Some(held); + } + } + } + // Every anchor and handle on one line, or nearly: along it from the first anchor, and across it. + let [o, f, _] = spanning(path_points(path, false))?; + let u = f.3 - o.3; + let span = u.hypot(); + let (t, across) = ((p - o.3).dot(u) / (span * span), u.cross(p - o.3) / (span * span)); + Self::Anchors { node, anchors: vec![weight(o, 1.0 - t), weight(f, t)], across }.holding(doc, p, span) + } + + /// `p` on the segment `edge`, at the curve parameter of the segment's point nearest to it. + /// None when the segment is gone or `p` is not on it. + pub fn on_segment(doc: &Document, edge: &SegmentRef, p: Point) -> Option { + use vectorcraft_geom::kurbo::ParamCurveNearest; + let sp = doc.node(edge.node)?.path_data()?.subpaths.get(edge.subpath)?; + if edge.segment >= sp.segment_count() { + return None; + } + let (a, b) = edge.endpoints(doc)?; + let t = sp.segment(edge.segment).nearest(p, 1e-12).t; + Self::OnSegment { edge: edge.clone(), t }.holding(doc, p, a.distance(b)) + } + + /// Itself when it is [`Self::valid`] and puts the point at `p`, within a tolerance that grows + /// with `span`, the size of the art that holds it. + fn holding(self, doc: &Document, p: Point, span: f64) -> Option { + let at = self.position(doc)?; + (at.distance(p) <= 1e-6 * span.max(1.0)).then_some(self) + } + + /// Whether its numbers can be used: a finite `t` from 0 to 1 on a segment; or two or three + /// weights that add up to 1, with `across` set only for two, each finite and at most + /// [`Self::MAX_WEIGHT`] across. + pub fn valid(&self) -> bool { + match self { + Self::OnSegment { t, .. } => (0.0..=1.0).contains(t), + Self::Anchors { anchors, across, .. } => { + (2..=3).contains(&anchors.len()) + && anchors.iter().all(|w| w.weight.is_finite() && w.weight.abs() <= Self::MAX_WEIGHT) + && (anchors.iter().map(|w| w.weight).sum::() - 1.0).abs() <= 1e-9 + && across.is_finite() + && across.abs() <= Self::MAX_WEIGHT + && (*across == 0.0 || anchors.len() == 2) + } + } + } + + /// Where the point is now. None when its segment or an anchor is gone or the point is not + /// [`Self::valid`]. + pub fn position(&self, doc: &Document) -> Option { + use vectorcraft_geom::kurbo::ParamCurve; + if !self.valid() { + return None; + } + let at = match self { + Self::OnSegment { edge, t } => { + let sp = doc.node(edge.node)?.path_data()?.subpaths.get(edge.subpath)?; + if edge.segment >= sp.segment_count() { + return None; + } + sp.segment(edge.segment).eval(*t) + } + Self::Anchors { node, anchors, across } => { + let path = doc.node(*node)?.path_data()?; + let place = |w: &AnchorWeight| path.subpaths.get(w.subpath).and_then(|sp| sp.anchors.get(w.anchor)).map(|a| w.part.of(a).to_vec2()); + let mut at = Vec2::ZERO; + for w in anchors { + at += place(w)? * w.weight; + } + if *across != 0.0 { + let u = place(anchors.get(1)?)? - place(anchors.first()?)?; + at += Vec2::new(-u.y, u.x) * *across; + } + at.to_point() + } + }; + (at.x.is_finite() && at.y.is_finite()).then_some(at) + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Dimension { @@ -441,6 +708,36 @@ impl Dimension { self.offset = offset; } } + /// A linear dimension whose art was reflected, its points now moved from `old`'s: its line goes + /// to the side of the points that `image` (where the reflection put `old`'s line) is on, as far + /// past the outermost point on that side as `old`'s line was. A line that lay between the two + /// points is left as it is. + pub fn mirror_line(&mut self, old: &Dimension, image: Point) { + if self.mode != DimensionMode::Linear || old.mode != DimensionMode::Linear { + return; + } + let gap = { + let n = old.normal(); + let across = |p: Point| p.to_vec2().dot(n); + let line = across(old.a) + old.offset; + let (low, high) = (across(old.a).min(across(old.b)), across(old.a).max(across(old.b))); + if line >= high { + line - high + } else if line <= low { + low - line + } else { + return; + } + }; + let n = self.normal(); + let across = |p: Point| p.to_vec2().dot(n); + let (low, high) = (across(self.a).min(across(self.b)), across(self.a).max(across(self.b))); + let at = if 2.0 * across(image) >= low + high { high + gap } else { low - gap }; + let offset = at - across(self.a); + if offset.is_finite() && offset.abs() <= 1e6 { + self.offset = offset; + } + } /// Whether a linear dimension's line lies strictly between its two points along the normal. fn line_between_points(&self) -> bool { let n = self.normal(); @@ -525,10 +822,15 @@ impl Dimension { } pub fn resolve(&mut self, doc: &Document) -> Result<(), String> { match &self.source { - Some(DimensionSource::Points { a, b, vertex }) => { - for (reference, point) in [(a, &mut self.a), (b, &mut self.b), (vertex, &mut self.vertex)] { - if let Some(r) = reference { - *point = r.position(doc).ok_or("Measured anchor no longer exists")?; + Some(DimensionSource::Points { a, b, vertex, free_a, free_b }) => { + for (reference, free, point) in [(a, free_a, &mut self.a), (b, free_b, &mut self.b), (vertex, &None, &mut self.vertex)] { + match (reference, free) { + // An end is an anchor or a free point, never both. + (Some(_), Some(_)) => return Err("Invalid measured point".into()), + (Some(r), None) => *point = r.position(doc).ok_or("Measured anchor no longer exists")?, + (None, Some(f)) if !f.valid() => return Err("Invalid measured point".into()), + (None, Some(f)) => *point = f.position(doc).ok_or("Measured anchor no longer exists")?, + (None, None) => {} } } self.length = if self.mode == DimensionMode::Linear { self.measured() } else { self.a.distance(self.b) }; diff --git a/crates/engine/src/cmd/dimension.rs b/crates/engine/src/cmd/dimension.rs index 01f1effe3e..fa2137c9d1 100644 --- a/crates/engine/src/cmd/dimension.rs +++ b/crates/engine/src/cmd/dimension.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Create Dimension", [], None, - "{mode?: linear|angular|radius|diameter, a?: [x,y], b?: [x,y], vertex?: [x,y], source?: {kind: segment, edge: {node,subpath,segment}}|{kind: angle, first: edge, second: edge}|{kind: circle, node}|{kind: arc, edge}|{kind: points, a?, b?, vertex?: {node,subpath,anchor}}, orientation?: aligned|horizontal|vertical (linear only: the distance between the points, or only its horizontal or vertical part; default aligned), offset?: pt (the line's distance from the first point: along the left normal of a → b when aligned, downward when horizontal, leftward when vertical; negative goes the other way), options?: {units: in|mm|pt|pc|px|cm|m|ft|yd, precision: 0-6, scale: drawing:real, hideUnits, lineWeight, lineType: solid|dashed|dotted, lineColor: [r,g,b], arrowStyle: filled|open|tick|none, arrowScale: %, extensionWeight, extensionType, extensionColor, extensionOffset, extensionLength, hideExtensions, fontFamily, fontStyle, fontSize, textColor, textPosition: away|between, textAlignment: left|center|right, textOffset}} → {id,label}; linked measurements follow source edits", + "{mode?: linear|angular|radius|diameter, a?: [x,y], b?: [x,y], vertex?: [x,y], source?: {kind: segment, edge: {node,subpath,segment}}|{kind: angle, first: edge, second: edge}|{kind: circle, node}|{kind: arc, edge}|{kind: points, a?, b?, vertex?: {node,subpath,anchor}, freeA?, freeB?: a point that is not an anchor, {kind: onSegment, edge: {node,subpath,segment}, t: 0..1 (curve parameter)}|{kind: anchors, node, anchors: [{subpath,anchor,part?: point|in|out (the anchor, default, or its in or out handle),weight}] (2 or 3 points of one path whose weights add up to 1), across?: (2 anchors only: times the first-to-second anchor vector (x,y) turned to (-y,x), which points down when the vector points right)} (an end is an anchor or a free point, not both)}, orientation?: aligned|horizontal|vertical (linear only: the distance between the points, or only its horizontal or vertical part; default aligned), offset?: pt (the line's distance from the first point: along the left normal of a → b when aligned, downward when horizontal, leftward when vertical; negative goes the other way), options?: {units: in|mm|pt|pc|px|cm|m|ft|yd, precision: 0-6, scale: drawing:real, hideUnits, lineWeight, lineType: solid|dashed|dotted, lineColor: [r,g,b], arrowStyle: filled|open|tick|none, arrowScale: %, extensionWeight, extensionType, extensionColor, extensionOffset, extensionLength, hideExtensions, fontFamily, fontStyle, fontSize, textColor, textPosition: away|between, textAlignment: left|center|right, textOffset}} → {id,label}; linked measurements follow source edits", has_doc, create ), @@ -231,9 +231,10 @@ fn apply_to_all(s: &mut Session, p: &Value) -> Result { fn replace(doc: &mut Document, id: NodeId, dim: Dimension) -> Result<()> { let old: Vec = doc.node(id).and_then(Node::children).map(|ch| ch.iter().map(|n| n.id).collect()).unwrap_or_default(); let children = artwork(doc, &dim, &old)?; + let name = format!("Dimension · {}", dim.label().map_err(error)?); if let Some(n) = doc.node_mut(id) { n.kind = NodeKind::Group { children, clip: false }; - n.name = Some(format!("Dimension · {}", dim.label().map_err(error)?)); + n.name = Some(name); n.extras_mut().dimension = Some(Box::new(dim)); } Ok(()) @@ -272,7 +273,8 @@ impl EndedLink { /// The [`reached`] dimensions become plain groups, whose art does not redraw or follow what it /// measured. A command that replaces art calls this before it changes anything, since replacing -/// the art a dimension measures would remove the dimension, and then [`restore_unchanged`]. +/// the art a dimension measures would otherwise leave the dimension unlinked, and then +/// [`restore_unchanged`]. pub(crate) fn end_links(doc: &mut Document, roots: &[NodeId]) -> Vec { let mut ended = Vec::new(); for id in reached(doc, roots) { @@ -315,18 +317,37 @@ pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result { let mut next = old.clone(); let moved = next.moved.take(); if next.source.as_ref().is_some_and(|s| s.topology_changed(before, doc)) || next.resolve(doc).is_err() { - // A deleted/degenerate source has no valid measurement. Remove its annotation in - // this same undo step instead of keeping a silently wrong number. - doc.remove(id)?; + // The art it measured was deleted, or rebuilt with other anchors, or no longer fits + // (an ellipse that was a circle): it stays where it was, no longer linked, in this + // same undo step, rather than attaching to a different edge. + let mut unlinked = old.clone(); + unlinked.source = None; + unlinked.moved = None; + if let Some(v) = moved { + let mut shifted = unlinked.clone(); + (shifted.a, shifted.b, shifted.vertex) = (shifted.a + v, shifted.b + v, shifted.vertex + v); + if shifted.validate().is_ok() { + unlinked = shifted; + } + } + if replace(doc, id, unlinked).is_err() + && let Some(n) = doc.node_mut(id) + { + // Its recipe cannot be drawn again: its art stays as it is, as a plain group. + n.extras_mut().dimension = None; + } changed = true; continue; } next.keep_gap(&old); + if let Some(xf) = next.source.as_ref().and_then(|s| s.reflection(before, doc)) { + next.mirror_line(&old, xf * old.handle()); + } // Moved on its own (the art it measures stayed put): it keeps its new place. if let Some(v) = moved && (next.a, next.b, next.vertex) == (old.a, old.b, old.vertex) { - next.place(next.handle() + v); + next.place(old.handle() + v); if next.resolve(doc).is_err() { next = old.clone(); next.moved = None; diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 546b5a8258..c5c0fbd65b 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -242,7 +242,7 @@ fn expand_steps(s: &mut Session, p: &Value) -> Result { let to = GradientTo::parse(p)?; let from = s.doc()?.history.undo.len(); let mut e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?); - // Before anything expands: outlining the art a dimension measures would remove the dimension. + // Before anything expands: outlining the art a dimension measures would leave the dimension unlinked. let before = s.doc()?.doc.clone(); let mut ended = vec![]; end_dimension_links(s, &mut ended)?; diff --git a/crates/engine/tests/it/dimensions.rs b/crates/engine/tests/it/dimensions.rs index bcb684b0a5..934108905c 100644 --- a/crates/engine/tests/it/dimensions.rs +++ b/crates/engine/tests/it/dimensions.rs @@ -59,9 +59,11 @@ fn dimension_length_scale_live_edit_undo_and_roundtrip() { run(&mut reopened, "object.scale", json!({"sx":50,"sy":100})); assert_eq!(text(&reopened, id), "10.00 in"); run(&mut reopened, "edit.clear", json!({})); - assert!(reopened.doc().unwrap().doc.node(NodeId(id)).is_none()); + assert_eq!(text(&reopened, id), "10.00 in", "deleting the art leaves the dimension"); + assert!(recipe(&reopened, id).source.is_none(), "no longer linked"); run(&mut reopened, "edit.undo", json!({})); assert_eq!(text(&reopened, id), "10.00 in"); + assert!(recipe(&reopened, id).source.is_some(), "undo links it again"); } /// Documents without dimensions skip the refresh, and lose the flag when the last goes. @@ -567,6 +569,425 @@ fn dimension_anchor_links_and_native_file_survive_source_moves() { assert_eq!(text(&s, id), "100 pt"); } +/// The anchors of path `id`'s first subpath. +fn anchors(s: &Session, id: u64) -> Vec { + let doc = &s.doc().unwrap().doc; + doc.node(NodeId(id)).unwrap().path_data().unwrap().subpaths[0].anchors.iter().map(|a| a.p).collect() +} +fn near(p: vectorcraft_geom::Point, q: vectorcraft_geom::Point) -> bool { + p.distance(q) < 1e-6 +} +/// Clicks with the Dimension tool at each point in turn. +fn clicks(s: &mut Session, points: &[(f64, f64)]) -> u64 { + s.select_tool("dimension", ViewInfo::default()).unwrap(); + for &(x, y) in points { + ev(s, PointerKind::Move, x, y); + ev(s, PointerKind::Down, x, y); + ev(s, PointerKind::Up, x, y); + } + s.doc().unwrap().selection.objects[0].0 +} + +/// A dimension from an anchor to a point on a straight segment follows the art at both ends: the +/// segment end keeps its place along the segment when the art is scaled, moved or reflected. +#[test] +fn a_dimension_to_a_point_on_a_segment_follows_the_art_at_both_ends() { + let mut s = session(); + let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(100.0, 100.0), (200.0, 150.0), (120.0, 90.0)]); + let length = recipe(&s, id).length; + for (command, p) in [ + ("object.scale", json!({"sx":150,"sy":150})), + ("object.move", json!({"dx":40,"dy":-30})), + ("object.rotate", json!({"angle":30})), + ("object.reflect", json!({"axis":"vertical"})), + ] { + run(&mut s, "select.set", json!({"ids":[rect]})); + run(&mut s, command, p); + let (d, at) = (recipe(&s, id), anchors(&s, rect)); + assert!(near(d.a, at[0]), "{command}: {:?} {:?}", d.a, at[0]); + assert!(near(d.b, at[1].midpoint(at[2])), "{command}: {:?} {:?}", d.b, at[1].midpoint(at[2])); + assert!(d.source.is_some(), "{command}: still linked"); + } + assert!((recipe(&s, id).length - 1.5 * length).abs() < 1e-6); +} + +/// A point off the art is held against the anchors of the art at the other end, and moves with it. +#[test] +fn a_dimension_to_a_point_off_the_art_moves_with_the_art() { + let mut s = session(); + let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(200.0, 200.0), (330.0, 260.0), (260.0, 330.0)]); + let before = recipe(&s, id); + run(&mut s, "select.set", json!({"ids":[rect]})); + run(&mut s, "object.move", json!({"dx":40,"dy":-30})); + let d = recipe(&s, id); + let v = vectorcraft_geom::Vec2::new(40.0, -30.0); + assert!(near(d.a, before.a + v) && near(d.b, before.b + v), "{before:?} → {d:?}"); + assert!((d.length - before.length).abs() < 1e-6 && (d.offset - before.offset).abs() < 1e-6); + run(&mut s, "object.scale", json!({"sx":200,"sy":200})); + assert!((recipe(&s, id).length - 2.0 * before.length).abs() < 1e-6); +} + +/// When the art it measures is deleted or gets another number of anchors, a dimension stays where +/// it was, no longer linked: later edits of the art do not move it, and undo links it again. +#[test] +fn a_dimension_whose_art_changes_its_anchors_stays_where_it_was() { + let mut s = session(); + let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(100.0, 100.0), (200.0, 150.0), (120.0, 90.0)]); + let before = recipe(&s, id); + let label = text(&s, id); + run(&mut s, "path.insertAnchor", json!({"id":rect,"segment":3,"t":0.5})); + let d = recipe(&s, id); + assert!(d.source.is_none(), "no longer linked"); + assert_eq!((d.a, d.b, d.offset, text(&s, id)), (before.a, before.b, before.offset, label.clone())); + run(&mut s, "select.set", json!({"ids":[rect]})); + run(&mut s, "object.scale", json!({"sx":150,"sy":150})); + assert_eq!((recipe(&s, id).a, recipe(&s, id).b), (before.a, before.b), "does not follow the art"); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(recipe(&s, id).source, before.source, "undo links it again"); + run(&mut s, "select.set", json!({"ids":[rect]})); + run(&mut s, "edit.clear", json!({})); + assert!(s.doc().unwrap().doc.node(NodeId(rect)).is_none()); + assert_eq!((recipe(&s, id).source, text(&s, id)), (None, label)); +} + +/// A linked dimension whose art is reflected has its line on the reflected side: a line above the +/// top edge is below that edge's points after a flip top to bottom. +#[test] +fn a_linked_dimension_of_reflected_art_has_its_line_on_the_reflected_side() { + use vectorcraft_doc::dimension::Orientation; + for orientation in ["aligned", "horizontal"] { + let mut s = session(); + let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap(); + let at = anchors(&s, rect); + let top = |p: vectorcraft_geom::Point| (p.y - 100.0).abs() < 1e-9; + let (i, j) = (at.iter().position(|p| top(*p)).unwrap(), at.iter().rposition(|p| top(*p)).unwrap()); + let anchor = |anchor: usize| json!({"node":rect,"subpath":0,"anchor":anchor}); + let src = json!({"kind":"points","a":anchor(i),"b":anchor(j)}); + // The line 30 pt above the top edge: the offset is along the left normal of a → b when + // aligned, downward when horizontal. + let offset = if orientation == "aligned" && at[i].x > at[j].x { 30.0 } else { -30.0 }; + let id = run(&mut s, "dimension.create", json!({"source":src,"orientation":orientation,"offset":offset}))["id"].as_u64().unwrap(); + let line_y = |s: &Session| recipe(s, id).line_ends().0.y; + assert!((line_y(&s) - 70.0).abs() < 1e-6, "{orientation}: {}", line_y(&s)); + run(&mut s, "select.set", json!({"ids":[rect]})); + run(&mut s, "object.reflect", json!({"axis":"horizontal"})); + let d = recipe(&s, id); + assert!(d.source.is_some()); + assert!(((d.a.y, d.b.y) == (200.0, 200.0)), "{orientation}: {d:?}"); + assert!((line_y(&s) - 230.0).abs() < 1e-6, "{orientation}: line at {}", line_y(&s)); + let want = if orientation == "aligned" { Orientation::Aligned } else { Orientation::Horizontal }; + assert_eq!(d.orientation, want); + run(&mut s, "edit.undo", json!({})); + assert!((line_y(&s) - 70.0).abs() < 1e-6); + } +} + +/// Reflected across the line through the two points it measures, an arch keeps those points +/// where they were, and the dimension's line goes to the other side with the arch. +#[test] +fn a_dimension_of_art_reflected_across_its_points_changes_sides() { + let mut s = session(); + let arch = json!([{"x":100,"y":200,"out":[100,150]},{"x":200,"y":200,"in":[200,150]}]); + let curve = run(&mut s, "path.create", json!({"anchors":arch,"closed":false}))["id"].as_u64().unwrap(); + let anchor = |anchor: u64| json!({"node":curve,"subpath":0,"anchor":anchor}); + let src = json!({"kind":"points","a":anchor(0),"b":anchor(1)}); + let id = run(&mut s, "dimension.create", json!({"source":src,"offset":-30}))["id"].as_u64().unwrap(); + assert!((recipe(&s, id).line_ends().0.y - 170.0).abs() < 1e-6); + run(&mut s, "select.set", json!({"ids":[curve]})); + run(&mut s, "object.reflect", json!({"axis":"horizontal","origin":[0,200]})); + let d = recipe(&s, id); + assert_eq!((d.a, d.b), (vectorcraft_geom::Point::new(100.0, 200.0), vectorcraft_geom::Point::new(200.0, 200.0))); + assert!((d.line_ends().0.y - 230.0).abs() < 1e-6, "below with the arch: {:?}", d.line_ends()); + // Reflected together with the art, it ends in the same place. + run(&mut s, "edit.undo", json!({})); + run(&mut s, "select.set", json!({"ids":[curve, id]})); + run(&mut s, "object.reflect", json!({"axis":"horizontal","origin":[0,200]})); + assert!((recipe(&s, id).line_ends().0.y - 230.0).abs() < 1e-6, "{:?}", recipe(&s, id).line_ends()); +} + +/// A dimension between two paths that are each reflected in their own way in one edit is not +/// mirrored by the map of one of them. +#[test] +fn a_dimension_between_paths_reflected_each_in_its_own_way_keeps_its_offset() { + let mut s = session(); + let low = json!([{"x":100,"y":200,"out":[100,150]},{"x":200,"y":200,"in":[200,150]}]); + let high = json!([{"x":300,"y":200,"out":[300,100]},{"x":400,"y":200,"in":[400,100]}]); + let first = run(&mut s, "path.create", json!({"anchors":low,"closed":false}))["id"].as_u64().unwrap(); + let second = run(&mut s, "path.create", json!({"anchors":high,"closed":false}))["id"].as_u64().unwrap(); + let src = json!({"kind":"points","a":{"node":first,"subpath":0,"anchor":0},"b":{"node":second,"subpath":0,"anchor":1}}); + let id = run(&mut s, "dimension.create", json!({"source":src,"offset":30}))["id"].as_u64().unwrap(); + run(&mut s, "select.set", json!({"ids":[first, second]})); + run(&mut s, "object.transformEach", json!({"reflectY":true})); + let d = recipe(&s, id); + assert!(d.source.is_some() && (d.a.y - d.b.y).abs() > 1.0, "{d:?}"); + assert!((d.offset - 30.0).abs() < 1e-6, "{d:?}"); +} + +/// A recipe's free points are checked: weights that do not add up to 1, or an end that is both an +/// anchor and a free point, are refused without changing the document. +#[test] +fn dimension_free_points_are_checked() { + let mut s = session(); + let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap(); + let original = serde_json::to_value(&*s.doc().unwrap().doc).unwrap(); + let w = |anchor: u64, weight: f64| json!({"subpath":0,"anchor":anchor,"weight":weight}); + let held = |anchors: Value, across: f64| json!({"kind":"anchors","node":rect,"anchors":anchors,"across":across}); + let on = |segment: u64, t: f64| json!({"kind":"onSegment","edge":{"node":rect,"subpath":0,"segment":segment},"t":t}); + let anchor = json!({"node":rect,"subpath":0,"anchor":0}); + for free in [ + held(json!([w(1, 0.5), w(2, 0.6)]), 0.0), + held(json!([w(1, 1.0)]), 0.0), + held(json!([w(1, 1e300), w(2, -1e300)]), 0.0), + held(json!([w(1, 0.5), w(99, 0.5)]), 0.0), + held(json!([w(1, 0.5), w(2, f64::MAX)]), 0.0), + held(json!([w(1, 0.5), w(2, 0.5)]), 1e300), + held(json!([w(1, 0.5), w(2, 0.25), w(3, 0.25)]), 0.5), + on(1, 1.5), + on(1, -0.1), + on(99, 0.5), + json!({"kind":"elsewhere","node":rect}), + ] { + let p = json!({"source":{"kind":"points","a":anchor,"freeB":free}}); + assert!(s.execute("dimension.create", &p).is_err(), "{p}"); + assert_eq!(serde_json::to_value(&*s.doc().unwrap().doc).unwrap(), original); + } + let both = json!({"source":{"kind":"points","a":anchor,"freeA":held(json!([w(1, 0.5), w(2, 0.5)]), 0.0),"b":anchor}}); + assert!(s.execute("dimension.create", &both).is_err()); + let at = anchors(&s, rect); + for (free, want) in [(held(json!([w(1, 0.5), w(2, 0.5)]), 0.0), at[1].midpoint(at[2])), (on(1, 0.5), at[1].midpoint(at[2]))] { + let ok = json!({"source":{"kind":"points","a":anchor,"freeB":free}}); + let id = run(&mut s, "dimension.create", ok)["id"].as_u64().unwrap(); + assert!(near(recipe(&s, id).b, want), "{:?}", recipe(&s, id).b); + } +} + +/// A point off a straight two-anchor line is held by its distance along and across the line: it +/// moves, turns and scales with the line. +#[test] +fn a_dimension_to_a_point_off_a_line_follows_the_line() { + let mut s = session(); + let line = run(&mut s, "shape.line", json!({"x1":100,"y1":100,"x2":200,"y2":100}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(100.0, 100.0), (150.0, 160.0), (60.0, 130.0)]); + let d = recipe(&s, id); + assert!(d.source.as_ref().is_some_and(|src| src.nodes() == vec![NodeId(line), NodeId(line)]), "{:?}", d.source); + run(&mut s, "dimension.update", json!({"ids":[id],"orientation":"aligned"})); + run(&mut s, "select.set", json!({"ids":[line]})); + run(&mut s, "object.rotate", json!({"angle":90,"origin":[100,100]})); + run(&mut s, "object.scale", json!({"sx":200,"sy":200,"origin":[100,100]})); + run(&mut s, "object.move", json!({"dx":10,"dy":20})); + let at = anchors(&s, line); + let (o, u) = (at[0], at[1] - at[0]); + // 50 along the line and 60 across it before, now in the line's new frame at twice the size. + let want = o + u * 0.5 + vectorcraft_geom::Vec2::new(-u.y, u.x) * 0.6; + let d = recipe(&s, id); + assert!(near(d.a, o) && near(d.b, want), "{d:?} want {want:?}"); + assert!((d.length - 2.0 * (50.0f64.hypot(60.0))).abs() < 1e-6); +} + +/// A point on a straight segment stays on the segment when its handles are edited into a curve. +#[test] +fn a_point_on_a_segment_stays_on_it_when_the_segment_curves() { + let mut s = session(); + let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(100.0, 100.0), (200.0, 150.0), (120.0, 90.0)]); + let at = anchors(&s, rect); + assert!(near(at[1], vectorcraft_geom::Point::new(200.0, 100.0)) && near(at[2], vectorcraft_geom::Point::new(200.0, 200.0)), "{at:?}"); + let a = |p: vectorcraft_geom::Point| json!({"x":p.x,"y":p.y}); + let mut curved = vec![a(at[0]), a(at[1]), a(at[2]), a(at[3])]; + curved[1]["out"] = json!([240, 110]); + curved[2]["in"] = json!([240, 190]); + run(&mut s, "path.setAnchors", json!({"id":rect,"subpaths":[{"anchors":curved,"closed":true}]})); + let d = recipe(&s, id); + assert!(d.source.is_some(), "still linked"); + assert!(near(d.b, vectorcraft_geom::Point::new(230.0, 150.0)), "on the curve: {:?}", d.b); +} + +/// The line of a linked dimension of a curved two-anchor path goes to the reflected side when the +/// path is reflected: its handles tell a reflection from a turn. +#[test] +fn a_linked_dimension_of_a_reflected_curve_has_its_line_on_the_reflected_side() { + let mut s = session(); + let arch = json!([{"x":100,"y":200,"out":[100,150]},{"x":200,"y":200,"in":[200,150]}]); + let curve = run(&mut s, "path.create", json!({"anchors":arch,"closed":false}))["id"].as_u64().unwrap(); + let anchor = |anchor: u64| json!({"node":curve,"subpath":0,"anchor":anchor}); + let src = json!({"kind":"points","a":anchor(0),"b":anchor(1)}); + // The line 30 pt below the two points (the left normal of a → b points down). + let id = run(&mut s, "dimension.create", json!({"source":src,"offset":30}))["id"].as_u64().unwrap(); + assert!((recipe(&s, id).line_ends().0.y - 230.0).abs() < 1e-6); + run(&mut s, "select.set", json!({"ids":[curve]})); + run(&mut s, "object.reflect", json!({"axis":"vertical"})); + let d = recipe(&s, id); + assert!((d.a.y, d.b.y) == (200.0, 200.0) && d.a.x > d.b.x, "{d:?}"); + assert!((d.line_ends().0.y - 230.0).abs() < 1e-6, "below the points: {:?}", d.line_ends()); + run(&mut s, "object.reflect", json!({"axis":"horizontal"})); + let d = recipe(&s, id); + assert!((d.line_ends().0.y - (d.a.y - 30.0)).abs() < 1e-6, "above the points: {d:?}"); +} + +/// A point off an arch, whose anchors lie on one line, is held against its anchors and handles: +/// it is reflected with the arch, to the same side as the dimension's line. +#[test] +fn a_point_off_an_arch_is_reflected_with_the_arch() { + let mut s = session(); + let arch = json!([{"x":100,"y":200,"out":[100,150]},{"x":200,"y":200,"in":[200,150]}]); + let curve = run(&mut s, "path.create", json!({"anchors":arch,"closed":false}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(100.0, 200.0), (150.0, 120.0), (60.0, 120.0)]); + let d = recipe(&s, id); + assert!(d.source.as_ref().is_some_and(|src| src.nodes() == vec![NodeId(curve), NodeId(curve)]), "{:?}", d.source); + assert!(near(d.b, vectorcraft_geom::Point::new(150.0, 120.0)), "{:?}", d.b); + run(&mut s, "select.set", json!({"ids":[curve]})); + run(&mut s, "object.reflect", json!({"axis":"horizontal","origin":[0,200]})); + let d = recipe(&s, id); + assert!(near(d.a, vectorcraft_geom::Point::new(100.0, 200.0)), "{:?}", d.a); + assert!(near(d.b, vectorcraft_geom::Point::new(150.0, 280.0)), "below with the arch: {:?}", d.b); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "select.set", json!({"ids":[curve]})); + run(&mut s, "object.reflect", json!({"axis":"vertical","origin":[150,0]})); + let d = recipe(&s, id); + assert!(near(d.a, vectorcraft_geom::Point::new(200.0, 200.0)), "{:?}", d.a); + assert!(near(d.b, vectorcraft_geom::Point::new(150.0, 120.0)), "above with the arch: {:?}", d.b); +} + +/// A point beside a curve that is almost straight is still held against it. +#[test] +fn a_point_beside_an_almost_straight_curve_follows_it() { + let mut s = session(); + let flat = json!([{"x":100,"y":200,"out":[100,199.999]},{"x":200,"y":200}]); + let curve = run(&mut s, "path.create", json!({"anchors":flat,"closed":false}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(100.0, 200.0), (150.0, 150.0), (60.0, 120.0)]); + let d = recipe(&s, id); + assert!(d.source.as_ref().is_some_and(|src| src.nodes() == vec![NodeId(curve), NodeId(curve)]), "{:?}", d.source); + run(&mut s, "select.set", json!({"ids":[curve]})); + run(&mut s, "object.move", json!({"dx":40,"dy":-30})); + let d = recipe(&s, id); + assert!(near(d.b, vectorcraft_geom::Point::new(190.0, 120.0)), "{:?}", d.b); +} + +/// A point off a loop whose two anchors are in one place is held against the loop's handles. +#[test] +fn a_point_off_a_loop_with_its_anchors_in_one_place_follows_it() { + let mut s = session(); + let tear = json!([{"x":100,"y":200,"out":[60,120]},{"x":100,"y":200,"in":[140,120]}]); + let curve = run(&mut s, "path.create", json!({"anchors":tear,"closed":false}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(100.0, 200.0), (100.0, 60.0), (30.0, 130.0)]); + let d = recipe(&s, id); + assert!(d.source.as_ref().is_some_and(|src| src.nodes() == vec![NodeId(curve), NodeId(curve)]), "{:?}", d.source); + run(&mut s, "select.set", json!({"ids":[curve]})); + run(&mut s, "object.move", json!({"dx":40,"dy":-30})); + let d = recipe(&s, id); + assert!(near(d.b, vectorcraft_geom::Point::new(140.0, 30.0)), "{:?}", d.b); +} + +/// Beside a straight two-anchor line, a point is moved by a reflection as a half turn would move +/// it: the line's positions alone do not tell the two apart. +#[test] +fn a_point_beside_a_reflected_line_moves_as_a_half_turn_would() { + let mut s = session(); + let line = run(&mut s, "shape.line", json!({"x1":100,"y1":100,"x2":200,"y2":100}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(100.0, 100.0), (150.0, 160.0), (60.0, 130.0)]); + run(&mut s, "select.set", json!({"ids":[line]})); + run(&mut s, "object.reflect", json!({"axis":"vertical","origin":[150,0]})); + let d = recipe(&s, id); + assert!(d.source.is_some()); + assert!(near(d.a, vectorcraft_geom::Point::new(200.0, 100.0)), "{:?}", d.a); + assert!(near(d.b, vectorcraft_geom::Point::new(150.0, 40.0)), "{:?}", d.b); +} + +/// Opening a closed path that a dimension measures leaves the dimension where it was, no longer +/// linked, and undo links it again. +#[test] +fn a_dimension_whose_art_is_opened_stays_where_it_was() { + let mut s = session(); + let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(100.0, 100.0), (200.0, 150.0), (120.0, 90.0)]); + let before = recipe(&s, id); + let label = text(&s, id); + let a = |p: vectorcraft_geom::Point| json!({"x":p.x,"y":p.y}); + let open: Vec = anchors(&s, rect).into_iter().map(a).collect(); + run(&mut s, "path.setAnchors", json!({"id":rect,"subpaths":[{"anchors":open,"closed":false}]})); + let d = recipe(&s, id); + assert!(d.source.is_none(), "no longer linked"); + assert_eq!((d.a, d.b, d.offset, text(&s, id)), (before.a, before.b, before.offset, label)); + run(&mut s, "edit.undo", json!({})); + assert_eq!(recipe(&s, id).source, before.source, "undo links it again"); +} + +/// An angle whose arm becomes a curve, and an arc radius whose arc stops being circular, stay where +/// they were, no longer linked. +#[test] +fn angle_and_arc_dimensions_whose_art_no_longer_fits_stay_where_they_were() { + let mut s = session(); + let first = edge(&mut s); + let second = run(&mut s, "shape.line", json!({"x1":100,"y1":100,"x2":100,"y2":172}))["id"].as_u64().unwrap(); + let angle = json!({"mode":"angular","source":{"kind":"angle","first":{"node":first,"subpath":0,"segment":0},"second":{"node":second,"subpath":0,"segment":0}}}); + let id = run(&mut s, "dimension.create", angle)["id"].as_u64().unwrap(); + let label = text(&s, id); + let curved = json!([{"x":100,"y":100,"out":[140,130]},{"x":100,"y":172}]); + run(&mut s, "path.setAnchors", json!({"id":second,"subpaths":[{"anchors":curved,"closed":false}]})); + assert_eq!((recipe(&s, id).source, text(&s, id)), (None, label)); + let circle = run(&mut s, "shape.ellipse", json!({"x":300,"y":100,"width":144,"height":144}))["id"].as_u64().unwrap(); + let arc = json!({"mode":"radius","source":{"kind":"arc","edge":{"node":circle,"subpath":0,"segment":0}},"options":{"units":"in","precision":1}}); + let id = run(&mut s, "dimension.create", arc)["id"].as_u64().unwrap(); + assert_eq!(text(&s, id), "1.0 in"); + run(&mut s, "select.set", json!({"ids":[circle]})); + run(&mut s, "object.scale", json!({"sx":200,"sy":100})); + assert_eq!((recipe(&s, id).source, text(&s, id)), (None, "1.0 in".to_string())); +} + +/// Saved and opened again, a dimension to points that are not anchors still follows its art. +#[test] +fn free_points_survive_a_native_file() { + let mut s = session(); + let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(200.0, 150.0), (330.0, 260.0), (260.0, 330.0)]); + let before = recipe(&s, id); + let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let mut reopened = Session::new(); + reopened.add_document(doc, None); + assert_eq!(recipe(&reopened, id).source, before.source); + run(&mut reopened, "select.set", json!({"ids":[rect]})); + run(&mut reopened, "object.move", json!({"dx":40,"dy":-30})); + let d = recipe(&reopened, id); + let v = vectorcraft_geom::Vec2::new(40.0, -30.0); + assert!(near(d.a, before.a + v) && near(d.b, before.b + v), "{before:?} → {d:?}"); +} + +/// A linked dimension opened from a file with options it cannot be drawn with keeps its art, as a +/// plain group, when the art it measures is deleted. +#[test] +fn a_dimension_that_cannot_be_drawn_again_keeps_its_art() { + let mut s = session(); + let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap(); + let id = clicks(&mut s, &[(100.0, 100.0), (200.0, 100.0), (150.0, 80.0)]); + let saved = vectorcraft_format::save(&s.doc().unwrap().doc, false); + let mut v: Value = serde_json::from_slice(&saved).unwrap(); + fn bad_precision(v: &mut Value) { + match v { + Value::Object(m) => { + if let Some(Value::Object(o)) = m.get_mut("dimension").and_then(|d| d.get_mut("options")) { + o.insert("precision".into(), json!(99)); + } + m.values_mut().for_each(bad_precision); + } + Value::Array(a) => a.iter_mut().for_each(bad_precision), + _ => {} + } + } + bad_precision(&mut v); + let mut reopened = Session::new(); + reopened.add_document(vectorcraft_format::load(&serde_json::to_vec(&v).unwrap()).unwrap(), None); + let art = reopened.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap().clone(); + run(&mut reopened, "select.set", json!({"ids":[rect]})); + run(&mut reopened, "edit.clear", json!({})); + let n = reopened.doc().unwrap().doc.node(NodeId(id)).cloned().expect("the dimension's art stays"); + assert!(n.extras().dimension.is_none(), "no longer a live dimension"); + assert_eq!(n.children().unwrap(), &art); +} + /// A linked dimension moved on its own keeps its new place when its art changes later; moved with /// its art, it keeps its distance from it. #[test] @@ -653,7 +1074,8 @@ fn an_expanded_dimension_stays_plain_art() { assert_eq!((art(&s, id), bounds(&s, id)), (plain, at)); run(&mut s, "edit.clear", json!({})); assert_eq!((art(&s, id), bounds(&s, id)), (plain, at)); - assert!(s.doc().unwrap().doc.node(NodeId(other)).is_none()); + assert_eq!(text(&s, other), "20.00 in", "the live dimension stays, no longer linked"); + assert!(recipe(&s, other).source.is_none()); // Saved and opened again, it is still plain art. let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); let mut reopened = Session::new(); @@ -975,7 +1397,9 @@ fn dimension_linked_angle_and_circle_update_and_reject_non_circles() { run(&mut s, "object.scale", json!({"sx":200,"sy":200})); assert_eq!(text(&s, id), "4 in"); run(&mut s, "object.scale", json!({"sx":200,"sy":100})); - assert!(s.doc().unwrap().doc.node(NodeId(id)).is_none()); + // An ellipse has no single radius: the dimension stays where it was, no longer linked. + assert_eq!(text(&s, id), "4 in"); + assert!(recipe(&s, id).source.is_none()); assert!(s.execute("dimension.create", &json!({"mode":"radius","source":{"kind":"circle","node":circle}})).is_err()); } diff --git a/crates/tools/src/dimension.rs b/crates/tools/src/dimension.rs index d132273698..7a3d3de765 100644 --- a/crates/tools/src/dimension.rs +++ b/crates/tools/src/dimension.rs @@ -5,8 +5,8 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; use serde_json::{Value, json}; use vectorcraft_doc::{ - Node, - dimension::{self, Dimension, DimensionMode, DimensionOptions, DimensionSource, Orientation, PointRef, SegmentRef}, + Node, NodeId, + dimension::{self, Dimension, DimensionMode, DimensionOptions, DimensionSource, FreePoint, Orientation, PointRef, SegmentRef}, }; use vectorcraft_geom::{BezPath, Point, Vec2}; @@ -213,6 +213,30 @@ fn nearest_on(cx: &ToolContext, edge: &SegmentRef, p: Point) -> Option { (q.x.is_finite() && q.y.is_finite()).then_some(q) } +/// What a dimension between two picked points links to. A point on an anchor links to the anchor. +/// A point on a segment keeps its place along the segment. Any other point is held +/// against the anchors of the path under it, or, with no path under it, the path of the other +/// point, so that it moves with that path. None when neither point has a path to link to. +fn points_source(cx: &ToolContext, (a, ra): (Point, &Option), (b, rb): (Point, &Option)) -> Option { + let (edge_a, edge_b) = (pick(cx, a), pick(cx, b)); + let node = |r: &Option, e: &Option| r.as_ref().map(|r| r.node).or_else(|| e.as_ref().map(|e| e.node)); + let (path_a, path_b) = (node(ra, &edge_a), node(rb, &edge_b)); + let free = |p: Point, r: &Option, edge: &Option, own: Option, other: Option| { + if r.is_some() { + return None; + } + edge.as_ref().and_then(|e| FreePoint::on_segment(cx.doc, e, p)).or_else(|| own.or(other).and_then(|node| FreePoint::on(cx.doc, node, p))) + }; + let (free_a, free_b) = (free(a, ra, &edge_a, path_a, path_b), free(b, rb, &edge_b, path_b, path_a)); + (ra.is_some() || rb.is_some() || free_a.is_some() || free_b.is_some()).then(|| DimensionSource::Points { + a: ra.clone(), + b: rb.clone(), + vertex: None, + free_a, + free_b, + }) +} + impl DimensionTool { /// Where a Linear pick at `p` lands: on the nearest anchor of any path, within reach (kept as /// a link to its path); else where Smart Guides put it (another object's anchor or centre, in @@ -364,7 +388,7 @@ impl DimensionTool { } self.points.push((at, reference)); if let [(a, ra), (b, rb)] = self.points.as_slice() { - let source = (ra.is_some() || rb.is_some()).then(|| DimensionSource::Points { a: ra.clone(), b: rb.clone(), vertex: None }); + let source = points_source(cx, (*a, ra), (*b, rb)); let mut d = self.recipe(DimensionMode::Linear, *a, *b, Point::ZERO, source); if d.resolve(cx.doc).is_ok() { self.second = Some((*b, p)); @@ -667,7 +691,7 @@ mod tests { let d = t.pending.as_ref().expect("both anchors were picked"); assert_eq!((d.a, d.b), (Point::new(100.0, 100.0), Point::new(200.0, 200.0))); let anchor = |anchor| Some(PointRef { node: id, subpath: 0, anchor }); - assert_eq!(d.source, Some(DimensionSource::Points { a: anchor(0), b: anchor(2), vertex: None })); + assert_eq!(d.source, Some(DimensionSource::Points { a: anchor(0), b: anchor(2), vertex: None, free_a: None, free_b: None })); // Dragged past the anchor's reach, the press drags out the dimension of a segment there. let mut t = DimensionTool::default(); t.pointer(&c, &ev(PointerKind::Down, 100.0, 100.0)); @@ -677,6 +701,66 @@ mod tests { assert!(matches!(t.pointer(&c, &ev(PointerKind::Up, 100.0, 60.0)).last(), Some(Action::Commit))); } + /// A point picked on a segment, not on an anchor, keeps its place along the segment. A point + /// off the art is held against the anchors of the other point's path. Both then follow the + /// path, as the anchor at the other end does. + #[test] + fn points_off_anchors_are_held_against_the_path() { + use vectorcraft_doc::dimension::FreePoint; + let (d, id) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let click = |t: &mut DimensionTool, x: f64, y: f64| { + t.pointer(&c, &PointerEvent::new(PointerKind::Down, x, y)); + t.pointer(&c, &PointerEvent::new(PointerKind::Up, x, y)); + }; + let mut t = DimensionTool::default(); + click(&mut t, 100.0, 100.0); + click(&mut t, 200.0, 150.0); + let pending = t.pending.as_ref().expect("both points were picked"); + let anchor = Some(PointRef { node: id, subpath: 0, anchor: 0 }); + let Some(DimensionSource::Points { a, b: None, vertex: None, free_a: None, free_b: Some(FreePoint::OnSegment { edge, t }) }) = + &pending.source + else { + panic!("{:?}", pending.source) + }; + assert_eq!((a, edge), (&anchor, &SegmentRef { node: id, subpath: 0, segment: 1 })); + assert!((t - 0.5).abs() < 1e-9, "{t}"); + let mut t = DimensionTool::default(); + click(&mut t, 400.0, 400.0); + click(&mut t, 200.0, 200.0); + let pending = t.pending.as_ref().expect("both points were picked"); + let Some(DimensionSource::Points { free_a: Some(free), b: Some(b), .. }) = &pending.source else { panic!("{:?}", pending.source) }; + let FreePoint::Anchors { node, anchors, across } = free else { panic!("{free:?}") }; + assert_eq!((*node, anchors.len(), *across, b.node, b.anchor), (id, 3, 0.0, id, 2)); + let at = free.position(&d).expect("the anchors that hold it exist"); + assert!(at.distance(pending.a) < 1e-9, "{at:?} {:?}", pending.a); + } + + /// A point that Smart Guides put in line with the path's center is held, where the guide put + /// it, against the anchors of the path at the other end. + #[test] + fn a_point_smart_guides_place_is_held_against_the_path() { + use vectorcraft_doc::dimension::FreePoint; + let (d, id) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + assert!(c.smart_guides); + let mut t = DimensionTool::default(); + for (x, y) in [(200.0, 200.0), (151.5, 330.0)] { + t.pointer(&c, &PointerEvent::new(PointerKind::Move, x, y)); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, x, y)); + t.pointer(&c, &PointerEvent::new(PointerKind::Up, x, y)); + } + let pending = t.pending.as_ref().expect("both points were picked"); + assert!((pending.b.x - 150.0).abs() < 1e-9, "in line with the center: {:?}", pending.b); + let Some(DimensionSource::Points { a: Some(a), free_b: Some(free), .. }) = &pending.source else { panic!("{:?}", pending.source) }; + assert!(matches!(free, FreePoint::Anchors { node, .. } if *node == id), "{free:?}"); + assert_eq!(a.node, id); + let at = free.position(&d).expect("the anchors that hold it exist"); + assert!(at.distance(pending.b) < 1e-9, "{at:?} {:?}", pending.b); + } + /// The anchors of a live rectangle are picked the same way. #[test] fn a_press_on_a_live_shapes_anchor_picks_it() { diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 4ac5d5dd66..a438b240ea 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5535,6 +5535,7 @@ theme Appearance Външен вид @msg Invalid drawing scale Невалиден мащаб на чертежа @msg Invalid real scale Невалиден реален мащаб @msg Measured anchor no longer exists Измерената опорна точка вече не съществува +@msg Invalid measured point Невалидна измерена точка @msg Measured segment no longer exists Измереният сегмент вече не съществува @msg Non-finite dimension Размерът не е крайно число @msg Only linear dimensions are horizontal or vertical Само линейните размери са хоризонтални или вертикални diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index aa76735421..d88c223240 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5583,6 +5583,7 @@ mark-style Japanese Japanski @msg Invalid drawing scale Nevažeća skala crteža @msg Invalid real scale Nevažeća stvarna skala @msg Measured anchor no longer exists Izmjereno sidro više ne postoji +@msg Invalid measured point Neispravna izmjerena tačka @msg Measured segment no longer exists Izmjereni segment više ne postoji @msg Non-finite dimension Nekonačna kota @msg Only linear dimensions are horizontal or vertical Samo linearne kote su horizontalne ili vertikalne diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index f14cc12c44..d0ee71c659 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5360,6 +5360,7 @@ digits Malayalam Malaiàlam @msg Make Guides: select paths Crea guies: seleccioneu traçats @msg mask art ends outside a mask dictionary l’art de la màscara acaba fora d’un diccionari de màscara @msg Measured anchor no longer exists El punt d’ancoratge mesurat ja no existeix +@msg Invalid measured point El punt mesurat no és vàlid @msg Measured segment no longer exists El segment mesurat ja no existeix @msg missing `folder` falta `folder` @msg missing `id` (see plugin.list) falta `id` (vegeu plugin.list) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9f2e06fd11..9f80a397cc 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5714,6 +5714,7 @@ mark-style Japanese Japonské @msg Invalid drawing scale Neplatné měřítko výkresu @msg Invalid real scale Neplatné skutečné měřítko @msg Measured anchor no longer exists Měřený kotevní bod už neexistuje +@msg Invalid measured point Neplatný měřený bod @msg Measured segment no longer exists Měřený segment už neexistuje @msg Non-finite dimension Kóta není konečné číslo @msg Only linear dimensions are horizontal or vertical Vodorovné nebo svislé jsou jen lineární kóty diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 577f4d1ee4..af4ac9b0ae 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5305,6 +5305,7 @@ mark-style Japanese Japansk @msg Invalid drawing scale Ugyldig tegningsmålestok @msg Invalid real scale Ugyldig virkelig målestok @msg Measured anchor no longer exists Det målte ankerpunkt findes ikke længere +@msg Invalid measured point Ugyldigt målt punkt @msg Measured segment no longer exists Det målte segment findes ikke længere @msg Non-finite dimension Ikke-endeligt mål @msg Only linear dimensions are horizontal or vertical Kun lineære mål er vandrette eller lodrette diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index cd44eba7dc..c0d48f2e9a 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5291,6 +5291,7 @@ effect Soft Weich @msg Invalid drawing scale Ungültiger Zeichnungsmaßstab @msg Invalid real scale Ungültiger realer Maßstab @msg Measured anchor no longer exists Der gemessene Ankerpunkt existiert nicht mehr +@msg Invalid measured point Ungültiger gemessener Punkt @msg Measured segment no longer exists Das gemessene Segment existiert nicht mehr @msg Non-finite dimension Die Bemaßung ist keine endliche Zahl @msg Only linear dimensions are horizontal or vertical Nur lineare Bemaßungen sind horizontal oder vertikal diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e9d8df097d..49f9523656 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5669,6 +5669,7 @@ effect Points Σημεία @msg Invalid drawing scale Μη έγκυρη κλίμακα σχεδίου @msg Invalid real scale Μη έγκυρη πραγματική κλίμακα @msg Measured anchor no longer exists Το μετρημένο σημείο αγκύρωσης δεν υπάρχει πλέον +@msg Invalid measured point Μη έγκυρο μετρημένο σημείο @msg Measured segment no longer exists Το μετρημένο τμήμα δεν υπάρχει πλέον @msg Non-finite dimension Η διάσταση δεν είναι πεπερασμένος αριθμός @msg Only linear dimensions are horizontal or vertical Μόνο οι γραμμικές διαστάσεις είναι οριζόντιες ή κατακόρυφες diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 148c48fd52..8f8b5e17e7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5655,6 +5655,7 @@ mark-style Japanese Japonesas @msg Invalid drawing scale Escala de dibujo no válida @msg Invalid real scale Escala real no válida @msg Measured anchor no longer exists El punto de ancla medido ya no existe +@msg Invalid measured point Punto medido no válido @msg Measured segment no longer exists El segmento medido ya no existe @msg Non-finite dimension Cota no finita @msg Only linear dimensions are horizontal or vertical Solo las cotas lineales son horizontales o verticales diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 4fe1a150d1..d86f68b4fc 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5681,6 +5681,7 @@ mark-style Japanese Japanilainen @msg Invalid drawing scale Virheellinen piirustuksen mittakaava @msg Invalid real scale Virheellinen todellinen mittakaava @msg Measured anchor no longer exists Mitattua ankkuripistettä ei ole enää +@msg Invalid measured point Virheellinen mitattu piste @msg Measured segment no longer exists Mitattua segmenttiä ei ole enää @msg Non-finite dimension Mitta ei ole äärellinen luku @msg Only linear dimensions are horizontal or vertical Vain lineaariset mitat ovat vaaka- tai pystysuuntaisia diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index fc07cea935..933e02b953 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5709,6 +5709,7 @@ theme Appearance Apparence @msg Invalid drawing scale Échelle du dessin non valide @msg Invalid real scale Échelle réelle non valide @msg Measured anchor no longer exists Le point d’ancrage mesuré n’existe plus +@msg Invalid measured point Point mesuré non valide @msg Measured segment no longer exists Le segment mesuré n’existe plus @msg Non-finite dimension Cote non finie @msg Only linear dimensions are horizontal or vertical Seules les cotes linéaires sont horizontales ou verticales diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 6ef0b56668..06f8f25dcc 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -6612,6 +6612,7 @@ antiAlias Sharp शार्प @msg matrix must be finite and invertible, depth finite matrix: मैट्रिक्स को परिमित और उलटा, गहराई परिमित होना चाहिए @msg matte: {e} मैट: {e} @msg Measured anchor no longer exists मापने वाला एंकर अब मौजूद नहीं है +@msg Invalid measured point मापा गया बिंदु अमान्य है @msg Measured segment no longer exists मापदंड अब मौजूद नहीं है @msg member is for order front, forward, backward or back member केवल order front, forward, backward या back के लिए है @msg memory budget (the selection is too large to hand over) स्मृति बजट (चुनाव बहुत बड़ा है) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 87a745a439..af7810f02b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5696,6 +5696,7 @@ imageTrace Outline Körvonal @msg Invalid drawing scale Érvénytelen rajzlépték @msg Invalid real scale Érvénytelen valós lépték @msg Measured anchor no longer exists A mért horgonypont már nem létezik +@msg Invalid measured point Érvénytelen mért pont @msg Measured segment no longer exists A mért szakasz már nem létezik @msg Non-finite dimension Nem véges méret @msg Only linear dimensions are horizontal or vertical Csak a lineáris méretek lehetnek vízszintesek vagy függőlegesek diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 2da754164b..e1170943b9 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5305,6 +5305,7 @@ mark-style Japanese Jepang @msg Invalid drawing scale Skala gambar tidak valid @msg Invalid real scale Skala sebenarnya tidak valid @msg Measured anchor no longer exists Titik jangkar yang diukur sudah tidak ada +@msg Invalid measured point Titik yang diukur tidak valid @msg Measured segment no longer exists Segmen yang diukur sudah tidak ada @msg Non-finite dimension Dimensi bukan bilangan berhingga @msg Only linear dimensions are horizontal or vertical Hanya dimensi linear yang horizontal atau vertikal diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 89e3c7dc22..bb0650e8c3 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5326,6 +5326,7 @@ lighting Light Luce @msg Invalid drawing scale Scala del disegno non valida @msg Invalid real scale Scala reale non valida @msg Measured anchor no longer exists Il punto di ancoraggio misurato non esiste più +@msg Invalid measured point Punto misurato non valido @msg Measured segment no longer exists Il segmento misurato non esiste più @msg Non-finite dimension Quota non finita @msg Only linear dimensions are horizontal or vertical Solo le quote lineari sono orizzontali o verticali diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5f62b25c09..f8b0e6ab4a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5369,6 +5369,7 @@ lighting Light ライト @msg Invalid drawing scale 図面の縮尺が無効です @msg Invalid real scale 実寸の縮尺が無効です @msg Measured anchor no longer exists 測定したアンカーポイントはもう存在しません +@msg Invalid measured point 測定した点が無効です @msg Measured segment no longer exists 測定したセグメントはもう存在しません @msg Non-finite dimension 寸法が有限ではありません @msg Only linear dimensions are horizontal or vertical 水平または垂直にできるのは直線寸法のみです diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 1cec04df12..62a6512326 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5320,6 +5320,7 @@ imageTrace Outline კონტური @msg Invalid drawing scale ნახაზის არასწორი მასშტაბი @msg Invalid real scale რეალური ზომის არასწორი მასშტაბი @msg Measured anchor no longer exists გაზომილი საყრდენი წერტილი აღარ არსებობს +@msg Invalid measured point გაზომილი წერტილი არასწორია @msg Measured segment no longer exists გაზომილი სეგმენტი აღარ არსებობს @msg Non-finite dimension ზომა სასრული რიცხვი არ არის @msg Only linear dimensions are horizontal or vertical მხოლოდ ხაზოვანი ზომებია ჰორიზონტალური ან ვერტიკალური diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b3bdd0c361..318df7a4f2 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5287,6 +5287,7 @@ lighting Light 광원 @msg Invalid drawing scale 잘못된 도면 축척 @msg Invalid real scale 잘못된 실제 축척 @msg Measured anchor no longer exists 측정한 고정점이 더 이상 없습니다 +@msg Invalid measured point 측정한 점이 잘못되었습니다 @msg Measured segment no longer exists 측정한 세그먼트가 더 이상 없습니다 @msg Non-finite dimension 유한하지 않은 치수 @msg Only linear dimensions are horizontal or vertical 선형 치수만 가로 또는 세로로 지정할 수 있습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 3359ff99b3..9ffc7e1749 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5317,6 +5317,7 @@ mark-style Japanese Japans @msg Invalid drawing scale Ongeldige tekeningsschaal @msg Invalid real scale Ongeldige werkelijke schaal @msg Measured anchor no longer exists Gemeten ankerpunt bestaat niet meer +@msg Invalid measured point Ongeldig gemeten punt @msg Measured segment no longer exists Gemeten segment bestaat niet meer @msg Non-finite dimension Niet-eindige maatlijn @msg Only linear dimensions are horizontal or vertical Alleen lineaire maatlijnen zijn horizontaal of verticaal diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index d43c83a0a1..b0c3f12d68 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5566,6 +5566,7 @@ mark-style Japanese Japońskie @msg Invalid drawing scale Nieprawidłowa skala rysunku @msg Invalid real scale Nieprawidłowa skala rzeczywista @msg Measured anchor no longer exists Mierzony punkt kontrolny już nie istnieje +@msg Invalid measured point Nieprawidłowy mierzony punkt @msg Measured segment no longer exists Mierzony odcinek już nie istnieje @msg Non-finite dimension Wymiar nie jest liczbą skończoną @msg Only linear dimensions are horizontal or vertical Tylko wymiary liniowe są poziome lub pionowe diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 9117394f7e..6eb8eb5212 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5645,6 +5645,7 @@ mark-style Japanese Japonesas @msg Invalid drawing scale Escala de desenho inválida @msg Invalid real scale Escala real inválida @msg Measured anchor no longer exists O ponto de ancoragem medido não existe mais +@msg Invalid measured point Ponto medido inválido @msg Measured segment no longer exists O segmento medido não existe mais @msg Non-finite dimension Cota não finita @msg Only linear dimensions are horizontal or vertical Somente cotas lineares são horizontais ou verticais diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index cef9bafd20..87f497f96a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5320,6 +5320,7 @@ lighting Light Свет @msg Invalid drawing scale Недопустимый масштаб чертежа @msg Invalid real scale Недопустимый реальный масштаб @msg Measured anchor no longer exists Измеряемая опорная точка больше не существует +@msg Invalid measured point Недопустимая измеряемая точка @msg Measured segment no longer exists Измеряемый сегмент больше не существует @msg Non-finite dimension Размер не является конечным числом @msg Only linear dimensions are horizontal or vertical Только линейные размеры бывают горизонтальными или вертикальными diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 1fdf7ce255..2bc44ef31d 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5673,6 +5673,7 @@ theme Appearance Utseende @msg Invalid drawing scale Ogiltig ritningsskala @msg Invalid real scale Ogiltig verklig skala @msg Measured anchor no longer exists Den mätta ankarpunkten finns inte längre +@msg Invalid measured point Ogiltig mätt punkt @msg Measured segment no longer exists Det mätta segmentet finns inte längre @msg Non-finite dimension Måttet är inte ett ändligt tal @msg Only linear dimensions are horizontal or vertical Endast linjära mått är vågräta eller lodräta diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 91e2b3a4bd..050e7c05d2 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5394,6 +5394,7 @@ theme Appearance Görünüm @msg Invalid drawing scale Geçersiz çizim ölçeği @msg Invalid real scale Geçersiz gerçek ölçek @msg Measured anchor no longer exists Ölçülen çapa artık mevcut değil +@msg Invalid measured point Geçersiz ölçülen nokta @msg Measured segment no longer exists Ölçülen segment artık mevcut değil @msg Non-finite dimension Sonlu olmayan boyut @msg Only linear dimensions are horizontal or vertical Yalnızca doğrusal boyutlar yatay veya dikey olabilir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 5eb9f255fc..f4c4f6ee86 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5306,6 +5306,7 @@ lighting Light Світло @msg Invalid drawing scale Неприпустимий масштаб креслення @msg Invalid real scale Неприпустимий реальний масштаб @msg Measured anchor no longer exists Виміряна опорна точка більше не існує +@msg Invalid measured point Недійсна виміряна точка @msg Measured segment no longer exists Виміряний сегмент більше не існує @msg Non-finite dimension Розмір не є скінченним числом @msg Only linear dimensions are horizontal or vertical Лише лінійні розміри бувають горизонтальними або вертикальними diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 1c17d61e65..0dda56d3de 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5307,6 +5307,7 @@ theme Appearance Giao diện @msg Invalid drawing scale Tỷ lệ bản vẽ không hợp lệ @msg Invalid real scale Tỷ lệ thực không hợp lệ @msg Measured anchor no longer exists Điểm neo được đo không còn tồn tại +@msg Invalid measured point Điểm được đo không hợp lệ @msg Measured segment no longer exists Đoạn được đo không còn tồn tại @msg Non-finite dimension Kích thước không phải là số hữu hạn @msg Only linear dimensions are horizontal or vertical Chỉ kích thước tuyến tính mới nằm ngang hoặc thẳng đứng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 5c0c35240c..f6e3c9c140 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4757,6 +4757,7 @@ imageTrace Outline 轮廓 @msg Invalid drawing scale 图纸比例无效 @msg Invalid real scale 实际比例无效 @msg Measured anchor no longer exists 测量锚点已不存在 +@msg Invalid measured point 测量点无效 @msg Measured segment no longer exists 测量线段已不存在 @msg Non-finite dimension 尺寸标注不是有限值 @msg Only linear dimensions are horizontal or vertical 只有线性尺寸可以是水平或垂直的 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b538cb9d32..a02e03cc29 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2589,6 +2589,7 @@ file-filter Templates 範本 @msg Invalid drawing scale 圖紙比例無效 @msg Invalid real scale 實際比例無效 @msg Measured anchor no longer exists 測量的錨點已不存在 +@msg Invalid measured point 測量點無效 @msg Measured segment no longer exists 測量的線段已不存在 @msg Non-finite dimension 尺寸標註不是有限值 @msg Only linear dimensions are horizontal or vertical 只有線性尺寸可以是水平或垂直的 diff --git a/docs/dimensions.md b/docs/dimensions.md index 6327c9f501..bcd08c3b83 100644 --- a/docs/dimensions.md +++ b/docs/dimensions.md @@ -6,7 +6,13 @@ The **Dimension Tool** is in the Modify section of the toolbar. Its strip offers - **Linear:** hover a path segment and drag outwards: releasing places the dimension. Or click two points (anchors snap, Shift constrains to 45°), then click to place. A click within 8 pixels of an anchor of any path, in groups and compound paths too, takes that anchor, and the dimension - stays linked to it. A press on an anchor picks it when the pointer moves less than 8 pixels + stays linked to it. A point on a segment stays at its place along the segment, also when the + segment's anchors or handles are edited. Any other point is linked to the path under it, or, + with no path under it, to the path at the other end, and moves, scales, turns and reflects with + that path. Beside a path whose anchors and handles all lie on one line, such as a straight line, + the point keeps its distance along and across the path: a reflection moves it as a half turn + does, and a scale that is not uniform changes its distance across the path in proportion to the + path's length. A press on an anchor picks it when the pointer moves less than 8 pixels before the release; a longer drag drags out the dimension of the segment under the pointer. A curved segment is measured from end to end. With Smart Guides on, both points also snap into line with other objects' anchors and centres, and the second onto the construction guides through the first; the guides @@ -52,10 +58,15 @@ position and alignment; changing one changes that dimension. **Apply to All** gi dimension in the document its style; **Set as Default** makes it the style of new dimensions. Measurements follow the art they measure through moves, scaling and anchor and handle edits, -undo and redo included. Deleting the art, or changing its number of anchors or whether it is -closed, removes its dimensions in the same undo step rather than attaching them to a different -edge. A linked dimension moved on its own stays where it was dropped (its distance from the art -changes); moved together with its art, it keeps its distance. +undo and redo included. When the art is reflected, the line of a linked linear dimension goes to +the reflected side, as far past the outermost point as before. This does not happen for art whose +anchors and handles all lie on one line, such as a straight line, which a half turn puts in the +same place as a reflection. Deleting the art, changing its number of anchors or whether it is +closed, turning an angle's arm into a curve, or making a measured circle or arc no longer circular +leaves its dimensions where they were, no longer linked, in the same undo step, rather than +attaching them to a different edge. Undo links them again. A linked dimension moved on its own +stays where it was dropped (its distance from the art changes); moved together with its art, it +keeps its distance. Native `.vectorcraft` files keep the recipes and links. SVG, PDF and bitmap output contain ordinary paths and text, so exported dimensions look the same. Ungrouping a dimension leaves its diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cc6e7a73a5..70c415cffc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -565,6 +565,7 @@ since 2026-10-01. **Drawing tools — Polygon and Star counts:** The existing canvas placement dialog reads the stored count and saves it only after successful placement. `polygon_and_star_canvas_counts_persist_without_new_toolbar_dialogs` covers both tools, repeated drags, tool switching, reopening and Cancel. Counts remain session tool options. Existing click and toolbar gestures are retained; no score or hour change. **Drawing tools, Star inner radius held with Ctrl/Cmd:** While a star is dragged out, Ctrl/Cmd keeps the inner radius the star showed when the key went down, also when the key is pressed with the pointer still. `a_star_drag_holds_its_inner_radius_with_ctrl_and_straightens_with_alt` and `a_star_drag_holds_the_inner_radius_it_shows_when_ctrl_goes_down` cover it, with Alt's straight shoulders and a drag canceled with Escape. **Drawing tools, Dimension tool horizontal and vertical linear dimensions:** A linear dimension between two clicked points or dragged out from a segment is aligned, horizontal or vertical, chosen by where the pointer moves while it is placed, and a click again by the second point gives aligned; `dimension.create` and `dimension.update` take `orientation`, and the dimension stays linked to the anchors it measures, keeps its orientation when it or the art turns, with a line that lay outside the points kept outside them; an unlinked horizontal or vertical dimension that is reflected has its line on the reflected side. `the_pointer_chooses_aligned_horizontal_or_vertical`, `two_points_measure_horizontally_vertically_or_aligned`, `clicking_again_by_the_second_point_places_an_aligned_dimension`, `a_segment_drag_measures_horizontally_vertically_or_aligned`, `points_on_a_vertical_line_measure_aligned`, `horizontal_and_vertical_dimensions_draw_lines_along_their_axis`, `turned_horizontal_dimensions_stay_horizontal`, `a_linked_horizontal_dimension_keeps_its_gap_from_turned_art`, `a_linked_horizontal_dimension_below_or_between_its_points_follows_turned_art`, `a_reflected_horizontal_dimension_keeps_its_line_on_the_reflected_side`, `a_dimension_without_an_orientation_reads_as_aligned`, `dimension_orientation_is_checked` and `a_turned_dimension_keeps_its_line_on_its_side` cover it. +**Drawing tools, Dimension tool points that are not anchors follow the art:** A clicked point on a segment stays at its place along the segment, also when the segment curves, and a point off the art is linked to anchors (or, when the anchors lie on one line, anchors and handles) of the path, so both ends of the dimension follow when the art is moved, scaled, turned or reflected, also when the point was placed by a Smart Guide and when the dimension is saved and opened again; a linked linear dimension of reflected art (other than art on one line) has its line on the reflected side; deleting the art, changing its anchor count or whether it is closed, turning an angle's arm into a curve, or making a measured circle or arc no longer circular leaves the dimension where it was, no longer linked, instead of removing it. `a_dimension_to_a_point_on_a_segment_follows_the_art_at_both_ends`, `a_dimension_to_a_point_off_the_art_moves_with_the_art`, `a_dimension_whose_art_changes_its_anchors_stays_where_it_was`, `a_dimension_whose_art_is_opened_stays_where_it_was`, `angle_and_arc_dimensions_whose_art_no_longer_fits_stay_where_they_were`, `a_dimension_that_cannot_be_drawn_again_keeps_its_art`, `a_linked_dimension_of_reflected_art_has_its_line_on_the_reflected_side`, `a_dimension_to_a_point_off_a_line_follows_the_line`, `a_point_off_an_arch_is_reflected_with_the_arch`, `a_point_beside_an_almost_straight_curve_follows_it`, `a_point_off_a_loop_with_its_anchors_in_one_place_follows_it`, `a_point_beside_a_reflected_line_moves_as_a_half_turn_would`, `a_point_on_a_segment_stays_on_it_when_the_segment_curves`, `a_linked_dimension_of_a_reflected_curve_has_its_line_on_the_reflected_side`, `a_dimension_of_art_reflected_across_its_points_changes_sides`, `a_dimension_between_paths_reflected_each_in_its_own_way_keeps_its_offset`, `free_points_survive_a_native_file`, `dimension_free_points_are_checked`, `points_off_anchors_are_held_against_the_path` and `a_point_smart_guides_place_is_held_against_the_path` cover it. **Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views:** `live_trace_result_bounds_exclude_the_retained_bitmap_at_any_angle` checks every TraceView, geometric and visual bounds and rotated result bounds. `live_trace_selection_highlight_excludes_the_retained_source_rectangle` checks result highlights and the existing ordinary-group policy. Shared editing-child traversal also preserves Shaper handling. **Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards:** Shared existing preset definitions are reused; no external preset assets are added. Presets preserve each selected artboard's orientation, and orientation choices apply even when the active board already matches. Fit operations draft bounds until OK, affect only the active artboard and leave artwork in place. Regression coverage checks draft/Cancel, selected-set application, active-only fitting and Undo. From f8307b19cde0cb71648d2b7a04fc4676355321d9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 21:36:30 -0800 Subject: [PATCH 03/20] Type: on a vertical path, letters lie a quarter turn clockwise from upright glyphs, in a column centered on the path Follows #5557. Letters and digits lying on their side in vertical type on a path kept their top toward the path's end, opposite the upright glyphs. They are now set as horizontal type on the path sets them: baseline along the path, top to the path's left, a quarter turn clockwise from upright glyphs (CSS Writing Modes 3, Unicode UAX #50). - The column is centered on the path: upright glyphs, letters on their side and tate-chu-yoko blocks have their em box centers on the path line instead of a column one em wide to the path's right. - Upright glyphs are turned in the path effect's glyph space, so with Skew on a curve their top runs back along the tangent and their x axis stays up the page, as letters keep theirs. - Tate-chu-yoko blocks on a path stand upright, stay whole on a curve and move by Tate-chu-yoko Settings toward the path's start and left. - A tate-chu-yoko block of mixed sizes sets every glyph in its first character's em, on a path and off it. Existing vertical type on a path redraws with these placements. --- crates/text/src/layout.rs | 94 ++++++++-- crates/text/src/layout/driver.rs | 1 + crates/text/src/layout/wrapping_tests.rs | 1 + crates/text/src/tests/ligature_carets.rs | 10 +- crates/text/src/tests/vertical.rs | 214 +++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 6 files changed, 299 insertions(+), 23 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 8e594c0369..4a92c4ed34 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -137,6 +137,10 @@ struct Ctx<'a> { /// next glyph of its cluster would take, the turn it got, the line-space x that next glyph is /// set after), so that a combining mark shaped into the same cluster turns with it. upright_cluster: Option<(usize, usize, Affine, f64)>, + /// Vertical type on a path: the tate-chu-yoko block [`Self::emit`] is placing, as (the + /// block's transform from its first glyph's place on the path, the center of its em in the + /// column before that transform), so that the block's other glyphs stand with it on a curve. + tcy_block: Option<(Affine, Point)>, out: TextLayout, } @@ -365,28 +369,82 @@ impl Ctx<'_> { let proportional = g.vpal.filter(|_| g.face.vertical_glyph(g.gid).is_some()).map_or(0.0, |a| a.advance) / g.sy; (cell + proportional, top) }); - if self.vertical && self.on_path { - // Vertical path type keeps the baseline path and turns each glyph across it. - m = Affine::rotate_about(std::f64::consts::FRAC_PI_2, origin) * m; - if let Some((cell, top)) = path_cell { - // An upright glyph whose top then faces the path's end turns half a turn about the - // center of its cell (without its Character Rotation), so that its top faces the - // path's start and the characters of an upright ligature read along the path in - // order. The effects that keep the glyph's up direction fixed already turn it so on - // a path that runs to the left. - let turned = m * unturned; - let tangent = Vec2::new(angle.cos(), angle.sin()); - let along = |y: f64| (turned * Point::new(0.0, y) - origin).dot(tangent); - if along(-top) > along(cell - top) { - let centre = turned * Point::new(g.face.advance(g.gid) * 0.5, cell * 0.5 - top); - m = Affine::translate(centre.to_vec2() * 2.0) * Affine::scale(-1.0) * m; + // Vertical type on a path: the column is centered on the line Align to Path puts the baseline + // on, so the em boxes of upright glyphs, tate-chu-yoko blocks and glyphs lying on their side + // straddle the path. Glyph space here is horizontal path type's: x runs along the path and y + // across it, to the right of the path's direction. + if let (true, true, Some(t)) = (self.vertical, self.on_path, g.tcy) { + // Tate-chu-yoko stands upright as one block: set across the column, centered on its em of + // it and on the path, its top toward the path's start, then moved by + // its Up/Down toward the path's start and by its Left/Right to the path's left, whatever + // the effect does to the glyph's axes. The block's first glyph sets where the block + // stands; the others take its em, its place on the path and its column, so the block + // stays whole on a curve. + let (block, centre) = match self.tcy_block.filter(|_| t.pen > 0.0) { + Some(b) => b, + None => { + // A later glyph without its first glyph: placed back by its pen offset. + let (em, back) = + if t.pen > 0.0 { (t.pen, Affine::translate((-t.pen, 0.0))) } else { (self.style_at(g.byte).size, Affine::IDENTITY) }; + let centre = Point::new(em * 0.5, 0.0); + let squeeze = + Affine::translate((centre.x, 0.0)) * Affine::scale_non_uniform(t.squeeze, 1.0) * Affine::translate((-centre.x, 0.0)); + let on_path = pre * back; + let mut block = on_path * Affine::rotate_about(-std::f64::consts::FRAC_PI_2, centre) * squeeze; + // The effects that keep the glyph's axes fixed leave the block's top toward the + // path's end on a path that runs to the left: it turns half a turn about its + // center, as an upright glyph does. + let tangent = Vec2::new(angle.cos(), angle.sin()); + if (block * Point::new(centre.x, centre.y - 1.0) - block * centre).dot(tangent) > 0.0 { + block = Affine::translate((on_path * centre).to_vec2() * 2.0) * Affine::scale(-1.0) * block; + } + let left = Vec2::new(tangent.y, -tangent.x); + block = Affine::translate(-tangent * t.up + left * t.right) * block; + self.tcy_block = Some((block, centre)); + (block, centre) } + }; + let em = centre.x * 2.0; + let across = Affine::translate((centre.x - t.width * 0.5 + t.ink, centre.y + g.face.ideographic_centre() * em)); + m = block * across * local; + } else if self.vertical && self.on_path && (g.inline.is_some() || !stands_upright(g)) { + // A glyph lying on its side (and an inline graphic) is a quarter turn clockwise from an + // upright glyph, as horizontal type on the path sets it: its baseline runs along the path + // and its top faces the path's left. It moves across the path so that the center of its + // ideographic em box lies on the path. + // + // + let centre = g.face.ideographic_centre() * g.face.units_per_em() * g.sy; + m = pre * Affine::translate((0.0, centre)) * local; + } else if self.vertical && self.on_path { + // An upright glyph is a quarter turn counterclockwise from horizontal path type's pose, + // turned in glyph space so that every effect bends it as it bends glyphs lying on their + // side: its top faces the path's start and the characters of an upright ligature read + // along the path in order. The center of its cell (without its Character Rotation) keeps + // its place along the path, and its em box is centered across the path. + let (cell, top) = path_cell.unwrap_or((0.0, 0.0)); + let unrotated = local * unturned; + let middle = Point::new(g.face.units_per_em() * 0.5, cell * 0.5 - top); + // The em box's middle across the column, which the shaper's offsets then move. + let at = Point::new(g.face.units_per_em() * g.sx * 0.5, (unrotated * middle).y); + let target = Point::new(-at.y, 0.0); + let quarter = Affine::new([0.0, -1.0, 1.0, 0.0, 0.0, 0.0]); + let mut turn = Affine::translate(target.to_vec2() - (quarter * at).to_vec2()) * quarter; + // The effects that keep the glyph's axes fixed (Stair Step) leave its top toward the + // path's end on a path that runs to the left: it turns half a turn about its cell's + // center. + let tangent = Vec2::new(angle.cos(), angle.sin()); + let placed = pre * turn * unrotated; + if (placed * Point::new(middle.x, middle.y - 1.0) - placed * middle).dot(tangent) > 0.0 { + turn = Affine::translate(target.to_vec2() * 2.0) * Affine::scale(-1.0) * turn; } + m = pre * turn * local; } else if let (true, Some(t)) = (self.vertical, g.tcy) { // Tate-chu-yoko: the block set across the column, centred on its em of it, then moved up // by its Up/Down and right by its Left/Right (line x runs down the column and line y - // from right to left across it). - let em = self.style_at(g.byte).size; + // from right to left across it). Every glyph of the block takes the em of its first + // glyph, the pen offset of the others. + let em = if t.pen > 0.0 { t.pen } else { self.style_at(g.byte).size }; let start = origin.x - t.pen; let centre = Point::new(start + em * 0.5, origin.y - g.face.ideographic_centre() * em); let across = Affine::translate((centre.x - t.width * 0.5 + t.ink - origin.x, 0.0)); @@ -629,6 +687,7 @@ mod emit_kashida_mark_last { system_layout: false, fractional_widths: true, upright_cluster: None, + tcy_block: None, out: TextLayout::default(), }; // Last visual glyph of the cluster: a mark that wrongly keeps the mark run (run 1). @@ -692,6 +751,7 @@ mod emit_kashida_mark_last { system_layout: false, fractional_widths: true, upright_cluster: None, + tcy_block: None, out: TextLayout::default(), }; // Simulate the pre-fix path: clone run from the mark glyph only. diff --git a/crates/text/src/layout/driver.rs b/crates/text/src/layout/driver.rs index 805c4dead4..79a7ebd8d8 100644 --- a/crates/text/src/layout/driver.rs +++ b/crates/text/src/layout/driver.rs @@ -374,6 +374,7 @@ fn layout_runs(db: &FontDb, t: &TextObject, opts: &LayoutOptions, set: Option<&T system_layout: t.system_layout, fractional_widths: t.fractional_widths, upright_cluster: None, + tcy_block: None, out: TextLayout::default(), }; // Vertical type: line space turned a quarter turn clockwise (lines become columns, each next diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index 30f8d76eb8..c94d25f897 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -136,6 +136,7 @@ fn composition_trim_stays_along_the_line_when_a_character_rotates() { system_layout: false, fractional_widths: true, upright_cluster: None, + tcy_block: None, out: TextLayout::default(), }; cx.emit(&glyph, pre, origin, angle, 30.0 - lead, 0); diff --git a/crates/text/src/tests/ligature_carets.rs b/crates/text/src/tests/ligature_carets.rs index a93d1a9cd1..2f9699de68 100644 --- a/crates/text/src/tests/ligature_carets.rs +++ b/crates/text/src/tests/ligature_carets.rs @@ -718,7 +718,7 @@ fn an_upright_ligature_on_a_path_takes_its_stop_in_its_turned_cell() { /// On a path, an upright ligature in vertical type reads along the path in order: the top of its /// cell comes first, and its font caret (600 units down the cell) is drawn where the caret, a click /// and the selection put the stop, on a path running either way with any path effect. A glyph lying -/// on its side keeps its top toward the path's end. +/// on its side is a quarter turn clockwise from that, as horizontal type on the path sets it. #[test] fn an_upright_ligature_on_a_path_reads_along_it_in_order() { use vectorcraft_doc::PathEffect; @@ -763,8 +763,8 @@ fn an_upright_ligature_on_a_path_reads_along_it_in_order() { let (q0, q1) = (along(q[0][0]), along(q[0][1])); assert!((q0.min(q1) - drawn.min(0.0)).abs() < 1e-6 && (q0.max(q1) - drawn.max(0.0)).abs() < 1e-6, "{to} {effect:?}: {q:?} against {drawn}"); } - // Without Standard Vertical Roman Alignment the letter lies on its side: its top still faces - // the path's end and its advance runs across the path, to its right. + // Without Standard Vertical Roman Alignment the letter lies on its side: its top faces the + // path's left and its advance runs along the path. let mut line = BezPath::new(); line.move_to((0.0, 0.0)); line.line_to((1000.0, 0.0)); @@ -774,9 +774,9 @@ fn an_upright_ligature_on_a_path_reads_along_it_in_order() { let l = layout(&db, &t); let Some(s) = l.glyphs.iter().find(|g| g.len == 2) else { panic!("f + i is one glyph: {:?}", l.glyphs) }; let tangent = Vec2::new(s.angle.cos(), s.angle.sin()); - let right = Vec2::new(-tangent.y, tangent.x); + let left = Vec2::new(tangent.y, -tangent.x); let (pen, up, ahead) = (s.xf * Point::ZERO, s.xf * Point::new(0.0, -500.0), s.xf * Point::new(500.0, 0.0)); - assert!((up - pen).dot(tangent) > 1.0 && (ahead - pen).dot(right) > 1.0, "{pen:?} {up:?} {ahead:?}"); + assert!((up - pen).dot(left) > 1.0 && (ahead - pen).dot(tangent) > 1.0, "{pen:?} {up:?} {ahead:?}"); } /// On a path, an upright ligature whose cell Proportional Metrics shortens (`vpal`, from 1000 units diff --git a/crates/text/src/tests/vertical.rs b/crates/text/src/tests/vertical.rs index 225395d348..4df48c51ef 100644 --- a/crates/text/src/tests/vertical.rs +++ b/crates/text/src/tests/vertical.rs @@ -184,6 +184,220 @@ fn type_on_a_path_stays_horizontal() { assert!(!layout(FontDb::global(), &t).vertical); } +/// `text` in vertical type on a straight path from `from` to `to`, with path effect `effect`. +fn on_vertical_path(text: &str, from: (f64, f64), to: (f64, f64), effect: vectorcraft_doc::PathEffect) -> TextObject { + let mut t = vertical(text); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new(from, to).to_path(0.1)), start: 0.0, end: None }; + t.path_effect = effect; + t +} + +/// Glyph `i`'s top (its ascender direction) and its own advance direction in text space, as unit +/// vectors. +fn top_and_ahead(l: &TextLayout, i: usize) -> (Vec2, Vec2) { + let g = &l.glyphs[i]; + let pen = g.xf * Point::ZERO; + let unit = |v: Vec2| v / v.hypot(); + (unit(g.xf * Point::new(0.0, -500.0) - pen), unit(g.xf * Point::new(500.0, 0.0) - pen)) +} + +/// On a vertical path, an upright glyph's top faces the path's start, and a letter lying on its side +/// is a quarter turn clockwise from it: its top faces the path's left and its baseline runs along +/// the path. Both turn with the path when it runs the other way. +#[test] +fn on_a_vertical_path_letters_lie_a_quarter_turn_clockwise_from_upright_glyphs() { + use vectorcraft_doc::PathEffect; + for to in [(0.0, 1000.0), (0.0, -1000.0), (1000.0, 0.0), (-1000.0, 0.0), (700.0, 700.0)] { + let l = layout(FontDb::global(), &on_vertical_path("§L", (0.0, 0.0), to, PathEffect::Rainbow)); + let tangent = Vec2::new(to.0, to.1) / Vec2::new(to.0, to.1).hypot(); + let left = Vec2::new(tangent.y, -tangent.x); + let (upright, _) = top_and_ahead(&l, 0); + let (side, ahead) = top_and_ahead(&l, 1); + assert!((upright + tangent).hypot() < 1e-6, "{to:?}: the upright top faces the start: {upright:?}"); + // A clockwise quarter turn with y down takes (x, y) to (-y, x). + assert!((side - Vec2::new(-upright.y, upright.x)).hypot() < 1e-6, "{to:?}: {side:?} against {upright:?}"); + assert!((side - left).hypot() < 1e-6 && (ahead - tangent).hypot() < 1e-6, "{to:?}: {side:?} {ahead:?}"); + } +} + +/// On a vertical path, letters lying on their side are set as horizontal type on the same path sets +/// them, with every path effect and on a path running either way, and they follow one another along +/// the path without overlapping where the effect keeps them square to it. +#[test] +fn on_a_vertical_path_letters_keep_the_horizontal_path_pose() { + use vectorcraft_doc::PathEffect; + for effect in [PathEffect::Rainbow, PathEffect::Skew, PathEffect::Ribbon3d, PathEffect::StairStep, PathEffect::Gravity] { + for to in [(1000.0, 0.0), (-1000.0, 0.0), (700.0, 300.0)] { + let mut vertical = on_vertical_path("LLl", (0.0, 0.0), to, effect); + let v = layout(FontDb::global(), &vertical); + vertical.vertical = false; + let h = layout(FontDb::global(), &vertical); + for i in 0..3 { + let (a, b) = (v.glyphs[i].xf.as_coeffs(), h.glyphs[i].xf.as_coeffs()); + assert!(a.iter().zip(b).take(4).all(|(a, b)| (a - b).abs() < 1e-9), "{effect:?} {to:?} glyph {i}: {a:?} against {b:?}"); + } + let tangent = Vec2::new(to.0, to.1) / Vec2::new(to.0, to.1).hypot(); + let span = |i: usize| { + let along = v.glyphs[i].outline.elements().iter().filter_map(|el| el.end_point()).map(|p| p.to_vec2().dot(tangent)); + along.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), d| (lo.min(d), hi.max(d))) + }; + // Skew slants the letters, Stair Step keeps them level and Gravity fans them toward the + // path's center, so their ink can reach past the next letter's. + if matches!(effect, PathEffect::Rainbow | PathEffect::Ribbon3d) { + assert!((0..2).all(|i| span(i).1 <= span(i + 1).0 + 1e-6), "{effect:?} {to:?}: {:?}", (0..3).map(span).collect::>()); + } + } + } +} + +/// On a vertical path, the column is centered on the path: the em boxes of upright glyphs, of letters +/// lying on their side and of a tate-chu-yoko block straddle it, their centers on the path line, on +/// a path running any way. +#[test] +fn on_a_vertical_path_the_column_is_centered_on_the_path() { + use vectorcraft_doc::PathEffect; + let Some(face) = FontDb::global().face("Source Sans 3", "Regular") else { panic!("the bundled face") }; + let (em, centre) = (face.units_per_em(), face.ideographic_centre()); + for to in [(0.0, 1000.0), (0.0, -1000.0), (1000.0, 0.0), (-700.0, 700.0)] { + let l = layout(FontDb::global(), &on_vertical_path("§H12§", (0.0, 0.0), to, PathEffect::Rainbow)); + let tangent = Vec2::new(to.0, to.1) / Vec2::new(to.0, to.1).hypot(); + let across = |p: Point| p.to_vec2().dot(Vec2::new(tangent.y, -tangent.x)); + // An upright glyph (the shaper centers a narrow one in its em box, as off a path) has its + // ink centered on the path. + for i in [0, 4] { + let mid = across(ink(&l, i).center()); + assert!(mid.abs() < 1.0, "{to:?}: upright glyph {i} is {mid} off the path"); + } + // A letter lying on its side: its em box's center is `centre` em above its baseline. + let mid = across(l.glyphs[1].xf * Point::new(0.0, -centre * em)); + assert!(mid.abs() < 1e-6, "{to:?}: the letter's em box is {mid} off the path"); + // The digits of the block stand side by side across the path, the block centered on it. + let block = (across(ink(&l, 2).center()) + across(ink(&l, 3).center())) * 0.5; + assert!(block.abs() < 1.5, "{to:?}: the block is {block} off the path"); + } +} + +/// With Skew on a curved vertical path, every glyph's axis along the path follows the tangent and +/// its axis across the path stays upright on the page: an upright glyph's top runs back along the +/// tangent and its own x axis points up the page, and a letter lying on its side keeps its top up +/// the page and its baseline along the tangent. +#[test] +fn on_a_curved_vertical_path_skew_keeps_the_axis_across_the_path_upright() { + use vectorcraft_doc::PathEffect; + let mut arc = kurbo::BezPath::new(); + arc.move_to((0.0, 0.0)); + arc.curve_to((40.0, 0.0), (80.0, 40.0), (80.0, 120.0)); + let mut t = vertical("§L§L§"); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&arc), start: 0.0, end: None }; + t.path_effect = PathEffect::Skew; + let l = layout(FontDb::global(), &t); + let page_up = Vec2::new(0.0, -1.0); + for (i, g) in l.glyphs.iter().enumerate() { + let tangent = Vec2::new(g.angle.cos(), g.angle.sin()); + let (top, ahead) = top_and_ahead(&l, i); + if i % 2 == 0 { + assert!((top + tangent).hypot() < 1e-6 && (ahead - page_up).hypot() < 1e-6, "upright glyph {i}: {top:?} {ahead:?} at {tangent:?}"); + } else { + assert!((top - page_up).hypot() < 1e-6 && (ahead - tangent).hypot() < 1e-6, "letter {i}: {top:?} {ahead:?} at {tangent:?}"); + } + } + let turns = l.glyphs.iter().map(|g| g.angle).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), a| (lo.min(a), hi.max(a))); + assert!(turns.1 - turns.0 > 0.3, "the glyphs sit on the curve: {turns:?}"); +} + +/// A tate-chu-yoko block whose characters differ in size stays one block: every character stands on +/// the block's baseline in the em of its first character, in vertical type and on a vertical path. +#[test] +fn a_tate_chu_yoko_block_of_mixed_sizes_shares_its_first_characters_em() { + let run = |text: &str, size: f64| TextRun { text: text.into(), style: CharStyle { size, ..CharStyle::default() }, inline: None }; + let mut t = vertical(""); + t.runs = vec![run("§", 20.0), run("1", 20.0), run("2", 10.0), run("§", 20.0)]; + let mut on_path = t.clone(); + on_path.kind = + TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (0.0, 1000.0)).to_path(0.1)), start: 0.0, end: None }; + for (name, t) in [("vertical", &t), ("on a path", &on_path)] { + let l = layout(FontDb::global(), t); + assert!(l.glyphs[2].advance == 0.0, "{name}: 12 is one block: {:?}", l.glyphs.iter().map(|g| g.advance).collect::>()); + let (one, two) = (ink(&l, 1), ink(&l, 2)); + assert!((one.y1 - two.y1).abs() < 0.5 && two.center().x > one.center().x, "{name}: on one baseline: {one:?} {two:?}"); + } +} + +/// On a vertical path, Tate-chu-yoko Settings Up/Down move a marked block toward the path's start +/// and Left/Right move it to the right of its upright characters (the path's left), with every +/// effect on a path running any way. +#[test] +fn on_a_vertical_path_tate_chu_yoko_settings_keep_their_directions() { + use vectorcraft_doc::PathEffect; + for effect in [PathEffect::Rainbow, PathEffect::Skew, PathEffect::Ribbon3d, PathEffect::StairStep, PathEffect::Gravity] { + for to in [(1000.0, 0.0), (-1000.0, 0.0), (0.0, 1000.0), (-700.0, -700.0)] { + let at = |up: f64, right: f64| { + let marked = CharStyle { size: 20.0, tate_chu_yoko: true, tcy_up_down: up, tcy_left_right: right, ..CharStyle::default() }; + let mut t = on_vertical_path("", (0.0, 0.0), to, effect); + t.runs = vec![ + TextRun { text: "§".into(), style: CharStyle { size: 20.0, ..CharStyle::default() }, inline: None }, + TextRun { text: "12".into(), style: marked, inline: None }, + ]; + ink(&layout(FontDb::global(), &t), 1).center().to_vec2() + }; + let tangent = Vec2::new(to.0, to.1) / Vec2::new(to.0, to.1).hypot(); + let left = Vec2::new(tangent.y, -tangent.x); + let (plain, up, right) = (at(0.0, 0.0), at(4.0, 0.0), at(0.0, 3.0)); + assert!((up - plain + tangent * 4.0).hypot() < 1e-6, "{effect:?} {to:?}: Up/Down moved it {:?}", up - plain); + assert!((right - plain - left * 3.0).hypot() < 1e-6, "{effect:?} {to:?}: Left/Right moved it {:?}", right - plain); + } + } +} + +/// On a vertical path, a tate-chu-yoko block stands upright like the glyphs around it: its digits +/// read across the column, their tops toward the path's start, centered on the column. +#[test] +fn on_a_vertical_path_tate_chu_yoko_stands_upright() { + use vectorcraft_doc::PathEffect; + for to in [(0.0, 1000.0), (0.0, -1000.0), (1000.0, 0.0)] { + let l = layout(FontDb::global(), &on_vertical_path("§12§", (0.0, 0.0), to, PathEffect::Rainbow)); + assert!(l.glyphs[1].outline.elements().len() > 1, "{to:?}: the 1 draws"); + let tangent = Vec2::new(to.0, to.1) / Vec2::new(to.0, to.1).hypot(); + let left = Vec2::new(tangent.y, -tangent.x); + let (upright, _) = top_and_ahead(&l, 0); + for i in [1, 2] { + let (top, ahead) = top_and_ahead(&l, i); + assert!((top - upright).hypot() < 1e-6 && (ahead - left).hypot() < 1e-6, "{to:?} digit {i}: {top:?} {ahead:?}"); + } + let centre = |i: usize| ink(&l, i).center().to_vec2(); + let (one, two) = (centre(1), centre(2)); + assert!((two - one).dot(left) > 3.0 && (two - one).dot(tangent).abs() < 1.0, "{to:?}: 12 side by side: {one:?} {two:?}"); + let across = |v: Vec2| v.dot(left); + let block = (across(one) + across(two)) * 0.5; + assert!((block - across(centre(0))).abs() < 2.0, "{to:?}: the block is centered on the column: {block} against {}", across(centre(0))); + let (before, after) = ((centre(0)).dot(tangent), (centre(3)).dot(tangent)); + assert!(before < one.dot(tangent) && one.dot(tangent) < after, "{to:?}: the block stands between the marks"); + } + // On a curve the block stays whole: both digits turn alike and stand on one baseline. + let mut arc = kurbo::BezPath::new(); + arc.move_to((0.0, 0.0)); + arc.curve_to((0.0, 40.0), (20.0, 60.0), (60.0, 60.0)); + let mut t = vertical("§12§"); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&arc), start: 0.0, end: None }; + let l = layout(FontDb::global(), &t); + let (a, b) = (l.glyphs[1].xf.as_coeffs(), l.glyphs[2].xf.as_coeffs()); + assert!(a.iter().zip(b).take(4).all(|(a, b)| (a - b).abs() < 1e-9), "{a:?} against {b:?}"); + let (top, ahead) = top_and_ahead(&l, 1); + let (p1, p2) = (l.glyphs[1].xf * Point::ZERO, l.glyphs[2].xf * Point::ZERO); + assert!((p2 - p1).dot(top).abs() < 1e-6 && (p2 - p1).dot(ahead) > 3.0, "{p1:?} {p2:?}"); + // With every effect, on a path running either way, the block's top faces the path's start. + for effect in [PathEffect::Rainbow, PathEffect::Skew, PathEffect::Ribbon3d, PathEffect::StairStep, PathEffect::Gravity] { + for to in [(1000.0, 0.0), (-1000.0, 0.0)] { + let l = layout(FontDb::global(), &on_vertical_path("§12§", (0.0, 0.0), to, effect)); + let tangent = Vec2::new(to.0, to.1) / Vec2::new(to.0, to.1).hypot(); + for i in [1, 2] { + let (top, _) = top_and_ahead(&l, i); + assert!(top.dot(tangent) < -0.5, "{effect:?} {to:?} digit {i}: {top:?}"); + } + } + } +} + #[test] fn three_digits_stand_across_the_column_squeezed_into_one_em() { let l = layout(FontDb::global(), &vertical("§100§")); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c15e19cda6..562574cde0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1165,7 +1165,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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 (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 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; on a vertical path, upright glyphs and tate-chu-yoko blocks face the path's start, and letters on their side are a quarter turn clockwise from them in the same column, which is centered on the path); 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. Character panel menu › Tate-chu-yoko Settings… (with the East Asian options; `ui.tateChuYokoSettings`, `text.setFormat` or `text.setRangeStyle {tcyUpDown, tcyLeftRight}`, in points, 0 by default) moves a marked tate-chu-yoko block in vertical type up by a positive Up/Down and right by a positive Left/Right ([format East Asian characters in vertical text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/format-east-asian-characters-in-vertical-text.html), [formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); the block moves as one piece with its first character's values and keeps its one em down the column, automatic digit blocks do not move, Reset Panel sets both back to 0, and character styles and the native file keep them. From 693031c5093d349de1815c1b5760b4ececfb5cb2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 21:59:21 -0800 Subject: [PATCH 04/20] Scripts: #include and the other preprocessor lines run, and $.evalFile runs a file Before a script runs, `script.run` reads its preprocessor lines (`#` or `//@` at the start of a line, outside strings and comments): - `#include` and `//@include` put another file's text in place of the line, looked for beside the including file, then in each `#includepath` folder, then in each `JSINCLUDE` folder. A later `#includepath` replaces the list. - `#script` names the run unless `name` is given; `#target` takes any value; `#strict off` is taken. `#strict on`, `#targetengine` and `#engine` are refused as unsupported, and any other `#name` as unknown. `$.evalFile(path, timeout?)` runs a file in the same global environment and returns its last value, `$.fileName` is the file being run and `$.includePath` the include folders. A script run from a file reads these files only in its folder and the folders below it. Paths with a `..` name, links or junctions that lead out, device names, streams and network paths are refused; a script given as text reads none. A run reads at most 64 files 16 levels deep, 32 MiB of source in all and 1,024 paths looked up. An error in an included or evaluated file is reported at that file's line and column, and the report's new `file` field names it. Docs: docs/mcp.md (script.run, Script Console), docs/target-app-parity.md. --- crates/engine/src/cmd/scripts.rs | 7 +- crates/engine/src/file_access.rs | 85 +++++ crates/engine/src/script/include.rs | 431 ++++++++++++++++++++++ crates/engine/src/script/mod.rs | 218 +++++++++-- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/script_include.rs | 327 ++++++++++++++++ crates/engine/src/tests/script_run.rs | 11 +- crates/js/src/builtins.rs | 4 +- crates/js/src/bytecode.rs | 2 + crates/js/src/heap.rs | 4 +- crates/js/src/host.rs | 69 +++- crates/js/src/interp.rs | 12 +- crates/js/src/interp_tests.rs | 2 +- crates/js/src/lib.rs | 2 +- crates/js/src/realm.rs | 26 +- docs/mcp.md | 43 ++- docs/target-app-parity.md | 1 + 17 files changed, 1194 insertions(+), 51 deletions(-) create mode 100644 crates/engine/src/script/include.rs create mode 100644 crates/engine/src/tests/script_include.rs diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 1ed75194bd..aac077bb89 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Run Script", [], None, - "{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), name?: the name errors and the report use (default: the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: {} (no prompts exist yet: only an empty object is taken), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; vc.args is args; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error)}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, fuelLeft, fuelUsed, hostCalls and the fields above", + "{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), name?: the name errors and the report use (default: the script's #script line, else the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: {} (no prompts exist yet: only an empty object is taken), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; $.evalFile(path, timeout?) runs a file with the same globals and returns its last value (timeout, in milliseconds from 0, is taken and not waited on), $.fileName is the file being run (empty for text) and $.includePath the #includepath list; vc.args is args. Before anything runs, the script's preprocessor lines (# or //@ at the start of a line, outside strings and comments) are read: #include \"file\" and //@include put that file's text in place of the line, looked for beside the file that includes it, then in each #includepath folder in turn (separated by ;, relative to that file's folder; a later #includepath replaces the list), then in each JSINCLUDE folder; #script names the run; #target takes any value and changes nothing; #strict off is taken; #strict on, #targetengine and #engine are refused as unsupported, any other #name as unknown, and another //@name is a comment. A script run from a file reads its includes and $.evalFile's files only in that file's folder and the folders below it, refusing a path with a .. name or a link that leads out (a JSINCLUDE folder outside is passed over); a script given as text reads none. An error in another file is at that file's line and column; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source a file and 32 MiB in all, 64 included or evaluated files 16 deep, 1024 paths looked up for them, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error)}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, file (when the error is in another file than the script), fuelLeft, fuelUsed, hostCalls and the fields above", always, run ), @@ -50,7 +50,7 @@ pub fn specs() -> Vec { "Script Console", [], None, - "{} the Script Console's lines, oldest first: each run's start, its $.write and $.writeln output, its vc.log entries, its uncaught error as name:line:column: message, its end and a report of its time, host calls and slowest commands; a session keeps at most 2048 lines and 1 MiB of text, dropping the oldest, a line at most 4096 bytes and a script's name in its lines at most 256 bytes, each cut with … (VectorCraft's own limits); line feeds, carriage returns and the two together break lines, other control characters show as their Unicode Control Pictures → {lines: [{run: the run's number in the session, name, kind: start|output|log|error|end|report, text}], droppedLines, truncatedLines} (an end line's text is ended: finished|error|fuel|hostLimit|limit|stopped|internal)", + "{} the Script Console's lines, oldest first: each run's start, its $.write and $.writeln output, its vc.log entries, its uncaught error as name:line:column: message (name: file:line:column: message when it is in another file), its end and a report of its time, host calls and slowest commands; a session keeps at most 2048 lines and 1 MiB of text, dropping the oldest, a line at most 4096 bytes and a script's name in its lines at most 256 bytes, each cut with … (VectorCraft's own limits); line feeds, carriage returns and the two together break lines, other control characters show as their Unicode Control Pictures → {lines: [{run: the run's number in the session, name, kind: start|output|log|error|end|report, text}], droppedLines, truncatedLines} (an end line's text is ended: finished|error|fuel|hostLimit|limit|stopped|internal)", always, console_lines ), @@ -153,7 +153,8 @@ fn run(s: &mut Session, p: &Value) -> Result { _ => return Err(param("give exactly one of `path` and `source`")), }; let name = given_name.map(str::to_string).or_else(|| path.map(super::fileio::file_name)).unwrap_or_else(|| "script".to_string()); - s.script_start(script::Request { name: name.clone(), source: text, args, fuel })?; + let request = script::Request { name, name_given: given_name.is_some(), file: path.map(str::to_string), source: text, args, fuel }; + let name = s.script_start(request)?; if s.script_frames { return Ok(json!({"running": true, "name": name})); } diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index c6daff9e41..3f0a641e2e 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -23,6 +23,9 @@ //! A path is checked, then opened: a process that can change the folders in between (swapping in //! a link) can still win that race. The roots keep an agent to the files it was given; they don't //! guard against other programs on the computer. +//! +//! [`ScriptScope`] holds the folders a running script's own files (its includes and `$.evalFile`) +//! are read from, inside the roots. use std::cell::RefCell; use std::ffi::OsString; @@ -347,6 +350,88 @@ fn windows_name_refusal(path: &str) -> Option<&'static str> { }) } +/// The folders a running script's own files are read from: the files its `#include` lines name and +/// the files it passes to `$.evalFile` (`Scope::Script`'s reads). VectorCraft's own rules: a script +/// run from a file may read inside that file's folder and the folders below it, and a script given as +/// text may read none. A path is refused when it has a `..` component, a Windows device name or +/// stream, or names a computer on the network, or when it leads (through its links, junctions +/// included) out of every granted folder. The automation roots ([`check_read`]) apply as well. +/// +/// The file opened is the path with its links followed, and after reading, the path is followed +/// again and must still lead to the same place: a process that changes the folders twice in between +/// can still win that race. +#[derive(Clone, Debug, Default)] +pub(crate) struct ScriptScope { + /// The granted folders, made absolute with their links followed. + roots: Vec, +} + +/// Why a script's file was not read. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ScriptRead { + /// There is no such file. + Missing, + /// The scope does not let the script read it. + Refused, + /// The file has more than the bytes asked for. + TooLarge, + /// The file system gave this error. + Failed(String), +} + +impl ScriptScope { + /// The scope of a script run from a file in folder `dir`: that folder. + pub(crate) fn of_folder(dir: &Path) -> Self { + Self { roots: std::fs::canonicalize(dir).ok().into_iter().collect() } + } + + /// Is `path` refused by its name alone: a `..` component, a NUL, a Windows device name or + /// stream, or a computer on the network? + fn refused_by_name(path: &Path) -> bool { + let text = path.to_string_lossy(); + text.contains('\0') + || path.components().any(|c| c == Component::ParentDir) + || (cfg!(windows) && (windows_name_refusal(&text).is_some() || network_path(&text))) + } + + /// The real path of the file at `path` and its first `max` bytes, at most. + pub(crate) fn read(&self, path: &Path, max: usize) -> Result<(PathBuf, Vec), ScriptRead> { + use std::io::Read; + if self.roots.is_empty() || Self::refused_by_name(path) || check_read(&path.to_string_lossy()).is_err() { + return Err(ScriptRead::Refused); + } + let real = match std::fs::canonicalize(path) { + Ok(real) => real, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // A link whose target is missing is there all the same: it is not followed. + return Err(if std::fs::symlink_metadata(path).is_ok() { ScriptRead::Refused } else { ScriptRead::Missing }); + } + Err(e) => return Err(ScriptRead::Failed(e.to_string())), + }; + if !self.roots.iter().any(|r| inside(&real, r)) { + return Err(ScriptRead::Refused); + } + let file = std::fs::File::open(&real).map_err(|e| ScriptRead::Failed(e.to_string()))?; + let meta = file.metadata().map_err(|e| ScriptRead::Failed(e.to_string()))?; + if !meta.is_file() { + return Err(ScriptRead::Missing); + } + if meta.len() > max as u64 { + return Err(ScriptRead::TooLarge); + } + let mut bytes = Vec::new(); + // One byte more than asked for shows a file that grew while it was read. + file.take(max as u64 + 1).read_to_end(&mut bytes).map_err(|e| ScriptRead::Failed(e.to_string()))?; + if bytes.len() > max { + return Err(ScriptRead::TooLarge); + } + if std::fs::canonicalize(path).ok().as_ref() != Some(&real) { + return Err(ScriptRead::Refused); + } + Ok((real, bytes)) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/engine/src/script/include.rs b/crates/engine/src/script/include.rs new file mode 100644 index 0000000000..df7702d771 --- /dev/null +++ b/crates/engine/src/script/include.rs @@ -0,0 +1,431 @@ +//! A script's preprocessor lines, read before it runs, as the public guide lists them +//! (): +//! `#include` and `//@include` put the text of another file in place of the line, `#includepath` +//! sets the folders `#include` looks in, `#script` names the run, and `#target` and `#strict off` +//! are accepted. The same lines are read in a file `$.evalFile` runs. +//! +//! VectorCraft's own rules: +//! +//! - A line counts only at the start of a line (blanks before it), outside strings and comments, as +//! the lexer reads it, and each file must be a whole script on its own. +//! - An included file is looked for beside the file that includes it, then in each `#includepath` +//! folder in turn (a relative one is relative to that same file's folder), then in each folder of +//! the `JSINCLUDE` environment variable. A later `#includepath` replaces the list. +//! - Files are read through the script's [`ScriptScope`]: a refused path ends the run, except a +//! `JSINCLUDE` folder outside the scope, which is passed over. +//! - `#target` takes any value and changes nothing. `#targetengine`, `#engine` and `#strict on` are +//! refused as unsupported, and any other `#name` as unknown; `//@name` with another name stays a +//! comment. +//! - A run reads at most [`MAX_DEPTH`] levels of nested files, [`MAX_FILES`] files, +//! [`MAX_TOTAL_BYTES`] of source in all (the script's own included) and 8 MiB a file, and looks up +//! at most [`MAX_PROBES`] paths. A file may be included again once its earlier inclusion has +//! ended; one that includes itself, directly or not, is an error. +//! +//! The expanded source keeps a [`SourceMap`], so an error is reported at the line and column of +//! the file it is in. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use vectorcraft_js::{DirectiveName, Options}; + +use crate::file_access::{ScriptRead, ScriptScope}; + +/// The most files nested inside each other, below the script. +pub const MAX_DEPTH: usize = 16; +/// The most files a run includes or evaluates. +pub const MAX_FILES: usize = 64; +/// The most bytes of source a run reads, its own included. +pub const MAX_TOTAL_BYTES: usize = 32 << 20; +/// The most paths a run looks up for its files. +pub const MAX_PROBES: usize = 1024; +/// The longest value a preprocessor line may have, in bytes. +const MAX_VALUE_BYTES: usize = 4096; + +/// Where the lines of an expanded source came from: file 0 is the file expanded, the others the +/// files it included. +#[derive(Clone, Debug, Default)] +pub(crate) struct SourceMap { + files: Vec>, + segments: Vec, +} + +/// From line `out_line` of the expanded source on, the lines are those of file `file` from line +/// `line`; on `out_line` itself the columns are `shift` units further right. +#[derive(Clone, Copy, Debug)] +struct Segment { + out_line: u32, + file: usize, + line: u32, + shift: u32, +} + +impl SourceMap { + /// Line `line`, column `column` of the expanded source → the file it is in (its index and name) + /// and the line and column there. + pub(crate) fn locate(&self, line: u32, column: u32) -> (usize, Arc, u32, u32) { + let at = self.segments.partition_point(|s| s.out_line <= line); + let Some(seg) = at.checked_sub(1).and_then(|i| self.segments.get(i)) else { + return (0, self.files.first().cloned().unwrap_or_else(|| Arc::from("")), line, column); + }; + let column = if line == seg.out_line { column.saturating_sub(seg.shift).max(1) } else { column }; + let name = self.files.get(seg.file).cloned().unwrap_or_else(|| Arc::from("")); + (seg.file, name, seg.line.saturating_add(line - seg.out_line), column) + } +} + +/// How a preprocessor failure ends the run. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FailKind { + /// A line that is not a valid preprocessor line, or a file that is not a valid script. + Syntax, + /// A file that is missing, refused or unreadable, or a cycle. + Error, + /// One of the run's limits. + Limit, +} + +/// A preprocessor failure, at line `line`, column `column` of the file `file` (index `index` of +/// the expansion's [`SourceMap`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Failure { + pub kind: FailKind, + pub message: String, + pub index: usize, + pub file: Arc, + pub line: u32, + pub column: u32, +} + +/// A source with its preprocessor lines read: the text to run, where its lines came from, and the +/// name its `#script` line gave. +pub(crate) struct Expanded { + pub source: String, + pub map: SourceMap, + pub name: Option, +} + +/// A file being run: its name for people and the folder its relative paths start from (none for a +/// script given as text). +struct Frame { + shown: Arc, + dir: Option, +} + +/// The files of one run: its scope, the `#includepath` list, what it has read and the files open +/// now, innermost last. +pub(crate) struct Files { + scope: ScriptScope, + jsinclude: Vec, + include_path: Vec, + files: usize, + bytes: usize, + probes: usize, + /// The real paths of the files open now, for cycles. + chain: Vec, + /// How many of them are below the script. + levels: usize, + /// The files `$.evalFile` is running, the script first. + frames: Vec, +} + +/// The output being built: its UTF-16 text, the line it is on, the units since that line began, +/// and whether its last unit is a carriage return. +struct Out { + units: Vec, + line: u32, + column: u32, + after_cr: bool, + map: SourceMap, +} + +impl Out { + fn emit(&mut self, text: &[u16]) { + for &u in text { + match u { + 0x0A if self.after_cr => self.column = 0, + 0x0A | 0x0D | 0x2028 | 0x2029 => { + self.line = self.line.saturating_add(1); + self.column = 0; + } + _ => self.column = self.column.saturating_add(1), + } + self.after_cr = u == 0x0D; + } + self.units.extend_from_slice(text); + } + + /// The lines from here on come from file `file`, from its line `line`. + fn begin(&mut self, file: usize, line: u32) { + self.map.segments.push(Segment { out_line: self.line, file, line, shift: self.column }); + } + + /// Ends the line the output is on, unless it has just ended. + fn end_line(&mut self) { + if self.after_cr || !matches!(self.units.last(), Some(0x0A | 0x2028 | 0x2029)) { + self.emit(&[0x0A]); + } + } +} + +/// A file's text read as a script: a byte order mark is left out, and compiled scripts and bytes +/// that are not UTF-8 are refused. +fn decode(bytes: &[u8], value: &str) -> Result { + if bytes.starts_with(b"@JSXBIN@") { + return Err(format!("script file is compiled: {value}")); + } + let text = std::str::from_utf8(bytes).map_err(|_| format!("script file is not UTF-8 text: {value}"))?; + Ok(text.strip_prefix('\u{feff}').unwrap_or(text).to_string()) +} + +/// `value` without the single or double quotes around it, or `None` when a quote is not closed. +fn unquote(value: &str) -> Option<&str> { + match value.chars().next() { + Some(q @ ('"' | '\'')) => value.get(1..).and_then(|v| v.strip_suffix(q)), + _ => Some(value), + } +} + +/// The folder the file at `path` is in, without Windows' `\\?\` prefix: under that prefix `..` and +/// `/` in the names joined to it would be read as plain characters. +fn folder_of(path: &Path) -> Option { + path.parent().map(|p| crate::cmd::findfiles::plain(p.to_path_buf())) +} + +/// Does the path `p` have a `..` name, with `/` or `\` between names? +fn has_parent(p: &str) -> bool { + p.split(['/', '\\']).any(|name| name == "..") +} + +/// A real path as people read it. +fn display(path: &Path) -> Arc { + Arc::from(crate::cmd::findfiles::plain(path.to_path_buf()).to_string_lossy().as_ref()) +} + +impl Files { + /// The files of a run of the script at `path` (`None` for a script given as text), with the + /// `JSINCLUDE` folders `jsinclude`. + pub(crate) fn new(path: Option<&str>, jsinclude: Vec) -> Self { + let real = path.and_then(|p| std::fs::canonicalize(p).ok()); + let dir = real.as_deref().and_then(folder_of); + Self { + scope: dir.as_deref().map(ScriptScope::of_folder).unwrap_or_default(), + jsinclude, + include_path: Vec::new(), + files: 0, + bytes: 0, + probes: 0, + chain: real.into_iter().collect(), + levels: 0, + frames: vec![Frame { shown: Arc::from(path.unwrap_or_default()), dir }], + } + } + + /// The `JSINCLUDE` folders of this process: absolute folders separated by `;`. + pub(crate) fn jsinclude_env() -> Vec { + #[cfg(test)] + if let Some(list) = super::TEST_JSINCLUDE.with(|t| t.borrow().clone()) { + return list; + } + std::env::var("JSINCLUDE").map(|v| v.split(';').map(str::trim).filter(|p| !p.is_empty()).map(PathBuf::from).collect()).unwrap_or_default() + } + + /// The file being run now, as `$.fileName` gives it (empty for a script given as text). + pub(crate) fn file_name(&self) -> String { + self.frames.last().map(|f| f.shown.to_string()).unwrap_or_default() + } + + /// The `#includepath` list, as `$.includePath` gives it. + pub(crate) fn include_path(&self) -> String { + self.include_path.join(";") + } + + /// The script's own text, `text`, with its preprocessor lines read. + pub(crate) fn expand_script(&mut self, text: &str) -> Result { + self.bytes = self.bytes.saturating_add(text.len()); + let shown = self.frames.first().map(|f| f.shown.clone()).unwrap_or_else(|| Arc::from("")); + let dir = self.frames.first().and_then(|f| f.dir.clone()); + self.expand(shown, dir, text, true) + } + + /// The file `path` names for `$.evalFile`, relative to the file being run now, read and with its + /// preprocessor lines read. It becomes the file being run until [`Files::end_eval`]. + pub(crate) fn eval_file(&mut self, path: &str) -> Result { + let current = self.frames.last().map(|f| f.shown.clone()).unwrap_or_else(|| Arc::from("")); + let fail = |kind, message: String| Failure { kind, message, index: 0, file: current.clone(), line: 0, column: 0 }; + let dir = self.frames.last().and_then(|f| f.dir.clone()); + let (real, bytes) = self.open(path, dir.as_deref(), false).map_err(|(kind, message)| fail(kind, message))?; + let text = self.admit(&real, bytes, path).map_err(|(kind, message)| fail(kind, message))?; + let shown = display(&real); + self.chain.push(real.clone()); + self.levels += 1; + self.frames.push(Frame { shown: shown.clone(), dir: folder_of(&real) }); + let r = self.expand(shown, folder_of(&real), &text, false); + if r.is_err() { + self.end_eval(); + } + r + } + + /// The file `$.evalFile` ran has ended. + pub(crate) fn end_eval(&mut self) { + if self.frames.len() > 1 { + self.frames.pop(); + self.chain.pop(); + self.levels = self.levels.saturating_sub(1); + } + } + + /// Checks a file read at `real` against the run's limits and the files open now, and decodes + /// it. `value` is the path as the script wrote it. + fn admit(&mut self, real: &Path, bytes: Vec, value: &str) -> Result { + if self.chain.iter().any(|p| p == real) { + return Err((FailKind::Error, format!("include cycle: {value}"))); + } + if self.levels >= MAX_DEPTH { + return Err((FailKind::Limit, format!("include depth limit ({MAX_DEPTH}) reached"))); + } + if self.files >= MAX_FILES { + return Err((FailKind::Limit, format!("include file limit ({MAX_FILES}) reached"))); + } + if self.bytes.saturating_add(bytes.len()) > MAX_TOTAL_BYTES { + return Err((FailKind::Limit, "script source limit reached (32 MiB)".to_string())); + } + self.files += 1; + self.bytes += bytes.len(); + decode(&bytes, value).map_err(|m| (FailKind::Error, m)) + } + + /// Finds and reads the file `value` names, from the file in folder `dir`; with `search`, also in + /// the `#includepath` and `JSINCLUDE` folders. + fn open(&mut self, value: &str, dir: Option<&Path>, search: bool) -> Result<(PathBuf, Vec), (FailKind, String)> { + let path = Path::new(value); + let refused = || (FailKind::Error, format!("script file read refused: {value}")); + // A script given as text has no folder, and so no file it may read. + let Some(dir) = dir.filter(|_| !has_parent(value)) else { return Err(refused()) }; + // Each candidate, and whether the scope's refusal passes it over. + let mut candidates: Vec<(Option, bool)> = vec![(Some(dir.join(path)), false)]; + if search && !path.is_absolute() { + candidates.extend(self.include_path.iter().map(|p| ((!has_parent(p)).then(|| dir.join(p).join(path)), false))); + candidates.extend(self.jsinclude.iter().filter(|p| p.is_absolute()).map(|p| (Some(p.join(path)), true))); + } + for (candidate, passed_over_when_refused) in candidates { + let Some(candidate) = candidate else { return Err(refused()) }; + self.probes += 1; + if self.probes > MAX_PROBES { + return Err((FailKind::Limit, format!("include search limit ({MAX_PROBES} paths) reached"))); + } + match self.scope.read(&candidate, crate::script::MAX_SOURCE_BYTES) { + Ok(found) => return Ok(found), + Err(ScriptRead::Missing) => {} + Err(ScriptRead::Refused) if passed_over_when_refused => {} + Err(ScriptRead::Refused) => return Err(refused()), + Err(ScriptRead::TooLarge) => return Err((FailKind::Limit, format!("script file is too large: {value}"))), + Err(ScriptRead::Failed(e)) => return Err((FailKind::Error, format!("cannot read script file {value}: {e}"))), + } + } + Err((FailKind::Error, format!("include file not found: {value}"))) + } + + /// `text`, the file `shown` in folder `dir`, with its preprocessor lines read. With `script`, + /// its `#script` line names the run. + fn expand(&mut self, shown: Arc, dir: Option, text: &str, script: bool) -> Result { + let mut out = Out { units: Vec::with_capacity(text.len()), line: 1, column: 0, after_cr: false, map: SourceMap::default() }; + let mut name = None; + self.file(&mut out, shown, dir.as_deref(), text, script, &mut name)?; + let source = String::from_utf16(&out.units).map_err(|_| Failure { + kind: FailKind::Syntax, + message: "the script is not valid text".into(), + index: 0, + file: out.map.files.first().cloned().unwrap_or_else(|| Arc::from("")), + line: 0, + column: 0, + })?; + Ok(Expanded { source, map: out.map, name }) + } + + /// Adds file `shown` (in folder `dir`, with text `text`) to `out`, its included files in place + /// of their lines. + fn file( + &mut self, + out: &mut Out, + shown: Arc, + dir: Option<&Path>, + text: &str, + script: bool, + name: &mut Option, + ) -> Result<(), Failure> { + let index = out.map.files.len(); + out.map.files.push(shown.clone()); + let units: Vec = text.encode_utf16().collect(); + out.begin(index, 1); + // A source with neither `#` nor `@` has no preprocessor line. + if !units.iter().any(|&u| u == u16::from(b'#') || u == u16::from(b'@')) { + out.emit(&units); + return Ok(()); + } + let fail = |kind, message: String, line, column| Failure { kind, message, index, file: shown.clone(), line, column }; + let options = Options { preprocessor_lines: true, max_source_units: MAX_TOTAL_BYTES, ..Options::default() }; + let program = vectorcraft_js::parse_utf16(&units, &options).map_err(|e| fail(FailKind::Syntax, e.message, e.line, e.column))?; + let mut cursor = 0usize; + for d in &program.directives { + let (start, end) = (d.span.start as usize, d.span.end as usize); + let line_start = + units.get(..start).and_then(|u| u.iter().rposition(|&c| matches!(c, 0x0A | 0x0D | 0x2028 | 0x2029))).map_or(0, |i| i + 1); + let column = u32::try_from(start - line_start + 1).unwrap_or(u32::MAX); + let at = |kind, message: String| fail(kind, message, d.line, column); + out.emit(units.get(cursor..start).unwrap_or_default()); + cursor = end; + let label = format!("{}{}", d.form, d.name); + if d.value.len() > MAX_VALUE_BYTES { + return Err(at(FailKind::Syntax, format!("the value of {label} is too long"))); + } + let value = unquote(&d.value).ok_or_else(|| at(FailKind::Syntax, format!("unterminated quote in {label}")))?; + match d.known { + Some(DirectiveName::Include) => { + if value.is_empty() { + return Err(at(FailKind::Syntax, format!("{label} needs a file name"))); + } + let (real, bytes) = self.open(value, dir, true).map_err(|(kind, message)| at(kind, message))?; + let included = self.admit(&real, bytes, value).map_err(|(kind, message)| at(kind, message))?; + self.chain.push(real.clone()); + self.levels += 1; + let r = self.file(out, display(&real), folder_of(&real).as_deref(), &included, false, name); + self.chain.pop(); + self.levels -= 1; + r?; + out.end_line(); + out.begin(index, d.line); + } + Some(DirectiveName::IncludePath) => { + self.include_path = value.split(';').map(str::trim).filter(|p| !p.is_empty()).map(str::to_string).collect(); + } + Some(DirectiveName::Script) => { + if value.is_empty() { + return Err(at(FailKind::Syntax, format!("{label} needs a name"))); + } + if script && index == 0 { + *name = Some(value.to_string()); + } + } + Some(DirectiveName::Target) => { + if value.is_empty() { + return Err(at(FailKind::Syntax, format!("{label} needs a value"))); + } + } + Some(DirectiveName::Strict) => match value.to_ascii_lowercase().as_str() { + "off" => {} + "on" => return Err(at(FailKind::Syntax, format!("unsupported strict checking: {label} on"))), + _ => return Err(at(FailKind::Syntax, format!("{label} takes on or off"))), + }, + Some(DirectiveName::TargetEngine) => return Err(at(FailKind::Syntax, format!("unsupported script directive: {label}"))), + None if d.name.eq_ignore_ascii_case("engine") => { + return Err(at(FailKind::Syntax, format!("unsupported script directive: {label}"))); + } + None => return Err(at(FailKind::Syntax, format!("unknown script directive: {label}"))), + } + } + out.emit(units.get(cursor..).unwrap_or_default()); + Ok(()) + } +} diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index 772f2c0164..8f19187584 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -23,12 +23,18 @@ //! and the 64 levels, 16 MiB and 1,048,576 entries of [`vectorcraft_js::DataLimits`]'s default, //! and the console's ([`console`]). //! +//! Before a run starts, its preprocessor lines are read ([`include`]): included files take the +//! place of their lines, and an error in one is reported at its own file, line and column. +//! `$.evalFile(path)` runs another file in the same global environment and gives its last value, +//! `$.fileName` is the file being run and `$.includePath` the `#includepath` list. +//! //! A run writes to the Script Console with `$.write` and `$.writeln`, and `vc.log` adds a line //! there too. The session's console gets a start line, the run's output, its uncaught error, an end //! line and a report line; the run's report holds its own output and error, its time, its host //! calls by kind and its slowest commands. pub mod console; +pub mod include; use std::cell::{Cell, RefCell}; use std::collections::HashMap; @@ -131,7 +137,11 @@ pub(crate) fn refused_text(id: &str) -> String { /// A request to run a script. pub(crate) struct Request { + /// The run's name; unless `name_given`, a `#script` line in the script replaces it. pub name: String, + pub name_given: bool, + /// The file the script was read from, which its included files are read beside. + pub file: Option, pub source: String, pub args: Map, pub fuel: u64, @@ -468,8 +478,20 @@ struct Host { commands: RefCell>, /// The time the run's slices took, in nanoseconds. run_nanos: Cell, + /// The files the run reads: its includes and `$.evalFile`'s. + files: RefCell, + /// Where the lines of the script's expanded source came from. + script_map: RefCell, + /// The same for each source `$.evalFile` gave, in order. + eval_maps: RefCell>, + /// The last error `$.evalFile` raised for a file it could not run, with the file, line and + /// column that error is at. + eval_failure: RefCell>, } +/// An error `$.evalFile` raised, as thrown, and the file, line and column it is at. +type EvalFailure = (String, Arc, u32, u32); + /// A run's host calls by kind, as its report gives them in `hostCallCounts`. #[derive(Default)] struct Counts { @@ -899,6 +921,8 @@ fn each_line(text: &str, mut done: impl FnMut(String, bool)) { /// The `$` object: `$.write(...values)` writes the values to the Script Console as `String(value)` /// gives them, one after another, and `$.writeln(...values)` does the same and ends the line. +/// `$.evalFile(path[, timeout])` runs a file and gives its last value, `$.fileName` is the file +/// being run and `$.includePath` the `#includepath` list. fn dollar_object(host: &Rc) -> Vec<(String, HostItem)> { let writer = |end: bool| { let h = host.clone(); @@ -910,7 +934,67 @@ fn dollar_object(host: &Rc) -> Vec<(String, HostItem)> { Ok(Data::Undefined) })) }; - vec![("write".into(), writer(false)), ("writeln".into(), writer(true))] + let getter = |read: fn(&include::Files) -> String| { + let h = host.clone(); + HostItem::Getter(function(move |_| { + let text = read(&h.files.borrow()); + h.charge_call(text.len() as u64 + 2)?; + Ok(Data::String(text)) + })) + }; + let eval_file = { + let h = host.clone(); + let end = host.clone(); + HostItem::Evaluator { source: function(move |a| eval_file(&h, a)), end: Rc::new(move || end.files.borrow_mut().end_eval()) } + }; + vec![ + ("write".into(), writer(false)), + ("writeln".into(), writer(true)), + ("evalFile".into(), eval_file), + ("fileName".into(), getter(include::Files::file_name)), + ("includePath".into(), getter(include::Files::include_path)), + ] +} + +/// `$.evalFile(path[, timeout])`: the source of the file `path` names, relative to the file being +/// run, with its preprocessor lines read, which the interpreter then runs. `timeout`, in +/// milliseconds, is taken and not waited on: the file is read at once. +fn eval_file(host: &Host, args: Vec) -> std::result::Result { + let Some(Data::String(path)) = args.first().filter(|p| !matches!(p, Data::String(s) if s.is_empty())) else { + return Err(HostError::Type(EVAL_NO_PATH.to_string())); + }; + match args.get(1) { + None | Some(Data::Undefined) => {} + Some(Data::Number(ms)) if ms.is_finite() && *ms >= 0.0 => {} + Some(Data::Number(_)) => return Err(HostError::Range(EVAL_TIMEOUT.to_string())), + Some(_) => return Err(HostError::Type(EVAL_TIMEOUT.to_string())), + } + if args.len() > 2 { + return Err(HostError::Type(EVAL_ARGUMENTS.to_string())); + } + host.charge_call(path.len() as u64 + 2)?; + let expanded = host.files.borrow_mut().eval_file(path).map_err(|f| { + let message = if f.line > 0 { format!("{}:{}:{}: {}", f.file, f.line, f.column, f.message) } else { f.message }; + if f.line > 0 { + *host.eval_failure.borrow_mut() = Some((message.clone(), f.file.clone(), f.line, f.column)); + } + match f.kind { + include::FailKind::Limit => HostError::Abort(message), + include::FailKind::Syntax => HostError::Syntax(message), + include::FailKind::Error => HostError::Error(message), + } + })?; + // A file without preprocessor lines is parsed here first, so its syntax error names its place. + let options = vectorcraft_js::Options { max_source_units: include::MAX_TOTAL_BYTES, ..vectorcraft_js::Options::default() }; + if let Err(e) = vectorcraft_js::parse(&expanded.source, &options) { + host.files.borrow_mut().end_eval(); + let (_, file, line, column) = expanded.map.locate(e.line, e.column); + let message = format!("{file}:{line}:{column}: {}", e.message); + *host.eval_failure.borrow_mut() = Some((message.clone(), file, line, column)); + return Err(HostError::Syntax(message)); + } + host.eval_maps.borrow_mut().push(expanded.map); + Ok(Data::String(expanded.source)) } fn function(f: impl Fn(Vec) -> std::result::Result + 'static) -> HostFunction { @@ -974,21 +1058,67 @@ fn kind_id(kind: &RunErrorKind) -> &'static str { } /// `message`, with what to do about a failure that comes from a script written for another program. -fn explained(kind: &RunErrorKind, message: &str, source: &str, line: u32) -> String { +fn explained(kind: &RunErrorKind, message: &str) -> String { const HOST_OBJECTS: &[&str] = &["app", "alert", "confirm", "prompt", "File", "Folder", "Window", "BridgeTalk", "activeDocument", "documents"]; let names_host_object = message.strip_prefix("ReferenceError: ").is_some_and(|m| HOST_OBJECTS.iter().any(|o| m.split_whitespace().next() == Some(o))); - let directive = - *kind == RunErrorKind::Syntax && line > 0 && source.lines().nth(line as usize - 1).is_some_and(|l| l.trim_start().starts_with('#')); if *kind == RunErrorKind::Thrown && names_host_object { format!("{message} (scripts here use the `vc` object; objects other applications give their scripts are not available)") - } else if directive { - format!("{message} (preprocessor lines such as `#target` and `#include` are not read yet)") } else { message.to_string() } } +/// The error of a run that stopped in its preprocessor lines (`f`), as the realm gives its own. +fn preprocessor_error(f: include::Failure) -> (RunError, Option>) { + let (kind, message) = match f.kind { + include::FailKind::Syntax if f.line > 0 => { + (RunErrorKind::Syntax, format!("SyntaxError: {} (line {}, column {})", f.message, f.line, f.column)) + } + include::FailKind::Syntax => (RunErrorKind::Syntax, format!("SyntaxError: {}", f.message)), + include::FailKind::Error => (RunErrorKind::Thrown, format!("Error: {}", f.message)), + include::FailKind::Limit => (RunErrorKind::Limit(LimitKind::Host), f.message), + }; + let file = (f.index > 0).then_some(f.file); + (RunError { kind, message, line: f.line, column: f.column }, file) +} + +/// `e`, which the realm raised in source `tag` ([`Realm::error_source`]), at the line and column of +/// the file it is in, with that file's name unless it is the script's own. +fn located(host: &Host, tag: u32, e: &RunError) -> (RunError, Option>) { + let map = |m: &include::SourceMap, own: bool| { + let (index, file, line, column) = m.locate(e.line, e.column); + let message = if e.kind == RunErrorKind::Syntax { relocated(&e.message, e.line, e.column, line, column) } else { e.message.clone() }; + (RunError { kind: e.kind.clone(), message, line, column }, (!own || index > 0).then_some(file)) + }; + // An error `$.evalFile` raised for a file it could not run is at that file's place. + if let Some((_, file, line, column)) = host.eval_failure.borrow().as_ref().filter(|(thrown, ..)| e.message.contains(thrown.as_str())) { + let message = if e.kind == RunErrorKind::Syntax { relocated(&e.message, e.line, e.column, *line, *column) } else { e.message.clone() }; + return (RunError { kind: e.kind.clone(), message, line: *line, column: *column }, Some(file.clone())); + } + if e.line == 0 { + return (e.clone(), None); + } + match tag { + 0 => map(&host.script_map.borrow(), true), + n if n != vectorcraft_js::UNKNOWN_SOURCE => match host.eval_maps.borrow().get(n as usize - 1) { + Some(m) => map(m, false), + None => (e.clone(), None), + }, + _ => (e.clone(), None), + } +} + +/// A syntax error's `message`, which ends with its place `(line L, column C)`, with the place +/// given as `line`, `column`. +fn relocated(message: &str, old_line: u32, old_column: u32, line: u32, column: u32) -> String { + let old = format!(" (line {old_line}, column {old_column})"); + match message.strip_suffix(&old) { + Some(m) => format!("{m} (line {line}, column {column})"), + None => message.to_string(), + } +} + impl Session { /// Is a script running? While one is, the commands that change anything are refused. pub fn script_running(&self) -> bool { @@ -1091,8 +1221,8 @@ impl Session { Ok(()) } - /// Begins a run of `request`. - pub(crate) fn script_start(&mut self, request: Request) -> Result<()> { + /// Begins a run of `request`. → the run's name. + pub(crate) fn script_start(&mut self, request: Request) -> Result { self.settle_for_script()?; // In a build where a fault ends the program, every unsaved document has a copy first. if self.script_copy_first && self.docs.iter().any(DocState::is_dirty) && cmd::recovery::save_copies(self).is_err() { @@ -1103,8 +1233,14 @@ impl Session { self.script_high_water.retain(|uid, _| open.contains(uid)); self.script_stop.clear(); self.script_report = None; + let mut files = include::Files::new(request.file.as_deref(), include::Files::jsinclude_env()); + let expanded = files.expand_script(&request.source); + let name = match &expanded { + Ok(include::Expanded { name: Some(name), .. }) if !request.name_given => name.clone(), + _ => request.name, + }; let limits = Limits { fuel: request.fuel, heap_bytes: HEAP_BYTES, ..Limits::default() }; - let options = Options { max_source_units: MAX_SOURCE_BYTES.saturating_mul(2), ..Options::default() }; + let options = Options { max_source_units: include::MAX_TOTAL_BYTES.saturating_mul(2), ..Options::default() }; let mut realm = Realm::with_options(options, limits); realm.share_interrupt(&self.script_stop); realm.set_data_limits(DataLimits::default()); @@ -1126,34 +1262,45 @@ impl Session { log_bytes: Cell::new(0), written: RefCell::new(Vec::new()), run: self.script_runs, - name: console::line_name(&request.name), + name: console::line_name(&name), console: RefCell::default(), pending: RefCell::default(), counts: Counts::default(), commands: RefCell::default(), run_nanos: Cell::new(0), + files: RefCell::new(files), + script_map: RefCell::default(), + eval_maps: RefCell::default(), + eval_failure: RefCell::default(), }); let args = to_data(&Value::Object(request.args), &DataLimits::default(), 0, &mut (0, 0)) .map_err(|place| EngineError::BadParams { cmd: "script.run".into(), msg: format!("`args{place}` is nested more than 64 levels deep") })?; let line = console::ConsoleLine { run: host.run, name: host.name.clone(), entry: console::Entry::Start }; self.script_console.push(line, false); - let started = realm - .define_host_object("vc", vc_object(&host, args)) - .and_then(|()| realm.define_host_object("$", dollar_object(&host))) - .and_then(|()| realm.start(&request.source)); - if let Err(e) = started { - let mut report = failure_report(&request.name, &e, &request.source, &host, 0, 0); + let started = match expanded { + Err(f) => Err(preprocessor_error(f)), + Ok(expanded) => { + *host.script_map.borrow_mut() = expanded.map; + realm + .define_host_object("vc", vc_object(&host, args)) + .and_then(|()| realm.define_host_object("$", dollar_object(&host))) + .and_then(|()| realm.start(&expanded.source)) + .map_err(|e| located(&host, realm.error_source(), &e)) + } + }; + if let Err((e, file)) = started { + let mut report = failure_report(&name, &e, file.as_deref(), &host, 0, 0); close_run(self, &host, &mut report, ending(&e.kind)); let message = failure_text(&report); self.script_report = Some(report); return Err(EngineError::Other(message)); } let scope = crate::file_access::ScopeHandle::capture(); - let job = Job { realm, host, world: World::working(self), start_uids: open, name: request.name, scope }; + let job = Job { realm, host, world: World::working(self), start_uids: open, name: name.clone(), scope }; let id = NEXT_JOB.fetch_add(1, std::sync::atomic::Ordering::Relaxed); JOBS.with(|jobs| jobs.borrow_mut().insert(id, job)); self.script_key = Some(JobKey(id)); - Ok(()) + Ok(name) } /// Advances the running script by one slice. `None` while it is still running; otherwise the @@ -1194,7 +1341,8 @@ impl Session { fn script_failed(&mut self, job: Job, e: &RunError) -> Result { let fuel_left = job.realm.fuel_left(); - let mut report = failure_report(&job.name, e, "", &job.host, fuel_left, job.realm.limits().fuel.saturating_sub(fuel_left)); + let (e, file) = located(&job.host, job.realm.error_source(), e); + let mut report = failure_report(&job.name, &e, file.as_deref(), &job.host, fuel_left, job.realm.limits().fuel.saturating_sub(fuel_left)); close_run(self, &job.host, &mut report, ending(&e.kind)); drop(job); raise_ids(self); @@ -1308,13 +1456,16 @@ const TOO_MANY_ARGUMENTS: &str = "vc.run: takes a command id and an object of pa const PARAMS_NOT_OBJECT: &str = "vc.run: the parameters must be an object"; const LOG_FULL: &str = "vc.log: the log of this run is full"; const NO_RECOVERY_COPY: &str = "a recovery copy could not be written, so the script was not run"; +const EVAL_NO_PATH: &str = "$.evalFile requires a file path"; +const EVAL_TIMEOUT: &str = "$.evalFile: the timeout must be a finite number of milliseconds, 0 or more"; +const EVAL_ARGUMENTS: &str = "$.evalFile: takes a file path and a timeout"; -/// The report of a run that failed. -fn failure_report(name: &str, e: &RunError, source: &str, host: &Host, fuel_left: u64, fuel_used: u64) -> Value { - json!({ +/// The report of a run that failed; `file` is the included file the error is in. +fn failure_report(name: &str, e: &RunError, file: Option<&str>, host: &Host, fuel_left: u64, fuel_used: u64) -> Value { + let mut report = json!({ "ok": false, "kind": kind_id(&e.kind), - "message": explained(&e.kind, &e.message, source, e.line), + "message": explained(&e.kind, &e.message), "name": name, "line": e.line, "column": e.column, @@ -1323,7 +1474,11 @@ fn failure_report(name: &str, e: &RunError, source: &str, host: &Host, fuel_left "fuelLeft": fuel_left, "fuelUsed": fuel_used, "hostCalls": host.calls.get(), - }) + }); + if let (Some(file), Some(o)) = (file, report.as_object_mut()) { + o.insert("file".into(), json!(file)); + } + report } /// How the run that failed with `kind` ended, as its console end line gives it. @@ -1361,8 +1516,11 @@ fn close_run(s: &mut Session, host: &Host, report: &mut Value, ending: console:: if report["ok"] == false { let (line, column) = (report["line"].as_u64().unwrap_or(0), report["column"].as_u64().unwrap_or(0)); let message = report["message"].as_str().unwrap_or_default(); - let text = - if line > 0 { format!("{}:{line}:{column}: {message}", host.name) } else { format!("{}: location unavailable: {message}", host.name) }; + let text = match report["file"].as_str() { + Some(file) if line > 0 => format!("{}: {file}:{line}:{column}: {message}", host.name), + _ if line > 0 => format!("{}:{line}:{column}: {message}", host.name), + _ => format!("{}: location unavailable: {message}", host.name), + }; each_line(&text, |line, cut| line_to(s, host, console::Entry::Error(line), cut)); } let timing = clock().is_some(); @@ -1386,7 +1544,11 @@ fn close_run(s: &mut Session, host: &Host, report: &mut Value, ending: console:: fn failure_text(report: &Value) -> String { let s = |k: &str| report[k].as_str().unwrap_or_default(); let (line, column) = (report["line"].as_u64().unwrap_or(0), report["column"].as_u64().unwrap_or(0)); - let at = if line > 0 { format!(" line {line}, column {column}") } else { String::new() }; + let at = match report["file"].as_str() { + Some(file) if line > 0 => format!(" line {line}, column {column} of {file}"), + _ if line > 0 => format!(" line {line}, column {column}"), + _ => String::new(), + }; let written = report["written"].as_array().map_or(0, Vec::len); let kept = if written > 0 { let paths: Vec<&str> = report["written"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); @@ -1454,4 +1616,6 @@ thread_local! { pub(crate) static PANIC_IN_SLICE: Cell = const { Cell::new(false) }; /// Tests: the objects a run may add in place of [`GROWTH_OBJECTS`]. pub(crate) static TEST_GROWTH: Cell> = const { Cell::new(None) }; + /// Tests: the `JSINCLUDE` folders in place of the environment's. + pub(crate) static TEST_JSINCLUDE: RefCell>> = const { RefCell::new(None) }; } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 72b6d3ea80..c55327895b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -151,6 +151,7 @@ mod save; mod saveoptions; mod scalestrokes; mod script_console; +mod script_include; mod script_run; mod scripts; mod select_ids_first; diff --git a/crates/engine/src/tests/script_include.rs b/crates/engine/src/tests/script_include.rs new file mode 100644 index 0000000000..f0cf7a7535 --- /dev/null +++ b/crates/engine/src/tests/script_include.rs @@ -0,0 +1,327 @@ +//! A script's preprocessor lines: `#include` and `//@include` in place, the folders they are looked +//! for in, the run's limits and scope, the other lines, `$.evalFile`, `$.fileName`, and errors at the +//! line of the file they are in. + +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; + +use crate::script::TEST_JSINCLUDE; +use crate::*; + +/// A fresh folder for test `name`, with a `main` folder inside it for the script. +fn folder(name: &str) -> PathBuf { + let base = std::env::temp_dir().join(format!("vc-script-include-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(base.join("main")).unwrap(); + base +} + +fn write(path: &Path, text: &str) { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).unwrap(); + } + std::fs::write(path, text).unwrap(); +} + +/// Runs `main/main.jsx` in `base`, with the text `text`. +fn run(base: &Path, text: &str) -> Result { + let main = base.join("main/main.jsx"); + write(&main, text); + Session::new().execute("script.run", &json!({"path": main.to_string_lossy()})) +} + +/// The report of the run that failed with `e`. +fn failed(base: &Path, text: &str) -> Value { + let mut s = Session::new(); + let main = base.join("main/main.jsx"); + write(&main, text); + let e = s.execute("script.run", &json!({"path": main.to_string_lossy()})); + assert!(e.is_err(), "the run failed: {e:?}"); + s.script_report().unwrap().clone() +} + +#[test] +fn script_include_sibling_runs_in_place() { + let base = folder("sibling"); + write(&base.join("main/lib.jsxinc"), "var b = a + 1;"); + write(&base.join("main/two.jsxinc"), "b = b * 10;\r\n"); + let r = run(&base, "var a = 1;\n #include \"lib.jsxinc\"\n//@include 'two.jsxinc'\nvc.log(a + b);").unwrap(); + assert_eq!(r["log"], json!([21])); + // A file may be included again once its earlier inclusion has ended. + let r = run(&base, "var n = 0;\n#include \"three.jsxinc\"\n#include \"three.jsxinc\"\nvc.log(n);"); + assert!(r.is_err(), "three.jsxinc is missing"); + write(&base.join("main/three.jsxinc"), "n++;"); + let r = run(&base, "var n = 0;\n#include \"three.jsxinc\"\n#include three.jsxinc\nvc.log(n);").unwrap(); + assert_eq!(r["log"], json!([2])); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_nested_include_uses_including_folder() { + let base = folder("nested"); + write(&base.join("main/sub/one.jsxinc"), "#include \"two.jsxinc\"\nvar one = two + 1;"); + write(&base.join("main/sub/two.jsxinc"), "var two = 2;"); + write(&base.join("main/two.jsxinc"), "var two = 200;"); + let r = run(&base, "#include \"sub/one.jsxinc\"\nvc.log(one);").unwrap(); + assert_eq!(r["log"], json!([3]), "two.jsxinc beside one.jsxinc, not beside the script"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_missing_include_reports_origin() { + let base = folder("missing"); + let report = failed(&base, "vc.log(1);\n\t#include \"nope.jsxinc\"\n"); + assert_eq!(report["kind"], "thrown"); + assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(2), Some(2))); + assert!(report["message"].as_str().unwrap().contains("include file not found: nope.jsxinc"), "{report}"); + assert_eq!(report["log"], json!([]), "nothing ran before the include was read"); + assert!(report.get("file").is_none(), "the line is the script's own"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_includepath_searches_in_order() { + let base = folder("includepath"); + write(&base.join("main/a/x.jsxinc"), "var x = 'a';"); + write(&base.join("main/b/x.jsxinc"), "var x = 'b';"); + write(&base.join("main/b/y.jsxinc"), "var y = 'b';"); + let r = run(&base, "#includepath \"a; b\"\n#include \"x.jsxinc\"\n#include \"y.jsxinc\"\nvc.log(x + y + $.includePath);").unwrap(); + assert_eq!(r["log"], json!(["aba;b"])); + // A later #includepath replaces the list. + let report = failed(&base, "#includepath \"b\"\n#includepath \"a\"\n#include \"y.jsxinc\""); + assert!(report["message"].as_str().unwrap().contains("include file not found: y.jsxinc"), "{report}"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_include_cycle_and_depth_stop() { + let base = folder("cycle"); + write(&base.join("main/a.jsxinc"), "#include \"b.jsxinc\""); + write(&base.join("main/b.jsxinc"), "\n#include \"a.jsxinc\""); + let report = failed(&base, "#include \"a.jsxinc\""); + assert!(report["message"].as_str().unwrap().contains("include cycle: a.jsxinc"), "{report}"); + assert_eq!(report["line"], 2); + assert!(report["file"].as_str().unwrap().ends_with("b.jsxinc"), "{report}"); + // A script that includes itself. + let report = failed(&base, "#include \"main.jsx\""); + assert!(report["message"].as_str().unwrap().contains("include cycle"), "{report}"); + for i in 0..20 { + write(&base.join(format!("main/d{i}.jsxinc")), &format!("#include \"d{}.jsxinc\"", i + 1)); + } + let report = failed(&base, "#include \"d0.jsxinc\""); + assert_eq!(report["kind"], "limit"); + assert!(report["message"].as_str().unwrap().contains("include depth limit (16) reached"), "{report}"); + assert!(report["file"].as_str().unwrap().ends_with("d15.jsxinc"), "the 17th level is refused: {report}"); + let _ = std::fs::remove_dir_all(base); +} + +/// Makes `link` lead to the folder `target`: a junction on Windows, a symbolic link elsewhere. +/// `false` when the system does not allow it here. +fn link_folder(target: &Path, link: &Path) -> bool { + #[cfg(windows)] + { + let made = std::process::Command::new("cmd").args(["/C", "mklink", "/J"]).arg(link).arg(target).output().is_ok_and(|o| o.status.success()); + made && std::fs::canonicalize(link).ok() == std::fs::canonicalize(target).ok() + } + #[cfg(unix)] + { + std::os::unix::fs::symlink(target, link).is_ok() + } + #[cfg(not(any(unix, windows)))] + { + let _ = (target, link); + false + } +} + +#[test] +fn script_include_link_out_of_the_folder_is_refused() { + let base = folder("junction"); + write(&base.join("outside/x.jsxinc"), "var leaked = 1;"); + // `mklink` reads a `/` in its paths as a switch, so each name is joined on its own. + if !link_folder(&base.join("outside"), &base.join("main").join("away")) { + eprintln!("skipped: a folder link can't be made here"); + let _ = std::fs::remove_dir_all(base); + return; + } + assert!(std::fs::canonicalize(base.join("main/away/x.jsxinc")).unwrap().starts_with(std::fs::canonicalize(base.join("outside")).unwrap())); + let report = failed(&base, "#include \"away/x.jsxinc\"\nvc.log(leaked);"); + assert!(report["message"].as_str().unwrap().contains("script file read refused: away/x.jsxinc"), "{report}"); + let report = failed(&base, "$.evalFile('away/x.jsxinc');"); + assert!(report["message"].as_str().unwrap().contains("script file read refused: away/x.jsxinc"), "{report}"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_include_parent_component_refused() { + let base = folder("parent"); + write(&base.join("outside.jsxinc"), "var leaked = 1;"); + write(&base.join("main/sub/x.jsxinc"), "var x = 1;"); + let report = failed(&base, "#include \"../outside.jsxinc\""); + assert!(report["message"].as_str().unwrap().contains("script file read refused: ../outside.jsxinc"), "{report}"); + // Refused even where the path would come back inside the folder. + let report = failed(&base, "#include \"sub/../sub/x.jsxinc\""); + assert!(report["message"].as_str().unwrap().contains("script file read refused"), "{report}"); + let report = failed(&base, "#includepath \"..\"\n#include \"outside.jsxinc\""); + assert!(report["message"].as_str().unwrap().contains("script file read refused: outside.jsxinc"), "{report}"); + let absolute = base.join("outside.jsxinc").to_string_lossy().replace('\\', "/"); + let report = failed(&base, &format!("#include \"{absolute}\"")); + assert!(report["message"].as_str().unwrap().contains("script file read refused"), "an absolute path outside: {report}"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_jsinclude_outside_grants_ignored() { + let base = folder("jsinclude"); + write(&base.join("outside/x.jsxinc"), "var x = 'outside';"); + write(&base.join("main/lib/y.jsxinc"), "var y = 'lib';"); + TEST_JSINCLUDE.with(|t| *t.borrow_mut() = Some(vec![base.join("outside"), base.join("main/lib")])); + let report = failed(&base, "#include \"x.jsxinc\""); + let r = run(&base, "#include \"y.jsxinc\"\nvc.log(y);"); + TEST_JSINCLUDE.with(|t| *t.borrow_mut() = None); + assert!(report["message"].as_str().unwrap().contains("include file not found: x.jsxinc"), "passed over, not refused: {report}"); + assert_eq!(r.unwrap()["log"], json!(["lib"]), "a JSINCLUDE folder inside the script's folder is searched"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_target_other_app_does_not_route() { + let mut s = Session::new(); + let r = s.execute("script.run", &json!({"source": "#target someapp-12\n#target \"other\"\n//@target x\n#strict off\nvc.log(1);"})).unwrap(); + assert_eq!(r["log"], json!([1])); + for (source, message) in [ + ("#target\nvc.log(1);", "#target needs a value"), + ("#targetengine \"session\"", "unsupported script directive: #targetengine"), + ("#engine \"main\"", "unsupported script directive: #engine"), + ("#strict on", "unsupported strict checking: #strict on"), + ("#strict maybe", "#strict takes on or off"), + ("#frobnicate 1", "unknown script directive: #frobnicate"), + ("#include \"x.jsxinc", "unterminated quote in #include"), + ("#include \"x.jsxinc\"", "script file read refused: x.jsxinc"), + ] { + let e = s.execute("script.run", &json!({"source": format!("vc.log(0);\n{source}")})).unwrap_err().to_string(); + assert!(e.contains(message), "{source}: {e}"); + assert_eq!(s.script_report().unwrap()["line"], 2, "{source}"); + assert_eq!(s.script_report().unwrap()["log"], json!([]), "{source}"); + } + // An unknown `//@` name stays a comment. + assert_eq!(s.execute("script.run", &json!({"source": "//@frobnicate 1\nvc.log(2);"})).unwrap()["log"], json!([2])); +} + +#[test] +fn script_name_comes_from_the_script_line() { + let mut s = Session::new(); + let r = s.execute("script.run", &json!({"source": "#script \"First\"\n#script Second\nvc.log($.fileName);"})).unwrap(); + assert_eq!((r["name"].as_str(), r["log"].clone()), (Some("Second"), json!([""]))); + let r = s.execute("script.run", &json!({"source": "#script Second\n", "name": "given.js"})).unwrap(); + assert_eq!(r["name"], "given.js", "a name given to script.run wins"); + let base = folder("name"); + write(&base.join("main/lib.jsxinc"), "#script Inner\n"); + let r = run(&base, "#include \"lib.jsxinc\"").unwrap(); + assert_eq!(r["name"], "main.jsx", "an included file's #script line does not rename the run"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_strict_on_is_refused() { + let mut s = Session::new(); + let e = s.execute("script.run", &json!({"source": "#strict on\nvc.log(1);"})).unwrap_err().to_string(); + assert!(e.contains("unsupported strict checking: #strict on"), "{e}"); + assert_eq!(s.script_report().unwrap()["kind"], "syntax"); +} + +#[test] +fn script_eval_file_returns_last_value_and_shares_globals() { + let base = folder("evalfile"); + write(&base.join("main/sub/e.jsx"), "var inner = $.fileName;\nfunction h(x) { return x * g; }\n#include \"f.jsxinc\"\ng + f;"); + write(&base.join("main/sub/f.jsxinc"), "var f = 40;"); + write(&base.join("main/empty.jsx"), ""); + let r = run( + &base, + "var g = 2;\nvar r = $.evalFile('sub/e.jsx', 1000);\nvc.log(r);\nvc.log(h(3));\nvc.log(/e\\.jsx$/.test(inner));\nvc.log(/main\\.jsx$/.test($.fileName));\nvc.log($.evalFile('empty.jsx') === undefined);", + ) + .unwrap(); + assert_eq!(r["log"], json!([42, 6, true, true, true])); + // Its errors can be caught; a file is evaluated again after it has returned. + let r = run( + &base, + "var g = 1; var out = [];\ntry { $.evalFile(''); } catch (e) { out.push(e.name); }\ntry { $.evalFile('nope.jsx'); } catch (e) { out.push(String(e)); }\ntry { $.evalFile('sub/e.jsx', -1); } catch (e) { out.push(e.name); }\ntry { $.evalFile('sub/e.jsx', Infinity); } catch (e) { out.push(e.name); }\nout.push($.evalFile('sub/e.jsx') + $.evalFile('sub/e.jsx'));\nvc.log(out);", + ) + .unwrap(); + assert_eq!(r["log"], json!([["TypeError", "Error: include file not found: nope.jsx", "RangeError", "TypeError", 82]])); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_eval_file_outside_scope_refused() { + let base = folder("evaloutside"); + write(&base.join("outside.jsx"), "1;"); + let absolute = base.join("outside.jsx").to_string_lossy().replace('\\', "/"); + let script = format!( + "var out = [];\ntry {{ $.evalFile('../outside.jsx'); }} catch (e) {{ out.push(String(e)); }}\ntry {{ $.evalFile('{absolute}'); }} catch (e) {{ out.push(String(e)); }}\nvc.log(out);" + ); + let r = run(&base, &script).unwrap(); + let out = r["log"][0].as_array().unwrap(); + assert_eq!(out.len(), 2); + for e in out { + assert!(e.as_str().unwrap().starts_with("Error: script file read refused"), "{e}"); + } + // A script given as text has no folder to read from. + let e = Session::new().execute("script.run", &json!({"source": "$.evalFile('x.jsx');"})).unwrap_err().to_string(); + assert!(e.contains("script file read refused: x.jsx"), "{e}"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_directives_in_strings_and_block_comments_are_inert() { + let mut s = Session::new(); + let source = "#target x\nvar a = '#include \"nope.jsxinc\"';\n/*\n#include \"nope.jsxinc\"\n//@include \"nope.jsxinc\"\n*/\nvar b = 1; // #include \"nope.jsxinc\"\nvar c = 2; #include\nvc.log(a.length + b);"; + let e = s.execute("script.run", &json!({"source": source})).unwrap_err().to_string(); + // `#` after code on a line is not a preprocessor line: the script has a syntax error there. + assert!(e.contains("line 8"), "{e}"); + let source = source.replace("var c = 2; #include\n", ""); + assert_eq!(s.execute("script.run", &json!({"source": source})).unwrap()["log"], json!([23])); +} + +#[test] +fn script_included_error_has_local_file_and_line() { + let base = folder("errors"); + write(&base.join("main/lib.jsxinc"), "var x = 1;\n\n throw new Error('boom');\n"); + write(&base.join("main/bad.jsxinc"), "var y = 1;\nvar = 2;\n"); + write(&base.join("main/five.jsxinc"), "1;\n2;\n3;\n4;\n5;"); + write(&base.join("main/sub/thrower.jsx"), "\n\nthrow new Error('deep');"); + let mut s = Session::new(); + let report = failed(&base, "var z = 0;\n#include \"lib.jsxinc\""); + assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(3), Some(5)), "{report}"); + assert!(report["file"].as_str().unwrap().ends_with("lib.jsxinc"), "{report}"); + let report = failed(&base, "#include \"bad.jsxinc\""); + assert_eq!((report["kind"].as_str(), report["line"].as_u64()), (Some("syntax"), Some(2)), "{report}"); + assert!(report["file"].as_str().unwrap().ends_with("bad.jsxinc"), "{report}"); + // A line of the script after an include keeps its own number. + let report = failed(&base, "#include \"five.jsxinc\"\n throw new Error('after');"); + assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(2), Some(3)), "{report}"); + assert!(report.get("file").is_none(), "{report}"); + let report = failed(&base, "#include \"five.jsxinc\"\nvar = 1;"); + assert_eq!((report["kind"].as_str(), report["line"].as_u64()), (Some("syntax"), Some(2)), "{report}"); + // An uncaught error in a file $.evalFile ran is at that file's line. + let report = failed(&base, "var a = 1;\n$.evalFile('sub/thrower.jsx');"); + assert_eq!(report["line"], 3, "{report}"); + assert!(report["file"].as_str().unwrap().ends_with("thrower.jsx"), "{report}"); + // So is a file $.evalFile could not run: a syntax error, or a bad preprocessor line. + write(&base.join("main/sub/broken.jsx"), "var a = 1;\n\nvar = 2;"); + write(&base.join("main/sub/directive.jsx"), "\n#frobnicate 1"); + for (file, line) in [("broken.jsx", 3), ("directive.jsx", 2)] { + let report = failed(&base, &format!("var a = 1;\n$.evalFile('sub/{file}');")); + assert_eq!(report["line"], line, "{report}"); + assert!(report["file"].as_str().unwrap().ends_with(file), "{report}"); + } + // The console's error line names the file. + let main = base.join("main/main.jsx"); + write(&main, "#include \"lib.jsxinc\""); + assert!(s.execute("script.run", &json!({"path": main.to_string_lossy()})).is_err()); + let console = s.execute("script.console", &json!({})).unwrap(); + let error = console["lines"].as_array().unwrap().iter().find(|l| l["kind"] == "error").unwrap()["text"].as_str().unwrap().to_string(); + assert!(error.starts_with("main.jsx: ") && error.contains("lib.jsxinc:3:5: Error: boom"), "{error}"); + let _ = std::fs::remove_dir_all(base); +} diff --git a/crates/engine/src/tests/script_run.rs b/crates/engine/src/tests/script_run.rs index eba254116a..8294aa638a 100644 --- a/crates/engine/src/tests/script_run.rs +++ b/crates/engine/src/tests/script_run.rs @@ -166,7 +166,7 @@ fn an_error_names_the_script_its_place_and_kind() { let e = run(&mut s, "var = 1;").unwrap_err().to_string(); assert!(e.starts_with("t.js: line 1,") && e.ends_with("(syntax)"), "{e}"); assert!(run(&mut s, "app.activeDocument;").unwrap_err().to_string().contains("`vc`")); - assert!(run(&mut s, "#target x\n1").unwrap_err().to_string().contains("#target")); + assert!(run(&mut s, "#targetengine x\n1").unwrap_err().to_string().contains("unsupported script directive: #targetengine")); assert_eq!(s.script_report().unwrap()["line"], 1); } @@ -989,6 +989,15 @@ fn source_text() -> impl proptest::strategy::Strategy { Just("{".to_string()), Just("try {".to_string()), Just("} catch (e) {}".to_string()), + Just("\n#include \"x.jsxinc\"\n".to_string()), + Just("\n//@include 'x\n".to_string()), + Just("\n#includepath \";;..;\"\n".to_string()), + Just("\n#script \"\"\n".to_string()), + Just("\n#target\n".to_string()), + Just("\n#strict on\n".to_string()), + Just("\n#\u{0}\n".to_string()), + Just("try { $.evalFile('x.jsx', NaN); } catch (e) {}".to_string()), + "\n#[a-z]{0,12}[ -~]{0,20}\n", "[ -~]{0,30}", "\\PC{0,10}", ]; diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index d420b94b31..62f6ade7a9 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -472,7 +472,7 @@ fn function_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsRe 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 source = Rc::new(Source { lines: program.lines.clone(), text: units.into(), tag: interp.source_tag }); 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")), @@ -501,7 +501,7 @@ fn function_to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> return Ok(Value::String(Str::from_units(units.to_vec()))); } Some(Func::Native { name, .. }) => format!("function {name}() {{ [native code] }}"), - Some(Func::Host { name, .. } | Func::Data { name, .. }) => format!("function {name}() {{ [native code] }}"), + Some(Func::Host { name, .. } | Func::Data { name, .. } | Func::Eval { name, .. }) => format!("function {name}() {{ [native code] }}"), Some(Func::Bound { .. }) => "function () { [native code] }".to_string(), None => return Err(interp.type_error("Function.prototype.toString was called on a value that is not a function")), }; diff --git a/crates/js/src/bytecode.rs b/crates/js/src/bytecode.rs index b23d9a525d..0a666d1f25 100644 --- a/crates/js/src/bytecode.rs +++ b/crates/js/src/bytecode.rs @@ -171,6 +171,8 @@ pub(crate) struct Handler { pub(crate) struct Source { pub(crate) lines: LineTable, pub(crate) text: Rc<[u16]>, + /// Which source this is, as [`crate::Realm::error_source`] gives it. + pub(crate) tag: u32, } #[derive(Debug)] diff --git a/crates/js/src/heap.rs b/crates/js/src/heap.rs index 1399c05701..b229ce6e6c 100644 --- a/crates/js/src/heap.rs +++ b/crates/js/src/heap.rs @@ -176,13 +176,15 @@ pub(crate) enum NativeKind { /// What a function object runs. `Bound` is a function made by `Function.prototype.bind` (section /// 15.3.4.5): calling it calls `target` with `this` and `args` before the arguments it is given. /// `Data` is a host function given [`crate::Data`]; with `text`, each argument arrives as the -/// string `String(value)` gives. +/// string `String(value)` gives. `Eval` is a host function that gives source to run as global code +/// ([`crate::HostItem::Evaluator`]); `end` is called when that code ends. #[derive(Clone)] pub(crate) enum Func { Closure { code: Rc, env: EnvId }, Native { f: NativeFn, kind: NativeKind, constructor: bool, name: &'static str }, Host { f: Rc, name: Rc }, Data { f: DataFn, name: Rc, text: bool }, + Eval { f: DataFn, end: Rc, name: Rc }, Bound { target: ObjId, this: Value, args: Rc<[Value]> }, } diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index 888fb3d95b..b725245ce8 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -55,6 +55,8 @@ pub enum HostError { Type(String), /// Thrown to the script as a `RangeError`. Range(String), + /// Thrown to the script as a `SyntaxError`. + Syntax(String), /// Ends the run with [`LimitKind::Host`] and this message. The script cannot catch it. Abort(String), } @@ -74,6 +76,16 @@ pub enum HostItem { Getter(HostFunction), /// A frozen object with these members. Object(Vec<(String, HostItem)>), + /// A function whose host function gives JavaScript source as a string, which then runs as + /// global code in the realm, as an indirect `eval` runs it, with the run's fuel and limits: + /// the call gives that code's completion value, and its declarations become globals. A result + /// that is not a string is the call's result. `end` is called when the source has run, + /// normally or not. Each source given has the next tag of [`crate::Realm::error_source`], + /// from 1. + Evaluator { + source: HostFunction, + end: Rc, + }, } enum Step { @@ -280,6 +292,39 @@ impl Interp { /// Calls host function `f`: reads its arguments (with `text`, each as the string `String(value)` /// gives), runs it, and gives its result to the script. pub(crate) fn call_data(&mut self, f: &crate::heap::DataFn, name: &Rc, args: &[Value], text: bool) -> JsResult { + let data = self.read_args(name, args, text)?; + match f(data) { + Ok(d) => self.write_data(d, 0, false), + Err(e) => Err(self.host_error(e)?), + } + } + + /// Calls evaluator `f` ([`HostItem::Evaluator`]): reads its arguments, runs it, and runs the + /// source it gives as global code, as an indirect `eval` does, giving that code's completion + /// value. A result that is not a string is given to the script as it is. `end` is called once + /// the source has run, normally or not. + pub(crate) fn call_evaluator(&mut self, f: &crate::heap::DataFn, end: &Rc, name: &Rc, args: &[Value]) -> JsResult { + let data = self.read_args(name, args, false)?; + let source = match f(data) { + Ok(Data::String(source)) => source, + Ok(d) => return self.write_data(d, 0, false), + Err(e) => return Err(self.host_error(e)?), + }; + self.evaluated = self.evaluated.saturating_add(1); + let tag = std::mem::replace(&mut self.source_tag, self.evaluated); + let code = self.encode_source(&source).and_then(|units| self.compile_source(&units, false, crate::bytecode::CodeKind::Eval)); + self.source_tag = tag; + let r = match code { + Ok(code) => self.run_global(code, true), + Err(e) => Err(self.compile_error_to_abrupt(e)), + }; + end(); + r + } + + /// The arguments of host function `name` as [`Data`] (with `text`, each as the string + /// `String(value)` gives). + fn read_args(&mut self, name: &Rc, args: &[Value], text: bool) -> JsResult> { let mut reading = Reading { limits: self.data_limits, bytes: 0, entries: 0, ancestors: Vec::new(), path: Vec::new(), function: name.clone() }; let mut data = Vec::with_capacity(args.len()); // A conversion can run the script's own `toString`, which may collect garbage. @@ -302,15 +347,20 @@ impl Interp { } self.temp_roots.truncate(mark); read?; - match f(data) { - Ok(d) => self.write_data(d, 0, false), - Err(HostError::Abort(message)) => { + Ok(data) + } + + /// What host error `e` throws, or the limit it ends the run with. + fn host_error(&mut self, e: HostError) -> JsResult { + match e { + HostError::Abort(message) => { self.host_abort = Some(message); - Err(Abrupt::Limit(LimitKind::Host)) + Ok(Abrupt::Limit(LimitKind::Host)) } - Err(HostError::Error(message)) => Err(self.throw_host(ErrorType::Error, &message)?), - Err(HostError::Type(message)) => Err(self.throw_host(ErrorType::Type, &message)?), - Err(HostError::Range(message)) => Err(self.throw_host(ErrorType::Range, &message)?), + HostError::Error(message) => self.throw_host(ErrorType::Error, &message), + HostError::Type(message) => self.throw_host(ErrorType::Type, &message), + HostError::Range(message) => self.throw_host(ErrorType::Range, &message), + HostError::Syntax(message) => self.throw_host(ErrorType::Syntax, &message), } } @@ -361,6 +411,11 @@ impl Interp { HostItem::TextFunction(f) => Prop::Data { value: Value::Object(self.host_function(f, &full, true)), flags: ENUMERABLE }, HostItem::Getter(f) => Prop::Accessor { get: Some(self.host_function(f, &full, false)), set: None, flags: ENUMERABLE }, HostItem::Object(items) => Prop::Data { value: Value::Object(self.host_object(&full, items)?), flags: ENUMERABLE }, + HostItem::Evaluator { source, end } => { + let proto = self.intrinsics.function_proto; + let f = self.heap.alloc_object(Object::new(Class::Function(Func::Eval { f: source, end, name: full.into() }), Some(proto))); + Prop::Data { value: Value::Object(f), flags: ENUMERABLE } + } }; self.define(o, key(&name), prop) } diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index ee711ffb3a..22bbb6c0d3 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -185,6 +185,12 @@ pub(crate) struct Interp { pub(crate) host_abort: Option, /// How much a value passed to or from a host function may carry. pub(crate) data_limits: crate::DataLimits, + /// The tag the source compiled next gets ([`crate::Realm::error_source`]). + pub(crate) source_tag: u32, + /// The sources [`crate::HostItem::Evaluator`] functions have given in this realm. + pub(crate) evaluated: u32, + /// The tag of the source the last run's error was raised in. + pub(crate) error_tag: u32, } /// The bytes all live [`HeldBytes`] hold, and the bytes dropped holders released that the heap's @@ -376,6 +382,9 @@ impl Interp { pause: Rc::default(), host_abort: None, data_limits: crate::DataLimits::default(), + source_tag: crate::realm::UNKNOWN_SOURCE, + evaluated: 0, + error_tag: crate::realm::UNKNOWN_SOURCE, }; crate::builtins::install(&mut interp); interp.fuel = limits.fuel; @@ -1851,6 +1860,7 @@ impl Interp { // keeps itself reachable. return Ok(Prepared::Done(self.call_data(&f, &name, &args, text)?)); } + Func::Eval { f, end, name } => return Ok(Prepared::Done(self.call_evaluator(&f, &end, &name, &args)?)), Func::Host { f: host, .. } => { let js_args: Vec = args.iter().map(|v| self.to_js_value(v)).collect(); return match host(&js_args) { @@ -2123,7 +2133,7 @@ impl Interp { 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() }); + let source = Rc::new(Source { lines: program.lines.clone(), text: units.into(), tag: self.source_tag }); compile_program(&program, &source, kind).map_err(|e| match e { CompileFail::Syntax(s) => CompileError::Syntax(s), CompileFail::Internal(m) => CompileError::Internal(m), diff --git a/crates/js/src/interp_tests.rs b/crates/js/src/interp_tests.rs index 03d9e6639f..c7f69ec9b6 100644 --- a/crates/js/src/interp_tests.rs +++ b/crates/js/src/interp_tests.rs @@ -774,7 +774,7 @@ fn deep_nesting_compiles_on_a_small_stack() { // ---- the verifier ---- fn code(ops: Vec) -> Code { - let source = Rc::new(Source { lines: crate::LineTable::new(&[]), text: Rc::from(Vec::::new()) }); + let source = Rc::new(Source { lines: crate::LineTable::new(&[]), text: Rc::from(Vec::::new()), tag: 0 }); Code { positions: vec![0; ops.len()], ops, diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index a95a4b8847..67ee2712c9 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -69,7 +69,7 @@ pub use error::{SyntaxError, TOO_DEEP, TOO_LONG}; pub use host::{Data, DataLimits, HostError, HostFunction, HostItem}; pub use lexer::{Goal, Lexer}; pub use number::to_string as number_to_string; -pub use realm::{Interrupt, JsValue, LimitKind, Limits, MAX_UNBROKEN_FUEL, Progress, Realm, RunError, RunErrorKind}; +pub use realm::{Interrupt, JsValue, LimitKind, Limits, MAX_UNBROKEN_FUEL, Progress, Realm, RunError, RunErrorKind, UNKNOWN_SOURCE}; pub use token::{Directive, DirectiveName, Keyword, Punct, Span, Token, TokenKind}; /// How a script is read. diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index a28b150a52..beaad25574 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -58,6 +58,10 @@ pub enum LimitKind { Host, } +/// The tag [`Realm::error_source`] gives for a source that is neither the realm's script nor an +/// evaluator's: code that `eval` or `Function` compiled, or an error with no place. +pub const UNKNOWN_SOURCE: u32 = u32::MAX; + /// The fuel [`Realm::advance`] lets a slice spend, past the slice it was asked for, in a nested run /// it cannot pause. pub const MAX_UNBROKEN_FUEL: u64 = 200_000_000; @@ -218,10 +222,17 @@ impl Realm { interp.throw_site = None; interp.last_thrown = Value::Undefined; interp.host_abort = None; + interp.error_tag = UNKNOWN_SOURCE; let strict = interp.options.strict; - match interp.encode_source(source).and_then(|units| interp.compile_source(&units, strict, CodeKind::Global)) { + interp.source_tag = 0; + let code = interp.encode_source(source).and_then(|units| interp.compile_source(&units, strict, CodeKind::Global)); + interp.source_tag = UNKNOWN_SOURCE; + match code { Ok(code) => Ok(code), - Err(CompileError::Syntax(e)) => Err(e.into()), + Err(CompileError::Syntax(e)) => { + interp.error_tag = 0; + Err(e.into()) + } Err(CompileError::Internal(m)) => Err(RunError { kind: RunErrorKind::Internal, message: m, line: 0, column: 0 }), } } @@ -229,7 +240,9 @@ impl Realm { /// The end of a run: its completion value or error, with the interpreter's stacks emptied. fn outcome(&mut self, result: Result) -> Result { let interp = &mut self.interp; - let (line, column) = interp.throw_site.take().map_or((0, 0), |(code, pc)| code.line_column(pc)); + let site = interp.throw_site.take(); + interp.error_tag = site.as_ref().map_or(UNKNOWN_SOURCE, |(code, _)| code.source.tag); + let (line, column) = site.map_or((0, 0), |(code, pc)| code.line_column(pc)); interp.stack.clear(); interp.frames.clear(); interp.temp_roots.clear(); @@ -299,6 +312,13 @@ impl Realm { self.interp.interrupt = interrupt.0.clone(); } + /// Which source the last error's `line` and `column` are in: 0 for the script given to + /// [`Realm::run`] or [`Realm::start`], `n` for the `n`th source a + /// [`crate::HostItem::Evaluator`] gave in this realm, and [`UNKNOWN_SOURCE`] otherwise. + pub fn error_source(&self) -> u32 { + self.interp.error_tag + } + /// Fuel the run has left. pub fn fuel_left(&self) -> u64 { self.interp.fuel diff --git a/docs/mcp.md b/docs/mcp.md index 19f16205cd..5edb74d7e7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1549,7 +1549,7 @@ describes that menu item. Pass exactly one of `path` and `source`. A `path` must case. A `.jsxbin` file or text that starts with `@JSXBIN@` is compiled and ends with an error, as does text that is not UTF-8. The language is ES5.1, except that identifiers take the characters Unicode's `ID_Start` and `ID_Continue` properties allow, as later editions define them, and may use `\u{...}` escapes. The script reaches the -app through the global object `vc`, and writes to the Script Console through `$`: +app through the global object `vc`, and writes to the Script Console and runs other files through `$`: - `vc.run(id, params)` runs a command and returns its result. A command that fails throws an error the script can catch. The documents and the session state the command changed, preferences included, go back, and any file it @@ -1561,9 +1561,41 @@ app through the global object `vc`, and writes to the Script Console through `$` - `vc.log(value)` adds a value to the report and a line to the Script Console. - `$.write(...values)` writes the values to the Script Console with no separator, each as `String(value)` gives it, and `$.writeln(...values)` does the same and ends the line. Each call counts as a host call. +- `$.evalFile(path, timeout?)` runs the file `path` names, relative to the file being run, in the same global + environment, so its functions and variables stay defined, and returns its last value (`undefined` for an empty + file). Its preprocessor lines are read as the script's are. `timeout` is a number of milliseconds from 0; the file + is read at once and `timeout` is not waited on. The run's fuel and limits apply to the file. An empty path, a + missing file and a refused path throw errors the script can catch. +- `$.fileName` is the file being run, which is the file `$.evalFile` is running while it runs, and is empty for a + script given as `source`. `$.includePath` is the current `#includepath` list, separated by `;`. - `vc.args` is the `args` object. - `vc.api.version` is `"1.0"`, and `vc.api.has(feature)` says whether the API has a feature. +Before any of the script runs, its preprocessor lines are read. The help page +https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/ describes these lines, and +https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/ describes `$.evalFile`, `$.fileName` +and `$.includePath`. A line counts when it starts with `#` or `//@`, after blanks only, outside strings and comments. +VectorCraft reads them by these rules of its own: + +- `#include "file"` and `//@include "file"` put the text of that file in place of the line. A value with spaces or + punctuation goes in single or double quotes. The file is looked for beside the file that includes it, then in each + `#includepath` folder in turn, then in each folder of the `JSINCLUDE` environment variable (absolute folders + separated by `;`). The included file must be a whole script on its own. A file may be included again after its + earlier inclusion has ended, and a file that includes itself, directly or through others, is an error. +- `#includepath "a;b"` sets the folders `#include` looks in, separated by `;`. A relative folder is relative to the + folder of the file that includes. A later `#includepath` replaces the list. +- `#script "name"` names the run in the report and the Script Console, unless `name` is given. A `#script` line in an + included file does not rename the run. +- `#target` takes any value and does not change how the script runs. `#strict off` is taken. +- `#strict on`, `#targetengine` and `#engine` end the run with an unsupported-directive error, and any other `#name` + with an unknown-directive error. A `//@` line with another name is a comment. +- A script run from a file reads its included files and the files `$.evalFile` runs only in that file's folder and + the folders below it. A path with a `..` name, a path that leads out of the folder through a link or a junction, a + Windows device name or stream, and a computer on the network are refused, and so is every path for a script given as + `source`. A `JSINCLUDE` folder outside the script's folder is passed over. +- A missing or refused included file ends the run before any of it runs. An error in an included file or a file `$.evalFile` + ran is reported at that file's line and column, and the report's `file` names the file. + The run is one transaction. When the script ends, each document it changed gets one undo step named Run Script. A run that changes no document adds no undo step. The journal records each command the run finished, as it records commands run one at a time. A preference the run changed takes the run's value, and a preference changed in the app @@ -1590,7 +1622,8 @@ VectorCraft sets these limits itself: | Limit | Value | |-------|-------| -| Source text | 8 MiB | +| Source text | 8 MiB a file, 32 MiB in all | +| Included and evaluated files | 64 in all, 16 levels deep, 1,024 paths looked up | | Heap | 256 MiB (128 MiB on the web) | | Host calls | 2,000,000 | | `maxFuel`, instructions in all | 1 to 100,000,000,000; 1,000,000,000 by default | @@ -1607,7 +1640,8 @@ In the app, a script past the growth limit stops at a question to continue or to with an error at that limit. The headless reply is `{ok: true, name, changed, written, log, fuelUsed, hostCalls, runTimeMs, timingAvailable, hostCallCounts, slowestCommands, console}`. In the app the reply is `{running: true, name}`, and the report appears later as `scriptReport` in `ui.inspect`. The report of a failed run has the same -fields after `kind`, `message`, `line`, `column` and `fuelLeft`. `answers` takes only an empty object, since scripts +fields after `kind`, `message`, `line`, `column`, `file` (when the error is in another file than the script) and +`fuelLeft`. `answers` takes only an empty object, since scripts have no prompts yet. - `runTimeMs` is the time the script ran, its commands included. Waits between frames and for a person's answer are @@ -1626,7 +1660,8 @@ vectorcraft-cli run --cmd script.run --params '{"path": "Tidy Layers.js", "args" ``` Window › Script Console shows the session's script lines, oldest first: each run's start, its `$.write` and -`$.writeln` output, its `vc.log` entries, its uncaught error as `name:line:column: message`, its end, and a report of +`$.writeln` output, its `vc.log` entries, its uncaught error as `name:line:column: message` (or `name: file:line:column: message` when the error is in another +file), its end, and a report of its time, host calls and slowest commands. Each line shows the run's number and the script's name. Line feeds, carriage returns and the two together break lines, and other control characters show as their Unicode Control Pictures. The console keeps at most 2,048 lines and 1 MiB of text and drops the oldest lines past that. A line longer diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f8fc74c474..b8e15f7b2a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -728,6 +728,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The control server writes each reply through a 64 KiB buffer. On Windows 11 it sends the 17 MB `document.inspect` reply of a document with 50,000 objects on one layer to a client over loopback in 0.09 s instead of 34 s. - Scripts: File › Scripts › Other Script… and `script.run` run a JavaScript file against the open documents as one transaction with one undo step for each changed document. A failed or stopped run puts every document back. `crates/engine/src/tests/script_run.rs`. - Scripts: Window › Script Console shows each run's `$.write` and `$.writeln` output, `vc.log` entries, uncaught error with the script's name, line and column, and a report of its time, host calls and slowest commands, keeping at most 2,048 lines and counting the lines it dropped. `script.run`'s report carries the same data, and `script.console`, `script.console.clear` and `ui.copyScriptConsole` read, clear and copy it. `crates/engine/src/tests/script_console.rs`. + - Scripts: a run reads its preprocessor lines before any of it runs: `#include` and `//@include` put another file in place of the line, looked for beside the including file, in the `#includepath` folders and in `JSINCLUDE`; `#script` names the run; `#target` and `#strict off` are taken, and `#strict on`, `#targetengine` and `#engine` are refused. `$.evalFile` runs a file with the same globals and returns its last value, and `$.fileName` and `$.includePath` give the file being run and the include folders. A script run from a file reads these files only in its folder and below, without `..` names or links that lead out, and an error in one is reported at its own file, line and column ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/), [the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/); `crates/engine/src/tests/script_include.rs`). - Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`. - MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832); each flag may be given more than once, a path inside any of its folders being allowed (#3546, `file_access::tests::several_roots_of_a_kind_each_grant_their_access`, `cli::mcp_automation_roots_take_several_folders`). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172). - 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). From f4496d61aafda82ee93df5d46cd3336537a1451d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 22:17:08 -0800 Subject: [PATCH 05/20] Paths: Add Anchor Point keeps the same anchors and segments selected path.insertAnchor (the Add Anchor Point tool, the Pen's Auto Add, the Smart Pen's Alt-click on a segment and Alt-click with the Scissors) added the anchor without changing the selection, so the direct-selected anchors and selected segments after it on the subpath kept their old numbers and the selection shifted onto the next points along. It now renumbers them with renumber_after_insert, the helper path.reshape uses. A selected segment that the new anchor splits stays selected as its two halves, and the new anchor is direct-selected with them. Undo restores the path and the selection in one step. Docs: params text, docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/path.rs | 13 +++++- crates/engine/src/tests/draw2.rs | 76 ++++++++++++++++++++++++++++++++ docs/mcp.md | 4 ++ docs/target-app-parity.md | 1 + 4 files changed, 92 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index e249a23fcc..9ee6b61bb5 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -135,7 +135,15 @@ pub fn specs() -> Vec { has_doc, reshape ), - cmd!("path.insertAnchor", "Add Anchor Point", [], None, "{id, subpath?, segment, t: 0..1}", has_doc, insert_anchor), + cmd!( + "path.insertAnchor", + "Add Anchor Point", + [], + None, + "{id, subpath?, segment, t: 0..1} add an anchor at t on the segment → {anchor}; the direct-selected anchors and selected segments keep the same points (a selected segment it splits stays selected as its two halves), and a path that isn't selected becomes the selection with the new anchor direct-selected", + has_doc, + insert_anchor + ), cmd!( "path.cutAtAnchors", "Cut Path at Selected Anchor Points", @@ -1526,7 +1534,7 @@ fn stretch_weights(anchors: &[Anchor], closed: bool, roles: &[Stretch]) -> Vec<[ /// After an anchor is added at `(si, added)` of path `id`, number the direct-selected anchors and /// selected segments after it on that subpath one higher, so the selection keeps the same points. /// A selected segment that the new anchor splits stays selected as its two halves, and the new -/// anchor joins its ends in the anchors that move with it. +/// anchor is added to the direct-selected anchors. fn renumber_after_insert(sel: &mut Selection, id: NodeId, si: usize, added: usize) { if let Some(set) = sel.anchors.get_mut(&id) { // A selected anchor index can name no anchor (`select.anchors` takes any index), so the @@ -1669,6 +1677,7 @@ fn insert_anchor(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Other("no such segment".into())); } let idx = sp.insert_anchor(seg, t); + renumber_after_insert(sel, id, si, idx); // A path that wasn't selected becomes the selection, its new anchor direct-selected, so // the point added shows and can be edited at once (#4237). if !sel.contains(id) { diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index df3a5f0667..fcf9b63636 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -2380,6 +2380,82 @@ fn add_anchor_point_selects_the_path_it_was_added_to() { assert!(selected_anchors(&s, kept).is_empty(), "a path selected as a whole stays so"); } +fn path_from(s: &mut Session, d: &str) -> NodeId { + NodeId(s.execute("path.create", &json!({ "d": d })).unwrap()["id"].as_u64().unwrap()) +} + +fn selected_segments(s: &Session, id: NodeId) -> Vec<(usize, usize)> { + s.doc().unwrap().selection.segments.get(&id).map(|set| set.iter().copied().collect()).unwrap_or_default() +} + +fn selected_points(s: &Session, id: NodeId) -> Vec { + let pd = path(s, id); + selected_anchors(s, id).iter().filter_map(|&(si, ai)| pd.subpaths.get(si)?.anchors.get(ai)).map(|a| a.p).collect() +} + +#[test] +fn add_anchor_point_keeps_the_same_anchors_selected() { + // The direct-selected anchors after the one added are numbered one higher, so the selection + // stays on the same points; one undo step restores the path and the selection. + let mut s = session(); + let id = path_from(&mut s, "M0 0 L70 0 L140 0 L210 0 L280 0 L350 0 L420 0"); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1], [0, 5]], "mode": "set"})).unwrap(); + let before = selected_points(&s, id); + let r = s.execute("path.insertAnchor", &json!({"id": id.0, "segment": 2, "t": 0.5})).unwrap(); + assert_eq!(r["anchor"], 3); + assert_eq!(selected_anchors(&s, id), [(0, 1), (0, 6)], "the anchor at 350 is now number 6"); + assert_eq!(selected_points(&s, id), before); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path(&s, id).subpaths[0].anchors.len(), 7); + assert_eq!(selected_anchors(&s, id), [(0, 1), (0, 5)]); +} + +#[test] +fn add_anchor_point_on_a_selected_segment_keeps_both_halves_and_the_new_anchor_selected() { + let mut s = session(); + let id = path_from(&mut s, "M0 0 L70 0 L140 0 L210 0 L280 0 L350 0 L420 0"); + s.execute("select.segments", &json!({"id": id.0, "segments": [[0, 2], [0, 4]], "mode": "set"})).unwrap(); + s.execute("path.insertAnchor", &json!({"id": id.0, "segment": 2, "t": 0.5})).unwrap(); + assert_eq!(selected_segments(&s, id), [(0, 2), (0, 3), (0, 5)]); + assert_eq!(selected_anchors(&s, id), [(0, 2), (0, 3), (0, 4), (0, 5), (0, 6)], "the new anchor is selected with the segment's ends"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path(&s, id).subpaths[0].anchors.len(), 7); + assert_eq!(selected_segments(&s, id), [(0, 2), (0, 4)]); +} + +#[test] +fn add_anchor_point_on_the_closing_segment_keeps_it_selected_as_two_halves() { + let mut s = session(); + let id = path_from(&mut s, "M0 0 L100 0 L100 100 L0 100 Z"); + s.execute("select.segments", &json!({"id": id.0, "segments": [[0, 3]], "mode": "set"})).unwrap(); + let r = s.execute("path.insertAnchor", &json!({"id": id.0, "segment": 3, "t": 0.5})).unwrap(); + assert_eq!(r["anchor"], 4); + assert_eq!(selected_segments(&s, id), [(0, 3), (0, 4)]); + assert_eq!(selected_anchors(&s, id), [(0, 0), (0, 3), (0, 4)]); + assert_eq!(selected_points(&s, id), [Point::new(0.0, 0.0), Point::new(0.0, 100.0), Point::new(0.0, 50.0)]); +} + +#[test] +fn add_anchor_point_renumbers_only_the_subpath_it_was_added_to() { + let mut s = session(); + let id = path_from(&mut s, "M0 0 L100 0 L200 0 M0 100 L100 100 L200 100"); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2], [1, 1], [1, 2]], "mode": "set"})).unwrap(); + let before = selected_points(&s, id); + s.execute("path.insertAnchor", &json!({"id": id.0, "subpath": 1, "segment": 0, "t": 0.5})).unwrap(); + assert_eq!(selected_anchors(&s, id), [(0, 2), (1, 2), (1, 3)]); + assert_eq!(selected_points(&s, id), before); +} + +#[test] +fn add_anchor_point_with_a_selected_anchor_index_past_the_end_does_not_panic() { + // `select.anchors` takes any index, so a selected one can name no anchor. + let mut s = session(); + let id = path_from(&mut s, "M0 0 L70 0 L140 0 L210 0"); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1], [0, usize::MAX]], "mode": "set"})).unwrap(); + s.execute("path.insertAnchor", &json!({"id": id.0, "segment": 0, "t": 0.5})).unwrap(); + assert_eq!(selected_anchors(&s, id), [(0, 2), (0, usize::MAX)]); +} + fn selected_anchors(s: &Session, id: NodeId) -> Vec<(usize, usize)> { s.doc().unwrap().selection.anchors.get(&id).map(|set| set.iter().copied().collect()).unwrap_or_default() } diff --git a/docs/mcp.md b/docs/mcp.md index 19f16205cd..fda4c0012b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3675,6 +3675,10 @@ the canvas (coordinates out of range)". `path.setHandle`, `path.moveAnchor` and `path.insertAnchor` edit subpath 0 when `subpath` is absent or null, and refuse a `subpath` that is not a whole number of 0 or more, such as 1.5, -1 or "1" ("missing integer `subpath`"); `path.split` refuses such an `anchor` or `segment` in the same way. The document does not change and no undo step is recorded. +`path.insertAnchor` keeps the direct-selected anchors and selected segments of the path on the same points: those +after the new anchor on its subpath are numbered one higher, and a selected segment that the new anchor splits stays +selected as its two halves, and the new anchor is direct-selected with them. Undo restores the path and the selection +in one step. `path.reshape {id, x, y, dx, dy, tol?, focal?, radius?}` (a Reshape tool drag) grabs the path at `x`, `y`, adding an anchor there unless one lies within `tol` (3 by default), and moves that point by `dx`, `dy`. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f8fc74c474..ea62257e0c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -943,6 +943,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With Direct Selection and Group Selection, a drag that starts on an object while the selection also holds direct-selected anchors or segments moves those anchors and the objects selected whole in one undo step (`path.moveAnchors`), as a drag of a selected anchor does, and the other anchors of a partly selected path stay in place. While Alt is held during the drag, it copies the selected objects instead (`object.transform`). When direct-selected anchors are moved or transformed together with objects selected whole (such a drag, an anchor drag, an arrow key, or Move, Rotate, Scale, Reflect and Shear without a copy), a path inside a selected group is transformed once, with the group. A path segment with a control point whose x or y is infinite or larger in size than `f64::MAX / 16` is measured by its control points, so its bounds hold the whole curve and the canvas check refuses an edit that leaves such a path selected (`path.create` included). With Direct Selection, the Pen and the anchor tools, a group, compound path or clip group selected whole shows the anchors of its paths filled, as a path selected whole does; the direction handles still show only on a path that is selected itself. Public references: [Adjust anchor point, handle, and bounding box display size](https://web.archive.org/web/20251114182206/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/learn-drawing-basics/adjust-anchor-point-handle-and-bounding-box-display-size.html) and [Drawing basics](https://web.archive.org/web/20231130064834/https://helpx.adobe.com/illustrator/using/drawing-basics.html) (archived copies). + When the Add Anchor Point tool, the Pen's Auto Add, the Smart Pen's Alt-click on a segment or Alt-click with the Scissors adds an anchor to a path with direct-selected anchors or segments (`path.insertAnchor`), the same points stay selected: the anchors and segments after the new anchor are numbered one higher, and a selected segment that the new anchor splits stays selected as its two halves. `path.curvatureEdit`, `path.removeAnchor`, `path.convertAnchor`, `path.reshapeSegment`, `path.split`, `path.setHandle`, `path.moveAnchor` and `path.insertAnchor` refuse a `subpath` that is not a whole number of 0 or more, and `path.split` refuses such an `anchor` or `segment`; without a `subpath` they edit subpath 0. `path.reshapeSegment` refuses a delta of 1e9 pt or longer, as `object.blend.spine.reshapeSegment` does, and `path.convertAnchor` and `path.freehand` refuse a point whose x or y is 1e9 pt or more in size. `path.reshapeSegment`, `object.blend.spine.reshapeSegment`, `path.convertAnchor`, `path.curvatureEdit`, `path.insertAnchor`, `path.moveAnchors`, `path.reshape`, `path.close` and the Pencil, Curvature, Knife, Eraser, Path Eraser, Smooth, Join and Blob Brush commands refuse a number, point or handle given as text, such as "10", and leave the document unchanged; `path.convertAnchor` refuses an `x` without a `y`, or a `y` without an `x`. From fdbf85d4c8e75f9b157cd9ac208410bc7cfc7164 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 22:24:39 -0800 Subject: [PATCH 06/20] Stores: saving a preset, profile or plug-in reads at most one byte past it from the file it replaces Before writing a PDF preset, ICC profile, plug-in, Color Settings, disabled list or grants file, the store compared the new bytes with the file already there by reading the whole file into memory, so a large file left in the folder was read in full. The comparison now reads at most one byte more than the new content. --- crates/engine/src/store.rs | 9 ++++++- crates/engine/src/tests/store.rs | 43 +++++++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index f6fe8ff5bc..01ebc46526 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -135,11 +135,18 @@ fn too_large(max: u64) -> String { format!("larger than {max} bytes") } +/// Whether `old` holds exactly `bytes`. It is read no further than one byte past their length, so a +/// large file is not read into memory. +pub(crate) fn holds(old: impl Read, bytes: &[u8]) -> bool { + let mut read = vec![]; + old.take(bytes.len() as u64 + 1).read_to_end(&mut read).is_ok() && read == bytes +} + /// Write `bytes` to `dir/file`, making the folder; nothing is written when the file has them /// already. A failure is logged: remembering is best effort and never fails the command. fn put(dir: &Path, file: &str, bytes: &[u8]) -> bool { let path = dir.join(file); - if std::fs::read(&path).is_ok_and(|old| old == bytes) { + if std::fs::File::open(&path).is_ok_and(|old| holds(old, bytes)) { return true; } let written = std::fs::create_dir_all(dir).and_then(|()| write_atomic(&path, bytes)); diff --git a/crates/engine/src/tests/store.rs b/crates/engine/src/tests/store.rs index 37eafe6e46..b6ac7b0201 100644 --- a/crates/engine/src/tests/store.rs +++ b/crates/engine/src/tests/store.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_color::cms; use crate::Session; -use crate::store::{MAX_PRESET_FILE, file_stem}; +use crate::store::{MAX_PRESET_FILE, file_stem, holds}; fn fresh_dir(tag: &str) -> std::path::PathBuf { let dir = vectorcraft_testkit::temp_dir(&format!("store-{tag}")); @@ -128,6 +128,47 @@ fn a_folder_that_does_not_exist_yet_takes_the_presets_the_preferences_held() { assert_eq!(saved_names(&mut b), ["From Preferences"]); } +/// Serves `0` bytes without end and counts how many were read. +struct Endless(u64); + +impl std::io::Read for Endless { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if self.0 > 1 << 24 { + return Err(std::io::Error::other("read too far")); + } + buf.fill(0); + self.0 += buf.len() as u64; + Ok(buf.len()) + } +} + +#[test] +fn a_write_reads_the_file_it_replaces_only_one_byte_past_the_new_length() { + let mut old = Endless(0); + assert!(!holds(&mut old, &[0; 10])); + assert_eq!(old.0, 11); + assert!(holds(&[1, 2, 3][..], &[1, 2, 3])); + assert!(!holds(&[1, 2, 3, 4][..], &[1, 2, 3])); + assert!(!holds(&[1, 2][..], &[1, 2, 3])); + assert!(holds(&[][..], &[])); +} + +#[test] +fn a_saved_preset_replaces_an_oversized_file_in_its_place() { + let dir = fresh_dir("oversized-put").join("PDF Presets"); + let mut a = Session::new(); + a.attach_pdf_preset_store(dir.clone()); + a.execute("pdf.preset.save", &json!({"name": "Web"})).unwrap(); + let file = dir.join(format!("{}.vcpdfpresets", file_stem("Web"))); + let saved = std::fs::read(&file).unwrap(); + let mut oversized = saved.clone(); + oversized.resize(usize::try_from(MAX_PRESET_FILE).unwrap() * 2, b' '); + std::fs::write(&file, &oversized).unwrap(); + + a.execute("pdf.preset.save", &json!({"name": "Web"})).unwrap(); + assert_eq!(std::fs::read(&file).unwrap(), saved); +} + #[test] fn damaged_unsafe_and_oversized_preset_files_are_skipped() { let dir = fresh_dir("damaged").join("PDF Presets"); From 5a8a57d5f69964bbfd64ce2a9d946f6b9c78fb12 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 22:27:53 -0800 Subject: [PATCH 07/20] Type: upright glyphs on a vertical path keep vertical point type's cells, and the caret spans the column On a vertical path, an upright glyph is now placed in its column as vertical point type places it, then the column is centered on the path. - Its cell starts at its place on the path; it started 0.12 em earlier with fonts whose cell top is less than a full cell above the baseline. - A baseline shift moves it across the column instead of along the path. - A horizontal scale widens it evenly about the column's center. - A combining mark stays on its upright letter. - The caret and the selection highlight span the column, half its em on each side of the path, instead of the font's ascent and descent. With Skew, 3D Ribbon and Stair Step on a path that runs to the left, letters are a quarter turn counterclockwise from upright glyphs: Skew and 3D Ribbon mirror both kinds there, and Stair Step keeps letters level. The test, the progress doc and the params text of Tate-chu-yoko Settings now state these conditions and the block's directions on a path. --- crates/engine/src/cmd/panelcmds.rs | 2 +- crates/engine/src/cmd/textedit.rs | 2 +- crates/text/src/layout.rs | 65 +++++++++++------ crates/text/src/layout/on_path.rs | 9 +++ crates/text/src/tests/ligature_carets.rs | 18 ++--- crates/text/src/tests/vertical.rs | 90 +++++++++++++++++++++++- docs/mcp.md | 5 +- docs/target-app-parity.md | 4 +- 8 files changed, 155 insertions(+), 40 deletions(-) diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 04b1ce6857..d7788df10c 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "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), 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\", 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), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), 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), 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\", 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), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), 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, and on a vertical path toward or away from the path's start, to its left or right), 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 ), diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index c315c2a1cb..689a73ea08 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), 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: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), 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: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_range_style ), diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 4a92c4ed34..15b71ae233 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -93,6 +93,34 @@ fn takes_cell(g: &SGlyph) -> bool { g.adv > 0.0 || g.face.advance(g.gid) > 0.0 } +/// Vertical type on a path: the turn that takes upright glyph `g` (of size `em`, its glyph space +/// mapped onto the path by `pre`) from its local transform `local` (`unturned` takes its Character +/// Rotation off) to the path, and where the next glyph of its cluster goes in its glyph space. +/// +/// It stands in glyph space as it stands in vertical point type's line space: a quarter turn +/// counterclockwise from horizontal path type's pose about the center of its cell, a baseline shift +/// kept across the column and a leading space taken off moving it along the path, then moved across +/// the path as a glyph lying on its side is. +fn upright_path_turn(g: &SGlyph, em: f64, pre: Affine, local: Affine, unturned: Affine, angle: f64, advance: f64) -> (Affine, f64) { + let cell = if takes_cell(g) { upright_cell(g) } else { advance }; + let (shift, unshift) = (Affine::translate((0.0, -g.bshift)), Affine::translate((0.0, g.bshift))); + let about = Point::new(cell * 0.5, -upright_centre(g, cell, em)); + let mut turn = + Affine::translate((-g.lead, g.face.ideographic_centre() * em)) * shift * Affine::rotate_about(-std::f64::consts::FRAC_PI_2, about) * unshift; + // The effects that keep the glyph's axes fixed (Stair Step) leave its top toward the path's end on + // a path that runs to the left: it turns half a turn about its cell's center. + let tangent = Vec2::new(angle.cos(), angle.sin()); + let placed = pre * turn * local * unturned; + if (placed * Point::new(0.0, -1.0) - placed * Point::ZERO).dot(tangent) > 0.0 { + let centre = turn * shift * about; + turn = Affine::translate(centre.to_vec2() * 2.0) * Affine::scale(-1.0) * turn; + } + // Its own advance across, from where the cell centers it: where the shaper set the next glyph of + // its cluster. + let across = g.face.advance(g.gid) * g.sx; + (pre * turn, (cell - across) * 0.5 + across) +} + /// Clear the caret stops of each glyph that shares its cluster with another glyph: only a glyph /// that alone stands for its characters is a ligature. fn keep_lone_ligature_carets(glyphs: &mut [PositionedGlyph]) { @@ -333,7 +361,7 @@ impl Ctx<'_> { }; // A glyph whose leading space was taken off (mojikumi) is drawn that much earlier: an // upright one in vertical type by moving it up the column once it stands upright. - let upright = self.vertical && !self.on_path && g.tcy.is_none() && stands_upright(g); + let upright = self.vertical && g.tcy.is_none() && stands_upright(g) && !(self.on_path && g.inline.is_some()); let lead = if upright { 0.0 } else { g.lead }; let local = match &g.inline { // Inline graphics: art space to glyph space (no font outline), moved along the line by @@ -417,28 +445,21 @@ impl Ctx<'_> { let centre = g.face.ideographic_centre() * g.face.units_per_em() * g.sy; m = pre * Affine::translate((0.0, centre)) * local; } else if self.vertical && self.on_path { - // An upright glyph is a quarter turn counterclockwise from horizontal path type's pose, - // turned in glyph space so that every effect bends it as it bends glyphs lying on their - // side: its top faces the path's start and the characters of an upright ligature read - // along the path in order. The center of its cell (without its Character Rotation) keeps - // its place along the path, and its em box is centered across the path. - let (cell, top) = path_cell.unwrap_or((0.0, 0.0)); - let unrotated = local * unturned; - let middle = Point::new(g.face.units_per_em() * 0.5, cell * 0.5 - top); - // The em box's middle across the column, which the shaper's offsets then move. - let at = Point::new(g.face.units_per_em() * g.sx * 0.5, (unrotated * middle).y); - let target = Point::new(-at.y, 0.0); - let quarter = Affine::new([0.0, -1.0, 1.0, 0.0, 0.0, 0.0]); - let mut turn = Affine::translate(target.to_vec2() - (quarter * at).to_vec2()) * quarter; - // The effects that keep the glyph's axes fixed (Stair Step) leave its top toward the - // path's end on a path that runs to the left: it turns half a turn about its cell's - // center. - let tangent = Vec2::new(angle.cos(), angle.sin()); - let placed = pre * turn * unrotated; - if (placed * Point::new(middle.x, middle.y - 1.0) - placed * middle).dot(tangent) > 0.0 { - turn = Affine::translate(target.to_vec2() * 2.0) * Affine::scale(-1.0) * turn; + // An upright glyph is turned in glyph space ([`upright_path_turn`]), so every effect bends it + // as it bends glyphs lying on their side: its top faces the path's start and the characters + // of an upright ligature read along the path in order. + let next = self.out.glyphs.len(); + let after_base = self.out.glyphs.last().is_some_and(|p| p.byte == g.byte && p.line == line && p.len > 0); + if let Some((_, _, turn, after)) = cluster.filter(|c| c.0 == g.byte && c.1 == next && after_base && g.len > 0) { + // A later glyph of an upright glyph's cluster: set after it as the shaper placed it, + // and turned with it, in its glyph space. + m = turn * Affine::translate((after, 0.0)) * local; + self.upright_cluster = Some((g.byte, next + 1, turn, after)); + } else { + let (turn, after) = upright_path_turn(g, self.style_at(g.byte).size, pre, local, unturned, angle, advance); + m = turn * local; + self.upright_cluster = (g.len > 0).then_some((g.byte, next + 1, turn, after)); } - m = pre * turn * local; } else if let (true, Some(t)) = (self.vertical, g.tcy) { // Tate-chu-yoko: the block set across the column, centred on its em of it, then moved up // by its Up/Down and right by its Left/Right (line x runs down the column and line y diff --git a/crates/text/src/layout/on_path.rs b/crates/text/src/layout/on_path.rs index aafe175310..398928ee98 100644 --- a/crates/text/src/layout/on_path.rs +++ b/crates/text/src/layout/on_path.rs @@ -174,6 +174,15 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, let s = style_metrics(cx.face_at(0).as_deref(), cx.style_at(0)); (s.0, s.1) }); + // Vertical type: the caret and the selection span the column, half its em on each side of the + // line Align to Path puts the baseline on. + let m = if cx.vertical { + let half = sg.iter().map(|g| g.face.units_per_em() * g.sx * 0.5).fold(f64::NAN, f64::max); + let half = if half.is_finite() { half } else { cx.style_at(0).size * 0.5 }; + (half, half) + } else { + m + }; if ap.is_empty() { cx.out.overflow = !sg.is_empty(); cx.out.lines.push(LineInfo { diff --git a/crates/text/src/tests/ligature_carets.rs b/crates/text/src/tests/ligature_carets.rs index 2f9699de68..a1e06887a7 100644 --- a/crates/text/src/tests/ligature_carets.rs +++ b/crates/text/src/tests/ligature_carets.rs @@ -655,7 +655,7 @@ fn vertical_only_settings_keep_a_horizontal_ligature_whole() { } /// On a path, an upright ligature in vertical type stands with its top toward the path's start, and -/// its cell runs from 0.12 em before the pen to 0.88 em after it, with or without the font's vertical +/// its cell runs one em from the pen, as in vertical point type, with or without the font's vertical /// metrics. The inner stop lies at the middle of that cell, and the first character's selection ends /// there. #[test] @@ -673,21 +673,21 @@ fn an_upright_ligature_on_a_path_takes_its_stop_in_its_turned_cell() { assert!(l.on_path); let Some(g) = l.glyphs.iter().find(|g| g.len == 2) else { panic!("f + i is one glyph: {:?}", l.glyphs) }; assert_eq!(g.carets, vec![(1, 0.5)], "{family}"); - assert!((g.width - 100.0).abs() < 1e-9 && (g.width_start + 12.0).abs() < 1e-9, "{family}: {} {}", g.width, g.width_start); + assert!((g.width - 100.0).abs() < 1e-9 && g.width_start.abs() < 1e-9, "{family}: {} {}", g.width, g.width_start); let x = |b| caret_position(&l, b).0.x - g.origin.x; - assert!((x(1) - 38.0).abs() < 1e-6, "{family}: {}", x(1)); + assert!((x(1) - 50.0).abs() < 1e-6, "{family}: {}", x(1)); let q = selection_quads(&l, 0, 1); assert_eq!(q.len(), 1, "{q:?}"); - assert!(((q[0][1] - q[0][0]).hypot() - 38.0).abs() < 1e-6, "{family}: {q:?}"); - // A baseline shift moves the turned glyph along the path, and its stop with it. - let ink = g.outline.bounding_box().x0; + assert!(((q[0][1] - q[0][0]).hypot() - 50.0).abs() < 1e-6, "{family}: {q:?}"); + // A baseline shift moves the turned glyph across the path, and leaves its stop in place. + let ink = g.outline.bounding_box(); t.runs[0].style.baseline_shift = 10.0; let l = layout(&db, &t); let Some(s) = l.glyphs.iter().find(|g| g.len == 2) else { panic!("f + i is one glyph: {:?}", l.glyphs) }; - let moved = s.outline.bounding_box().x0 - ink; - assert!(moved.abs() > 1.0, "{family}: the shift moves the glyph along the path: {moved}"); + let moved = s.outline.bounding_box().center() - ink.center(); + assert!((moved.x.abs()) < 1e-6 && (moved.y + 10.0).abs() < 1e-6, "{family}: the shift moves the glyph to the path's left: {moved:?}"); let stop = caret_position(&l, 1).0.x - s.origin.x; - assert!((stop - 38.0 - moved).abs() < 1e-6, "{family}: {stop} against {}", 38.0 + moved); + assert!((stop - 50.0).abs() < 1e-6, "{family}: {stop}"); // Horizontal Scale widens the glyph across the path; its stop stays inside the glyph. t.runs[0].style.baseline_shift = 0.0; t.runs[0].style.h_scale = 200.0; diff --git a/crates/text/src/tests/vertical.rs b/crates/text/src/tests/vertical.rs index 4df48c51ef..12069e007e 100644 --- a/crates/text/src/tests/vertical.rs +++ b/crates/text/src/tests/vertical.rs @@ -201,9 +201,10 @@ fn top_and_ahead(l: &TextLayout, i: usize) -> (Vec2, Vec2) { (unit(g.xf * Point::new(0.0, -500.0) - pen), unit(g.xf * Point::new(500.0, 0.0) - pen)) } -/// On a vertical path, an upright glyph's top faces the path's start, and a letter lying on its side -/// is a quarter turn clockwise from it: its top faces the path's left and its baseline runs along -/// the path. Both turn with the path when it runs the other way. +/// On a vertical path with Rainbow, an upright glyph's top faces the path's start, and a letter lying +/// on its side is a quarter turn clockwise from it: its top faces the path's left and its baseline +/// runs along the path. Both turn with the path when it runs the other way. The effects that keep +/// the glyphs' axes fixed on the page keep that relation only on a path that runs to the right. #[test] fn on_a_vertical_path_letters_lie_a_quarter_turn_clockwise_from_upright_glyphs() { use vectorcraft_doc::PathEffect; @@ -218,6 +219,24 @@ fn on_a_vertical_path_letters_lie_a_quarter_turn_clockwise_from_upright_glyphs() assert!((side - Vec2::new(-upright.y, upright.x)).hypot() < 1e-6, "{to:?}: {side:?} against {upright:?}"); assert!((side - left).hypot() < 1e-6 && (ahead - tangent).hypot() < 1e-6, "{to:?}: {side:?} {ahead:?}"); } + // Skew, 3D Ribbon and Stair Step keep axes of the glyphs fixed on the page: on a path that runs + // to the right the relation holds. On one that runs to the left, Skew and 3D Ribbon mirror both + // kinds, and Stair Step keeps letters level while upright glyphs still face the path's start, so + // letters are a quarter turn counterclockwise from upright glyphs there. + for effect in [PathEffect::Skew, PathEffect::Ribbon3d, PathEffect::StairStep] { + for to in [(1000.0, 0.0), (700.0, 700.0), (0.0, 1000.0), (0.0, -1000.0), (-1000.0, 0.0), (-700.0, 300.0)] { + // Skew and 3D Ribbon slant glyphs on a slanted path, where no turn relates them exactly. + if to.1 != 0.0 && effect != PathEffect::StairStep { + continue; + } + let l = layout(FontDb::global(), &on_vertical_path("§L", (0.0, 0.0), to, effect)); + let (upright, _) = top_and_ahead(&l, 0); + let (side, _) = top_and_ahead(&l, 1); + let clockwise = to.0 >= 0.0; + let want = if clockwise { Vec2::new(-upright.y, upright.x) } else { Vec2::new(upright.y, -upright.x) }; + assert!((side - want).hypot() < 1e-6, "{effect:?} {to:?}: {side:?} against {upright:?}"); + } + } } /// On a vertical path, letters lying on their side are set as horizontal type on the same path sets @@ -277,6 +296,71 @@ fn on_a_vertical_path_the_column_is_centered_on_the_path() { } } +/// On a vertical path, the caret and the selection span the column, half its em on each side of the +/// path, as the glyphs do. +#[test] +fn on_a_vertical_path_the_caret_and_selection_span_the_column() { + use vectorcraft_doc::PathEffect; + for to in [(0.0, 1000.0), (1000.0, 0.0), (-700.0, 700.0)] { + let l = layout(FontDb::global(), &on_vertical_path("§L§", (0.0, 0.0), to, PathEffect::Rainbow)); + let tangent = Vec2::new(to.0, to.1) / Vec2::new(to.0, to.1).hypot(); + let across = |p: Point| p.to_vec2().dot(Vec2::new(tangent.y, -tangent.x)); + let (top, bottom) = caret_position(&l, "§".len()); + assert!((across(top) - 10.0).abs() < 1e-6 && (across(bottom) + 10.0).abs() < 1e-6, "{to:?}: caret {top:?} {bottom:?}"); + for q in selection_quads(&l, 0, "§L§".len()) { + let (lo, hi) = q.iter().map(|p| across(*p)).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), a| (lo.min(a), hi.max(a))); + assert!((lo + 10.0).abs() < 1e-6 && (hi - 10.0).abs() < 1e-6, "{to:?}: highlight from {lo} to {hi}"); + } + } +} + +/// On a straight path running down from the anchor, vertical type sets every glyph where vertical +/// point type sets it: upright glyphs with a horizontal scale stay centered on the column, a baseline +/// shift moves an upright glyph across the column, every upright glyph keeps its own cell along the +/// path, and a combining mark stays on its upright letter. +#[test] +fn on_a_downward_vertical_path_glyphs_sit_where_vertical_point_type_sets_them() { + use crate::test_fonts::{VERTICAL_FAMILY, vertical_font}; + let run = |text: &str, style: CharStyle| TextRun { text: text.into(), style, inline: None }; + let runs = |plain: CharStyle| { + vec![ + run("§×", plain.clone()), + run("§", CharStyle { h_scale: 150.0, ..plain.clone() }), + run("§", CharStyle { baseline_shift: 3.0, ..plain.clone() }), + run("§", CharStyle { rotation: 30.0, ..plain.clone() }), + run("L", CharStyle { h_scale: 150.0, ..plain.clone() }), + run("L", CharStyle { baseline_shift: 3.0, ..plain.clone() }), + run("q\u{301}x", CharStyle { standard_vertical_roman: true, ..plain.clone() }), + run("§", plain), + ] + }; + let compare = |db: &FontDb, point: &TextObject, name: &str| { + let mut on_path = point.clone(); + on_path.kind = + TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (0.0, 3000.0)).to_path(0.1)), start: 0.0, end: None }; + let (p, v) = (layout(db, point), layout(db, &on_path)); + assert_eq!(p.glyphs.len(), v.glyphs.len(), "{name}"); + let apart: Vec<_> = (p.glyphs.iter().zip(&v.glyphs).enumerate()) + .filter(|(_, (a, b))| a.xf.as_coeffs().iter().zip(b.xf.as_coeffs()).any(|(a, b)| (a - b).abs() > 1e-6)) + .map(|(i, (a, b))| format!("glyph {i}: {:?} on the path, {:?} in point type", b.xf.as_coeffs(), a.xf.as_coeffs())) + .collect(); + assert!(apart.is_empty(), "{name}: {apart:#?}"); + }; + let mut point = vertical(""); + point.runs = runs(CharStyle { size: 20.0, ..CharStyle::default() }); + compare(FontDb::global(), &point, "the bundled face"); + // A face with vertical metrics, whose cells hang from their vertical origins. + let db = FontDb::with_font_dirs(vec![]); + db.add_font(vertical_font(true).unwrap()); + point.runs = runs(CharStyle { size: 20.0, font_family: VERTICAL_FAMILY.into(), ..CharStyle::default() }); + compare(&db, &point, "vertical metrics"); + // The horizontally scaled upright glyph is wider across the column, centered where the plain one is. + point.kind = TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (0.0, 3000.0)).to_path(0.1)), start: 0.0, end: None }; + let l = layout(&db, &point); + let (plain, wide) = (ink(&l, 0), ink(&l, 2)); + assert!(wide.width() > plain.width() * 1.4 && (wide.center().x - plain.center().x).abs() < 1e-6, "{plain:?} {wide:?}"); +} + /// With Skew on a curved vertical path, every glyph's axis along the path follows the tangent and /// its axis across the path stays upright on the page: an upright glyph's top runs back along the /// tangent and its own x axis points up the page, and a letter lying on its side keeps its top up diff --git a/docs/mcp.md b/docs/mcp.md index 14a5bfb6cf..d0b41e8ca3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6374,8 +6374,9 @@ with binding punctuation (`Tg`) on, which opens as `forced`. `none` isn't saved. `text.setFormat` and `text.setRangeStyle {tcyUpDown?, tcyLeftRight?}` set the Character panel menu's Tate-chu-yoko Settings of the characters, in points from -1296 to 1296 (document points, as `baselineShift`). In vertical type a block of tate-chu-yoko characters (`tateChuYoko`) moves up by a positive Up/Down and down by a negative one, and right -by a positive Left/Right and left by a negative one. The block moves as one piece with the values of its first -character, and keeps its one em down the column, its caret positions and its hit region. Two or three digits set +by a positive Left/Right and left by a negative one. On a vertical path, a positive Up/Down moves the block toward the +path's start and a positive Left/Right moves it to the path's left, with every path effect. The block moves as one piece +with the values of its first character, and keeps its one em down the column, its caret positions and its hit region. Two or three digits set across the column without the mark do not move, and horizontal type keeps the values without moving the characters. Neither value turns `tateChuYoko` on. Both are 0 by default and saved only when not 0, and character styles carry them. A value that is not a number is refused with `` `tcyUpDown` must be a number ``, and one outside the range with diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 562574cde0..d0db4c3424 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1165,10 +1165,10 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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 (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; on a vertical path, upright glyphs and tate-chu-yoko blocks face the path's start, and letters on their side are a quarter turn clockwise from them in the same column, which is centered on the path); 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 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; on a vertical path, upright glyphs and tate-chu-yoko blocks face the path's start, and letters on their side are a quarter turn clockwise from them (with Skew, 3D Ribbon and Stair Step only on a path that runs to the right), in one column that Align to Path Baseline or Center centers on the path and that the caret and selection span); 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. - Character panel menu › Tate-chu-yoko Settings… (with the East Asian options; `ui.tateChuYokoSettings`, `text.setFormat` or `text.setRangeStyle {tcyUpDown, tcyLeftRight}`, in points, 0 by default) moves a marked tate-chu-yoko block in vertical type up by a positive Up/Down and right by a positive Left/Right ([format East Asian characters in vertical text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/format-east-asian-characters-in-vertical-text.html), [formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); the block moves as one piece with its first character's values and keeps its one em down the column, automatic digit blocks do not move, Reset Panel sets both back to 0, and character styles and the native file keep them. + Character panel menu › Tate-chu-yoko Settings… (with the East Asian options; `ui.tateChuYokoSettings`, `text.setFormat` or `text.setRangeStyle {tcyUpDown, tcyLeftRight}`, in points, 0 by default) moves a marked tate-chu-yoko block in vertical type up by a positive Up/Down and right by a positive Left/Right ([format East Asian characters in vertical text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/format-east-asian-characters-in-vertical-text.html), [formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); the block moves as one piece with its first character's values and keeps its one em down the column, on a vertical path it moves toward the path's start and to the path's left, automatic digit blocks do not move, Reset Panel sets both back to 0, and character styles and the native file keep them. A file whose character styles hold a Tate-chu-yoko Up/Down or Left/Right offset past the coordinate limit (`MAX_COORD`) either way opens with the nearer end, as the type's own offsets do; an offset within it stays as saved. The Character panel's All Caps, Underline and Strikethrough buttons and its menu's All Caps, Underline, Strikethrough, Proportional Metrics, Tate-chu-yoko and No Break show off while only some of the selected characters have the attribute, and choosing one then turns it on for all of them, as `type.underline` (Cmd+Shift+U) does for underlining; the Character Alignment and Digits submenus check no item while the selected characters differ. `text.setFormat` refuses a value that is not true or false for its on/off attributes, as `text.setRangeStyle` does. - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. The constant alpha soft mask that PDF and `.ai` export draw a CMYK page with transparency through is white, so applications that read every soft mask by its luminosity show the page too (#1359, part of #1286). From 76517afebc66e0d0361d66a5b974197c209cef93 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 22:25:33 -0800 Subject: [PATCH 08/20] Character Styles: the Fill and Stroke Tint fields have distinct names The two Tint fields in the Character Color section were both named "Tint", so a screen reader gave no way to tell the Fill field from the Stroke one. They are now named "Fill Tint" and "Stroke Tint"; the visible caption stays "Tint". The names have rows in every catalog, and a test checks both names in Character Style Options and New Character Style. --- .../ui-egui/src/dialogs/char_style_options.rs | 46 +++++++++++++++++-- crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/bs.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/da.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hi.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ka.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + 28 files changed, 97 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs/char_style_options.rs b/crates/ui-egui/src/dialogs/char_style_options.rs index e1f564d08a..2011d2616a 100644 --- a/crates/ui-egui/src/dialogs/char_style_options.rs +++ b/crates/ui-egui/src/dialogs/char_style_options.rs @@ -804,19 +804,19 @@ fn paint_choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str) { /// the stroke Weight and Overprint Fill and Overprint Stroke, each unset or set. pub(crate) fn color_body(ui: &mut egui::Ui, d: &mut Dialog) { egui::Grid::new("character-style-color").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| { - for (key, caption) in [("fill", tl!("Fill")), ("stroke", tl!("Stroke"))] { + for (key, caption, tint_name) in [("fill", tl!("Fill"), tl!("Fill Tint")), ("stroke", tl!("Stroke"), tl!("Stroke Tint"))] { let row = ui.label(caption); ui.label_inputs_by(row.id, |ui| paint_choice(ui, d, key)); ui.end_row(); let tint_key = format!("{key}_tint"); - let row = ui.label(tl!("Tint")); + ui.label(tl!("Tint")); if d.fields.get(&tint_key).is_none_or(Value::is_null) { d.fields.insert(tint_key.clone(), json!("")); } let linked = d.fields.get(key).and_then(|p| p.get("swatch")).is_some_and(Value::is_string); ui.add_enabled_ui(linked, |ui| { ui.horizontal(|ui| { - ui.label_inputs_by(row.id, |ui| form::text(ui, d, &tint_key, 160.0)); + form::text_named(ui, d, &tint_key, tint_name, 160.0); ui.label("%"); }); }); @@ -1893,6 +1893,46 @@ mod tests { } } + #[test] + fn fill_and_stroke_tint_fields_have_distinct_names() { + use egui::accesskit::Role; + use egui_kittest::kittest::{NodeT, Queryable}; + for new_style in [false, true] { + let mut app = app(); + app.run("swatch.new", json!({"name": "Brand", "color": "#2050a0", "global": true})).unwrap(); + if new_style { + app.run("ui.newCharStyle", json!({})).unwrap(); + } else { + app.run("charStyle.new", json!({"name": "Body", "attrs": {}})).unwrap(); + open(&mut app, "Body").unwrap(); + } + let brand = json!(app.session.doc().unwrap().doc.tint_paint("Brand", 1.0).unwrap()); + { + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("section".into(), json!("color")); + d.fields.insert("fill".into(), brand.clone()); + d.fields.insert("stroke".into(), brand); + } + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1100.0, 900.0)).build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if !std::mem::replace(&mut fonts, true) { + crate::theme::install_fonts(ui.ctx()); + return; + } + super::super::show(app, ui.ctx()); + }, + app, + ); + h.run_steps(4); + for name in ["Fill Tint", "Stroke Tint"] { + let field = h.get_by_role_and_label(Role::TextInput, name); + assert!(!field.accesskit_node().is_disabled(), "new {new_style}: {name} is enabled for a linked swatch"); + } + assert!(h.query_by_role_and_label(Role::TextInput, "Tint").is_none(), "new {new_style}: a field is named only Tint"); + } + } + #[test] fn invalid_number_keeps_last_valid_preview() { let mut app = app(); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index ee32cee33f..8bfb93915a 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -3486,6 +3486,8 @@ digits Malayalam Малаялам Tiles are numbered across, then down; leave the range empty for every tile. Плочките се номерират напречно, след това надолу; оставете диапазона празен за всяка плочка. Copies per Sheet (as many as fit, in a grid) Копия на лист (колкото се побират, в мрежа) Tint Оттенък + Fill Tint Оттенък на запълване + Stroke Tint Оттенък на щриха Tip extends past the end point Връхът продължава след крайната точка Tip on the end point Връх върху крайната точка Title Case Заглавни букви diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 0970a70929..31ada1b70e 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -2256,6 +2256,8 @@ digits Malayalam Malajalam Green Zelena Shifts the tints of global and spot colors; other colors stay. Pomjera nijanse globalnih i spot boja; ostale boje ostaju. Tint Preliv + Fill Tint Preliv ispune + Stroke Tint Preliv poteza Color Guide Options Opcije vodiča za boje Less Manje Steps: Koraci: diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 84b6833c4e..4898a45f4d 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -4478,6 +4478,8 @@ digits Malayalam Malaiàlam Tilt Inclinació Time (hhmmss) Hora (hhmmss) Tint To + Fill Tint To de farciment + Stroke Tint To del traç Tint Reduction Reducció de to Tints Tons Tints and Shades Tons i ombres diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 733a3e813b..1b4a6bcb85 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2285,6 +2285,8 @@ digits Malayalam Malajálamské Green Zelená Shifts the tints of global and spot colors; other colors stay. Posune tóny globálních a přímých barev; ostatní barvy zůstanou. Tint Tón + Fill Tint Tón výplně + Stroke Tint Tón tahu Color Guide Options Možnosti průvodce barvami Less Méně Steps: Kroky: diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index d1871b2ed9..53f0e48f15 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2089,6 +2089,8 @@ digits Malayalam Malayalam Green Grøn Shifts the tints of global and spot colors; other colors stay. Flytter nuancerne af globale farver og spotfarver; andre farver forbliver uændrede. Tint Nuance + Fill Tint Fyldnuance + Stroke Tint Stregnuance Color Guide Options Indstillinger for farveguide Less Mindre Steps: Trin: diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 8c75b25b6b..4241811e5c 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3520,6 +3520,8 @@ effect Points Punkte Copies per Sheet (as many as fit, in a grid) Kopien pro Blatt (so viele wie passen, im Raster) Gutter: Zwischenraum: Tint Tonwert + Fill Tint Flächentonwert + Stroke Tint Konturtonwert Tip extends past the end point Spitze ragt über den Endpunkt hinaus Tip on the end point Spitze auf dem Endpunkt Title Case Wortanfänge groß diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 7813ee6cf8..7d3858b17f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2085,6 +2085,8 @@ font-technology Variable Μεταβλητές Threshold Κατώφλι Tile Παράθεση Tint Χρωματική απόχρωση + Fill Tint Χρωματική απόχρωση γεμίσματος + Stroke Tint Χρωματική απόχρωση περιγράμματος Tolerance Ανοχή Tolerance: Ανοχή: Tool Options… Επιλογές εργαλείου… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d8bcecbc6e..c8d60f1d41 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3575,6 +3575,8 @@ digits Malayalam Malayálam Copies per Sheet (as many as fit, in a grid) Copias por hoja (tantas como quepan, en cuadrícula) Gutter: Medianil: Tint Matiz + Fill Tint Matiz de relleno + Stroke Tint Matiz de trazo Tip extends past the end point La punta se extiende más allá del punto final Tip on the end point Punta en el punto final Title Case Tipo Título diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index d3b9fb3260..37ff372c43 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2244,6 +2244,8 @@ digits Malayalam Malajalam Green Vihreä Shifts the tints of global and spot colors; other colors stay. Siirtää globaalien värien ja spottivärien sävyjä; muut värit säilyvät. Tint Sävy + Fill Tint Täytön sävy + Stroke Tint Viivan sävy Color Guide Options Väriopasteen asetukset Less Vähemmän Steps: Vaiheet: diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6db5062046..3c9ad20474 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3566,6 +3566,8 @@ digits Malayalam Malayalam Copies per Sheet (as many as fit, in a grid) Copies par feuille (autant qu’il en tient, en grille) Tiles are numbered across, then down; leave the range empty for every tile. Les carreaux sont numérotés de gauche à droite, puis de haut en bas ; laissez la plage vide pour tous les carreaux. Tint Teinte + Fill Tint Teinte du fond + Stroke Tint Teinte du contour Tip extends past the end point La pointe dépasse le point d’extrémité Tip on the end point Pointe sur le point d’extrémité Title Case Majuscule à chaque mot diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 3fa2269085..7aa88cfb68 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -4853,6 +4853,8 @@ antiAlias Sharp शार्प Tilt झुकाव Time (hhmmss) समय (hhmms) Tint टिंट + Fill Tint फ़िल टिंट + Stroke Tint स्ट्रोक टिंट Tint Reduction टिंट में कमी Tints टिंट्स Tints and Shades टिंट्स और शेड्स diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 27e22d0ad0..de2999dcf7 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4249,6 +4249,8 @@ mark-style Japanese Japán Tilt Dőlés Copies per Sheet (as many as fit, in a grid) Példány laponként (ahány elfér, rácsban) Tint Tónus + Fill Tint Kitöltés tónusa + Stroke Tint Körvonal tónusa Tints Tónusok Tints and Shades Tónusok és árnyalatok Tips Tippek diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index c4e97482a8..daa818885e 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2089,6 +2089,8 @@ digits Malayalam Malayalam Green Hijau Shifts the tints of global and spot colors; other colors stay. Menggeser tint warna global dan warna spot; warna lainnya tetap. Tint Tint + Fill Tint Tint isian + Stroke Tint Tint stroke Color Guide Options Opsi panduan warna Less Lebih sedikit Steps: Langkah: diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 987505a43c..5f75418902 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3612,6 +3612,8 @@ digits Malayalam Malayalam Copies per Sheet (as many as fit, in a grid) Copie per foglio (quante ne entrano, in griglia) Gutter: Spazio intermedio: Tint Tinta + Fill Tint Tinta riempimento + Stroke Tint Tinta traccia Tip extends past the end point La punta si estende oltre il punto finale Tip on the end point Punta sul punto finale Title Case Iniziali maiuscole diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d3c0b6603d..3c092ac172 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3890,6 +3890,8 @@ file-filter Templates テンプレート Text Wrap Options テキストの回り込みオプション Tile Full Pages ページ全体を分割 Tint 濃度 + Fill Tint 濃度 (塗り) + Stroke Tint 濃度 (線) Tip extends past the end point 先端をパスの端点より先に伸ばす Tip on the end point 先端をパスの端点に合わせる Top 上 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 5a05ccec34..c08146eea3 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1995,6 +1995,8 @@ digits Malayalam მალაიალამური Green მწვანე Shifts the tints of global and spot colors; other colors stay. ცვლის გლობალური და სპოტ-ფერების ტონირებებს; სხვა ფერები უცვლელი რჩება. Tint ტონირება + Fill Tint შევსების ტონირება + Stroke Tint კონტურის ტონირება Color Guide Options ფერის გზამკვლევის პარამეტრები Less ნაკლები Steps: ბიჯები: diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b0fc26d1fe..e59645e7db 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3878,6 +3878,8 @@ file-filter Templates 템플릿 Text Wrap Options 텍스트 감싸기 옵션 Tile Full Pages 전체 페이지 타일링 Tint 색조 + Fill Tint 칠 색조 + Stroke Tint 선 색조 Tip extends past the end point 끝이 끝점을 지나 연장됨 Tip on the end point 끝이 끝점에 위치 Top 위쪽 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c30a50e71d..925cd97e0b 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2103,6 +2103,8 @@ digits Malayalam Malayalam Green Groen Shifts the tints of global and spot colors; other colors stay. Verschuift de tinten van globale kleuren en spotkleuren; andere kleuren blijven ongewijzigd. Tint Tint + Fill Tint Vultint + Stroke Tint Contourtint Color Guide Options Opties voor kleurengids Less Minder Steps: Stappen: diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8450cb6540..343339d6d2 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2201,6 +2201,8 @@ digits Malayalam Malajalam Green Zielony Shifts the tints of global and spot colors; other colors stay. Przesuwa tinty kolorów globalnych i dodatkowych; pozostałe kolory bez zmian. Tint Tinta + Fill Tint Tinta wypełnienia + Stroke Tint Tinta obrysu Color Guide Options Opcje przewodnika kolorów Less Mniej Steps: Kroki: diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 00d9a89484..88753d8dc8 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5019,6 +5019,8 @@ axis Both Ambos Text Wrap Options Opções de Quebra de Texto Tile Full Pages Dividir em Páginas Inteiras Tint Tom + Fill Tint Tom de Preenchimento + Stroke Tint Tom do Traçado Tip extends past the end point A ponta se estende além do ponto final Tip on the end point Ponta no ponto final Toolbar: Add Tool Barra de Ferramentas: Adicionar Ferramenta diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 24a6c74d5b..d487080adb 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3606,6 +3606,8 @@ digits Malayalam Малаялам Tiles are numbered across, then down; leave the range empty for every tile. Плитки нумеруются слева направо, затем вниз; оставьте диапазон пустым для всех плиток. Copies per Sheet (as many as fit, in a grid) Копий на лист (сколько поместится, сеткой) Tint Оттенок + Fill Tint Оттенок заливки + Stroke Tint Оттенок обводки Tip extends past the end point Кончик выходит за конечную точку Tip on the end point Кончик на конечной точке Title Case Как в заголовке diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index c038612c18..366111ee8b 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3496,6 +3496,8 @@ digits Malayalam Malayalam Tiles are numbered across, then down; leave the range empty for every tile. Rutorna numreras från vänster till höger och sedan nedåt. Lämna omfånget tomt för att få med alla rutor. Copies per Sheet (as many as fit, in a grid) Kopior per ark (så många som får plats, i ett rutnät) Tint Färgton + Fill Tint Fyllnadsfärgton + Stroke Tint Konturfärgton Tip extends past the end point Spetsen sträcker sig förbi slutpunkten Tip on the end point Spetsen på slutpunkten Title Case Inledande versaler diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 282990771a..138287eda4 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3385,6 +3385,8 @@ digits Malayalam Malayalam tile {number} kiremit {number} Tiles are numbered across, then down; leave the range empty for every tile. Fayanslar önce baştan sonra aşağı doğru numaralandırılır; aralığı her döşeme için boş bırakın. Tint Renk tonu + Fill Tint Dolgu Renk Tonu + Stroke Tint Kontur Renk Tonu Tip extends past the end point İpucu bitiş noktasının ötesine uzanıyor Tip on the end point Bitiş noktasına dair ipucu Title Case Başlık Durumu diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 88bcb4702a..443d15b9de 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3605,6 +3605,8 @@ effect Points Точки Copies per Sheet (as many as fit, in a grid) Копій на аркуш (скільки поміститься, сіткою) Gutter: Проміжок: Tint Відтінок + Fill Tint Відтінок заливки + Stroke Tint Відтінок обведення Tip extends past the end point Кінчик виходить за кінцеву точку Tip on the end point Кінчик на кінцевій точці Title Case Як у заголовку diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 90c292bc92..bba3d862d2 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3952,6 +3952,8 @@ mark-style Japanese Kiểu Nhật Copies per Sheet (as many as fit, in a grid) Nhiều bản sao trên một tờ (xếp vừa bao nhiêu in bấy nhiêu, theo lưới) Gutter: Khoảng cách: Tint Tông màu + Fill Tint Tông màu tô + Stroke Tint Tông màu nét Tints Sắc pha Tints and Shades Sắc pha và bóng Tips Mẹo diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 77578eec12..eea0e9373b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3438,6 +3438,8 @@ digits Malayalam 马拉雅拉姆数字 Tiles are numbered across, then down; leave the range empty for every tile. 拼贴先由左至右再由上至下编号;范围留空表示全部拼贴。 Copies per Sheet (as many as fit, in a grid) 每张纸印多份 (尽量排满,按网格排列) Tint 色调 + Fill Tint 填色色调 + Stroke Tint 描边色调 Tip extends past the end point 箭头超出端点 Tip on the end point 箭头位于端点 Title Case 词首大写 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index bdec63590f..95457c4d3f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -4549,6 +4549,8 @@ digits Malayalam 馬來亞拉姆數字 Copies per Sheet (as many as fit, in a grid) 每張紙印多份 (盡量排滿,以格點排列) Gutter: 間距: Tint 色調 + Fill Tint 填色色調 + Stroke Tint 筆畫色調 Tip extends past the end point 尖端延伸超過終點 Tip on the end point 尖端位於終點 Title Case 標題式大小寫 From 4110f8c24e7419c3d437482746638ffc5311c1f0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 22:30:18 -0800 Subject: [PATCH 09/20] Reshape: a step recorded without focal points plays by the earlier rule Before the Reshape tool had focal points it sent path.reshape without focal or radius, and the command stretched an open path from the grabbed point to its ends whatever the selection, and moved the anchors of a closed path within half the diagonal of its bounding box with a falloff. Such a step, in a saved action or a script, now moved a closed path whole. A step without focal (or with focal null) and without radius now plays by that earlier rule. The Reshape tool always sends focal, an empty list included, so its drags keep the focal-point rule. --- crates/engine/src/cmd/path.rs | 23 +++++++++++++++++----- crates/engine/src/tests.rs | 36 ++++++++++++++++++++++++++++------- crates/tools/src/extra.rs | 7 ++----- docs/mcp.md | 11 +++++++---- 4 files changed, 56 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index e249a23fcc..d6c532224b 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -131,7 +131,7 @@ pub fn specs() -> Vec { "Reshape", [], None, - "{id, x, y, dx, dy, tol=3, focal?: [[subpath, anchor]…], radius?} Reshape tool: grab the path at (x, y) (adding an anchor there unless one is within tol) and drag it by (dx, dy). The grabbed anchor and the anchors in focal are focal points and move by the whole drag. Anchors that are not direct-selected stay in place (a path selected whole, or not selected, has every anchor selected), and so do an open path's two ends unless they are focal points. A selected anchor between two focal points moves whole; one between a focal point and an anchor that stays takes a share of the drag that falls smoothly from 1 at the focal point to 0 at the anchor that stays, by its distance along the path's control polygon. A closed subpath with every anchor selected moves whole; a subpath with no focal point does not move. With radius, only the grabbed point's subpath changes: anchors follow with a smooth falloff over radius, measured along the path (0 or more), whatever the selection; focal and radius cannot be used together", + "{id, x, y, dx, dy, tol=3, focal?: [[subpath, anchor]…], radius?} Reshape tool: grab the path at (x, y) (adding an anchor there unless one is within tol) and drag it by (dx, dy). The grabbed anchor and the anchors in focal are focal points and move by the whole drag. Anchors that are not direct-selected stay in place (a path selected whole, or not selected, has every anchor selected), and so do an open path's two ends unless they are focal points. A selected anchor between two focal points moves whole; one between a focal point and an anchor that stays takes a share of the drag that falls smoothly from 1 at the focal point to 0 at the anchor that stays, by its distance along the path's control polygon. A closed subpath with every anchor selected moves whole; a subpath with no focal point does not move. With radius, only the grabbed point's subpath changes: anchors follow with a smooth falloff over radius, measured along the path (0 or more), whatever the selection; focal and radius cannot be used together. Without focal (or with focal null) and without radius, the earlier rule applies: the selection does not matter, an open path stretches from the grabbed point to its two ends, and on a closed path the anchors follow with the falloff over half the diagonal of the subpath's bounding box; the Reshape tool always sends focal", has_doc, reshape ), @@ -1573,6 +1573,11 @@ fn reshape(s: &mut Session, p: &Value) -> Result { if radius.is_some() && !focal.is_empty() { return Err(bad("path.reshape", "`focal` and `radius` cannot be used together")); } + // A step that names neither `focal` nor `radius` (as the Reshape tool recorded one before it + // had focal points) keeps that earlier rule: the selection does not matter, an open path + // stretches from the grabbed point to its two ends, and a closed one follows with a falloff + // over the default radius. + let earlier = radius.is_none() && matches!(p.get("focal"), None | Some(Value::Null)); { let st = s.doc()?; let node = st.doc.node(id).ok_or(EngineError::NoNode(id))?; @@ -1605,11 +1610,12 @@ fn reshape(s: &mut Session, p: &Value) -> Result { added } }; - let Some(r) = radius else { + let earlier_closed = earlier && sp.closed; + if radius.is_none() && !earlier_closed { focal.insert((si, grabbed)); // Anchors that are not direct-selected stay; a path selected whole, or not selected, - // has every anchor selected. - let selected = sel.anchors.get(&id); + // has every anchor selected, and so does every path under the earlier rule. + let selected = sel.anchors.get(&id).filter(|_| !earlier); for (k, sp) in path.subpaths.iter_mut().enumerate() { if focal.range((k, 0)..=(k, usize::MAX)).next().is_none() { continue; @@ -1629,13 +1635,20 @@ fn reshape(s: &mut Session, p: &Value) -> Result { } } return Ok(()); - }; + } let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("empty path".into()))?; let closed = sp.closed; // Each anchor's distance along the path from the grabbed one, the shorter way round a // closed path: the lengths are measured once, as a path can have many anchors. let pos = sp.anchor_arc_positions(); let total = pos.last().copied().unwrap_or(0.0); + // The default reach of the earlier rule: half the diagonal of the subpath's bounding box. + let r = radius.unwrap_or_else(|| { + let mut bp = vectorcraft_geom::BezPath::new(); + sp.to_bezpath_into(&mut bp); + let b = vectorcraft_geom::Shape::bounding_box(&bp); + (b.width().hypot(b.height()) / 2.0).max(1.0) + }); let from = pos.get(grabbed).copied().unwrap_or(0.0); for (i, a) in sp.anchors.iter_mut().enumerate() { let w = if i == grabbed { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 72b6d3ea80..217d31e018 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3217,9 +3217,8 @@ fn flare_rotation_turns_only_the_rays() { fn reshape_moves_the_grabbed_point_and_its_neighbourhood() { let mut s = session(); let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0); - // Grab the middle of the top edge: a new anchor appears there and moves the full delta. The - // radius, about half the square's diagonal, bounds the reach. - s.execute("path.reshape", &json!({"id": r.0, "x": 50.0, "y": 0.0, "dx": 0.0, "dy": -40.0, "radius": 70.0})).unwrap(); + // Grab the middle of the top edge: a new anchor appears there and moves the full delta. + s.execute("path.reshape", &json!({"id": r.0, "x": 50.0, "y": 0.0, "dx": 0.0, "dy": -40.0})).unwrap(); let st = s.doc().unwrap(); let pd = st.doc.node(r).unwrap().path_data().unwrap().clone(); assert_eq!(pd.anchor_count(), 5); @@ -3478,7 +3477,7 @@ fn reshape_of_a_closed_path_selected_whole_moves_all_of_it() { let mut s = session(); let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0); let before = s.doc().unwrap().doc.node(r).unwrap().path_data().unwrap().clone(); - s.execute("path.reshape", &json!({"id": r.0, "x": 0.0, "y": 0.0, "dx": 15.0, "dy": -40.0})).unwrap(); + s.execute("path.reshape", &json!({"id": r.0, "x": 0.0, "y": 0.0, "dx": 15.0, "dy": -40.0, "focal": []})).unwrap(); let after = s.doc().unwrap().doc.node(r).unwrap().path_data().unwrap().clone(); assert_eq!(after.anchor_count(), before.anchor_count(), "a grab on an anchor adds none"); let drag = vectorcraft_geom::Vec2::new(15.0, -40.0); @@ -3492,7 +3491,7 @@ fn reshape_leaves_the_anchors_that_are_not_selected_in_place() { let mut s = session(); let id = reshape_open_path(&mut s, reshape_line_of_seven()); s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2], [0, 3], [0, 4], [0, 5]], "mode": "set"})).unwrap(); - s.execute("path.reshape", &json!({"id": id.0, "x": 350.0, "y": 0.0, "dx": 0.0, "dy": 100.0})).unwrap(); + s.execute("path.reshape", &json!({"id": id.0, "x": 350.0, "y": 0.0, "dx": 0.0, "dy": 100.0, "focal": []})).unwrap(); let y: Vec = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect(); assert_eq!((y[0], y[1], y[5], y[6]), (0.0, 0.0, 100.0, 0.0), "{y:?}"); // The stretch runs from the last anchor that is not selected, 280 from the focal point. @@ -3502,6 +3501,29 @@ fn reshape_leaves_the_anchors_that_are_not_selected_in_place() { } } +/// A step with neither `focal` nor `radius`, as one recorded before focal points, plays as it did +/// then: the selection does not matter, and a closed path follows with the default falloff. +#[test] +fn reshape_without_focal_or_radius_keeps_the_earlier_rule() { + let mut s = session(); + let id = reshape_open_path(&mut s, reshape_line_of_seven()); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2], [0, 3], [0, 4], [0, 5]], "mode": "set"})).unwrap(); + s.execute("path.reshape", &json!({"id": id.0, "x": 350.0, "y": 0.0, "dx": 0.0, "dy": 100.0})).unwrap(); + let y: Vec = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect(); + assert_eq!((y[0], y[5], y[6]), (0.0, 100.0, 0.0), "{y:?}"); + // Anchor 1 is not selected and still takes its share, measured from the end 350 away. + for i in 1..5 { + let u = i as f64 / 5.0; + assert!((y[i] - 100.0 * reshape_share(u)).abs() < 1e-9, "anchor {i} takes {u} of the way: {y:?}"); + } + // `focal: null` is the same as leaving it out. + let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("path.reshape", &json!({"id": r.0, "x": 0.0, "y": 0.0, "dx": 0.0, "dy": -40.0, "focal": null})).unwrap(); + let pts: Vec = s.doc().unwrap().doc.node(r).unwrap().path_data().unwrap().anchors().map(|(_, _, a)| a.p).collect(); + assert!(pts.contains(&Point::new(0.0, -40.0)), "{pts:?}"); + assert!(pts.contains(&Point::new(100.0, 100.0)), "the far corner stays: {pts:?}"); +} + #[test] fn reshape_of_a_partly_selected_closed_path_stretches_between_the_anchors_that_stay() { let mut s = session(); @@ -3516,7 +3538,7 @@ fn reshape_of_a_partly_selected_closed_path_stretches_between_the_anchors_that_s ); // Anchors 5, 0, 1 and 2 are selected; 3 and 4 stay. The walk runs 4, 5, 0, 1, 2, 3. s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 5], [0, 0], [0, 1], [0, 2]], "mode": "set"})).unwrap(); - s.execute("path.reshape", &json!({"id": id.0, "x": 0.0, "y": 0.0, "dx": 0.0, "dy": -50.0})).unwrap(); + s.execute("path.reshape", &json!({"id": id.0, "x": 0.0, "y": 0.0, "dx": 0.0, "dy": -50.0, "focal": []})).unwrap(); let y: Vec = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect(); assert_eq!((y[0], y[3], y[4]), (-50.0, 100.0, 100.0), "{y:?}"); // From anchor 4 to the focal point is 200: anchor 5 is halfway. From anchor 3 back to it is @@ -3571,7 +3593,7 @@ fn reshape_of_a_segment_keeps_a_selected_segment_as_its_two_halves() { fn reshape_moves_only_the_subpaths_with_a_focal_point() { let mut s = session(); let id = NodeId(s.execute("path.create", &json!({"d": "M0 0 L100 0 L200 0 M0 100 L100 100 L200 100"})).unwrap()["id"].as_u64().unwrap()); - s.execute("path.reshape", &json!({"id": id.0, "x": 200.0, "y": 0.0, "dx": 0.0, "dy": 40.0})).unwrap(); + s.execute("path.reshape", &json!({"id": id.0, "x": 200.0, "y": 0.0, "dx": 0.0, "dy": 40.0, "focal": []})).unwrap(); let pd = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone(); let y: Vec> = pd.subpaths.iter().map(|sp| sp.anchors.iter().map(|a| a.p.y).collect()).collect(); assert_eq!(y[1], vec![100.0, 100.0, 100.0], "{y:?}"); diff --git a/crates/tools/src/extra.rs b/crates/tools/src/extra.rs index bcf7d6e655..4f8e867a54 100644 --- a/crates/tools/src/extra.rs +++ b/crates/tools/src/extra.rs @@ -402,11 +402,8 @@ impl ReshapeTool { /// `path.reshape`'s params for `press` dragged by `d`. fn params(cx: &ToolContext, press: &ReshapePress, d: Vec2) -> Value { let others: Vec<[usize; 2]> = press.others.iter().map(|&((si, ai), _)| [si, ai]).collect(); - let mut v = json!({ "id": press.id.0, "x": press.at.x, "y": press.at.y, "dx": d.x, "dy": d.y, "tol": cx.tol(4.0) }); - if !others.is_empty() { - v["focal"] = json!(others); - } - v + // `focal` is sent when empty too: without it, `path.reshape` follows the earlier rule. + json!({ "id": press.id.0, "x": press.at.x, "y": press.at.y, "dx": d.x, "dy": d.y, "tol": cx.tol(4.0), "focal": others }) } } diff --git a/docs/mcp.md b/docs/mcp.md index 19f16205cd..d01aa4a742 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3681,8 +3681,8 @@ anchor there unless one lies within `tol` (3 by default), and moves that point b [Adjust path segments](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/adjust-path-segments.html) has the Reshape tool stretch part of a path from the dragged point without losing the path's general form, with every highlighted focal point pulling the path. Which anchors stay, how far the stretch reaches and the share of the drag each -point takes are VectorCraft's own rule. The grabbed point and the anchors that `focal` lists as `[subpath, anchor]` pairs are focal -points and move by the whole drag; a pair that names no anchor of the path is refused ("`focal` names [0, 7], which is +point takes are VectorCraft's own rule. When `focal` is given (an empty list included), the grabbed point and the +anchors that `focal` lists as `[subpath, anchor]` pairs are focal points and move by the whole drag; a pair that names no anchor of the path is refused ("`focal` names [0, 7], which is not an anchor of the path"). Anchors that are not direct-selected stay in place (on a path selected whole, or not selected, every anchor is selected), and so do the two ends of an open path unless they are focal points. A selected anchor between two focal points moves by the whole drag. A selected anchor between a focal point and an anchor that @@ -3695,8 +3695,11 @@ point does not move. An anchor added on a segment of a partly selected path is s anchors stay selected. With `radius`, only the grabbed point's subpath changes, whatever the selection: the anchors within `radius` of the grabbed point along the path follow with a smooth falloff and the others stay; a negative `radius` is refused ("`radius` must be 0 or more"), and so is `radius` given with `focal` ("`focal` and `radius` -cannot be used together"). A drag of no length on an anchor (`dx` and `dy` 0 or left out) leaves the path as it is -and records no undo step. +cannot be used together"). Without `focal` (or with `focal` null) and without `radius`, the earlier rule applies, so +steps recorded before focal points play as they did: the selection does not matter, an open path stretches from the +grabbed point to its two ends as above, and on a closed path the anchors within half the diagonal of the subpath's +bounding box follow with the smooth falloff. The Reshape tool always sends `focal`. A drag of no length on an anchor +(`dx` and `dy` 0 or left out) leaves the path as it is and records no undo step. With the Reshape tool, a click on an anchor of a selected path makes it the focal point, and a click on a segment adds an anchor there (one undo step) that becomes the focal point. Shift-click adds an anchor or a point on a segment to the From 548fb9aa6171fb4645f68afbcb523661c5dea08d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 22:35:21 -0800 Subject: [PATCH 10/20] Docs: the roadmap lists each milestone once Merges of main kept both sides of conflicting edits to the milestone table, so M3, M7, M10, M11 and M13 appeared two to four times, each copy with a different subset of later edits. Keep one row per milestone. For M3, M10, M11 and M13 the kept row already contains every statement in the removed copies. For M7 no copy held them all: the kept row gains the Hyphenation and Justification clause that only the second copy carried. --- docs/roadmap.md | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index dba1b580f6..6613ebb220 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -143,25 +143,15 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M2 | Drawing tools + smart guides | ✅ mostly done (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | -| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default | 4–8 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, three generated libraries (Basic Shapes, UI Frames, Map Markers), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, four generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | -| M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged) | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | -| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol and brush libraries done; symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | -| M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | -| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, three generated libraries (Basic Shapes, UI Frames, Map Markers), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, four generated libraries, User Defined, Other Library…, Save Brushes); brushes/symbols in progress | 23–37 | -| M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged) | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | -| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol and brush libraries done; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | | 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; Vector Halftone Preview keeps the selection box without drawing dot selection marks (#6369) | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; `script.run` and File › Scripts › Other Script… run JavaScript files, and Window › Script Console shows their output; the script document model and batch work remain | 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) | 🟡 Icon-strip flyouts resize on their free edges and retain sizes (#5713); interim macOS cursor delivery (#2292) uses existing artwork and host-owned AppKit cursors; headless coverage only, native menu/focus/handback and display validation pending. 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 , 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; floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), with pointer and native-window regression coverage; estimates unchanged; numeric spinner arrows keep repeating while held with the mouse (#6307) | 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 | From 1f1b7a8b7cef0b5a66a68d9bc4e1040caecc28db Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 22:36:45 -0800 Subject: [PATCH 11/20] Scripts: test that an include ending in a carriage return keeps the next line's number --- crates/engine/src/tests/script_include.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/engine/src/tests/script_include.rs b/crates/engine/src/tests/script_include.rs index f0cf7a7535..2d857b2301 100644 --- a/crates/engine/src/tests/script_include.rs +++ b/crates/engine/src/tests/script_include.rs @@ -304,6 +304,13 @@ fn script_included_error_has_local_file_and_line() { assert!(report.get("file").is_none(), "{report}"); let report = failed(&base, "#include \"five.jsxinc\"\nvar = 1;"); assert_eq!((report["kind"].as_str(), report["line"].as_u64()), (Some("syntax"), Some(2)), "{report}"); + // An included file that ends in a carriage return does not join the script's next line ending. + write(&base.join("main/cr.jsxinc"), "1;\r2;\r"); + let report = failed(&base, "#include \"cr.jsxinc\"\n\n throw new Error('after');"); + assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(3), Some(3)), "{report}"); + assert!(report.get("file").is_none(), "{report}"); + let report = failed(&base, "#include \"cr.jsxinc\"\r\nvar = 1;"); + assert_eq!((report["kind"].as_str(), report["line"].as_u64()), (Some("syntax"), Some(2)), "{report}"); // An uncaught error in a file $.evalFile ran is at that file's line. let report = failed(&base, "var a = 1;\n$.evalFile('sub/thrower.jsx');"); assert_eq!(report["line"], 3, "{report}"); From 6701a1f8e553db2f506a6a927f52895db0de3363 Mon Sep 17 00:00:00 2001 From: jmverges Date: Wed, 11 Nov 2026 05:10:57 +0000 Subject: [PATCH 12/20] .ai import: stroked type in an envelope maps from its glyphs grown by half its stroke With Distort Appearance, the stroked candidate for type in an envelope mapped from the type's bounds with strokes, which for a stroke on the type's appearance (or on a group round it) reach as far as a miter join may at a corner, the miter limit times half the weight. In users' files the drawn art maps from the glyphs grown by what the strokes paint along a side: half the weight of a centred stroke. The candidate now uses that box. Character strokes keep the measure the type's bounds with strokes already give them (they have no miter allowance), and other objects in the contents keep their bounds with strokes. Co-Authored-By: Claude Opus 5.5 --- crates/eps/src/import/ai/envelope.rs | 138 +++++++++++++++++++++++++-- docs/file-format-parity.md | 3 +- docs/target-app-parity.md | 4 +- 3 files changed, 134 insertions(+), 11 deletions(-) diff --git a/crates/eps/src/import/ai/envelope.rs b/crates/eps/src/import/ai/envelope.rs index 9bd3e937ad..25701650c9 100644 --- a/crates/eps/src/import/ai/envelope.rs +++ b/crates/eps/src/import/ai/envelope.rs @@ -131,15 +131,48 @@ fn saved_margin(o: &Obj, content: &[Arc]) -> Option<[f32; 4]> { envelope_stroke_margin(stroked, plain) } -/// Envelope `kind` mapping from its contents' bounds with their strokes, for an envelope with -/// Distort Appearance whose contents have strokes it doesn't map from already; `None` otherwise. +/// The box of `content` an envelope of type with Distort Appearance maps from in a file: type by +/// its glyph outlines grown by as far as its strokes paint along a side (half the weight of a +/// centred stroke), without what miter joins add at corners, as are groups by their own strokes; +/// other objects by their bounds with strokes. `None` when nothing in it has bounds. +fn half_stroked_box(content: &[Arc]) -> Option { + /// How far the visible strokes of `appearance` paint along the sides of closed outlines. + fn along(appearance: &Appearance) -> f64 { + appearance.painted_strokes().map(|s| s.side_reach(true)).fold(0.0, f64::max) + } + /// `r` grown by `by` on each side, where that is a distance. + fn grown(r: Rect, by: f64) -> Rect { + if by.is_finite() && by > 0.0 { r.inflate(by, by) } else { r } + } + fn walk(n: &Arc, acc: Option) -> Option { + let one = std::slice::from_ref(n); + match &n.kind { + NodeKind::Text(t) => { + let Some((_, ink)) = envelope_content_boxes(one, Affine::IDENTITY) else { return acc }; + // The characters' own strokes as the type's bounds with strokes measure them (they + // have no miter allowance there), and the strokes of its appearance along a side. + vectorcraft_geom::union_opt(acc, Some(grown(ink, t.char_stroke_outset().max(along(&n.appearance))))) + } + NodeKind::Group { children, clip: false } => { + let members = children.iter().fold(None, |acc, c| walk(c, acc)); + vectorcraft_geom::union_opt(acc, members.map(|b| grown(b, along(&n.appearance)))) + } + _ => vectorcraft_geom::union_opt(acc, envelope_content_boxes(one, Affine::IDENTITY).map(|(stroked, _)| stroked)), + } + } + content.iter().fold(None, |acc, c| walk(c, acc)) +} + +/// Envelope `kind` mapping from its contents' glyphs grown by as far as their strokes paint along a +/// side ([`half_stroked_box`]), for an envelope with Distort Appearance whose contents have strokes it +/// doesn't map from already; `None` otherwise. fn with_strokes(kind: &NodeKind) -> Option { let NodeKind::Envelope { content, options, .. } = kind else { return None }; if !options.distort_appearance { return None; } - let (stroked, plain) = envelope_content_boxes(content, Affine::IDENTITY)?; - let margin = envelope_stroke_margin(stroked, plain)?; + let (_, plain) = envelope_content_boxes(content, Affine::IDENTITY)?; + let margin = envelope_stroke_margin(half_stroked_box(content)?, plain)?; if margin == options.bounds_margin || margin.iter().any(|m| *m < 0.0) || margin.iter().all(|m| *m == 0.0) { return None; } @@ -518,7 +551,8 @@ impl WarpedType { /// doesn't draw as its drawn art is checked again as a mesh envelope on the grid the file saves /// for it ([`live_on_saved_grid`]), which opens in its place where it does: its type stays /// editable in that shape, and the warp's style and bend are lost. With Distort Appearance, each - /// is also checked mapping from its contents' bounds with their strokes ([`with_strokes`]). Each + /// is also checked mapping from its type's glyphs grown by half their strokes' weight + /// ([`with_strokes`]). Each /// check uses one of the import's checks and, for an envelope that shows, adds to what the page /// check allows ([`drawn_alike`]). Elsewhere, and where the type is set in a font that isn't /// available, the drawn art and the hidden type stay. @@ -581,9 +615,9 @@ impl WarpedType { // A warp that VectorCraft's own warp doesn't draw as its drawn art may still draw as the // grid the file saves for it: then it opens as a mesh envelope on that grid. let on_grid = if is_warp(&obj) { live_on_saved_grid(&obj, before.clone(), inner.clone()) } else { None }; - // With Distort Appearance, a file's envelope may map its contents from their bounds with - // their strokes or without them, and for type the size the file saves for that box is often - // out of date (not the size of the type there now): each is checked. + // With Distort Appearance, a file's envelope may map its type from its glyphs grown by half + // their strokes' weight or from the glyphs alone, and for type the size the file saves for + // that box is often out of date (not the size of the type there now): each is checked. let kinds: Vec = live(&obj, before, inner) .into_iter() .chain(on_grid) @@ -772,3 +806,91 @@ fn alike_within(live: &Node, drawn: &Node, reach: Option, layer: NodeId, s *spent = Spent { any, solid }; true } + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_color::{Color, Paint}; + use vectorcraft_doc::{AppearanceItem, CharStyle, LineJoin, StrokeLayer, TextObject}; + + /// An envelope of `content` with Distort Appearance, on a flat one-patch mesh. + fn envelope_of(content: Vec>) -> NodeKind { + let points = vec![Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(0.0, 50.0), Point::new(100.0, 50.0)]; + NodeKind::Envelope { + content, + kind: Box::new(EnvelopeKind::Mesh { rows: 1, cols: 1, points, handles: vec![] }), + fidelity: 50.0, + editing: false, + options: EnvelopeOptions::NEW, + frame: Box::default(), + } + } + + /// The bounds margin of the stroked candidate for an envelope of `content`. + fn stroked_margin(content: Vec>) -> Option<[f32; 4]> { + match with_strokes(&envelope_of(content)) { + Some(NodeKind::Envelope { options, .. }) => Some(options.bounds_margin), + _ => None, + } + } + + /// "AVA" at 40 points, at (10, 60). + fn type_node(id: u64, style: CharStyle) -> Node { + Node::new(NodeId(id), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 60.0), "AVA", CharStyle { size: 40.0, ..style })))) + } + + /// A 4 point stroke with miter joins at a limit of 4. + fn mitred() -> StrokeLayer { + let mut stroke = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0); + (stroke.join, stroke.miter_limit) = (LineJoin::Miter, 4.0); + stroke + } + + /// Type with Distort Appearance and a stroke from its appearance is tried mapped from its glyphs + /// grown by half the stroke's weight, not from its bounds with strokes, which reach as far as a + /// miter join may at a corner. + #[test] + fn stroked_type_is_tried_mapped_from_its_glyphs_grown_by_half_its_stroke() { + let mut text = type_node(1, CharStyle::default()); + let mut appearance = (*text.appearance).clone(); + appearance.items.push(AppearanceItem::Stroke(mitred())); + text.appearance = appearance.into(); + let content = vec![Arc::new(text)]; + let (with, glyphs) = envelope_content_boxes(&content, Affine::IDENTITY).unwrap(); + let half = glyphs.inflate(2.0, 2.0); + assert!(with.width() > half.width() + 1.0, "{with:?} {half:?}"); + assert_eq!(stroked_margin(content), envelope_stroke_margin(half, glyphs)); + } + + /// Type stroked by its characters alone is tried mapped from its bounds with strokes, as before, + /// turned and stretched as well as upright. + #[test] + fn type_with_a_character_stroke_is_tried_mapped_from_its_bounds_with_strokes() { + let style = CharStyle { stroke: Paint::solid(Color::BLACK), stroke_width: 4.0, ..CharStyle::default() }; + for xf in [ + Affine::IDENTITY, + Affine::rotate(std::f64::consts::FRAC_PI_4), + Affine::scale_non_uniform(std::f64::consts::SQRT_2, std::f64::consts::FRAC_1_SQRT_2), + ] { + let mut text = type_node(1, style.clone()); + if let NodeKind::Text(t) = &mut text.kind { + t.xf = xf * t.xf; + } + let content = vec![Arc::new(text)]; + let (with, glyphs) = envelope_content_boxes(&content, Affine::IDENTITY).unwrap(); + assert_eq!(stroked_margin(content), envelope_stroke_margin(with, glyphs), "{xf:?}"); + } + } + + /// A stroke on a group's own appearance round type grows the box by half its weight too. + #[test] + fn a_stroke_on_a_group_of_type_grows_the_box_by_half_its_weight() { + let mut group = Node::new(NodeId(2), NodeKind::Group { children: vec![Arc::new(type_node(1, CharStyle::default()))], clip: false }); + let mut appearance = (*group.appearance).clone(); + appearance.items.push(AppearanceItem::Stroke(mitred())); + group.appearance = appearance.into(); + let content = vec![Arc::new(group)]; + let (_, glyphs) = envelope_content_boxes(&content, Affine::IDENTITY).unwrap(); + assert_eq!(stroked_margin(content), envelope_stroke_margin(glyphs.inflate(2.0, 2.0), glyphs)); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 6ef0a1d863..0831a87699 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-11 · **Change:** minor (`.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; 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-11 · **Change:** minor (`.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; 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 @@ -339,6 +339,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | `.ai` and EPS import: stroked type in an envelope with Distort Appearance is tried mapped from its glyphs grown by as far as its strokes paint along a side (half the weight of a centred stroke), without what miter joins add at corners, for a stroke on its appearance or on a group round it as well as its characters'; it was mapped from its bounds with strokes, which reach as far as a miter join may at a corner. Tests `ai::envelope::tests::stroked_type_is_tried_mapped_from_its_glyphs_grown_by_half_its_stroke`, `type_with_a_character_stroke_is_tried_mapped_from_its_bounds_with_strokes`, `a_stroke_on_a_group_of_type_grows_the_box_by_half_its_weight` (part of #3179) | | 2026-11-11 | minor | `.ai` and EPS import: a warp of type (an envelope made with a warp around type) whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the warp grid the file saves, with the same drawn-art check, budget and page comparison as #6421: where it passes, the type is editable and keeps the file's shape, and the warp's settings (style, bend) are lost. A second try is only made while more checks are left than there are envelopes of type and warped text objects still to come. Type with strokes in an envelope with Distort Appearance on is also tried mapped from its box with strokes, where the size the file saves for it doesn't already give that box (a rule chosen by measuring real files). An envelope's grid (warp, mesh or top object) now reads its way round from all its rows and columns, not its first row alone: a mesh bent far round (its end columns leaning more than halfway over) reads the way it runs, not turned a quarter round, and a grid symmetric about its middle reads the same wherever it is on the page. Tests `tests_native::a_warp_of_type_opens_as_a_mesh_on_its_saved_grid_and_keeps_its_shape_when_its_text_changes`, `a_warp_of_stroked_type_maps_from_its_bounds_with_strokes_where_its_drawn_art_does`, `a_warp_of_type_without_a_saved_grid_keeps_its_drawn_art`, `a_warp_of_type_tried_again_leaves_the_next_envelope_its_check`, `ai::tests::a_mesh_envelope_bent_far_round_opens_the_way_it_runs`, `a_mesh_envelope_bent_halfway_round_opens_the_same_way_wherever_it_is` (part of #3179) | | 2026-11-11 | minor | `.ai` and EPS import: type stroked in the text document keeps its stroke's cap, join and miter limit (keys 61, 62 and 64 of its character attributes, numbered as PostScript's `setlinecap` and `setlinejoin`: 0 butt or miter, 1 round, 2 projecting or bevel). They were left at a miter join with a limit of 10 whatever the file said, so type the file strokes with round joins, or with the usual limit of 4 or less, drew spikes at its sharp corners, some many points long. In users' files, used locally and never committed, the page strokes the type with the cap, join and miter limit these keys give; no file had a bevel join, which is read by the same numbering. A value that isn't one of theirs keeps the default, a miter limit past 500 is 500, and type without a stroke keeps the defaults. Dashes are not read: no file had dashed type to tell which keys hold them. Tests `ate::tests::a_run_keeps_its_stroke_cap_join_and_miter_limit`, `tests_native::stroked_type_keeps_its_cap_join_and_miter_limit`; scores unchanged | | 2026-11-10 | minor | `.ai` and EPS import: an envelope of type (a warp, a mesh or a top object around type) kept its drawn art, its type hidden, because the type is laid out from the text document only after the layers are read. It now waits for that: its text slots get their type, and where VectorCraft's own envelope of that type draws as the drawn art the file saves (the same check and budget as other envelopes, seen on white and on black), it opens live in the drawn art's place, its type editable in Edit Contents, and the hidden copy goes. As with other live envelopes, the layers with one are then compared with the page, which weighs the envelope as the art it draws (not as type laid out again, which the comparison allows more for), and when they don't draw as it the drawn art and the hidden type are put back. Layers used though they don't draw as the page (the editing data trusted, or the objects that differ coming in as the page's art) leave envelopes of type their drawn art and hidden type too. A `.ai` whose layers would open alone, without its PDF part, imports the PDF part to compare them with, and should they still not draw as it, or should no envelope of type open live, they open alone as before, with the envelopes' drawn art. Type in a font that isn't available, slots without type of their own (threaded, bound to a text or a visibility variable, a wrap object, type a look draws), envelopes in a symbol's or a pattern's art, and envelopes that draw differently keep their drawn art, with the note (#3179). Tests `tests_native::an_envelope_of_type_that_draws_as_its_drawn_art_opens_live`, `an_envelope_of_type_that_draws_apart_from_its_drawn_art_keeps_it`, `an_envelope_of_white_type_that_draws_apart_from_its_drawn_art_keeps_it`, `an_envelope_of_type_waits_on_the_page_of_layers_that_would_open_alone`, `an_envelope_of_type_that_draws_apart_from_the_page_does_not_open_live`, `trusted_layers_that_draw_apart_from_the_page_leave_an_envelope_of_type_its_drawn_art`, `layers_with_objects_that_come_in_as_the_page_art_leave_an_envelope_of_type_its_drawn_art`, `an_envelope_of_type_bound_to_a_visibility_variable_keeps_its_drawn_art` | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 33d1c3e642..d200ba07b6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -1419,7 +1419,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A `.ai`, or a PDF saved with Illustrator's editing data, whose editing data can't be decompressed opens from its PDF part with a warning that its layers weren't read and why, and an export or Save As to its own path asks first (`acknowledgeLoss`), as for a file whose layers the reader can't read (`crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). A `.ai` or EPS opened from its editing data with one object on a shown layer that the reader can't read (an unknown operator inside a group, a pattern fill whose pattern it doesn't have, a symbol instance, a placed file without a usable preview) keeps its layers and groups: in that object's place, its layer gets a group of the page's objects inside the object's box, named after it, with a warning that lists what came in as the page's art and an export or Save As to its own path asking first (`acknowledgeLoss`). The file still opens from its page or PDF part when no box can be found, when live art or another unread object shares the page art inside it, 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 the object's transparency (#4806; `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). A `.ai` or EPS opened from its editing data with one object on a shown layer that the reader can't read (an unknown operator inside a group, a pattern fill whose pattern it doesn't have, a symbol instance, a placed file without a usable preview) keeps its layers and groups: in that object's place, its layer gets a group of the page's objects inside the object's box, named after it, with a warning that lists what came in as the page's art and an export or Save As to its own path asking first (`acknowledgeLoss`). The file still opens from its page or PDF part when no box can be found, when live art or another unread object shares the page art inside it, when the object spans two artboards, or when the page art doesn't carry the object's transparency (#4806; `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). - In a `.ai` opened from its editing data, an envelope made with a warp, a mesh or a top object opens as a live envelope of its contents, with its warp style, bend and distortions, its mesh points and handles or its top object's outline, its Envelope Options, its Fidelity and whether its contents are being edited. When Distort Appearance is on and the size the file saves for the contents matches their bounds with strokes, the contents map from that stroked box, until an edit in Edit Contents changes how far the strokes reach; VectorCraft's new envelopes map from the geometric box, as do contents with no width or no height, such as a single straight line. An envelope that can't be read, or that VectorCraft draws differently from the file's drawn art, keeps that drawn art, with a note. An envelope of type opens live, its type laid out from the text document as its contents, where VectorCraft's own envelope of that type draws as the drawn art the file saves (compared as for the other envelopes, seen on white and on black, so white type counts, and only where the layers with it are compared with the page and draw as it, the envelope weighed as the art it draws); elsewhere, and where the type's font isn't available, a text or visibility variable binds the type, or the envelope is in a symbol's or a pattern's art, it keeps its drawn art, with its type hidden and a note that says the type isn't editable (#3179; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). A warp of type whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the grid the file saves for it: its type stays editable and keeps the file's shape, but the warp's settings (style, bend) are lost, as the document has no place for them on a mesh. Type with strokes in an envelope with Distort Appearance on is also tried mapped from its box with strokes, where the size the file saves for it doesn't already give that box; the drawn-art check decides which one opens. An envelope's grid reads its way round from all its rows and columns, so a mesh bent far round reads the way it runs. + In a `.ai` opened from its editing data, an envelope made with a warp, a mesh or a top object opens as a live envelope of its contents, with its warp style, bend and distortions, its mesh points and handles or its top object's outline, its Envelope Options, its Fidelity and whether its contents are being edited. When Distort Appearance is on and the size the file saves for the contents matches their bounds with strokes, the contents map from that stroked box, until an edit in Edit Contents changes how far the strokes reach; VectorCraft's new envelopes map from the geometric box, as do contents with no width or no height, such as a single straight line. An envelope that can't be read, or that VectorCraft draws differently from the file's drawn art, keeps that drawn art, with a note. An envelope of type opens live, its type laid out from the text document as its contents, where VectorCraft's own envelope of that type draws as the drawn art the file saves (compared as for the other envelopes, seen on white and on black, so white type counts, and only where the layers with it are compared with the page and draw as it, the envelope weighed as the art it draws); elsewhere, and where the type's font isn't available, a text or visibility variable binds the type, or the envelope is in a symbol's or a pattern's art, it keeps its drawn art, with its type hidden and a note that says the type isn't editable (#3179; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). A warp of type whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the grid the file saves for it: its type stays editable and keeps the file's shape, but the warp's settings (style, bend) are lost, as the document has no place for them on a mesh. Type with strokes in an envelope with Distort Appearance on is also tried mapped from its glyphs grown by as far as its strokes paint along a side (half the weight of a centred stroke), without what miter joins add at corners, where the size the file saves for it doesn't already give that box; the drawn-art check decides which one opens. An envelope's grid reads its way round from all its rows and columns, so a mesh bent far round reads the way it runs. In a `.ai` opened from its editing data, Convert to Shape on a whole path or type object opens as a live Rectangle, Rounded Rectangle or Ellipse effect with its Absolute or Relative size, its corner radius, its place among the object's effects and whether it is hidden. A relative size in the file is added to each side. A relative Ellipse, or a setting VectorCraft doesn't read, keeps the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, Bas Relief in an art style takes its Detail, Smoothness and Light direction from the style's settings block, and an object other than type with it opens as itself with the live effect. A settings block that VectorCraft can't read (malformed, another effect's, a value out of range or a key it doesn't know) keeps the object's drawn look (part of #637; [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646); `crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`). PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); a PDF 1.5 file with such a page is written as PDF 1.6 with PDF 1.5's encryption, PDF/X files and PDF 1.3 and 1.4 files write it in points (in PDF 1.3 and 1.4, a page past 32,767 pt that needs a real number past 32,767 is refused with an error naming it), a page whose short side is 3 pt or longer but would be under 3 units at that `UserUnit` gets a smaller one that keeps the short side at 3 units, and the export's warnings give each such page's size and how it is written (#6382). From 988bfeb6ec7175648d7e87861da7017fa8167363 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 22:50:48 -0800 Subject: [PATCH 13/20] Symbols: Break Link keeps new art on a locked artboard selected Break Link to Symbol on an instance just placed on a locked artboard selected nothing: the art that replaced the instance has a new id, so the locked-artboard check did not see it as new art. The art of an instance that was new art is now checked without the locked artboards, and the session keeps it selected as new art. The art of an older instance on a locked artboard, named by id, is still left out of the selection. --- crates/engine/src/cmd/brushsym/symbols.rs | 25 +++++++++++++++-------- crates/engine/src/cmd/select.rs | 14 ++++++++++++- crates/engine/src/tests/brushsym.rs | 25 +++++++++++++++++++++++ 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index 395c07052c..4c5cd89edf 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -391,9 +391,10 @@ fn expanded_bound(d: &Document, inst: &Node, own: bool) -> u64 { } /// Replace instances `ids` with their art ([`expanded_instance`], `own` as there). A hidden or -/// locked instance's art stays hidden or locked. Returns the art's ids. Fails before any edit when -/// the instances together would make more than [`MAX_EXPAND_NODES`] objects. -fn break_links(d: &mut Document, ids: &[NodeId], own: bool) -> Result> { +/// locked instance's art stays hidden or locked. Returns each replaced instance's id with its art's +/// id. Fails before any edit when the instances together would make more than +/// [`MAX_EXPAND_NODES`] objects. +fn break_links(d: &mut Document, ids: &[NodeId], own: bool) -> Result> { let total = ids.iter().filter_map(|id| d.node(*id)).fold(0u64, |sum, n| sum.saturating_add(expanded_bound(d, n, own))); if total > MAX_EXPAND_NODES as u64 { return Err(EngineError::Other("Too many pieces to make at once".into())); @@ -408,7 +409,7 @@ fn break_links(d: &mut Document, ids: &[NodeId], own: bool) -> Result Result { } let iso = s.doc()?.isolation; let out = s.edit("Break Link to Symbol", |d, sel| { - let out = break_links(d, &ids, true)?; + let fresh = d.new_art.get(); + let made = break_links(d, &ids, true)?; // Art that is hidden or locked, or in a hidden or locked group or layer, is left out of the - // selection. + // selection, as is art on a locked artboard unless the instance it replaces was new art. let locks = d.locked_artboards(); - sel.set(out.iter().copied().filter(|id| crate::cmd::select::is_selectable(d, iso, &locks, *id))); - Ok(out) + sel.set(made.iter().filter_map(|(inst, art)| { + let keep = if fresh.contains(inst) { + crate::cmd::select::is_selectable_on_any_artboard(d, iso, *art) + } else { + crate::cmd::select::is_selectable(d, iso, &locks, *art) + }; + keep.then_some(*art) + })); + Ok(made.into_iter().map(|(_, art)| art).collect::>()) })?; Ok(json!({ "ids": ids_json(&out) })) } diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 0f16246dff..4ef1e6bc6a 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -252,6 +252,18 @@ pub(crate) fn selectable_ids(d: &Document, iso: Option) -> BTreeSet, locks: &LockedArtboards, id: NodeId) -> bool { + is_selectable_within(d, iso, Some(locks), id) +} + +/// [`is_selectable`] without the locked artboards, for an edit's new selection: after the edit, +/// the session takes the art on a locked artboard out of the selection, except the new art the +/// edit made. +pub(crate) fn is_selectable_on_any_artboard(d: &Document, iso: Option, id: NodeId) -> bool { + is_selectable_within(d, iso, None, id) +} + +/// [`is_selectable`], leaving out the locked artboards when `locks` is `None`. +fn is_selectable_within(d: &Document, iso: Option, locks: Option<&LockedArtboards>, id: NodeId) -> bool { let Some(a) = d.ancestry(id) else { return false }; if !a.iter().all(|i| d.node(*i).is_some_and(|n| n.visible && !n.locked && !n.is_template())) { return false; @@ -262,7 +274,7 @@ pub(crate) fn is_selectable(d: &Document, iso: Option, locks: &LockedArt None => { // The object Select All takes: the outermost one that is not a layer. let top = a.iter().filter_map(|i| d.node(*i)).find(|n| !n.is_layer()); - top.is_some_and(|t| locks.is_empty() || !locks.holds(t)) + top.is_some_and(|t| locks.is_none_or(|l| l.is_empty() || !l.holds(t))) } } } diff --git a/crates/engine/src/tests/brushsym.rs b/crates/engine/src/tests/brushsym.rs index ccf277585f..b86cf89ee7 100644 --- a/crates/engine/src/tests/brushsym.rs +++ b/crates/engine/src/tests/brushsym.rs @@ -1386,6 +1386,31 @@ fn break_link_leaves_art_in_hidden_or_locked_groups_unselected() { assert!(!a.contains(&hidden) && !a.contains(&locked), "{a:?}"); } +/// Break Link to Symbol on an instance just placed on a locked artboard selects the art that +/// replaces it, which stays new art there. The art of an older instance there, named by id, is not +/// selected. +#[test] +fn break_link_keeps_new_art_on_a_locked_artboard_selected() { + let mut s = session(); + let (old, name, _) = make_symbol(&mut s); + s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 400})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap(); + select(&mut s, &[old]); + s.execute("object.move", &json!({"dx": 1000, "dy": 0})).unwrap(); + assert!(s.doc().unwrap().selection.objects.is_empty(), "moved onto the locked artboard"); + let inst = NodeId(s.execute("symbol.place", &json!({"name": name, "x": 1100, "y": 100})).unwrap()["id"].as_u64().unwrap()); + assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [inst], "placed art is selected"); + let r = s.execute("symbol.breakLink", &json!({})).unwrap(); + let art: Vec = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect(); + assert_eq!(art.len(), 1, "{r}"); + assert_eq!(s.doc().unwrap().selection.objects.to_vec(), art, "the art that replaces it"); + s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects.to_vec(), art, "the art stays editable"); + let r = s.execute("symbol.breakLink", &json!({"ids": [old.0]})).unwrap(); + assert_eq!(r["ids"].as_array().unwrap().len(), 1, "{r}"); + assert!(s.doc().unwrap().selection.objects.is_empty(), "the older instance's art"); +} + /// Delete Symbol, expanding the instances, keeps a hidden instance's art hidden and a locked /// instance's art locked. #[test] From 9bfd6b7be5d5c99e2151cb5485889ecfea91f57c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 23:00:40 -0800 Subject: [PATCH 14/20] Paths: describe the anchor added on a selected segment as an end of its halves A selected segment that path.insertAnchor splits stays selected as its two halves. The new anchor is the end both halves share, so it moves with them, and Delete cuts both halves. docs/mcp.md and the comment on renumber_after_insert called it a direct-selected anchor; they now describe it as an end of the halves that moves with them. --- crates/engine/src/cmd/path.rs | 2 +- docs/mcp.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 9ee6b61bb5..12d76470cd 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -1534,7 +1534,7 @@ fn stretch_weights(anchors: &[Anchor], closed: bool, roles: &[Stretch]) -> Vec<[ /// After an anchor is added at `(si, added)` of path `id`, number the direct-selected anchors and /// selected segments after it on that subpath one higher, so the selection keeps the same points. /// A selected segment that the new anchor splits stays selected as its two halves, and the new -/// anchor is added to the direct-selected anchors. +/// anchor joins its ends in the anchors that move with it. fn renumber_after_insert(sel: &mut Selection, id: NodeId, si: usize, added: usize) { if let Some(set) = sel.anchors.get_mut(&id) { // A selected anchor index can name no anchor (`select.anchors` takes any index), so the diff --git a/docs/mcp.md b/docs/mcp.md index fda4c0012b..88a485d83e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3677,8 +3677,8 @@ is not a whole number of 0 or more, such as 1.5, -1 or "1" ("missing integer `su an `anchor` or `segment` in the same way. The document does not change and no undo step is recorded. `path.insertAnchor` keeps the direct-selected anchors and selected segments of the path on the same points: those after the new anchor on its subpath are numbered one higher, and a selected segment that the new anchor splits stays -selected as its two halves, and the new anchor is direct-selected with them. Undo restores the path and the selection -in one step. +selected as its two halves, with the new anchor as an end of both that moves with them. Undo restores the path and +the selection in one step. `path.reshape {id, x, y, dx, dy, tol?, focal?, radius?}` (a Reshape tool drag) grabs the path at `x`, `y`, adding an anchor there unless one lies within `tol` (3 by default), and moves that point by `dx`, `dy`. From de5323efb63ada637dd6f86bbd10c201bdacb99c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 23:05:53 -0800 Subject: [PATCH 15/20] Expand Appearance: tinted symbols keep their colors under a color adjustment Expand Appearance on a symbol instance with a fill tint and a color adjustment applied the adjustment to the untinted art, so the expanded colors differed from the canvas. The expand path now passes the tinted instance art to adjust_in_document. --- crates/effects/src/adjust.rs | 8 +++++--- crates/effects/src/bake.rs | 2 +- crates/engine/src/cmd/effectcmd.rs | 9 ++++++++- crates/engine/src/tests/expand.rs | 23 +++++++++++++++++++++++ 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/crates/effects/src/adjust.rs b/crates/effects/src/adjust.rs index a656f11e85..c345136255 100644 --- a/crates/effects/src/adjust.rs +++ b/crates/effects/src/adjust.rs @@ -468,12 +468,14 @@ pub fn adjust(n: &Node, outer: Option<&ColorMap>, swatches: &SwatchKinds, images } /// [`adjust`] for export and Expand Appearance: recoloured embedded images become new images in -/// `d` (named by their content), and a symbol instance stands for its symbol's art. -pub fn adjust_in_document(d: &mut Document, n: &Node) -> Option { +/// `d` (named by their content), and a symbol instance stands for its symbol's art: `symbol_art` +/// when given, otherwise the symbol's art with the instance's own appearance changes. +pub fn adjust_in_document(d: &mut Document, n: &Node, symbol_art: Option) -> Option { let art; let src = match &n.kind { NodeKind::SymbolInstance { symbol, .. } if has_adjustment(n) => { - let symbol = d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n)); + let symbol = symbol_art + .or_else(|| d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n))); art = crate::reshape::as_art(Some(d), n, symbol.as_ref())?; &art } diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index afe256e596..ec0da9c46a 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -112,7 +112,7 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { } // Colour adjustments: the object recoloured (inside it too), then baked as it is. if has_adjustment(n) - && let Some(m) = adjust_in_document(d, n) + && let Some(m) = adjust_in_document(d, n, None) { return Some(bake_node(d, &m).unwrap_or(m)); } diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 8bf5c9a3d7..a19455bd51 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -814,8 +814,15 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br let mut changed = false; // Is `n` in `out` yet? let mut listed_n = false; + // A symbol instance's fill tints its art before the adjustment, as the canvas draws it. + let symbol_art = match &n.kind { + NodeKind::SymbolInstance { symbol, .. } if effects::has_adjustment(&n) => { + d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_render::instance_art(&s.art, &n)) + } + _ => None, + }; if effects::has_adjustment(&n) - && let Some(m) = effects::adjust_in_document(d, &n) + && let Some(m) = effects::adjust_in_document(d, &n, symbol_art) { out.push((id, 0)); listed_n = true; diff --git a/crates/engine/src/tests/expand.rs b/crates/engine/src/tests/expand.rs index 090cb98285..038b8844bb 100644 --- a/crates/engine/src/tests/expand.rs +++ b/crates/engine/src/tests/expand.rs @@ -75,6 +75,29 @@ fn symbol_instance_tint_survives_expanding_an_object_effect() { assert!(differing(&before, &after, 8) < 200, "expanding changed {} pixels", differing(&before, &after, 8)); } +/// The canvas tints a symbol's art with the instance fill before the instance's color adjustment, +/// and expanding gives the same colors. +#[test] +fn symbol_instance_tint_comes_before_a_color_adjustment_when_expanded() { + let mut s = session(); + square(&mut s); + let id = NodeId(run(&mut s, "symbol.new", json!({"name": "Tinted Square"}))["id"].as_u64().unwrap()); + run(&mut s, "appearance.addFill", json!({"ids": [id.0]})); + let fill = node(&s, id).appearance.items.len() - 1; + run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": fill, "color": "#00ff00", "opacity": 50})); + run(&mut s, "effect.apply", json!({"ids": [id.0], "item": null, "effect": "adjust.hueSaturation", "params": {"hue": 120}})); + let before = render(&s); + assert_ne!(before.pixel(100, 100), [255, 255, 255, 255], "the symbol is visible"); + assert!(expandable(&s)); + + run(&mut s, "effect.expandAppearance", json!({})); + assert!(!matches!(node(&s, id).kind, NodeKind::SymbolInstance { .. })); + let after = render(&s); + let (b, a) = (before.pixel(100, 100), after.pixel(100, 100)); + assert!(b.iter().zip(a.iter()).all(|(x, y)| x.abs_diff(*y) <= 8), "expanding changed the color from {b:?} to {a:?}"); + assert!(differing(&before, &after, 8) < 200, "expanding changed {} pixels", differing(&before, &after, 8)); +} + /// An instance fill with an opacity of 0 does not tint the symbol's art, so expanding keeps the /// art's global swatch link. #[test] From badcf3f63f682800464ca8ffd07cc45736eaaf0a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 23:07:27 -0800 Subject: [PATCH 16/20] Symbols: an instance that only moves holds art its 9-slice guides move A symbol whose natural size is not 20 x 20 is drawn through its 9-slice guides by an instance that only moves, so art the guides move can draw outside the art box moved by the instance. The visual box took that moved art box alone, and the renderer culled the instance when only the moved art was in view. The visual box now measures such an instance as it does a scaled one. --- crates/doc/src/node/ops.rs | 5 ++++- crates/render/src/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index f769572588..eb7e0d39b9 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -373,7 +373,10 @@ impl Node { NodeKind::SymbolInstance { symbol, xf } => { let b = match symbols.and_then(|s| Some((s, s.art(symbol)?))) { Some((s, art)) if !s.art_strokes() => xf.transform_rect_bbox(art.geometric), - Some((_, art)) if moves_only(*xf) => xf.transform_rect_bbox(art.visual), + // A symbol drawn at other than its natural size goes through its 9-slice guides. + Some((s, art)) if moves_only(*xf) && s.document().symbol_slice_map_of(symbol, art.geometric, *xf).is_none() => { + xf.transform_rect_bbox(art.visual) + } Some((s, art)) => { let placed = xf.transform_rect_bbox(art.geometric); match s.placed_visual(symbol, *xf).or_else(|| s.sliced_visual(symbol, *xf)) { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index dd7198d531..d5c129e5c1 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -425,6 +425,29 @@ fn an_instance_is_culled_by_the_art_nested_9_slice_guides_move() { assert_eq!(img.pixel(95, 50), [255, 0, 0, 255]); } +#[test] +fn an_instance_that_only_moves_is_culled_by_the_art_its_9_slice_guides_move() { + // A symbol made in the app: art in the 20 × 20 square, a natural size of 2 × 2, and guides a + // quarter in. An instance that only moves draws it at 20 × 20, ten times its natural size. The + // bar from x −9.9 to 0.1 crosses the left guide and is placed by the middle column's map, + // x → 1.9x: moved 115 pt right, it draws from x 96.19 to 115.19 while its art box starts at + // x 105, out of view. + let mut d = Document::new(100.0, 100.0); + let bar = rect_node(&mut d, Rect::new(-9.9, -1.0, 0.1, 1.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Bar".into(), art: Arc::new(bar) }); + let frame = rect_node(&mut d, Rect::new(-10.0, -10.0, 10.0, 10.0), Paint::None, Paint::None, 0.0); + let inner = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::IDENTITY }); + let art = Node::group(d.alloc_id(), vec![Arc::new(frame), Arc::new(inner)]); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Panel".into(), art: Arc::new(art) }); + d.unknown.insert(vectorcraft_doc::SYMBOL_SIZES.into(), serde_json::json!({ "Panel": [2.0, 2.0] })); + d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default())); + let (id, l) = (d.alloc_id(), d.layers[0].id); + let xf = Affine::translate((115.0, 50.0)); + d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Panel".into(), xf })).unwrap(); + let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()); + assert_eq!(img.pixel(98, 50), [255, 0, 0, 255]); +} + #[test] fn an_instance_with_a_geometry_effect_is_culled_by_its_reshaped_art() { // The art, (0, 0) to (20, 20), drawn from (-100, 10) is out of view, and the Transform effect From 22189986a9aac6973e3a850182ea1465abd98def Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 22:37:34 -0800 Subject: [PATCH 17/20] PDF export: flattening for PDF/X-1a, X-3 and 1.3 keeps art across clipping layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PDF/X-1a, PDF/X-3 and PDF 1.3 files leave the layers they don't print out of the flattening. A flattened group no longer takes the place of art in a layer that doesn't print, so the art of a clipping layer over such a layer prints, and art of a layer that doesn't print stays out of the file. - A flattened group goes into a lower layer only when that layer prints, shows and is a template as the clipping layer does, the clipping layer and the groups it leaves have no opacity, blend mode or isolation, and the clipping path has no fill. Otherwise the group holds only the art inside the clipping layer, drawn over the clipping path's fill and the art below, and the art below is flattened on its own. Before, the fill covered the group, the group lost the layer's opacity, or the clipping path cut the art below. - Transparent art that a clipping path hides entirely, such as one of area type holding only a space, is removed. Before, the export failed because the art kept its transparency. Object › Flatten Transparency follows the same rules. Documents without clipping layers or layers that don't print export the same bytes as before. --- crates/engine/src/cmd/fileio/pdf.rs | 25 ++- crates/engine/src/cmd/fileio/tests_pdfx.rs | 198 ++++++++++++++++++++ crates/engine/src/cmd/flatten/apply.rs | 205 +++++++++++++++++++-- crates/engine/src/cmd/flatten/commands.rs | 2 +- docs/mcp.md | 10 +- docs/target-app-parity.md | 2 +- 6 files changed, 411 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index e0acc2d52d..ccafc96e5e 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -122,7 +122,7 @@ pub fn specs() -> Vec { "Export PDF", [], None, - "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption; PDF/X files, 1.3 and 1.4 write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), 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; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). 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", + "{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, without the layers the file leaves out; 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; a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption; PDF/X files, 1.3 and 1.4 write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), 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; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). 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 ), @@ -360,9 +360,9 @@ fn encode_carrying_at( if set.pdf13() { vectorcraft_doc::cancel::progress(0, 0, "Flattening transparency"); } - let flat = flatten_for(cmd, doc, set, p)?; + let (flat, flattened) = flatten_for(cmd, doc, set, p)?; vectorcraft_doc::cancel::check()?; - if flat.is_some() { + if flattened { let kind = if set.standard.flattens() { set.standard.label() } else { set.compatibility.label() }; warnings.push(format!("transparency is flattened into opaque art and images ({kind} files have none): see the flattener preset")); } @@ -379,11 +379,19 @@ pub const DEFAULT_FLATTENER: &str = "High Resolution"; /// whose files have none), with `flattenerPreset` (default High Resolution) and `p`'s `flattener` /// options over it; images with see-through pixels and colorized images, including those in /// pattern tiles, count as transparency. Rasterized areas are clipped to their regions, and -/// discarded overprints are dropped. `None` when nothing changes (or the file keeps transparency). -fn flatten_for(cmd: &str, doc: &Document, set: &PdfSettings, p: &Value) -> Result> { +/// discarded overprints are dropped. Layers the pages leave out (non-printing ones, unless asked +/// for) are left out of the flattening, hidden. → the document to write (`None`: `doc` as it is) +/// and whether its transparency was flattened. +fn flatten_for(cmd: &str, doc: &Document, set: &PdfSettings, p: &Value) -> Result<(Option, bool)> { if !set.pdf13() { - return Ok(None); + return Ok((None, false)); } + let printed = if set.include_non_printing || set.create_layers { None } else { without_non_printing(&doc.layers) }; + let shown = printed.map(|layers| { + let mut d = doc.clone(); + d.layers = layers; + d + }); let name = Some(set.flattener_preset.trim()).filter(|n| !n.is_empty()).unwrap_or(DEFAULT_FLATTENER); let mut o = FlattenOptions::for_export(Some(name), p.get("flattener"), &[]).map_err(|e| bad(cmd, format!("PDF flattener: {e}")))?; o.preserve_overprints &= set.advanced.overprint == Overprint::Preserve; @@ -391,7 +399,10 @@ fn flatten_for(cmd: &str, doc: &Document, set: &PdfSettings, p: &Value) -> Resul // Without soft masks a rasterized area can't be a rectangle with see-through pixels around // its art: it is clipped to its regions (opaque inside them). o.clip_complex_regions = true; - flatten_document(doc, &o) + Ok(match flatten_document(shown.as_ref().unwrap_or(doc), &o)? { + Some(flat) => (Some(flat), true), + None => (shown, false), + }) } /// The pages `opts` export of `doc` drawn on white, [`THUMBNAIL_SIZE`] pixels on their long side: diff --git a/crates/engine/src/cmd/fileio/tests_pdfx.rs b/crates/engine/src/cmd/fileio/tests_pdfx.rs index 4063e090d6..68df66bd93 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfx.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfx.rs @@ -281,6 +281,13 @@ pub(super) fn lost(reference: &vectorcraft_render::Rendered, variant: &vectorcra reference.iter().zip(variant).filter(|(r, v)| r[..3].iter().any(|c| *c < 200) && v[..3].iter().all(|c| *c >= 250)).count() } +/// How many pixels of `a` and `b` differ by more than 24 in a channel. +fn differ(a: &vectorcraft_render::Rendered, b: &vectorcraft_render::Rendered) -> usize { + assert_eq!((a.width, a.height), (b.width, b.height)); + let (a, b) = (a.pixels.as_chunks::<4>().0, b.pixels.as_chunks::<4>().0); + a.iter().zip(b).filter(|(p, q)| p[..3].iter().zip(&q[..3]).any(|(x, y)| x.abs_diff(*y) > 24)).count() +} + /// The width and height of what each of `doc`'s images shows: its frame inside the clipping /// paths around it. pub(super) fn shown_image_sizes(doc: &vectorcraft_doc::Document) -> Vec<(f64, f64)> { @@ -501,3 +508,194 @@ fn flat_pdf_files_of_art_that_no_clip_group_hides_are_unchanged() { let (mut s, _) = cropped("cmyk", 400.0, Art::Image { see_through: true }); assert!(bytes(&mut s, &files[0]) != before(|| bytes(&mut s, &files[0]))); } + +/// The options of the PDF files without transparency. +const FLAT_FILES: [&str; 3] = [r#"{"standard": "pdfX1a"}"#, r#"{"standard": "pdfX3"}"#, r#"{"compatibility": "1.3", "preserveEditing": false}"#]; + +/// [`cropped_by`]'s clipping layer of a gradient at 80%, with a rectangle in the layer below that +/// crosses the crop's right edge. → the session and the clipping layer's id. +fn clipping_layer_over_a_rectangle(mode: &str) -> (Session, u64) { + let (mut s, layer) = cropped_by(mode, 400.0, Art::Gradient, true); + run(&mut s, "transparency.set", json!({"opacity": 80})); + let below = s.doc().unwrap().doc.layers[0].id.0; + run(&mut s, "layer.setCurrent", json!({"id": below})); + run(&mut s, "shape.rectangle", json!({"x": 100, "y": 60, "width": 120, "height": 40})); + run(&mut s, "paint.setFill", json!({"color": "#406020"})); + (s, layer) +} + +/// A clipping layer over a layer that doesn't print, and a clipping layer that doesn't print +/// over one that does: the files without transparency print what the file with transparency +/// prints, and nothing of the layer that doesn't print. +#[test] +fn flattening_for_pdfx_leaves_out_the_layers_that_do_not_print() { + let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut wrong = vec![]; + for mode in ["cmyk", "rgb"] { + for clipping_layer_prints in [true, false] { + let (mut s, layer) = clipping_layer_over_a_rectangle(mode); + let below = s.doc().unwrap().doc.layers[0].id.0; + let off = if clipping_layer_prints { below } else { layer }; + run(&mut s, "layer.setProps", json!({"id": off, "printable": false})); + let reference = drawn(&read_back(&mut s, &json!({}))); + for flat in FLAT_FILES { + let back = drawn(&read_back(&mut s, &serde_json::from_str(flat).unwrap())); + let (lost, gained) = (lost(&reference, &back), lost(&back, &reference)); + if lost + gained > 0 { + wrong.push(format!("{mode}, clipping layer prints: {clipping_layer_prints}, {flat}: {lost} pixels lost, {gained} gained")); + } + } + } + } + assert!(wrong.is_empty(), "{wrong:#?}"); +} + +/// A clipping layer whose clipping path has a fill: the files without transparency show the art +/// over the fill, not the fill over the art. +#[test] +fn flattening_for_pdfx_keeps_the_art_of_a_clipping_layer_over_its_clipping_path_fill() { + let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut wrong = vec![]; + for mode in ["cmyk", "rgb"] { + let (mut s, layer) = clipping_layer_over_a_rectangle(mode); + let clip = s.doc().unwrap().doc.node(vectorcraft_doc::NodeId(layer)).unwrap().children().unwrap()[0].id.0; + run(&mut s, "paint.setFill", json!({"ids": [clip], "color": "#ffdd00"})); + let reference = drawn(&read_back(&mut s, &json!({}))); + for flat in FLAT_FILES { + let back = drawn(&read_back(&mut s, &serde_json::from_str(flat).unwrap())); + // PDF/X-1a converts an RGB document's colors to CMYK. + let converted = mode == "rgb" && flat.contains("pdfX1a"); + let (lost, differ) = (lost(&reference, &back), if converted { 0 } else { differ(&reference, &back) }); + if lost > 0 || differ > 100 { + wrong.push(format!("{mode}, {flat}: {lost} pixels of art lost, {differ} pixels differ")); + } + } + } + assert!(wrong.is_empty(), "{wrong:#?}"); +} + +/// Transparent art in a clipping layer whose clipping path is area type holding only a space, +/// which hides it: the files without transparency are written, without the art. +#[test] +fn flattening_for_pdfx_removes_transparent_art_that_a_clipping_layer_hides_entirely() { + let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let [x, y, w, h] = FRAME; + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 400, "height": 300, "colorMode": "cmyk"})); + let layer = run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap(); + run(&mut s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h})); + let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]); + run(&mut s, "paint.setFill", json!({"gradient": {"kind": "linear", "stops": stops}})); + run(&mut s, "transparency.set", json!({"opacity": 80})); + run(&mut s, "text.create", json!({"x": 50, "y": 50, "text": " ", "area": {"width": 80, "height": 60}})); + run(&mut s, "layer.clippingMask.toggle", json!({"id": layer})); + let reference = drawn(&read_back(&mut s, &json!({}))); + for flat in FLAT_FILES { + let p: Value = serde_json::from_str(flat).unwrap(); + let back = match s.execute("document.exportPdf", &p) { + Ok(v) => vectorcraft_pdf::import(&vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()).unwrap(), + Err(e) => panic!("{flat}: {e}"), + }; + assert_eq!(lost(&drawn(&back), &reference), 0, "{flat}: the hidden art prints"); + } +} + +/// Object › Flatten Transparency of art in a clipping layer and art in the layer below that +/// reaches past the clipping path: when the layer below doesn't print, the clipping layer has +/// opacity, or the clipping path has a fill (see-through too, flattened keeping alpha, in a layer +/// with opacity or Multiply mode, or +/// over a layer with opacity, overlapping art of a layer with opacity or art an opacity mask +/// hides), the group stays in the clipping layer, the art below stays in its layer, and the page +/// looks the same. +#[test] +fn flattening_keeps_the_group_in_the_clipping_layer_when_the_layer_below_differs() { + let mut wrong = vec![]; + let cases = [ + "the layer below doesn't print", + "the clipping path has a fill", + "the clipping path has a see-through fill, keeping alpha", + "the clipping path has a see-through fill, in a layer with opacity", + "the clipping path has a see-through fill, in a layer in Multiply mode", + "the clipping path has a see-through fill, over a layer with opacity", + "the clipping path has a see-through fill, over art that an opacity mask hides", + "the clipping path has a see-through fill, over overlapping art of a layer at 50%", + "the clipping path has no fill, in a layer with opacity", + ]; + for case in cases { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 400, "height": 300, "colorMode": "rgb"})); + let below = s.doc().unwrap().doc.layers[0].id.0; + let orange = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 200, "height": 150}))["id"].clone(); + run(&mut s, "paint.setFill", json!({"color": "#ff8000"})); + let upper = run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap(); + let blue = run(&mut s, "shape.rectangle", json!({"x": 100, "y": 80, "width": 200, "height": 150}))["id"].clone(); + run(&mut s, "paint.setFill", json!({"color": "#2040c0"})); + run(&mut s, "transparency.set", json!({"opacity": 50})); + let clip = run(&mut s, "shape.rectangle", json!({"x": 60, "y": 40, "width": 300, "height": 220}))["id"].clone(); + run(&mut s, "layer.clippingMask.toggle", json!({"id": upper})); + if case.starts_with("the layer below") { + run(&mut s, "layer.setProps", json!({"id": below, "printable": false})); + } else if !case.contains("no fill") { + run(&mut s, "paint.setFill", json!({"ids": [clip], "color": "#88ffcc"})); + } + let keeping_alpha = case.ends_with("keeping alpha"); + if case.contains("see-through fill") { + run(&mut s, "transparency.set", json!({"ids": [clip], "opacity": 50})); + } + if case.ends_with("in a layer with opacity") { + run(&mut s, "transparency.set", json!({"ids": [upper], "opacity": 80})); + } + if case.ends_with("over a layer with opacity") { + run(&mut s, "transparency.set", json!({"ids": [below], "opacity": 50})); + } + if case.ends_with("an opacity mask hides") { + run(&mut s, "layer.setCurrent", json!({"id": below})); + run(&mut s, "select.set", json!({"ids": [orange]})); + let group = run(&mut s, "object.group", json!({}))["id"].clone(); + let mask = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 200, "height": 150}))["id"].clone(); + run(&mut s, "paint.setFill", json!({"color": "#000000"})); + run(&mut s, "select.set", json!({"ids": [group, mask]})); + run(&mut s, "transparency.makeOpacityMask", json!({})); + } + if case.ends_with("Multiply mode") { + run(&mut s, "transparency.set", json!({"ids": [upper], "blendMode": "multiply"})); + } + let mut art = vec![orange.clone()]; + if case.ends_with("a layer at 50%") { + run(&mut s, "layer.setCurrent", json!({"id": below})); + art.push(run(&mut s, "shape.rectangle", json!({"x": 80, "y": 60, "width": 120, "height": 120}))["id"].clone()); + run(&mut s, "paint.setFill", json!({"color": "#20a040"})); + run(&mut s, "transparency.set", json!({"ids": [below], "opacity": 50})); + } + art.push(blue.clone()); + // The page as it prints. + let printed = |d: &vectorcraft_doc::Document| { + let mut d = d.clone(); + if case.starts_with("the layer below") { + let mut layer = (*d.layers[0]).clone(); + layer.visible = false; + d.layers[0] = layer.into(); + } + drawn(&d) + }; + let before = printed(&s.doc().unwrap().doc); + run(&mut s, "select.set", json!({"ids": art})); + run(&mut s, "object.flattenTransparency", json!({"preserveAlpha": keeping_alpha})); + let d = &s.doc().unwrap().doc; + let layer_of = |id: &Value| id.as_u64().and_then(|id| d.ancestry(vectorcraft_doc::NodeId(id))).map(|a| a[0].0); + if layer_of(&orange) != Some(below) { + wrong.push(format!("{case}: the art below is in layer {:?}", layer_of(&orange))); + } + let upper_art = d.node(vectorcraft_doc::NodeId(upper)).and_then(|l| l.children()).map_or(0, Vec::len); + if d.node(vectorcraft_doc::NodeId(blue.as_u64().unwrap())).is_some() || upper_art != 2 { + wrong.push(format!("{case}: the clipping layer holds {upper_art} objects")); + } + let after = printed(d); + // Flat regions differ from see-through art along their antialiased edges. + let (lost, differ) = (lost(&before, &after), differ(&before, &after)); + if lost > 0 || differ > 1200 { + wrong.push(format!("{case}: {lost} pixels of art lost, {differ} pixels differ")); + } + } + assert!(wrong.is_empty(), "{wrong:#?}"); +} diff --git a/crates/engine/src/cmd/flatten/apply.rs b/crates/engine/src/cmd/flatten/apply.rs index c9459ed76c..555058e58f 100644 --- a/crates/engine/src/cmd/flatten/apply.rs +++ b/crates/engine/src/cmd/flatten/apply.rs @@ -1,4 +1,4 @@ -use super::analysis::{Scratch, outlined_text, overlapping, reach}; +use super::analysis::{Scratch, has_effects, masked, outlined_text, overlapping, reach}; use super::compose::{Builder, CLEAR, Composer, Elem, Fill, Kind, Px, WHITE}; use super::options::FlattenOptions; use super::{ @@ -108,30 +108,82 @@ pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: let mut sc = Scratch { brushes: vectorcraft_brush::library(&baked), doc: baked.clone() }; let plain: Vec = art.iter().map(|n| sc.plain(n)).collect(); // Each object as its clip groups and clipping layers show it. - let clipped: Vec = plain.iter().zip(roots).map(|(n, id)| clipped_by_ancestors(src, *id, n)).collect(); + let clipped: Vec = plain.iter().zip(roots).map(|(n, id)| clipped_by_ancestors(src, *id, n, None)).collect(); let reaches: Vec> = plain.iter().zip(&clipped).map(|(n, c)| reach(n).zip(reach(c)).map(|(a, b)| a.intersect(b))).collect(); let see_through: Vec = plain.iter().map(|n| o.shows_transparency(&baked, n) || o.draws_see_through_image(n)).collect(); let cmyk = src.color_mode == ColorMode::Cmyk; let mut out = Plan { flat: vec![], kept: vec![], transparent: roots.iter().zip(&see_through).filter(|(_, t)| **t).map(|(id, _)| *id).collect() }; // Baking above stopped early when cancelled; each group is a place to stop (a plan changes nothing). vectorcraft_doc::cancel::check()?; - for g in overlapping(&reaches)? { + let mut queue = overlapping(&reaches)?; + queue.reverse(); + while let Some(g) = queue.pop() { vectorcraft_doc::cancel::check()?; + // A group that stays inside the clip groups and clipping layers of its topmost object + // replaces only the objects inside them, over the others, which are flattened on their own. + let (g, under) = match inside_and_under(src, roots, &g) { + Some((inside, under)) => { + queue.push(under.clone()); + (inside, under) + } + None => (g, vec![]), + }; + // Composited over white, the group covers what it is drawn over, so it is composited over + // the fills of the clipping paths around it and the objects under it in the same kind of + // layer. Composited over nothing, it keeps its alpha and they show through. + let backdrop = !o.preserve_alpha; + let state = g.last().map(|&i| layer_state(src, roots[i])); + let top = g.last().map(|&i| roots[i]); + // A blend mode of a layer or group around the group alone would blend the art under it + // twice. + let blends = |i: usize| top.is_some_and(|t| own_layers(src, t, Some(roots[i])).iter().any(|a| a.blend != BlendMode::Normal)); + // Art under an opacity mask, effects or a knockout group is left out: the group can't draw it + // as those do. + let drawn_plainly = |i: usize| { + own_layers(src, roots[i], top).iter().all(|a| !masked(a) && !has_effects(a) && !matches!(a.knockout, vectorcraft_doc::Knockout::On)) + }; + let under_shown = under.iter().copied().filter(|&i| backdrop && Some(layer_state(src, roots[i])) == state && !blends(i) && drawn_plainly(i)); + let composited: Vec = under_shown.chain(g.iter().copied()).collect(); + let below = composited.len() - g.len(); + let extent = composited.iter().filter_map(|&i| reaches[i]).reduce(|a, b| a.union(b)).unwrap_or_default(); + let shown = |nodes: &[Node]| -> Vec { + let mut seen = vec![]; + let (mut under, mut out) = (vec![], vec![]); + for (k, &i) in composited.iter().enumerate() { + let n = clipped_by_ancestors(src, roots[i], &nodes[i], backdrop.then_some((&mut seen, extent))); + if k < below { + let groups = own_layers(src, roots[i], top).into_iter().filter(|a| !draws_as_is(a)).collect(); + under.push((groups, n)); + } else { + out.push(n); + } + } + let mut all = in_groups(under, 0); + all.extend(out); + all + }; let ids: Vec = g.iter().map(|&i| roots[i]).collect(); let group = g.iter().any(|&i| see_through[i]).then(|| { - let plain: Vec<&Node> = g.iter().map(|&i| &clipped[i]).collect(); - flatten_group(&plain, o, cmyk) + let plain = shown(&plain); + flatten_group(&plain.iter().collect::>(), o, cmyk) }); let Some((regions, area)) = group.flatten() else { - out.kept.extend(ids); + // Transparent art that its clip groups or clipping layers hide entirely draws nothing: + // it is removed. + let hides = |i: usize| see_through[i] && reach(&plain[i]).is_some() && reach(&clipped[i]).is_none(); + let (hidden, left): (Vec, Vec) = g.iter().partition(|&&i| hides(i)); + if !hidden.is_empty() { + let roots = hidden.iter().map(|&i| roots[i]).collect(); + out.flat.push(Flat { roots, regions: vec![], area: None, raster: None, lines: None }); + } + out.kept.extend(left.iter().map(|&i| roots[i])); continue; }; let raster = match area.as_ref().filter(|_| render) { Some(a) => { // Drawn as planned: the image can land outside the clip groups and clipping layers // around its art. - let art: Vec = g.iter().map(|&i| clipped_by_ancestors(src, roots[i], &art[i])).collect(); - Some(render_raster(&baked, &art, a.rect, a.ppi, a.clip.clone(), o)?) + Some(render_raster(&baked, &shown(&art), a.rect, a.ppi, a.clip.clone(), o)?) } None => None, }; @@ -146,8 +198,10 @@ pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: /// `plain` (object `id` of `src` as plain art) inside a clip group for each clip group and /// clipping layer around `id`, innermost first, each clipped by that group's or layer's clipping -/// path. `plain` itself when nothing around it clips. -fn clipped_by_ancestors(src: &Document, id: NodeId, plain: &Node) -> Node { +/// path. `plain` itself when nothing around it clips. With `fills` (clipping paths, and an area), +/// the fill of each clipping path not in them yet goes behind the art, inside the area, and the +/// path is added to them. +fn clipped_by_ancestors(src: &Document, id: NodeId, plain: &Node, mut fills: Option<(&mut Vec, Rect)>) -> Node { let mut out = plain.clone(); if !clips_count() { return out; @@ -170,13 +224,106 @@ fn clipped_by_ancestors(src: &Document, id: NodeId, plain: &Node) -> Node { if let NodeKind::Path { rule: r, .. } = &mut path.kind { *r = rule; } - let mut g = Node::group(above.id, vec![Arc::new(path), Arc::new(out)]); + let mut children = vec![Arc::new(path)]; + // The renderer paints the fill behind the clipped art. + if let Some((seen, r)) = fills.as_mut() + && !seen.contains(&clip.id) + && let Some(fill) = clip.clip_paint().fill + { + let r = *r; + seen.push(clip.id); + let mut at = Node::group(above.id, vec![Arc::new(Node::path(clip.id, shapes::rectangle(r), Appearance::default())), Arc::new(fill)]); + at.set_clips(true); + children.push(Arc::new(at)); + } + children.push(Arc::new(out)); + let mut g = Node::group(above.id, children); g.set_clips(true); out = g; } out } +/// Whether layer or group `n` draws what it holds as it is: opaque, in Normal mode, not isolated. +fn draws_as_is(n: &Node) -> bool { + n.opacity >= 1.0 && n.blend == BlendMode::Normal && !n.isolate +} + +/// `items` (in paint order, each an object and the layers and groups around it, outermost first, +/// that don't draw it as it is) with the objects of each such layer or group in one group with its +/// opacity, blend mode and isolation, from the `depth`th around them on: they then composite +/// together as that layer or group draws them. +fn in_groups(items: Vec<(Vec<&Node>, Node)>, depth: usize) -> Vec { + let mut out = vec![]; + let mut items = items.into_iter().peekable(); + while let Some((around, n)) = items.next() { + let Some(a) = around.get(depth).copied() else { + out.push(n); + continue; + }; + let mut run = vec![(around, n)]; + while let Some(next) = items.next_if(|(around, _)| around.get(depth).is_some_and(|b| b.id == a.id)) { + run.push(next); + } + let mut g = Node::group(a.id, in_groups(run, depth + 1).into_iter().map(Arc::new).collect()); + (g.opacity, g.blend, g.isolate) = (a.opacity, a.blend, a.isolate); + out.push(g); + } + out +} + +/// The layers and groups around object `id` of `src` that are not also around `other`, outermost +/// first. +fn own_layers(src: &Document, id: NodeId, other: Option) -> Vec<&Node> { + let mine = src.ancestry(id).unwrap_or_default(); + let theirs = other.and_then(|t| src.ancestry(t)).unwrap_or_default(); + let shared = mine.iter().zip(&theirs).take_while(|(a, b)| a == b).count(); + let own = mine.get(shared..mine.len().saturating_sub(1)).unwrap_or_default(); + own.iter().filter_map(|a| src.node(*a)).collect() +} + +/// The clip groups and clipping layers around object `id` of `d`, innermost first. +fn clip_ancestors(d: &Document, id: NodeId) -> Vec { + let mut a = d.ancestry(id).unwrap_or_default(); + a.pop(); + a.into_iter().rev().filter(|a| d.node(*a).is_some_and(Node::clips)).collect() +} + +/// Whether the layers around object `id` of `d` all print, all show, and whether one is a +/// template. +fn layer_state(d: &Document, id: NodeId) -> (bool, bool, bool) { + let mut a = d.ancestry(id).unwrap_or_default(); + a.pop(); + a.iter().filter_map(|a| d.node(*a)).fold((true, true, false), |(print, show, template), n| match &n.kind { + NodeKind::Layer { printable, template: t, .. } => (print && *printable, show && n.visible, template || *t), + _ => (print, show, template), + }) +} + +/// For group `g` (indices of `roots` of `src`, in paint order) whose flattened group can't leave +/// the clip groups and clipping layers of its topmost object ([`place_of`]): the objects inside +/// them and the objects under them. `None` when the group can leave them, or every object is +/// inside them. +fn inside_and_under(src: &Document, roots: &[NodeId], g: &[usize]) -> Option<(Vec, Vec)> { + if !clips_count() || g.len() < 2 { + return None; + } + let ids: Vec = g.iter().map(|&i| roots[i]).collect(); + let top = clip_ancestors(src, *ids.last()?); + if top.is_empty() { + return None; + } + let inside = |id: NodeId| { + let mine = clip_ancestors(src, id); + top.iter().all(|c| mine.contains(c)) + }; + if !inside(place_of(src, &ids)?) { + return None; + } + let (inside, under): (Vec, Vec) = g.iter().partition(|&&i| inside(roots[i])); + (!under.is_empty()).then_some((inside, under)) +} + /// The regions and the image area replacing one group of overlapping objects (`plain`: the art as /// composited; `cmyk`: in a CMYK document); `None` when it paints nothing. fn flatten_group(plain: &[&Node], o: &FlattenOptions, cmyk: bool) -> Option<(Regions, Option)> { @@ -493,6 +640,17 @@ pub(super) fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan for f in plan.flat { // A group always has objects. let Some(top) = place_of(d, &f.roots) else { continue }; + // Art hidden entirely is removed. + if f.regions.is_empty() && f.area.is_none() { + if one_pass { + flat.extend(f.roots.iter().map(|r| (*r, vec![]))); + } else { + for r in &f.roots { + d.remove(*r)?; + } + } + continue; + } let mut children = vec![]; // The image goes under the regions, reaching a pixel past its own: their edges then fall on // matching pixels instead of on what is behind the group. @@ -564,19 +722,26 @@ pub(super) fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan /// The object of `roots` (in paint order) whose place their flattened group takes: the topmost one /// that no clip group or clipping layer clips unless it clips every object of `roots`, else the -/// topmost one. +/// topmost one. The group stays with the topmost object rather than move to a layer that prints, +/// shows or is a template where the topmost object's layer differs, out from a clip group or +/// clipping layer whose clipping path has a fill, which would then cover it, or out from a layer +/// or group with opacity, a blend mode or isolation. fn place_of(d: &Document, roots: &[NodeId]) -> Option { - let top = roots.last().copied(); + let top = roots.last().copied()?; if !clips_count() || roots.len() < 2 { - return top; + return Some(top); } - let clips = |id: NodeId| -> Vec { - let ancestry = d.ancestry(id).unwrap_or_default(); - ancestry.iter().rev().skip(1).filter(|a| d.node(**a).is_some_and(Node::clips)).copied().collect() - }; - let all: Vec> = roots.iter().map(|r| clips(*r)).collect(); + let fills = |clip: &NodeId| d.node(*clip).and_then(|n| n.children()?.first().map(|c| c.clip_paint().fill.is_some())).unwrap_or(false); + let all: Vec> = roots.iter().map(|r| clip_ancestors(d, *r)).collect(); + let above = all.last().map_or(&[][..], Vec::as_slice); let shared = |mine: &Vec| mine.iter().all(|c| all.iter().all(|theirs| theirs.contains(c))); - roots.iter().zip(&all).rev().find(|(_, mine)| shared(mine)).map(|(r, _)| *r).or(top) + let leaves_fill = |mine: &Vec| above.iter().any(|c| !mine.contains(c) && fills(c)); + // Out of a layer or group with opacity, a blend mode or isolation, the group would lose it. + let leaves_compositing = |r: NodeId| own_layers(d, top, Some(r)).iter().any(|a| !draws_as_is(a)); + let state = layer_state(d, top); + let place = + roots.iter().zip(&all).rev().find(|(r, mine)| shared(mine) && !leaves_fill(mine) && layer_state(d, **r) == state && !leaves_compositing(**r)); + Some(place.map_or(top, |(r, _)| *r)) } /// An image of `r` (in a clip group when it is clipped). diff --git a/crates/engine/src/cmd/flatten/commands.rs b/crates/engine/src/cmd/flatten/commands.rs index d528e40d8b..53687713b5 100644 --- a/crates/engine/src/cmd/flatten/commands.rs +++ b/crates/engine/src/cmd/flatten/commands.rs @@ -10,7 +10,7 @@ pub fn specs() -> Vec { "Flatten Transparency…", ["Object"], None, - "{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach (only where a clip group's or clipping layer's clipping path shows them); objects without transparency stay; a frame of threaded text that is outlined or flattened leaves its thread with the text it shows → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", + "{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach (only where a clip group's or clipping layer's clipping path shows them, over its fill; transparent objects such a path hides entirely are removed); objects without transparency stay; a frame of threaded text that is outlined or flattened leaves its thread with the text it shows → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", has_doc, flatten ), diff --git a/docs/mcp.md b/docs/mcp.md index face503682..f979db1549 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -836,7 +836,8 @@ page with hint tables saying where every other page's objects are), which stays `compatibility: "1.3"` writes a PDF 1.3 file, which has no transparency: a copy of the document is flattened first with `flattenerPreset` (empty: High Resolution; `high`, `medium`, `low` or a saved preset, `flattener.presets.list`) and `flattener` options over it (as `object.flattenTransparency` takes them); images with see-through pixels count as -transparency and rasterized areas are clipped to their regions. A file that would still have transparency fails the +transparency and rasterized areas are clipped to their regions. Layers the file leaves out (non-printing ones, unless +`includeNonPrinting` or `createLayers` is set) are left out of the flattening. A file that would still have transparency fails the export. PDF/X-1a and PDF/X-3 files are PDF 1.3 files too. A page with a side longer than 14,400 pt is written with a `UserUnit` (PDF 1.6): its longer side divided by 14,400, rounded up to the next thousandth, with the page's sizes and coordinates in those units, so that no side is longer than @@ -3062,7 +3063,12 @@ Each other group becomes one group of atomic regions: paths filled with the flat white, which blend modes do not read; over nothing, keeping alpha, with `preserveAlpha: true`), plus one image where gradients, patterns, images, opacity masks or raster effects reach (clipped to those regions with `clipComplexRegions`, a rectangle without). The image leaves out the part of such art that the clipping path of a clip group or clipping layer hides, so art under -the hidden part of a cropped picture stays as it is. +the hidden part of a cropped picture stays as it is. Transparent art that such a clipping path hides entirely is +removed. A group that holds art from inside and outside a clipping layer goes in the place of the topmost art outside +it. When that layer prints, shows or is a template where the clipping layer differs, the clipping layer or a group +around the art has opacity, a blend mode or isolation, or the clipping path has a fill, +the group holds only the art inside the clipping layer, drawn over the fill and over the art below in the same kind of +layer as its own layers draw it (over nothing with `preserveAlpha`), and the art outside is flattened on its own. `preset` is `high`, `medium` (the default) or `low`; option keys (`balance` 0–100, `lineArtPpi`, `gradientPpi`, `textToOutlines`, `strokesToOutlines`, `clipComplexRegions`, `antiAlias`, `preserveAlpha`, `preserveOverprints`) override it, at the top level or in `options`. `balance: 0` rasterizes everything; lower balances rasterize groups diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5e291ded52..22a7cbb828 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1413,7 +1413,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur In a `.ai` opened from its editing data, Bas Relief in an art style takes its Detail, Smoothness and Light direction from the style's settings block, and an object other than type with it opens as itself with the live effect. A settings block that VectorCraft can't read (malformed, another effect's, a value out of range or a key it doesn't know) keeps the object's drawn look (part of #637; [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646); `crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`). PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); a PDF 1.5 file with such a page is written as PDF 1.6 with PDF 1.5's encryption, PDF/X files and PDF 1.3 and 1.4 files write it in points (in PDF 1.3 and 1.4, a page past 32,767 pt that needs a real number past 32,767 is refused with an error naming it), a page whose short side is 3 pt or longer but would be under 3 units at that `UserUnit` gets a smaller one that keeps the short side at 3 units, and the export's warnings give each such page's size and how it is written (#6382). PDF export warns when a PDF/X file has no bleed on a side where the document's bleed has one, and writes the file as asked. - PDF/X-1a, PDF/X-3 and PDF 1.3 files keep the art under the hidden part of a picture, gradient, pattern, mesh or raster effect that a clipping mask or clipping layer crops: flattening rasterizes only the part of such art that its clipping path shows, on its own page and on the next one (`crates/engine/src/cmd/fileio/tests_pdfx.rs`). + PDF/X-1a, PDF/X-3 and PDF 1.3 files keep the art under the hidden part of a picture, gradient, pattern, mesh or raster effect that a clipping mask or clipping layer crops: flattening rasterizes only the part of such art that its clipping path shows, on its own page and on the next one. Layers the file leaves out are left out of the flattening, and transparent art that a clipping path hides entirely is removed (`crates/engine/src/cmd/fileio/tests_pdfx.rs`). Placed (linked) files in a `.ai`'s editing data come in 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 (a file found next to the document, at its path, or in the folder the editing data names relative to the document's, such as `src/`, is read as the document opens, and its link takes that file's size, modification time and hash). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); 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 made taller with a warning in the open result (#1392). Placed (linked) files in a `.ai`'s editing data come in 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 (a file found next to the document or at its path is read as the document opens). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); 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 made taller with a warning in the open result (#1392). A placed PDF or `.ai` file in a `.ai`'s editing data comes in as a placed document instead of a linked image: it shows the page and Crop to box its dictionary names, and the preview the file keeps until its file is found, then the page as vectors. A placed file in opacity-mask art keeps showing its preview (`crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/placed_document.rs`). From a2c09544c814a70cd546be108813bb95d55090c0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 23:22:00 -0800 Subject: [PATCH 18/20] Render: a placed document drawn exactly lends its own composite fonts A placed document drawn without a bitmap (renders without progressive placed art, exports) drew type in the placed document's own composite font in the fallback font: only the outer document's composite fonts were lent to the font database. The placed document's composite fonts are now lent while its art is drawn. --- crates/render/src/placed_document.rs | 3 ++- crates/render/src/tests/fontchange.rs | 28 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/render/src/placed_document.rs b/crates/render/src/placed_document.rs index 3827829d05..6daf192fc4 100644 --- a/crates/render/src/placed_document.rs +++ b/crates/render/src/placed_document.rs @@ -425,7 +425,8 @@ impl Renderer { if !k.is_finite() || k <= 1e-12 { return; } - // The art in its own coordinates, with its own resources. + // The art in its own coordinates, with its own resources (its composite fonts included). + let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&e.doc.composite_fonts); 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, output: f.output * m, ..*f }; diff --git a/crates/render/src/tests/fontchange.rs b/crates/render/src/tests/fontchange.rs index 4ead4a5c1c..8f6d68f575 100644 --- a/crates/render/src/tests/fontchange.rs +++ b/crates/render/src/tests/fontchange.rs @@ -179,6 +179,34 @@ fn a_documents_own_composite_font_draws_without_being_registered() { assert_eq!(thumbnail(&composite, id), thumbnail(&plain, plain_id), "thumbnail"); let placed = |d: &Document| crate::placed_document::render_inside(&mut Renderer::new(), d, &d.layers[0], 300, 150, view).map(|r| r.pixels); assert_eq!(placed(&composite), placed(&plain), "a placed document's art"); + // A page that places the document, drawn exactly (no bitmap of the placed art). + let page = |d: &Document, id: NodeId, key: &str| { + use vectorcraft_doc::{ImageBlob, LinkInfo, PlacedDocument}; + let blob = ImageBlob::new("application/json", key.as_bytes().to_vec()); + let p = PlacedDocument { + link: LinkInfo::new("/art/type.vectorcraft"), + key: key.into(), + bounding: false, + page_box: Default::default(), + width: 300.0, + height: 150.0, + xf: Affine::IDENTITY, + placement: Default::default(), + }; + let art = d.node(id).unwrap().clone(); + vectorcraft_doc::placed_document::prime(&p, blob.bytes.clone(), (d.clone(), art, Rect::new(0.0, 0.0, 300.0, 150.0))); + let mut page = Document::new(300.0, 150.0); + page.images.insert(key.into(), blob); + let n = Node::new(page.alloc_id(), NodeKind::PlacedDocument(Box::new(p))); + let l = page.layers[0].id; + page.insert(Some(l), 0, n).unwrap(); + Renderer::new().render(&page, 300, 150, view, &opts).pixels + }; + assert_eq!( + page(&composite, id, "fontchange-composite-placed"), + page(&plain, plain_id, "fontchange-plain-placed"), + "a placed document's art drawn exactly" + ); assert!(db.composite_font(NAME).is_none(), "given back after each render"); } From 2443d1250b4b3ae62555da0411bbc97cc441b74c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 23:27:25 -0800 Subject: [PATCH 19/20] Character Styles: a tint error names the Fill Tint or Stroke Tint field An out-of-range or non-numeric tint in Character Style Options and New Character Style gave "Tint must be a number from 0 to 100" for both fields, so the message did not say which of the two to correct. It now names "Fill Tint" or "Stroke Tint", the names the two fields carry. --- crates/ui-egui/src/dialogs/char_style_options.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs/char_style_options.rs b/crates/ui-egui/src/dialogs/char_style_options.rs index 2011d2616a..d1ca76cc58 100644 --- a/crates/ui-egui/src/dialogs/char_style_options.rs +++ b/crates/ui-egui/src/dialogs/char_style_options.rs @@ -251,7 +251,8 @@ fn optional_number(d: &Dialog, key: &str) -> Result, String> { "v_scale" => tl!("Vertical Scale"), "baseline_shift" => tl!("Baseline Shift"), "rotation" => tl!("Character Rotation"), - "fill_tint" | "stroke_tint" => tl!("Tint"), + "fill_tint" => tl!("Fill Tint"), + "stroke_tint" => tl!("Stroke Tint"), "stroke_width" => tl!("Weight"), _ => return Err("unknown character style number field".into()), }; @@ -1649,8 +1650,8 @@ mod tests { fn color_tint_and_weight_outside_their_limits_are_errors() { let linked = json!({"type": "solid", "color": {"model": "gray", "k": 0.5}, "swatch": "Ink"}); for (key, value, message) in [ - ("fill_tint", "150", "Tint must be a number from 0 to 100"), - ("stroke_tint", "-1", "Tint must be a number from 0 to 100"), + ("fill_tint", "150", "Fill Tint must be a number from 0 to 100"), + ("stroke_tint", "-1", "Stroke Tint must be a number from 0 to 100"), ("stroke_width", "-1", "Weight must be a number from 0 to 4000000"), ("stroke_width", "thick", "Weight must be a number"), ] { From 99b7fb0ab8471a0c70f99be059a820d3627ecd15 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 23:58:23 -0800 Subject: [PATCH 20/20] Appearance: aligned Remove Item and Duplicate Item keep the active row After an aligned Remove Item or Duplicate Item, the active row was re-pointed by the indices of the first object in paint order. When the first selected object paints above the others, its own rows that changed are different ones, so the active row could be dropped or land on the wrong row, and a later paint edit changed that row instead. The active row now follows the rows removed or copied in the first selected object's own stack. Follows #5591. --- crates/engine/src/cmd/appearance.rs | 9 +++++++-- crates/engine/src/tests/appearance.rs | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 45b23a0cb1..024358adfe 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -939,7 +939,10 @@ fn remove_item(s: &mut Session, p: &Value) -> Result { 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())); + // The rows removed from the active row's object, which need not come first in `ids`. + let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied()); + let gone = active.and_then(|a| ids.iter().position(|id| *id == a)).and_then(|k| per.get(k)).unwrap_or(&idx); + s.remap_appearance_item(&ids, |a| (!gone.contains(&a)).then(|| a - gone.iter().filter(|i| **i < a).count())); ok() } @@ -1023,10 +1026,12 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result { // Rows at or above each copy move up by one (by the block, for copies placed at `to`). let m = idx.len(); let landed = active_at.or(first_at); + // The rows copied in the active row's object, which need not come first in `ids`. + let copied = active.and_then(|a| ids.iter().position(|id| *id == a)).and_then(|k| per.get(k)).unwrap_or(&idx); s.remap_appearance_item(&ids, |a| { Some(match to { Some(_) => a + if landed.is_some_and(|at| at <= a) { m } else { 0 }, - None => a + idx.iter().filter(|i| **i < a).count(), + None => a + copied.iter().filter(|i| **i < a).count(), }) }); ok() diff --git a/crates/engine/src/tests/appearance.rs b/crates/engine/src/tests/appearance.rs index 8d70237276..8d12c83719 100644 --- a/crates/engine/src/tests/appearance.rs +++ b/crates/engine/src/tests/appearance.rs @@ -647,6 +647,28 @@ fn edits_change_the_active_row_of_the_first_selected_object() { assert!(!e.contains("internal error"), "{e}"); } +/// After an aligned Remove Item or Duplicate Item, the active row follows the rows that changed in +/// the first selected object, also when that object paints above the others. +#[test] +fn aligned_remove_and_duplicate_keep_the_active_row_of_the_first_selected_object() { + let mut s = session(); + let (_, b) = reversed_pair(&mut s); + run(&mut s, "appearance.setActiveItem", json!({"index": 0})); // b's red fill + // a's row 0 is b's white fill, row 1, so b's red fill stays at row 0. + run(&mut s, "appearance.removeItem", json!({"indices": [0], "aligned": true})); + assert_eq!(node(&s, b).appearance.items.len(), 2); + assert_eq!(s.appearance_item(), Some(0)); + assert_eq!(fill_paint(&node(&s, b), 0).color().unwrap().to_hex(), "#ff0000"); + + let mut s = session(); + let (_, b) = reversed_pair(&mut s); + run(&mut s, "appearance.setActiveItem", json!({"index": 1})); // b's white fill, a's row 0 + // The copy goes above b's white fill, at 2; the active row stays on the original. + run(&mut s, "appearance.duplicateItem", json!({"indices": [0], "aligned": true})); + assert_eq!(node(&s, b).appearance.items.len(), 4); + assert_eq!(s.appearance_item(), Some(1)); +} + /// `effect.apply`'s reply names the new effect by its index in the object whose row `item` is, /// also when the objects painted after it already have effects there. #[test]