diff --git a/ROADMAP.md b/ROADMAP.md index c25b14cb..f3a267ef 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -127,7 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | Date | What landed | |---|---| -| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and Distribute Spacing keep the key object still also when it is inside a selected group or compound path (#1074). | +| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and Distribute Spacing keep the key object still also when it is inside a selected group or compound path (#1074). A `.ai` whose layers can't be read yet still opens with its artboards where they were (in a grid, say), not in a row (#1068). Layer Options (`ui.layerOptions`) refuses a malformed or unknown row id instead of skipping it or opening on the highlighted rows (#1077). Shaper scribbles given a list of object ids refuse a malformed or unknown id instead of skipping it and editing the shapes that remain (#1078). Delete Width Point (`stroke.widthPoint.remove`) refuses a list of indices with a malformed entry instead of deleting the points that remain, and no longer wraps a huge index round on the web (#1079). Preferences' checkboxes have an outline when the pointer isn't over them, in every interface brightness, as the app's other dialogs' checkboxes do (#1080). | | 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) | | 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day | | 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections | diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index 34027e70..05adb398 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -323,11 +323,13 @@ fn width_point_copy(s: &mut Session, p: &Value) -> Result { fn width_point_remove(s: &mut Session, p: &Value) -> Result { const C: &str = "stroke.widthPoint.remove"; let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; - let mut indices: Vec = match (p.get("indices").and_then(Value::as_array), p.get("index").and_then(Value::as_u64)) { - (Some(a), _) => a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect(), - (None, Some(i)) => vec![i as usize], + let indices = match (p.get("indices"), p.get("index")) { + (Some(a), _) => point_indices(a), + (None, Some(i)) => point_indices(&json!([i])), (None, None) => return Err(bad(C, "missing index")), }; + // A malformed index fails the whole delete instead of being dropped while the others go. + let mut indices = indices.ok_or_else(|| bad(C, "no such width point"))?; indices.sort_unstable(); indices.dedup(); s.edit("Delete Width Point", |d, _| { diff --git a/crates/engine/src/cmd/freeform.rs b/crates/engine/src/cmd/freeform.rs index 531e9bf9..8d74f7b2 100644 --- a/crates/engine/src/cmd/freeform.rs +++ b/crates/engine/src/cmd/freeform.rs @@ -161,12 +161,6 @@ pub(crate) fn parse_freeform(v: &Value) -> std::result::Result Ok(f) } -/// `v` as a list of point indices: `None` unless it is an array of non-negative integers, so no -/// malformed index is dropped or wrapped round. -fn point_indices(v: &Value) -> Option> { - v.as_array()?.iter().map(|i| usize::try_from(i.as_u64()?).ok()).collect() -} - pub(crate) fn parse_mode(m: &str) -> std::result::Result { FreeformMode::parse(m).ok_or_else(|| format!("unknown freeform mode `{m}` (points, lines)")) } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 49be3c08..64d57147 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -340,6 +340,12 @@ pub(crate) fn finite_numbers(v: &Value) -> Option<[f64; N]> { Some(out) } +/// `v` as a list of point indices: `None` unless it is an array of non-negative integers, so no +/// malformed index is dropped or wrapped round. +pub(crate) fn point_indices(v: &Value) -> Option> { + v.as_array()?.iter().map(|i| usize::try_from(i.as_u64()?).ok()).collect() +} + pub(crate) fn point_param(p: &Value, key: &str) -> Option { let a = p.get(key)?.as_array()?; Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?)) diff --git a/crates/engine/src/cmd/shaper.rs b/crates/engine/src/cmd/shaper.rs index f9e5e52b..b0966ccf 100644 --- a/crates/engine/src/cmd/shaper.rs +++ b/crates/engine/src/cmd/shaper.rs @@ -500,7 +500,7 @@ pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> { fn candidates(s: &Session, p: &Value, points: &[Point], tolerance: f64) -> Result> { let st = s.doc()?; let area = points.iter().fold(Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| b.union_pt(*p)).inflate(tolerance, tolerance); - let supplied = p.get("ids").and_then(Value::as_array).map(|v| v.iter().filter_map(Value::as_u64).map(NodeId).collect::>()); + let supplied = p.get("ids").map(|_| checked_ids_param(s, p, "ids", "shaper.scribble")).transpose()?; let expand = supplied.is_none() && st.selection.objects.len() < 2; let ids = supplied.unwrap_or_else(|| if st.selection.objects.len() > 1 { st.selection.objects.clone() } else { st.doc.selectable_art() }); let eligible: Vec = ids diff --git a/crates/engine/src/tests_shaper.rs b/crates/engine/src/tests_shaper.rs index a3275608..77e8da0a 100644 --- a/crates/engine/src/tests_shaper.rs +++ b/crates/engine/src/tests_shaper.rs @@ -42,6 +42,22 @@ fn stroke(s: &Session, id: NodeId, x: f64, y: f64) -> bool { }) } +/// An invalid explicit Shaper target must never silently change which shapes get edited. +#[test] +fn shaper_scribble_rejects_malformed_explicit_targets() { + let mut s = session(); + let a = rectangle(&mut s, 0.0, "#ff0000"); + let untouched = s.doc().unwrap().doc.layers.clone(); + let undo = s.doc().unwrap().history.undo.len(); + for ids in [json!([a.0, "bad"]), json!([a.0, 999999]), json!({"id": a.0}), json!("bad")] { + let err = s.execute("shaper.scribble", &json!({"points": [[25, 20], [35, 30]], "ids": ids})); + assert!(err.is_err(), "malformed targets were accepted"); + assert_eq!(s.doc().unwrap().doc.layers, untouched); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + } + s.execute("shaper.scribble", &json!({"points": [[25, 20], [35, 30]], "ids": [a.0]})).unwrap(); +} + #[test] fn shaper_merges_with_origin_color_keeps_live_originals_and_undoes_once() { for reverse in [false, true] { diff --git a/crates/engine/src/tests_widthpoints.rs b/crates/engine/src/tests_widthpoints.rs index de287868..a2132778 100644 --- a/crates/engine/src/tests_widthpoints.rs +++ b/crates/engine/src/tests_widthpoints.rs @@ -30,6 +30,31 @@ fn dark(s: &Session, x: u32, y: u32) -> bool { vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts).pixel(x, y)[0] < 128 } +/// Invalid entries in an explicit multi-point delete must not delete the valid entries. +#[test] +fn invalid_width_point_indices_leave_the_entire_profile_unchanged() { + let (mut s, id) = line_session(); + set(&mut s, json!({"id": id.0, "t": 0.5, "left": 10, "right": 10})); + let before = points(&s, id); + let history = s.doc().unwrap().history.undo.len(); + for bad in [ + json!({"indices": [1, "bad"]}), + json!({"indices": [-1, 1]}), + json!({"indices": [1.5, 1]}), + json!({"indices": {"point": 1}}), + json!({"index": "bad"}), + ] { + let mut params = bad.as_object().unwrap().clone(); + params.insert("id".into(), json!(id.0)); + let err = s.execute("stroke.widthPoint.remove", &serde_json::Value::Object(params)); + assert!(err.is_err(), "accepted malformed width point indices"); + assert_eq!(points(&s, id), before); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + } + s.execute("stroke.widthPoint.remove", &json!({"id": id.0, "indices": [1]})).unwrap(); + assert_eq!(points(&s, id).len(), before.len() - 1); +} + #[test] fn a_point_moved_onto_another_makes_a_discontinuous_point_that_renders_a_step() { let (mut s, id) = line_session(); diff --git a/crates/eps/src/import/ai/mod.rs b/crates/eps/src/import/ai/mod.rs index c753fc72..49dc7b45 100644 --- a/crates/eps/src/import/ai/mod.rs +++ b/crates/eps/src/import/ai/mod.rs @@ -27,7 +27,7 @@ use std::io::Read as _; use super::lex::find; use crate::ps; -pub use read::{Structure, read}; +pub use read::{Structure, artboards, read}; pub(crate) use read::{slot_frame, slot_of}; #[cfg(test)] diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 6d4f2aa4..4ef288a8 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -106,6 +106,15 @@ pub fn read(data: &[u8]) -> Result { r.finish() } +/// The artboards (art space) of the editing data `data` that [`read`] can't read as a whole, as far +/// as it gets: where to put the pages of its PDF part instead (#1068). +pub fn artboards(data: &[u8]) -> Vec { + let mut r = Reader::new(data); + // Artboards read before an error still say where they are. + let _ = r.run(); + r.artboards.into_iter().map(|(_, rect)| rect).collect() +} + #[derive(Clone, Debug)] struct GState { fill: Paint, diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index 36b082fe..da28e910 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -1041,11 +1041,53 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec, outl Err(why) => { let mut all = warnings; all.push(format!("{LAYERS_UNREAD} from its editing data ({why}): they come from its PDF part")); + let mut visible = visible; + place_pages(&ai::artboards(&data), &mut visible); (visible, all) } } } +/// The pages of `doc` (a `.ai` read as its PDF part: a page per artboard, in a row) moved, with +/// the art on each, to where `boards` (the artboards of its editing data, in art space) are, so +/// the artboards keep their layout when the layers can't be read (#1068). Left as they are unless +/// there are as many artboards as pages, of the same sizes. +fn place_pages(boards: &[Rect], doc: &mut Document) { + let (Some(first), true) = (boards.first(), boards.len() == doc.artboards.len() && boards.len() > 1) else { return }; + if boards.iter().zip(&doc.artboards).any(|(b, a)| (b.width() - a.rect.width()).abs() > 0.5 || (b.height() - a.rect.height()).abs() > 0.5) { + return; + } + // Art space (y up) → the document, the first artboard's top left at the origin, as `read` has it. + let to_doc = Affine::new([1.0, 0.0, 0.0, -1.0, -first.x0, first.y1]); + let pages: Vec = doc.artboards.iter().map(|a| a.rect).collect(); + let moves: Vec = boards.iter().zip(&pages).map(|(b, page)| to_doc.transform_rect_bbox(*b).origin() - page.origin()).collect(); + for layer in &mut doc.layers { + place_art(Arc::make_mut(layer), &pages, &moves); + } + for (a, v) in doc.artboards.iter_mut().zip(&moves) { + a.rect = a.rect + *v; + } +} + +/// The objects in `layer` (and its sublayers) moved by the move of the page they are on. +fn place_art(layer: &mut Node, pages: &[Rect], moves: &[Vec2]) { + let Some(children) = layer.children_mut() else { return }; + for child in children { + if matches!(child.kind, NodeKind::Layer { .. }) { + place_art(Arc::make_mut(child), pages, moves); + continue; + } + let Some(r) = child.visual_bounds() else { continue }; + // The page it is on: the one with most of it (pages keep only the art that reaches into + // them), else the nearest. + let fit = |p: &Rect| (p.intersect(r).area().max(0.0), -(p.center() - r.center()).hypot()); + let Some((i, _)) = pages.iter().map(fit).enumerate().max_by(|(_, a), (_, b)| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1))) else { continue }; + if let Some(v) = moves.get(i).filter(|v| **v != Vec2::ZERO) { + Arc::make_mut(child).transform(Affine::translate(*v), false); + } + } +} + /// A `.ai` saved without its PDF part (its pages show only a placeholder) from its editing copy /// alone (`private`, the joined `AIPrivateData` streams) → the document and the import's notes. pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec), String> { diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index bd8e4c32..e6fcaced 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -237,6 +237,49 @@ fn an_ai_file_that_cant_be_read_through_its_editing_data_keeps_its_pdf_part() { assert!(w.iter().any(|w| w.contains("different page")), "{w:?}"); } +/// A `.ai` whose layers can't be read comes in as its PDF part, a page per artboard in a row; its +/// pages still go where its editing data puts its artboards, with their art (#1068). +#[test] +fn an_ai_file_read_as_its_pdf_part_keeps_its_artboard_layout() { + // Two 100-point artboards, the second below the first (art space, y up). + let board = |x0: i32, y0: i32| { + format!( + "%_/Dictionary :\n%_{x0} {} /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_{} {y0} /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n", + y0 + 100, + x0 + 100 + ) + }; + let boards = |list: &[(i32, i32)]| { + let all: String = list.iter().map(|(x, y)| board(*x, *y)).collect(); + format!("%_/Document :\n%_/Dictionary :\n%_/Array :\n{all}%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n") + }; + // An operator this doesn't know, so the layers aren't read. + let unread = |list: &[(i32, i32)]| native(&(boards(list) + &layer("Art", true, &(square(10, 10) + "\n1 Zz")))); + // The PDF part: the pages side by side, 20 points apart, a square on each. + let mut pdf = pdf_part(10.0, 10.0); + let second = pdf_part(130.0, 10.0).layers[0].children().unwrap()[0].clone(); + let mut second = (*second).clone(); + second.id = pdf.alloc_id(); + let layer_id = pdf.layers[0].id; + pdf.insert(Some(layer_id), 1, second).unwrap(); + let mut page = pdf.artboards[0].clone(); + (page.id, page.rect) = (2, vectorcraft_geom::Rect::new(120.0, 0.0, 220.0, 100.0)); + pdf.artboards.push(page); + let squares = |d: &Document| d.layers[0].children().unwrap().iter().map(|n| n.visual_bounds().unwrap().origin()).collect::>(); + let (d, w) = crate::layered_ai(&private(&unread(&[(0, 0), (0, -150)])), pdf.clone(), vec![], false); + assert!(w.iter().any(|w| w.contains("layers weren't read")), "{w:?}"); + let rects: Vec<_> = d.artboards.iter().map(|a| a.rect).collect(); + assert_eq!(rects, [vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0), vectorcraft_geom::Rect::new(0.0, 150.0, 100.0, 250.0)]); + let moved = squares(&d); + assert!((moved[0] - squares(&pdf)[0]).hypot() < 1e-6, "the first page's art stays: {moved:?}"); + assert!((moved[1] - vectorcraft_geom::Point::new(10.0, 160.0)).hypot() < 1e-6, "the second's goes with its page: {moved:?}"); + // Artboards that don't match the pages leave them in a row. + for list in [&[(0, 0)][..], &[(0, 0), (0, -150), (200, 0)]] { + let (d, _) = crate::layered_ai(&private(&unread(list)), pdf.clone(), vec![], false); + assert_eq!(d.artboards[1].rect, pdf.artboards[1].rect, "{list:?}"); + } +} + #[test] fn a_gray_is_a_grayscale_colour() { // `g` is a gray: 0.25 is 75 % of the black ink, as the page's own CMYK black says; the colour diff --git a/crates/ui-egui/src/dialogs/layer_options.rs b/crates/ui-egui/src/dialogs/layer_options.rs index 7a9f5117..3fd7b56f 100644 --- a/crates/ui-egui/src/dialogs/layer_options.rs +++ b/crates/ui-egui/src/dialogs/layer_options.rs @@ -54,9 +54,16 @@ fn fields_of(n: &Node) -> serde_json::Map { /// the current layer), filled in from the first. pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { let st = app.session.active().ok_or("no document open")?; - let ids: Vec = match (p.get("ids").and_then(Value::as_array), p.get("id").and_then(Value::as_u64)) { - (Some(a), _) => a.iter().filter_map(Value::as_u64).map(NodeId).collect(), - (None, Some(id)) => vec![NodeId(id)], + // Rows given are used as given: a malformed or unknown id fails rather than being skipped or + // the dialog opening on the highlighted rows instead. + let row = |v: &Value| -> Result { + let id = NodeId(v.as_u64().ok_or_else(|| format!("object ids are non-negative integers, not {v}"))?); + st.doc.node(id).map(|_| id).ok_or_else(|| "no such layer or object".to_string()) + }; + let ids: Vec = match (p.get("ids"), p.get("id")) { + (Some(Value::Array(a)), _) => a.iter().map(row).collect::>()?, + (Some(v), _) => return Err(format!("`ids` must be an array of object ids, not {v}")), + (None, Some(v)) => vec![row(v)?], (None, None) => { let rows = st.highlighted_rows(); if rows.is_empty() { st.current_layer().into_iter().collect() } else { rows } @@ -252,6 +259,21 @@ mod tests { app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); } + /// Explicit object ids must not silently disappear or fall back to the highlighted row. + #[test] + fn invalid_layer_options_targets_do_not_open_a_partial_dialog() { + let mut app = app(); + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + for params in + [json!({"ids": [id, "invalid"]}), json!({"ids": [id, 999999]}), json!({"ids": "invalid"}), json!({"id": "invalid"}), json!({"id": null})] + { + assert!(app.run("ui.layerOptions", params.clone()).is_err(), "accepted {params}"); + assert!(app.ui.dialog.is_none(), "opened partial options for {params}"); + } + app.run("ui.layerOptions", json!({"ids": [id]})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["ids"], json!([id])); + } + #[test] fn layer_options_edit_the_layer_and_ok_is_one_undo_step() { let mut app = app(); diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index 5f1bdca4..911f5d3d 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -55,6 +55,7 @@ fn harness(app: VectorcraftApp, size: egui::Vec2) -> Harness<'static, Vectorcraf move |ui, app: &mut VectorcraftApp| { if !ready { crate::theme::install_fonts(ui.ctx()); + egui_extras::install_image_loaders(ui.ctx()); crate::theme::apply(ui.ctx(), crate::theme::Brightness::MediumDark); ready = true; return; diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index afdafe57..67decc23 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -363,9 +363,10 @@ fn picas_in_use(d: &Dialog) -> bool { } fn bool_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { - let mut b = d.bool(key); - if ui.checkbox(&mut b, tl!(label)).changed() { - d.fields.insert(key.into(), json!(b)); + // The app's outlined checkbox (it translates `label`): egui's own has no border at rest. + let on = d.bool(key); + if widgets::check(ui, label, on, ui.is_enabled()) { + d.fields.insert(key.into(), json!(!on)); } } @@ -386,6 +387,28 @@ mod tests { use crate::Services; use vectorcraft_engine::Session; + /// A resting checkbox must have a visible outline in both states and every brightness. + #[test] + fn preference_checkboxes_draw_outlines_without_hover() { + for brightness in Brightness::ALL { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + theme::apply(&ctx, brightness); + for checked in [false, true] { + let mut d = Dialog::new("preferences", json!({"showToolTips": checked})); + let raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 200.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| bool_row(ui, &mut d, "showToolTips", "Show Tool Tips")); + out.textures_delta.clear(); + let outlined = out.shapes.iter().any(|shape| match &shape.shape { + egui::Shape::Rect(r) => r.stroke.width > 0.0 && r.rect.width() <= 24.0 && r.rect.height() <= 24.0, + _ => false, + }); + assert!(outlined, "{brightness:?}, checked={checked}: no checkbox outline"); + } + } + } + /// User Interface › Canvas Color (#663): White and the three greys paint the canvas around the /// artboards; Match User Interface Brightness keeps the theme's. #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2adb0684..b0934b6d 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-10-11 · **Change:** minor (Lab spot colours in `.ai` and PDF, #1032) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (`.ai` artboard layout when read as its PDF part, #1068) · **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/mod.rs`). Illustrator's list comes from its public @@ -44,7 +44,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. Symbols, pattern fills whose pattern it can't read, placed files and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. Symbols, pattern fills whose pattern it can't read, placed files and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. ## Format by format @@ -80,6 +80,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | A `.ai` read as its PDF part keeps its artboard layout from the editing data (#1068) | | 2026-10-11 | minor | Spot colours defined in Lab keep their Lab values, from the editing data and from a PDF part whose ink alternate is an ICC Lab profile (#1032) | | 2026-10-11 | minor | `.ai` in PostScript form opens through its layers, also without its prolog (#1027) | | 2026-10-11 | minor | `.ai` editing data: global process colours and pattern fills read, so they no longer drop sublayers (#1025) |